diff --git a/adapter/encryption_admin.go b/adapter/encryption_admin.go index ae4d4ce3a..f380f3a9b 100644 --- a/adapter/encryption_admin.go +++ b/adapter/encryption_admin.go @@ -1032,6 +1032,211 @@ func freshCutoverResponse(sc *encryption.Sidecar, proposedIdx uint64, fanoutResu } } +// raftEnvelopeWrapEnabled gates the EnableRaftEnvelope RPC until +// the Stage 6E-2 wrap-on-propose / unwrap-on-apply / §7.1 +// proposal-quiescence-barrier triple ships. With only 6E-1 +// (admin RPC + FSM-apply machinery) deployed, accepting a cutover +// proposal records RaftEnvelopeCutoverIndex=N in the sidecar, but +// no entry between N and the eventual 6E-2 upgrade is wrapped — +// they all remain cleartext. The future engine apply-hook +// (designed in §6.3) dispatches `entry.Index > cutover` through +// the unwrap path, so a 6E-2 upgrade against a sidecar where N +// was recorded under a 6E-1-only build would treat every +// pre-upgrade cleartext entry above N as an envelope and halt +// apply cluster-wide. Gate fails closed until 6E-2 atomically +// flips this to true alongside the wrap/unwrap/barrier wiring. +const raftEnvelopeWrapEnabled = false + +// EnableRaftEnvelope is the Stage 6E Phase 2 cutover — flips Raft +// proposals from cleartext to §4.2-envelope. Structural mirror of +// EnableStorageEnvelope; the differences are: +// +// - Target Purpose is PurposeRaft. +// - Source DEK slot is sidecar.Active.Raft (not Active.Storage). +// - The "already active" sentinel is the single field +// sidecar.RaftEnvelopeCutoverIndex != 0 — there is no separate +// bool flag, so the raft variant has no equivalent of the +// §6.4 cutover_index_unknown defensive fallback (a zero index +// is exactly the not-active state, not a corrupted-active +// state, and the 6E-1a applier fail-closes on raftIdx == 0 +// before ApplyRegistration). +// +// The semaphore, pre-check / fan-out / propose / post-check +// sequence, and error mapping match the storage variant verbatim; +// see EnableStorageEnvelope for the full design rationale. +// +// **Gated**: refuses with FailedPrecondition until 6E-2 ships the +// wrap-on-propose / unwrap-on-apply / §7.1 barrier (see +// raftEnvelopeWrapEnabled for the rationale). The pre-gate +// validation surface (leader, semaphore acquire, request shape) +// still fires so operators get fast feedback on wiring problems, +// but no Raft proposal is composed and no sidecar mutation occurs. +func (s *EncryptionAdminServer) EnableRaftEnvelope(ctx context.Context, req *pb.EnableRaftEnvelopeRequest) (*pb.EnableRaftEnvelopeResponse, error) { + if err := s.acquireCutoverSemaphore(ctx); err != nil { + return nil, err + } + defer s.releaseCutoverSemaphore() + preSidecar, earlyResp, err := s.raftCutoverPrecheck(ctx, req) + if err != nil { + return nil, err + } + if earlyResp != nil { + return earlyResp, nil + } + if !raftEnvelopeWrapEnabled { + // Without 6E-2 wrap-on-propose, recording the cutover + // index here would let cleartext entries land at indexes + // > N. A 6E-2 upgrade would then treat those cleartext + // entries as envelopes and halt apply. Refuse before the + // fan-out and propose so no sidecar state changes. + return nil, grpcStatusError(codes.FailedPrecondition, + "encryption: enable-raft-envelope is gated until Stage 6E-2 ships wrap-on-propose / unwrap-on-apply / §7.1 proposal-quiescence-barrier; accepting the cutover now would brick the cluster on the next upgrade") + } + fanoutResult, err := s.runCutoverFanout(ctx) + if err != nil { + return nil, err + } + proposedIdx, err := s.proposeRaftCutoverEntry(ctx, preSidecar, req) + if err != nil { + return nil, err + } + return s.raftCutoverPostcheck(proposedIdx, fanoutResult) +} + +// raftCutoverPrecheck runs the Stage 6E §3.2 steps 1-5: input +// validation, leader gate, bootstrap gate, idempotent-retry +// short-circuit. Returns: +// +// - (preSidecar, nil, nil) on the propose-path +// - (nil, earlyResp, nil) on the idempotent retry +// - (nil, nil, err) on any refusal +func (s *EncryptionAdminServer) raftCutoverPrecheck(ctx context.Context, req *pb.EnableRaftEnvelopeRequest) (*encryption.Sidecar, *pb.EnableRaftEnvelopeResponse, error) { + if err := s.requireLeader(ctx); err != nil { + return nil, nil, err + } + if s.proposer == nil { + return nil, nil, grpcStatusError(codes.FailedPrecondition, "encryption: proposer is not configured on this node") + } + if s.sidecarPath == "" { + return nil, nil, grpcStatusError(codes.FailedPrecondition, "encryption: sidecar path is not configured on this node") + } + if err := validateEnableRaftEnvelopeRequest(req); err != nil { + return nil, nil, err + } + preSidecar, err := encryption.ReadSidecar(s.sidecarPath) + if err != nil { + return nil, nil, statusFromSidecarErr(err) + } + if preSidecar.Active.Raft == 0 { + return nil, nil, grpcStatusError(codes.FailedPrecondition, + "encryption: cluster not bootstrapped (Active.Raft == 0) — call BootstrapEncryption first") + } + if preSidecar.RaftEnvelopeCutoverIndex != 0 { + // Idempotent retry — return OK with was_already_active=true + // and the original cutover index. Skip the fan-out: the + // original cutover already passed the gate. + return nil, idempotentRaftCutoverResponse(preSidecar), nil + } + return preSidecar, nil, nil +} + +// proposeRaftCutoverEntry composes the §2.1 RotationPayload for +// the raft variant and drives it through Raft. Purpose=PurposeRaft, +// DEKID = sidecar.Active.Raft, Wrapped=empty (length-based, not +// nil, matching the 6E-1a applier's length-based reject). +func (s *EncryptionAdminServer) proposeRaftCutoverEntry(ctx context.Context, preSidecar *encryption.Sidecar, req *pb.EnableRaftEnvelopeRequest) (uint64, error) { + payload := fsmwire.RotationPayload{ + SubTag: fsmwire.RotateSubEnableRaftEnvelope, + DEKID: preSidecar.Active.Raft, + Purpose: fsmwire.PurposeRaft, + Wrapped: []byte{}, + ProposerRegistration: fsmwire.RegistrationPayload{ + DEKID: preSidecar.Active.Raft, + FullNodeID: req.GetProposerNodeId(), + LocalEpoch: uint32ToLocalEpoch(req.GetProposerLocalEpoch()), + }, + } + return s.proposeEncryptionEntry(ctx, fsmwire.OpRotation, fsmwire.EncodeRotation(payload)) +} + +// raftCutoverPostcheck re-reads the sidecar after the Raft propose +// returns and discriminates the §2.1 outcomes: +// +// - Fresh success: RaftEnvelopeCutoverIndex == proposedIdx → §3.2 +// happy path. +// - Stale-DEKID race: RaftEnvelopeCutoverIndex still 0 because +// a RotateDEK raced and the applier consumed the entry as a +// benign no-op → FailedPrecondition with retry hint. +// - Concurrent overlap: RaftEnvelopeCutoverIndex != 0 but != +// proposedIdx → another cutover landed first (operator- +// impossible under the semaphore, but the applier records +// the FIRST cutover's index; surface that index with +// was_already_active=false because THIS call's propose +// committed an entry that the applier treated as the +// idempotent path). +func (s *EncryptionAdminServer) raftCutoverPostcheck(proposedIdx uint64, fanoutResult admin.CapabilityFanoutResult) (*pb.EnableRaftEnvelopeResponse, error) { + postSidecar, err := encryption.ReadSidecar(s.sidecarPath) + if err != nil { + return nil, statusFromSidecarErr(err) + } + if postSidecar.RaftEnvelopeCutoverIndex == 0 { + return nil, grpcStatusError(codes.FailedPrecondition, + "encryption: cutover proposal raced a RotateDEK (sidecar.Active.Raft moved); retry against the new active DEK") + } + return freshRaftCutoverResponse(postSidecar, proposedIdx, fanoutResult), nil +} + +// validateEnableRaftEnvelopeRequest enforces the §3.2 step 1 +// gRPC-boundary checks. Pulled out so the EnableRaftEnvelope +// orchestration body stays under the cyclomatic-complexity budget +// and so tests can exercise the validation slice in isolation. +func validateEnableRaftEnvelopeRequest(req *pb.EnableRaftEnvelopeRequest) error { + if req.GetProposerNodeId() == 0 { + return grpcStatusError(codes.InvalidArgument, + "encryption: proposer_node_id must be non-zero (0 is reserved as the §6.1 not-capable sentinel)") + } + if req.GetProposerLocalEpoch() > math.MaxUint16 { + return grpcStatusErrorf(codes.InvalidArgument, + "encryption: proposer_local_epoch=%d exceeds the §4.1 16-bit bound (max 0xFFFF)", + req.GetProposerLocalEpoch()) + } + return nil +} + +// idempotentRaftCutoverResponse is the §3.2 step 5 retry-success +// shape for the raft variant: OK, was_already_active=true, +// applied_index = sidecar.RaftEnvelopeCutoverIndex (the original +// cutover's apply index). The storage variant's +// cutover_index_unknown defensive branch is intentionally absent +// — the raft variant uses the cutover index itself as the active +// sentinel, so a non-zero index here cannot coexist with the +// "active but unknown index" state the storage hedge was for. +func idempotentRaftCutoverResponse(sc *encryption.Sidecar) *pb.EnableRaftEnvelopeResponse { + return &pb.EnableRaftEnvelopeResponse{ + WasAlreadyActive: true, + CapabilitySummary: nil, + AppliedIndex: sc.RaftEnvelopeCutoverIndex, + } +} + +// freshRaftCutoverResponse is the §3.2 fresh-success shape for the +// raft variant. applied_index is sourced from the post-apply +// sidecar's RaftEnvelopeCutoverIndex, which raftCutoverPostcheck +// has already validated as non-zero (the stale-DEKID branch +// refuses earlier, so reaching here implies the apply set the +// cutover index). The storage variant's `appliedIndex == 0` +// defensive branch has no analogue here because the raft variant +// uses the cutover index itself as the active sentinel: a zero +// at this point would be an upstream invariant violation, not a +// hand-edit hazard. +func freshRaftCutoverResponse(sc *encryption.Sidecar, _ uint64, fanoutResult admin.CapabilityFanoutResult) *pb.EnableRaftEnvelopeResponse { + return &pb.EnableRaftEnvelopeResponse{ + AppliedIndex: sc.RaftEnvelopeCutoverIndex, + CapabilitySummary: projectCapabilityVerdicts(fanoutResult.Verdicts), + WasAlreadyActive: false, + } +} + // projectCapabilityVerdicts marshals the internal CapabilityVerdict // shape into the wire-format proto.CapabilityVerdict. Reachable / // Err fields are intentionally NOT projected: the cutover RPC only diff --git a/adapter/encryption_admin_test.go b/adapter/encryption_admin_test.go index 5a6769426..9c10620a6 100644 --- a/adapter/encryption_admin_test.go +++ b/adapter/encryption_admin_test.go @@ -1891,3 +1891,177 @@ func assertProtoVerdict(t *testing.T, v *pb.CapabilityVerdict, wantNodeID uint64 v, wantNodeID, wantBuildSHA) } } + +// validEnableRaftEnvelopeRequest is the canonical valid request +// for the raft-variant tests. Same proposer identity shape as the +// storage variant — the two requests' Go types are independent +// but structurally identical (proposer_node_id, proposer_local_epoch). +func validEnableRaftEnvelopeRequest() *pb.EnableRaftEnvelopeRequest { + return &pb.EnableRaftEnvelopeRequest{ + ProposerNodeId: 11, + ProposerLocalEpoch: 7, + } +} + +// applyRaftCutover is the §6.4-equivalent fresh-success apply +// effect for the raft variant: stamp RaftEnvelopeCutoverIndex +// with the apply's Raft index. Used by the EnableRaftEnvelope +// happy-path test to drive the post-Propose sidecar re-read into +// the fresh-success branch. +func applyRaftCutover(sc *encryption.Sidecar, raftIdx uint64) { + sc.RaftEnvelopeCutoverIndex = raftIdx + if raftIdx > sc.RaftAppliedIndex { + sc.RaftAppliedIndex = raftIdx + } +} + +// TestEncryptionAdmin_EnableRaftEnvelope_RejectsZeroProposerNodeID +// pins the §6.1 sentinel rejection at the gRPC boundary. +func TestEncryptionAdmin_EnableRaftEnvelope_RejectsZeroProposerNodeID(t *testing.T) { + t.Parallel() + srv := NewEncryptionAdminServer( + WithEncryptionAdminProposer(&recordingProposer{}), + WithEncryptionAdminLeaderView(stubLeaderView{state: raftengine.StateLeader}), + WithEncryptionAdminSidecarPath(cutoverReadySidecarFixture(t)), + ) + req := validEnableRaftEnvelopeRequest() + req.ProposerNodeId = 0 + _, err := srv.EnableRaftEnvelope(context.Background(), req) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("EnableRaftEnvelope status=%v, want InvalidArgument", status.Code(err)) + } +} + +// TestEncryptionAdmin_EnableRaftEnvelope_RejectsOversizedLocalEpoch +// pins the §4.1 16-bit bound at the gRPC boundary. +func TestEncryptionAdmin_EnableRaftEnvelope_RejectsOversizedLocalEpoch(t *testing.T) { + t.Parallel() + srv := NewEncryptionAdminServer( + WithEncryptionAdminProposer(&recordingProposer{}), + WithEncryptionAdminLeaderView(stubLeaderView{state: raftengine.StateLeader}), + WithEncryptionAdminSidecarPath(cutoverReadySidecarFixture(t)), + ) + req := validEnableRaftEnvelopeRequest() + req.ProposerLocalEpoch = math.MaxUint16 + 1 + _, err := srv.EnableRaftEnvelope(context.Background(), req) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("EnableRaftEnvelope status=%v, want InvalidArgument", status.Code(err)) + } +} + +// TestEncryptionAdmin_EnableRaftEnvelope_RejectsNotBootstrapped pins +// the raft-variant bootstrap gate: Active.Raft == 0 means +// BootstrapEncryption has not committed yet, so the cutover must +// refuse. +func TestEncryptionAdmin_EnableRaftEnvelope_RejectsNotBootstrapped(t *testing.T) { + t.Parallel() + path := writeSidecarFixture(t, &encryption.Sidecar{ + Active: encryption.ActiveKeys{Storage: 0, Raft: 0}, + Keys: map[string]encryption.SidecarKey{}, + }) + srv := NewEncryptionAdminServer( + WithEncryptionAdminProposer(&recordingProposer{}), + WithEncryptionAdminLeaderView(stubLeaderView{state: raftengine.StateLeader}), + WithEncryptionAdminSidecarPath(path), + ) + _, err := srv.EnableRaftEnvelope(context.Background(), validEnableRaftEnvelopeRequest()) + if status.Code(err) != codes.FailedPrecondition { + t.Errorf("EnableRaftEnvelope status=%v, want FailedPrecondition", status.Code(err)) + } + if err == nil || !strings.Contains(err.Error(), "BootstrapEncryption") { + t.Errorf("error %q does not hint at BootstrapEncryption", err) + } +} + +// TestEncryptionAdmin_EnableRaftEnvelope_IdempotentRetry pins the +// retry path: a duplicate call against a sidecar with +// RaftEnvelopeCutoverIndex != 0 returns OK with +// was_already_active=true and applied_index = the original +// cutover index. No fan-out, no propose. The raft variant has no +// CutoverIndexUnknown field, so the response is intentionally +// narrower than the storage variant's idempotent shape. +func TestEncryptionAdmin_EnableRaftEnvelope_IdempotentRetry(t *testing.T) { + t.Parallel() + path := writeSidecarFixture(t, &encryption.Sidecar{ + Active: encryption.ActiveKeys{Storage: 5, Raft: 6}, + Keys: map[string]encryption.SidecarKey{"5": {Purpose: encryption.SidecarPurposeStorage, Wrapped: []byte("ws"), Created: "x", LocalEpoch: 0}, "6": {Purpose: encryption.SidecarPurposeRaft, Wrapped: []byte("wr"), Created: "x", LocalEpoch: 0}}, + RaftEnvelopeCutoverIndex: 777, + RaftAppliedIndex: 900, + }) + proposer := &recordingProposer{} + srv := NewEncryptionAdminServer( + WithEncryptionAdminProposer(proposer), + WithEncryptionAdminLeaderView(stubLeaderView{state: raftengine.StateLeader}), + WithEncryptionAdminSidecarPath(path), + WithEncryptionAdminCapabilityFanout(failOnCallCapabilityFanout(t)), + ) + got, err := srv.EnableRaftEnvelope(context.Background(), validEnableRaftEnvelopeRequest()) + if err != nil { + t.Fatalf("EnableRaftEnvelope: %v", err) + } + if !got.WasAlreadyActive { + t.Error("WasAlreadyActive=false, want true (idempotent retry)") + } + if got.AppliedIndex != 777 { + t.Errorf("AppliedIndex=%d, want 777 (original RaftEnvelopeCutoverIndex)", got.AppliedIndex) + } + if len(got.CapabilitySummary) != 0 { + t.Errorf("CapabilitySummary len=%d, want 0 (empty on idempotent retries)", len(got.CapabilitySummary)) + } + if len(proposer.calls) != 0 { + t.Errorf("proposer.calls len=%d, want 0 (no propose on idempotent retry)", len(proposer.calls)) + } +} + +// TestEncryptionAdmin_EnableRaftEnvelope_GatedUntil6E2 pins the +// fail-closed gate: while raftEnvelopeWrapEnabled is false (i.e. +// before Stage 6E-2 ships wrap-on-propose / unwrap-on-apply / +// §7.1 barrier), the RPC MUST refuse fresh cutover proposals +// with FailedPrecondition rather than recording +// RaftEnvelopeCutoverIndex=N. Recording N now would let cleartext +// entries land at indexes > N and the 6E-2 engine apply-hook +// would treat them as envelopes on upgrade, halting apply +// cluster-wide. +// +// The test wires an `applyingProposer` exactly as a future +// happy-path test would, then verifies the gate refuses BEFORE +// any proposal is composed (proposer.calls is empty) and BEFORE +// any sidecar mutation lands (RaftEnvelopeCutoverIndex stays 0). +// When 6E-2 lands and flips raftEnvelopeWrapEnabled to true, this +// test becomes the regression pin for the gate-flip and a +// HappyPath sibling is added. +func TestEncryptionAdmin_EnableRaftEnvelope_GatedUntil6E2(t *testing.T) { + t.Parallel() + path := cutoverReadySidecarFixture(t) + proposer := &applyingProposer{ + recordingProposer: recordingProposer{commitIndex: 4242}, + sidecarPath: path, + applyFn: applyRaftCutover, + } + srv := NewEncryptionAdminServer( + WithEncryptionAdminProposer(proposer), + WithEncryptionAdminLeaderView(stubLeaderView{state: raftengine.StateLeader}), + WithEncryptionAdminSidecarPath(path), + WithEncryptionAdminCapabilityFanout(fixedCapabilityFanout(allOKFanoutResult(), nil)), + ) + _, err := srv.EnableRaftEnvelope(context.Background(), validEnableRaftEnvelopeRequest()) + if status.Code(err) != codes.FailedPrecondition { + t.Errorf("EnableRaftEnvelope status=%v, want FailedPrecondition (gated until 6E-2)", status.Code(err)) + } + if err == nil || !strings.Contains(err.Error(), "6E-2") { + t.Errorf("error %q does not hint at the 6E-2 gate", err) + } + if len(proposer.calls) != 0 { + t.Errorf("proposer.calls len=%d, want 0 (gate must refuse before propose)", len(proposer.calls)) + } + // Sidecar must remain untouched: RaftEnvelopeCutoverIndex + // still 0 means the cluster has not entered Phase 2 and a + // future 6E-2 upgrade is still safe. + sc, readErr := encryption.ReadSidecar(path) + if readErr != nil { + t.Fatalf("ReadSidecar: %v", readErr) + } + if sc.RaftEnvelopeCutoverIndex != 0 { + t.Errorf("RaftEnvelopeCutoverIndex=%d, want 0 (gate must refuse before sidecar mutation)", sc.RaftEnvelopeCutoverIndex) + } +} diff --git a/cmd/elastickv-admin/encryption.go b/cmd/elastickv-admin/encryption.go index 911d6d171..664da20e8 100644 --- a/cmd/elastickv-admin/encryption.go +++ b/cmd/elastickv-admin/encryption.go @@ -25,12 +25,11 @@ const encryptionDialTimeout = 5 * time.Second // with this one — main() picks based on argv[1] before flag.Parse() so // the two surfaces do not share a global flag namespace. // -// PR-A wired `status`. PR-B added `rotate-dek` and -// `register-writer`. PR-C adds `bootstrap`. 6D-2 added the -// `probe-node-id` collision-mitigation helper. 6D-6b (this PR) -// adds `enable-storage-envelope`; the §7.1 Phase 2 -// `enable-raft-envelope` lands in Stage 6E. ResyncSidecar is a -// server-side §5.5 fallback (no CLI surface). +// Subcommand surface (in shipping order): status, rotate-dek, +// register-writer, bootstrap, probe-node-id, +// enable-storage-envelope (6D-6b), enable-raft-envelope (6E-1b; +// the server method is gated until 6E-2 ships wrap-on-propose). +// ResyncSidecar is a server-side §5.5 fallback (no CLI surface). func encryptionMain(args []string) error { if len(args) == 0 { return errors.New("usage: elastickv-admin encryption [flags]") @@ -44,13 +43,13 @@ func encryptionMain(args []string) error { // subcommands; returning nil keeps the exit code at 0 // so shell scripts using $? to detect success do not // trip on a help request. - _, err := fmt.Fprintln(os.Stdout, "usage: elastickv-admin encryption [flags]\n\nsubcommands:\n status\n rotate-dek\n register-writer\n bootstrap\n enable-storage-envelope\n probe-node-id") + _, err := fmt.Fprintln(os.Stdout, "usage: elastickv-admin encryption [flags]\n\nsubcommands:\n status\n rotate-dek\n register-writer\n bootstrap\n enable-storage-envelope\n enable-raft-envelope\n probe-node-id") if err != nil { return errors.Wrap(err, "write usage") } return nil } - return errors.Errorf("encryption: unknown subcommand %q (supported: status, rotate-dek, register-writer, bootstrap, enable-storage-envelope, probe-node-id)", sub) + return errors.Errorf("encryption: unknown subcommand %q (supported: status, rotate-dek, register-writer, bootstrap, enable-storage-envelope, enable-raft-envelope, probe-node-id)", sub) } // encryptionSubcommands is the dispatch table for the encryption @@ -65,6 +64,7 @@ func encryptionSubcommands() map[string]func(args []string, out io.Writer) error "register-writer": runEncryptionRegisterWriter, "bootstrap": runEncryptionBootstrap, "enable-storage-envelope": runEncryptionEnableStorageEnvelope, + "enable-raft-envelope": runEncryptionEnableRaftEnvelope, "probe-node-id": runEncryptionProbeNodeID, } } diff --git a/cmd/elastickv-admin/encryption_mutators.go b/cmd/elastickv-admin/encryption_mutators.go index 64ffc0ed6..036120ed6 100644 --- a/cmd/elastickv-admin/encryption_mutators.go +++ b/cmd/elastickv-admin/encryption_mutators.go @@ -224,6 +224,106 @@ func printEnableStorageEnvelopeResult(out io.Writer, resp *pb.EnableStorageEnvel return nil } +// runEncryptionEnableRaftEnvelope invokes +// EncryptionAdmin.EnableRaftEnvelope on the configured endpoint. +// Structural mirror of runEncryptionEnableStorageEnvelope; the +// server method (Stage 6E-1b) composes the §3.2 sequence with +// Purpose=PurposeRaft and SubTag=RotateSubEnableRaftEnvelope, and +// the response carries only the (was_already_active, applied_index, +// capability_summary) shape — the storage variant's +// cutover_index_unknown defensive field is intentionally absent +// because RaftEnvelopeCutoverIndex != 0 IS the active sentinel. +// +// Output discriminates was_already_active so an automation script +// can tell whether THIS invocation proposed the cutover or hit the +// idempotent-retry path: +// +// fresh: "enabled applied_index=N +// capability summary: ..." +// already-on: "already-active applied_index=N" +func runEncryptionEnableRaftEnvelope(args []string, out io.Writer) error { + req, endpoint, err := parseEnableRaftEnvelopeArgs(args) + if err != nil { + return err + } + if req == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), *endpoint.timeout) + defer cancel() + client, closeFn, err := dialEncryption(ctx, endpoint) + if err != nil { + return err + } + defer func() { + if err := closeFn(); err != nil { + fmt.Fprintf(os.Stderr, "encryption: close connection: %v\n", err) + } + }() + resp, err := client.EnableRaftEnvelope(ctx, req) + if err != nil { + return errors.Wrap(err, "EnableRaftEnvelope") + } + return printEnableRaftEnvelopeResult(out, resp) +} + +// parseEnableRaftEnvelopeArgs returns the validated proto request +// and the shared endpoint flags. A nil request with no error means +// the caller requested --help; the caller then exits 0. +func parseEnableRaftEnvelopeArgs(args []string) (*pb.EnableRaftEnvelopeRequest, *encryptionEndpointFlags, error) { + fs := flag.NewFlagSet("encryption enable-raft-envelope", flag.ContinueOnError) + endpoint := newEncryptionEndpointFlags(fs) + proposerNodeID := fs.Uint64("proposer-node-id", 0, "The proposer's 64-bit full_node_id (registered in §4.1 writer registry); MUST be non-zero (0 is the §6.1 not-capable sentinel)") + proposerLocalEpoch := fs.Uint("proposer-local-epoch", 0, "The proposer's local_epoch at proposal time (0..0xFFFF)") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil, endpoint, nil + } + return nil, nil, errors.Wrap(err, "parse flags") + } + if *proposerNodeID == 0 { + return nil, nil, errors.New("encryption: --proposer-node-id is required and must be non-zero (0 is the §6.1 not-capable sentinel)") + } + if err := requireUint16Plus1(*proposerLocalEpoch, "proposer-local-epoch"); err != nil { + return nil, nil, err + } + return &pb.EnableRaftEnvelopeRequest{ + ProposerNodeId: *proposerNodeID, + ProposerLocalEpoch: narrowUint32(*proposerLocalEpoch), + }, endpoint, nil +} + +// printEnableRaftEnvelopeResult renders the §3.1 response in a +// shell-friendly shape. Lines start with a stable prefix +// (`enabled` / `already-active`) so scripts can `awk` on column 1 +// to discriminate. No cutover_index_unknown warning line — the +// raft variant does not have that defensive field (see the +// response message comment in encryption_admin.proto). +func printEnableRaftEnvelopeResult(out io.Writer, resp *pb.EnableRaftEnvelopeResponse) error { + if resp.GetWasAlreadyActive() { + if _, err := fmt.Fprintf(out, "already-active applied_index=%d\n", resp.GetAppliedIndex()); err != nil { + return errors.Wrap(err, "write result") + } + return nil + } + if _, err := fmt.Fprintf(out, "enabled applied_index=%d\n", resp.GetAppliedIndex()); err != nil { + return errors.Wrap(err, "write result") + } + if len(resp.GetCapabilitySummary()) == 0 { + return nil + } + if _, err := fmt.Fprintln(out, "capability summary:"); err != nil { + return errors.Wrap(err, "write capability summary header") + } + for _, v := range resp.GetCapabilitySummary() { + if _, err := fmt.Fprintf(out, " full_node_id=%d encryption_capable=%t build_sha=%s sidecar_present=%t\n", + v.GetFullNodeId(), v.GetEncryptionCapable(), v.GetBuildSha(), v.GetSidecarPresent()); err != nil { + return errors.Wrap(err, "write capability row") + } + } + return nil +} + // runEncryptionRegisterWriter invokes // EncryptionAdmin.RegisterEncryptionWriter for a single // (dek_id, full_node_id, local_epoch) triple. Multi-writer diff --git a/cmd/elastickv-admin/encryption_test.go b/cmd/elastickv-admin/encryption_test.go index cd1b05360..e658c16b2 100644 --- a/cmd/elastickv-admin/encryption_test.go +++ b/cmd/elastickv-admin/encryption_test.go @@ -173,13 +173,15 @@ func (s *stubSidecarErrorServer) GetSidecarState(context.Context, *pb.Empty) (*p // inherit Unimplemented defaults. type stubMutatorServer struct { pb.UnimplementedEncryptionAdminServer - rotateCalls []*pb.RotateDEKRequest - registerCalls []*pb.RegisterEncryptionWriterRequest - bootstrapCalls []*pb.BootstrapEncryptionRequest - enableEnvelopeCalls []*pb.EnableStorageEnvelopeRequest - enableEnvelopeResp *pb.EnableStorageEnvelopeResponse - appliedIndex uint64 - returnErr error + rotateCalls []*pb.RotateDEKRequest + registerCalls []*pb.RegisterEncryptionWriterRequest + bootstrapCalls []*pb.BootstrapEncryptionRequest + enableEnvelopeCalls []*pb.EnableStorageEnvelopeRequest + enableEnvelopeResp *pb.EnableStorageEnvelopeResponse + enableRaftEnvelopeCalls []*pb.EnableRaftEnvelopeRequest + enableRaftEnvelopeResp *pb.EnableRaftEnvelopeResponse + appliedIndex uint64 + returnErr error } func (s *stubMutatorServer) RotateDEK(_ context.Context, req *pb.RotateDEKRequest) (*pb.RotateDEKResponse, error) { @@ -223,6 +225,17 @@ func (s *stubMutatorServer) EnableStorageEnvelope(_ context.Context, req *pb.Ena return &pb.EnableStorageEnvelopeResponse{AppliedIndex: s.appliedIndex}, nil } +func (s *stubMutatorServer) EnableRaftEnvelope(_ context.Context, req *pb.EnableRaftEnvelopeRequest) (*pb.EnableRaftEnvelopeResponse, error) { + s.enableRaftEnvelopeCalls = append(s.enableRaftEnvelopeCalls, req) + if s.returnErr != nil { + return nil, s.returnErr + } + if s.enableRaftEnvelopeResp != nil { + return s.enableRaftEnvelopeResp, nil + } + return &pb.EnableRaftEnvelopeResponse{AppliedIndex: s.appliedIndex}, nil +} + func TestRunEncryptionBootstrap_HappyPath(t *testing.T) { t.Parallel() stub := &stubMutatorServer{appliedIndex: 117} @@ -799,3 +812,133 @@ func TestEncryptionMain_EnableStorageEnvelopeSubcommand(t *testing.T) { t.Errorf("dispatch reached wrong handler: got %v", err) } } + +// TestRunEncryptionEnableRaftEnvelope_HappyPath pins the §3.1 +// fresh-success rendering for the raft variant. Structurally +// identical to the storage HappyPath test except the response +// shape lacks the cutover_index_unknown field (raft variant uses +// RaftEnvelopeCutoverIndex != 0 as the sole active sentinel). +func TestRunEncryptionEnableRaftEnvelope_HappyPath(t *testing.T) { + t.Parallel() + stub := &stubMutatorServer{ + appliedIndex: 4242, + enableRaftEnvelopeResp: &pb.EnableRaftEnvelopeResponse{ + AppliedIndex: 4242, + WasAlreadyActive: false, + CapabilitySummary: []*pb.CapabilityVerdict{ + {FullNodeId: 11, EncryptionCapable: true, BuildSha: "build-n1", SidecarPresent: true}, + {FullNodeId: 22, EncryptionCapable: true, BuildSha: "build-n2", SidecarPresent: true}, + }, + }, + } + addr := startCustomEncryptionAdminTestServer(t, stub) + var buf bytes.Buffer + err := runEncryptionEnableRaftEnvelope([]string{ + "--endpoint", addr, + "--timeout", "3s", + "--proposer-node-id", "11", + "--proposer-local-epoch", "7", + }, &buf) + if err != nil { + t.Fatalf("runEncryptionEnableRaftEnvelope: %v", err) + } + out := buf.String() + if !strings.HasPrefix(out, "enabled applied_index=4242") { + t.Errorf("output prefix missing fresh-success shape, got:\n%s", out) + } + if !strings.Contains(out, "full_node_id=11") || !strings.Contains(out, "build_sha=build-n1") { + t.Errorf("output missing first verdict, got:\n%s", out) + } + if len(stub.enableRaftEnvelopeCalls) != 1 { + t.Fatalf("EnableRaftEnvelope calls=%d, want 1", len(stub.enableRaftEnvelopeCalls)) + } + call := stub.enableRaftEnvelopeCalls[0] + if call.ProposerNodeId != 11 || call.ProposerLocalEpoch != 7 { + t.Errorf("EnableRaftEnvelope call=%+v does not match flag inputs", call) + } +} + +// TestRunEncryptionEnableRaftEnvelope_IdempotentRetry pins the +// was_already_active=true rendering. applied_index reports the +// ORIGINAL cutover index from sidecar.RaftEnvelopeCutoverIndex. +// No warning line — the raft variant has no defensive +// cutover_index_unknown field. +func TestRunEncryptionEnableRaftEnvelope_IdempotentRetry(t *testing.T) { + t.Parallel() + stub := &stubMutatorServer{ + enableRaftEnvelopeResp: &pb.EnableRaftEnvelopeResponse{ + AppliedIndex: 777, + WasAlreadyActive: true, + CapabilitySummary: nil, + }, + } + addr := startCustomEncryptionAdminTestServer(t, stub) + var buf bytes.Buffer + err := runEncryptionEnableRaftEnvelope([]string{ + "--endpoint", addr, + "--timeout", "3s", + "--proposer-node-id", "11", + "--proposer-local-epoch", "7", + }, &buf) + if err != nil { + t.Fatalf("runEncryptionEnableRaftEnvelope: %v", err) + } + out := buf.String() + if !strings.HasPrefix(out, "already-active applied_index=777") { + t.Errorf("output prefix missing already-active shape, got:\n%s", out) + } + if strings.Contains(out, "capability summary") { + t.Errorf("idempotent retry must NOT print the capability summary header, got:\n%s", out) + } + if strings.Contains(out, "warning:") { + t.Errorf("raft variant must NOT emit a warning line (no cutover_index_unknown field), got:\n%s", out) + } +} + +// TestRunEncryptionEnableRaftEnvelope_RejectsZeroProposerNodeID +// pins the §6.1 sentinel rejection on the CLI side. Same +// posture as the storage variant. +func TestRunEncryptionEnableRaftEnvelope_RejectsZeroProposerNodeID(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + err := runEncryptionEnableRaftEnvelope([]string{ + "--endpoint", "127.0.0.1:1", + "--proposer-node-id", "0", + "--proposer-local-epoch", "7", + }, &buf) + if err == nil { + t.Fatal("runEncryptionEnableRaftEnvelope returned nil, want error on --proposer-node-id=0") + } + if !strings.Contains(err.Error(), "proposer-node-id") { + t.Errorf("error %q does not hint at the rejected flag", err) + } +} + +// TestRunEncryptionEnableRaftEnvelope_RejectsBadEpoch pins the +// §4.1 16-bit bound on the CLI side. +func TestRunEncryptionEnableRaftEnvelope_RejectsBadEpoch(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + err := runEncryptionEnableRaftEnvelope([]string{ + "--endpoint", "127.0.0.1:1", + "--proposer-node-id", "11", + "--proposer-local-epoch", "70000", + }, &buf) + if err == nil || !strings.Contains(err.Error(), "16-bit bound") { + t.Fatalf("runEncryptionEnableRaftEnvelope error=%v, want bound-violation", err) + } +} + +// TestEncryptionMain_EnableRaftEnvelopeSubcommand pins the +// dispatch entry in encryptionMain. Mirror of the storage variant +// subcommand test. +func TestEncryptionMain_EnableRaftEnvelopeSubcommand(t *testing.T) { + t.Parallel() + err := encryptionMain([]string{"enable-raft-envelope"}) + if err == nil { + t.Fatal("enable-raft-envelope with no flags: want error, got nil") + } + if !strings.Contains(err.Error(), "proposer-node-id") { + t.Errorf("dispatch reached wrong handler: got %v", err) + } +} diff --git a/docs/design/2026_05_31_proposed_6e_enable_raft_envelope.md b/docs/design/2026_05_31_partial_6e_enable_raft_envelope.md similarity index 87% rename from docs/design/2026_05_31_proposed_6e_enable_raft_envelope.md rename to docs/design/2026_05_31_partial_6e_enable_raft_envelope.md index b5042f48c..d113bfd48 100644 --- a/docs/design/2026_05_31_proposed_6e_enable_raft_envelope.md +++ b/docs/design/2026_05_31_partial_6e_enable_raft_envelope.md @@ -2,12 +2,57 @@ | Field | Value | |---|---| -| Status | proposed | +| Status | partial | | Date | 2026-05-31 | | Parent design | [`2026_04_29_partial_data_at_rest_encryption.md`](2026_04_29_partial_data_at_rest_encryption.md) (§4.2 raft envelope, §6.3 engine apply-hook, §6.6 admin RPC, §7.1 Phase-2 cutover) | | Builds on | Stage 6A–6D (capability gate, storage envelope cutover, sidecar field plumbing), Stage 7 (writer registry), Stage 8a (snapshot header v2 cutover carriage) | | Sibling slice | Stage 8b — WAL coverage (not blocked on 6E) | +## Implementation status + +| Milestone | Status | Shipped in | +|---|---|---| +| 6E-1a — FSM apply machinery (`applyEnableRaftEnvelope`, sidecar field plumbing, wire sub-tag whitelist) | shipped | #899 (3bffd344) | +| 6E-1b — `EnableRaftEnvelope` admin RPC + `elastickv-admin enable-raft-envelope` CLI subcommand (server method **gated** until 6E-2; see §3.3 below) | shipped | #907 | +| 6E-2 — engine unwrap-on-apply + coordinator wrap-on-propose + §7.1 proposal-quiescence barrier (atomic 3-piece flip; also flips the 6E-1b gate to true) | not started | — | +| 6E-3 — §6C-4 fail-closed guards (`ErrEnvelopeCutoverDivergence`, `ErrEncryptionNotBootstrapped`, `ErrLocalEpochOutOfRange`) | not started | — | + +With 6E-1 (both sub-milestones) complete, the wire-format and +operator surface for the cutover is reviewable end-to-end, but the +6E-1b server method **refuses fresh cutover proposals with +FailedPrecondition** until 6E-2 ships (see §3.3). With only 6E-1 +deployed, the cluster cannot enter Phase 2 — no +`RaftEnvelopeCutoverIndex` value is ever written, so a 6E-2 +upgrade against a 6E-1-only cluster is safe. + +### 3.3 The 6E-1b gate (codex P1 round-1) + +A naive 6E-1b that accepted cutover proposals immediately would +record `RaftEnvelopeCutoverIndex=N` while subsequent Raft entries +remain cleartext (because 6E-2's wrap-on-propose path is not yet +deployed). On a later 6E-2 upgrade, the engine apply-hook — +designed to dispatch `entry.Index > sidecar.RaftEnvelopeCutoverIndex` +through unwrap — would treat every cleartext entry committed at +indexes greater than N as a wrapped envelope and halt apply +cluster-wide. + +The gate is the package-level constant +`raftEnvelopeWrapEnabled` in `adapter/encryption_admin.go`. It +starts at `false`. The server method's pre-check (leader gate, +sidecar bootstrap gate, idempotent-retry short-circuit) still +fires so operators get fast feedback on misconfigured wiring; +only the propose path is refused. Idempotent retries against a +sidecar that already carries `RaftEnvelopeCutoverIndex != 0` +(operationally impossible until 6E-2 lifts the gate, but the +short-circuit is preserved for future replay symmetry) flow +through unchanged. + +6E-2 flips the constant to `true` atomically with the +wrap/unwrap/barrier wiring. The flip and the rest of the 6E-2 +slice MUST land in one commit so an operator who pulls the new +binary cannot enter the window where the gate is open but the +wrap/unwrap path is incomplete. + ## 0. Why this slice exists Today every leader proposes Raft entries as plaintext: the @@ -21,11 +66,14 @@ Without 6E, the cluster cannot reach Phase 2 even though every prerequisite (6D Phase-1 storage envelope, 7 writer registry, 8a snapshot cutover carriage) has shipped. -This slice lands the actual Phase-2 raft cutover end-to-end: -admin RPC, sidecar cutover-index recording, engine unwrap-on- -apply, coordinator wrap-on-propose, and the §7.1 proposal- -quiescence barrier that prevents the unwrap path from seeing a -plaintext entry at `index > cutover`. +This design (sliced into milestones 6E-1a / 6E-1b / 6E-2 / 6E-3 — +see the Implementation status table above) lands the Phase-2 raft +cutover end-to-end: admin RPC and sidecar cutover-index recording +(6E-1, shipped), engine unwrap-on-apply + coordinator +wrap-on-propose + §7.1 proposal-quiescence barrier that prevents +the unwrap path from seeing a plaintext entry at +`index > cutover` (6E-2, **planned**), and the §6C-4 fail-closed +guards (6E-3, planned). ## 1. Out of scope diff --git a/proto/encryption_admin.pb.go b/proto/encryption_admin.pb.go index 050db57ef..f3a11b505 100644 --- a/proto/encryption_admin.pb.go +++ b/proto/encryption_admin.pb.go @@ -978,6 +978,158 @@ func (x *EnableStorageEnvelopeResponse) GetWasAlreadyActive() bool { return false } +// EnableRaftEnvelopeRequest proposes the Stage 6E Phase 2 cutover +// from cleartext Raft proposals to §4.2-envelope Raft proposals. +// Defined in the 6E design doc §3.1; the server composes a +// RotationPayload with SubTag = RotateSubEnableRaftEnvelope (0x05) +// and routes it through the default Raft group's leader as a +// §11.3 0x05 OpRotation entry. Structural mirror of +// EnableStorageEnvelopeRequest; the difference is the target +// Purpose (PurposeRaft) and the source DEK slot +// (sidecar.Active.Raft). +// +// proposer_node_id MUST be non-zero (the §6.1 "not-capable" +// sentinel is rejected at the server boundary, matching the +// existing RotateDEK / BootstrapEncryption / EnableStorageEnvelope +// posture). +// +// proposer_local_epoch carries the §4.1 16-bit nonce field as +// uint32 (proto3 has no uint16); values above 0xFFFF are +// rejected at the server boundary before any Raft proposal is +// composed. ApplyRotation re-validates at apply time +// (defense-in-depth). +type EnableRaftEnvelopeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProposerNodeId uint64 `protobuf:"varint,1,opt,name=proposer_node_id,json=proposerNodeId,proto3" json:"proposer_node_id,omitempty"` + ProposerLocalEpoch uint32 `protobuf:"varint,2,opt,name=proposer_local_epoch,json=proposerLocalEpoch,proto3" json:"proposer_local_epoch,omitempty"` // MUST be <= 0xFFFF on the wire. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnableRaftEnvelopeRequest) Reset() { + *x = EnableRaftEnvelopeRequest{} + mi := &file_encryption_admin_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnableRaftEnvelopeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnableRaftEnvelopeRequest) ProtoMessage() {} + +func (x *EnableRaftEnvelopeRequest) ProtoReflect() protoreflect.Message { + mi := &file_encryption_admin_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnableRaftEnvelopeRequest.ProtoReflect.Descriptor instead. +func (*EnableRaftEnvelopeRequest) Descriptor() ([]byte, []int) { + return file_encryption_admin_proto_rawDescGZIP(), []int{14} +} + +func (x *EnableRaftEnvelopeRequest) GetProposerNodeId() uint64 { + if x != nil { + return x.ProposerNodeId + } + return 0 +} + +func (x *EnableRaftEnvelopeRequest) GetProposerLocalEpoch() uint32 { + if x != nil { + return x.ProposerLocalEpoch + } + return 0 +} + +// EnableRaftEnvelopeResponse reports the outcome of a raft-envelope +// cutover proposal. Structural mirror of +// EnableStorageEnvelopeResponse with one rename to match the +// raft variant's sole "Phase-2 active" sentinel: +// +// - was_already_active reflects sidecar.RaftEnvelopeCutoverIndex +// != 0 on the precheck (the raft variant has no separate bool +// flag — non-zero cutover index IS the active sentinel). +// +// On a fresh cutover (was_already_active == false), applied_index +// is the Raft index of the entry the leader just proposed and +// waited to apply. On a retried call, applied_index is the +// recorded sidecar.RaftEnvelopeCutoverIndex from the ORIGINAL +// cutover — stable across arbitrary subsequent encryption-relevant +// Raft activity. +// +// capability_summary records which (full_node_id) members were +// probed during the pre-flight gate and what they reported. +// Empty on idempotent retries; the membership view of the +// original cutover is not retained. +type EnableRaftEnvelopeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AppliedIndex uint64 `protobuf:"varint,1,opt,name=applied_index,json=appliedIndex,proto3" json:"applied_index,omitempty"` + CapabilitySummary []*CapabilityVerdict `protobuf:"bytes,2,rep,name=capability_summary,json=capabilitySummary,proto3" json:"capability_summary,omitempty"` + WasAlreadyActive bool `protobuf:"varint,3,opt,name=was_already_active,json=wasAlreadyActive,proto3" json:"was_already_active,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnableRaftEnvelopeResponse) Reset() { + *x = EnableRaftEnvelopeResponse{} + mi := &file_encryption_admin_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnableRaftEnvelopeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnableRaftEnvelopeResponse) ProtoMessage() {} + +func (x *EnableRaftEnvelopeResponse) ProtoReflect() protoreflect.Message { + mi := &file_encryption_admin_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnableRaftEnvelopeResponse.ProtoReflect.Descriptor instead. +func (*EnableRaftEnvelopeResponse) Descriptor() ([]byte, []int) { + return file_encryption_admin_proto_rawDescGZIP(), []int{15} +} + +func (x *EnableRaftEnvelopeResponse) GetAppliedIndex() uint64 { + if x != nil { + return x.AppliedIndex + } + return 0 +} + +func (x *EnableRaftEnvelopeResponse) GetCapabilitySummary() []*CapabilityVerdict { + if x != nil { + return x.CapabilitySummary + } + return nil +} + +func (x *EnableRaftEnvelopeResponse) GetWasAlreadyActive() bool { + if x != nil { + return x.WasAlreadyActive + } + return false +} + // CapabilityVerdict is one row of the §4 fan-out summary the // cutover RPC returns. full_node_id is the route member the leader // probed; the remaining fields mirror the corresponding member's @@ -996,7 +1148,7 @@ type CapabilityVerdict struct { func (x *CapabilityVerdict) Reset() { *x = CapabilityVerdict{} - mi := &file_encryption_admin_proto_msgTypes[14] + mi := &file_encryption_admin_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1008,7 +1160,7 @@ func (x *CapabilityVerdict) String() string { func (*CapabilityVerdict) ProtoMessage() {} func (x *CapabilityVerdict) ProtoReflect() protoreflect.Message { - mi := &file_encryption_admin_proto_msgTypes[14] + mi := &file_encryption_admin_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1021,7 +1173,7 @@ func (x *CapabilityVerdict) ProtoReflect() protoreflect.Message { // Deprecated: Use CapabilityVerdict.ProtoReflect.Descriptor instead. func (*CapabilityVerdict) Descriptor() ([]byte, []int) { - return file_encryption_admin_proto_rawDescGZIP(), []int{14} + return file_encryption_admin_proto_rawDescGZIP(), []int{16} } func (x *CapabilityVerdict) GetFullNodeId() uint64 { @@ -1132,13 +1284,20 @@ const file_encryption_admin_proto_rawDesc = "" + "\rapplied_index\x18\x01 \x01(\x04R\fappliedIndex\x12A\n" + "\x12capability_summary\x18\x02 \x03(\v2\x12.CapabilityVerdictR\x11capabilitySummary\x122\n" + "\x15cutover_index_unknown\x18\x03 \x01(\bR\x13cutoverIndexUnknown\x12,\n" + - "\x12was_already_active\x18\x04 \x01(\bR\x10wasAlreadyActive\"\xaa\x01\n" + + "\x12was_already_active\x18\x04 \x01(\bR\x10wasAlreadyActive\"w\n" + + "\x19EnableRaftEnvelopeRequest\x12(\n" + + "\x10proposer_node_id\x18\x01 \x01(\x04R\x0eproposerNodeId\x120\n" + + "\x14proposer_local_epoch\x18\x02 \x01(\rR\x12proposerLocalEpoch\"\xb2\x01\n" + + "\x1aEnableRaftEnvelopeResponse\x12#\n" + + "\rapplied_index\x18\x01 \x01(\x04R\fappliedIndex\x12A\n" + + "\x12capability_summary\x18\x02 \x03(\v2\x12.CapabilityVerdictR\x11capabilitySummary\x12,\n" + + "\x12was_already_active\x18\x03 \x01(\bR\x10wasAlreadyActive\"\xaa\x01\n" + "\x11CapabilityVerdict\x12 \n" + "\ffull_node_id\x18\x01 \x01(\x04R\n" + "fullNodeId\x12-\n" + "\x12encryption_capable\x18\x02 \x01(\bR\x11encryptionCapable\x12\x1b\n" + "\tbuild_sha\x18\x03 \x01(\tR\bbuildSha\x12'\n" + - "\x0fsidecar_present\x18\x04 \x01(\bR\x0esidecarPresent2\xfa\x03\n" + + "\x0fsidecar_present\x18\x04 \x01(\bR\x0esidecarPresent2\xcb\x04\n" + "\x0fEncryptionAdmin\x12,\n" + "\rGetCapability\x12\x06.Empty\x1a\x11.CapabilityReport\"\x00\x120\n" + "\x0fGetSidecarState\x12\x06.Empty\x1a\x13.SidecarStateReport\"\x00\x12R\n" + @@ -1146,7 +1305,8 @@ const file_encryption_admin_proto_rawDesc = "" + "\tRotateDEK\x12\x11.RotateDEKRequest\x1a\x12.RotateDEKResponse\"\x00\x12a\n" + "\x18RegisterEncryptionWriter\x12 .RegisterEncryptionWriterRequest\x1a!.RegisterEncryptionWriterResponse\"\x00\x12@\n" + "\rResyncSidecar\x12\x15.ResyncSidecarRequest\x1a\x16.ResyncSidecarResponse\"\x00\x12X\n" + - "\x15EnableStorageEnvelope\x12\x1d.EnableStorageEnvelopeRequest\x1a\x1e.EnableStorageEnvelopeResponse\"\x00B#Z!github.com/bootjp/elastickv/protob\x06proto3" + "\x15EnableStorageEnvelope\x12\x1d.EnableStorageEnvelopeRequest\x1a\x1e.EnableStorageEnvelopeResponse\"\x00\x12O\n" + + "\x12EnableRaftEnvelope\x12\x1a.EnableRaftEnvelopeRequest\x1a\x1b.EnableRaftEnvelopeResponse\"\x00B#Z!github.com/bootjp/elastickv/protob\x06proto3" var ( file_encryption_admin_proto_rawDescOnce sync.Once @@ -1161,7 +1321,7 @@ func file_encryption_admin_proto_rawDescGZIP() []byte { } var file_encryption_admin_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_encryption_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_encryption_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_encryption_admin_proto_goTypes = []any{ (RotateDEKRequest_Purpose)(0), // 0: RotateDEKRequest.Purpose (*Empty)(nil), // 1: Empty @@ -1178,40 +1338,45 @@ var file_encryption_admin_proto_goTypes = []any{ (*ResyncSidecarResponse)(nil), // 12: ResyncSidecarResponse (*EnableStorageEnvelopeRequest)(nil), // 13: EnableStorageEnvelopeRequest (*EnableStorageEnvelopeResponse)(nil), // 14: EnableStorageEnvelopeResponse - (*CapabilityVerdict)(nil), // 15: CapabilityVerdict - nil, // 16: SidecarStateReport.WrappedDeksByIdEntry - nil, // 17: SidecarStateReport.WriterRegistryForCallerEntry - nil, // 18: ResyncSidecarResponse.WrappedDeksByIdEntry - nil, // 19: ResyncSidecarResponse.WriterRegistryForCallerEntry + (*EnableRaftEnvelopeRequest)(nil), // 15: EnableRaftEnvelopeRequest + (*EnableRaftEnvelopeResponse)(nil), // 16: EnableRaftEnvelopeResponse + (*CapabilityVerdict)(nil), // 17: CapabilityVerdict + nil, // 18: SidecarStateReport.WrappedDeksByIdEntry + nil, // 19: SidecarStateReport.WriterRegistryForCallerEntry + nil, // 20: ResyncSidecarResponse.WrappedDeksByIdEntry + nil, // 21: ResyncSidecarResponse.WriterRegistryForCallerEntry } var file_encryption_admin_proto_depIdxs = []int32{ - 16, // 0: SidecarStateReport.wrapped_deks_by_id:type_name -> SidecarStateReport.WrappedDeksByIdEntry - 17, // 1: SidecarStateReport.writer_registry_for_caller:type_name -> SidecarStateReport.WriterRegistryForCallerEntry + 18, // 0: SidecarStateReport.wrapped_deks_by_id:type_name -> SidecarStateReport.WrappedDeksByIdEntry + 19, // 1: SidecarStateReport.writer_registry_for_caller:type_name -> SidecarStateReport.WriterRegistryForCallerEntry 4, // 2: BootstrapEncryptionRequest.writer_batch:type_name -> WriterRegistryEntry 0, // 3: RotateDEKRequest.purpose:type_name -> RotateDEKRequest.Purpose 4, // 4: RegisterEncryptionWriterRequest.writers:type_name -> WriterRegistryEntry - 18, // 5: ResyncSidecarResponse.wrapped_deks_by_id:type_name -> ResyncSidecarResponse.WrappedDeksByIdEntry - 19, // 6: ResyncSidecarResponse.writer_registry_for_caller:type_name -> ResyncSidecarResponse.WriterRegistryForCallerEntry - 15, // 7: EnableStorageEnvelopeResponse.capability_summary:type_name -> CapabilityVerdict - 1, // 8: EncryptionAdmin.GetCapability:input_type -> Empty - 1, // 9: EncryptionAdmin.GetSidecarState:input_type -> Empty - 5, // 10: EncryptionAdmin.BootstrapEncryption:input_type -> BootstrapEncryptionRequest - 7, // 11: EncryptionAdmin.RotateDEK:input_type -> RotateDEKRequest - 9, // 12: EncryptionAdmin.RegisterEncryptionWriter:input_type -> RegisterEncryptionWriterRequest - 11, // 13: EncryptionAdmin.ResyncSidecar:input_type -> ResyncSidecarRequest - 13, // 14: EncryptionAdmin.EnableStorageEnvelope:input_type -> EnableStorageEnvelopeRequest - 2, // 15: EncryptionAdmin.GetCapability:output_type -> CapabilityReport - 3, // 16: EncryptionAdmin.GetSidecarState:output_type -> SidecarStateReport - 6, // 17: EncryptionAdmin.BootstrapEncryption:output_type -> BootstrapEncryptionResponse - 8, // 18: EncryptionAdmin.RotateDEK:output_type -> RotateDEKResponse - 10, // 19: EncryptionAdmin.RegisterEncryptionWriter:output_type -> RegisterEncryptionWriterResponse - 12, // 20: EncryptionAdmin.ResyncSidecar:output_type -> ResyncSidecarResponse - 14, // 21: EncryptionAdmin.EnableStorageEnvelope:output_type -> EnableStorageEnvelopeResponse - 15, // [15:22] is the sub-list for method output_type - 8, // [8:15] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 20, // 5: ResyncSidecarResponse.wrapped_deks_by_id:type_name -> ResyncSidecarResponse.WrappedDeksByIdEntry + 21, // 6: ResyncSidecarResponse.writer_registry_for_caller:type_name -> ResyncSidecarResponse.WriterRegistryForCallerEntry + 17, // 7: EnableStorageEnvelopeResponse.capability_summary:type_name -> CapabilityVerdict + 17, // 8: EnableRaftEnvelopeResponse.capability_summary:type_name -> CapabilityVerdict + 1, // 9: EncryptionAdmin.GetCapability:input_type -> Empty + 1, // 10: EncryptionAdmin.GetSidecarState:input_type -> Empty + 5, // 11: EncryptionAdmin.BootstrapEncryption:input_type -> BootstrapEncryptionRequest + 7, // 12: EncryptionAdmin.RotateDEK:input_type -> RotateDEKRequest + 9, // 13: EncryptionAdmin.RegisterEncryptionWriter:input_type -> RegisterEncryptionWriterRequest + 11, // 14: EncryptionAdmin.ResyncSidecar:input_type -> ResyncSidecarRequest + 13, // 15: EncryptionAdmin.EnableStorageEnvelope:input_type -> EnableStorageEnvelopeRequest + 15, // 16: EncryptionAdmin.EnableRaftEnvelope:input_type -> EnableRaftEnvelopeRequest + 2, // 17: EncryptionAdmin.GetCapability:output_type -> CapabilityReport + 3, // 18: EncryptionAdmin.GetSidecarState:output_type -> SidecarStateReport + 6, // 19: EncryptionAdmin.BootstrapEncryption:output_type -> BootstrapEncryptionResponse + 8, // 20: EncryptionAdmin.RotateDEK:output_type -> RotateDEKResponse + 10, // 21: EncryptionAdmin.RegisterEncryptionWriter:output_type -> RegisterEncryptionWriterResponse + 12, // 22: EncryptionAdmin.ResyncSidecar:output_type -> ResyncSidecarResponse + 14, // 23: EncryptionAdmin.EnableStorageEnvelope:output_type -> EnableStorageEnvelopeResponse + 16, // 24: EncryptionAdmin.EnableRaftEnvelope:output_type -> EnableRaftEnvelopeResponse + 17, // [17:25] is the sub-list for method output_type + 9, // [9:17] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_encryption_admin_proto_init() } @@ -1225,7 +1390,7 @@ func file_encryption_admin_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_encryption_admin_proto_rawDesc), len(file_encryption_admin_proto_rawDesc)), NumEnums: 1, - NumMessages: 19, + NumMessages: 21, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/encryption_admin.proto b/proto/encryption_admin.proto index caf1e02ae..6926a1c01 100644 --- a/proto/encryption_admin.proto +++ b/proto/encryption_admin.proto @@ -50,6 +50,7 @@ service EncryptionAdmin { rpc RegisterEncryptionWriter (RegisterEncryptionWriterRequest) returns (RegisterEncryptionWriterResponse) {} rpc ResyncSidecar (ResyncSidecarRequest) returns (ResyncSidecarResponse) {} rpc EnableStorageEnvelope (EnableStorageEnvelopeRequest) returns (EnableStorageEnvelopeResponse) {} + rpc EnableRaftEnvelope (EnableRaftEnvelopeRequest) returns (EnableRaftEnvelopeResponse) {} } message Empty {} @@ -216,6 +217,57 @@ message EnableStorageEnvelopeResponse { bool was_already_active = 4; } +// EnableRaftEnvelopeRequest proposes the Stage 6E Phase 2 cutover +// from cleartext Raft proposals to §4.2-envelope Raft proposals. +// Defined in the 6E design doc §3.1; the server composes a +// RotationPayload with SubTag = RotateSubEnableRaftEnvelope (0x05) +// and routes it through the default Raft group's leader as a +// §11.3 0x05 OpRotation entry. Structural mirror of +// EnableStorageEnvelopeRequest; the difference is the target +// Purpose (PurposeRaft) and the source DEK slot +// (sidecar.Active.Raft). +// +// proposer_node_id MUST be non-zero (the §6.1 "not-capable" +// sentinel is rejected at the server boundary, matching the +// existing RotateDEK / BootstrapEncryption / EnableStorageEnvelope +// posture). +// +// proposer_local_epoch carries the §4.1 16-bit nonce field as +// uint32 (proto3 has no uint16); values above 0xFFFF are +// rejected at the server boundary before any Raft proposal is +// composed. ApplyRotation re-validates at apply time +// (defense-in-depth). +message EnableRaftEnvelopeRequest { + uint64 proposer_node_id = 1; + uint32 proposer_local_epoch = 2; // MUST be <= 0xFFFF on the wire. +} + +// EnableRaftEnvelopeResponse reports the outcome of a raft-envelope +// cutover proposal. Structural mirror of +// EnableStorageEnvelopeResponse with one rename to match the +// raft variant's sole "Phase-2 active" sentinel: +// +// - was_already_active reflects sidecar.RaftEnvelopeCutoverIndex +// != 0 on the precheck (the raft variant has no separate bool +// flag — non-zero cutover index IS the active sentinel). +// +// On a fresh cutover (was_already_active == false), applied_index +// is the Raft index of the entry the leader just proposed and +// waited to apply. On a retried call, applied_index is the +// recorded sidecar.RaftEnvelopeCutoverIndex from the ORIGINAL +// cutover — stable across arbitrary subsequent encryption-relevant +// Raft activity. +// +// capability_summary records which (full_node_id) members were +// probed during the pre-flight gate and what they reported. +// Empty on idempotent retries; the membership view of the +// original cutover is not retained. +message EnableRaftEnvelopeResponse { + uint64 applied_index = 1; + repeated CapabilityVerdict capability_summary = 2; + bool was_already_active = 3; +} + // CapabilityVerdict is one row of the §4 fan-out summary the // cutover RPC returns. full_node_id is the route member the leader // probed; the remaining fields mirror the corresponding member's diff --git a/proto/encryption_admin_grpc.pb.go b/proto/encryption_admin_grpc.pb.go index 1183a5e79..d9c3e0c7a 100644 --- a/proto/encryption_admin_grpc.pb.go +++ b/proto/encryption_admin_grpc.pb.go @@ -26,6 +26,7 @@ const ( EncryptionAdmin_RegisterEncryptionWriter_FullMethodName = "/EncryptionAdmin/RegisterEncryptionWriter" EncryptionAdmin_ResyncSidecar_FullMethodName = "/EncryptionAdmin/ResyncSidecar" EncryptionAdmin_EnableStorageEnvelope_FullMethodName = "/EncryptionAdmin/EnableStorageEnvelope" + EncryptionAdmin_EnableRaftEnvelope_FullMethodName = "/EncryptionAdmin/EnableRaftEnvelope" ) // EncryptionAdminClient is the client API for EncryptionAdmin service. @@ -80,6 +81,7 @@ type EncryptionAdminClient interface { RegisterEncryptionWriter(ctx context.Context, in *RegisterEncryptionWriterRequest, opts ...grpc.CallOption) (*RegisterEncryptionWriterResponse, error) ResyncSidecar(ctx context.Context, in *ResyncSidecarRequest, opts ...grpc.CallOption) (*ResyncSidecarResponse, error) EnableStorageEnvelope(ctx context.Context, in *EnableStorageEnvelopeRequest, opts ...grpc.CallOption) (*EnableStorageEnvelopeResponse, error) + EnableRaftEnvelope(ctx context.Context, in *EnableRaftEnvelopeRequest, opts ...grpc.CallOption) (*EnableRaftEnvelopeResponse, error) } type encryptionAdminClient struct { @@ -160,6 +162,16 @@ func (c *encryptionAdminClient) EnableStorageEnvelope(ctx context.Context, in *E return out, nil } +func (c *encryptionAdminClient) EnableRaftEnvelope(ctx context.Context, in *EnableRaftEnvelopeRequest, opts ...grpc.CallOption) (*EnableRaftEnvelopeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EnableRaftEnvelopeResponse) + err := c.cc.Invoke(ctx, EncryptionAdmin_EnableRaftEnvelope_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // EncryptionAdminServer is the server API for EncryptionAdmin service. // All implementations must embed UnimplementedEncryptionAdminServer // for forward compatibility. @@ -212,6 +224,7 @@ type EncryptionAdminServer interface { RegisterEncryptionWriter(context.Context, *RegisterEncryptionWriterRequest) (*RegisterEncryptionWriterResponse, error) ResyncSidecar(context.Context, *ResyncSidecarRequest) (*ResyncSidecarResponse, error) EnableStorageEnvelope(context.Context, *EnableStorageEnvelopeRequest) (*EnableStorageEnvelopeResponse, error) + EnableRaftEnvelope(context.Context, *EnableRaftEnvelopeRequest) (*EnableRaftEnvelopeResponse, error) mustEmbedUnimplementedEncryptionAdminServer() } @@ -243,6 +256,9 @@ func (UnimplementedEncryptionAdminServer) ResyncSidecar(context.Context, *Resync func (UnimplementedEncryptionAdminServer) EnableStorageEnvelope(context.Context, *EnableStorageEnvelopeRequest) (*EnableStorageEnvelopeResponse, error) { return nil, status.Error(codes.Unimplemented, "method EnableStorageEnvelope not implemented") } +func (UnimplementedEncryptionAdminServer) EnableRaftEnvelope(context.Context, *EnableRaftEnvelopeRequest) (*EnableRaftEnvelopeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method EnableRaftEnvelope not implemented") +} func (UnimplementedEncryptionAdminServer) mustEmbedUnimplementedEncryptionAdminServer() {} func (UnimplementedEncryptionAdminServer) testEmbeddedByValue() {} @@ -390,6 +406,24 @@ func _EncryptionAdmin_EnableStorageEnvelope_Handler(srv interface{}, ctx context return interceptor(ctx, in, info, handler) } +func _EncryptionAdmin_EnableRaftEnvelope_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EnableRaftEnvelopeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EncryptionAdminServer).EnableRaftEnvelope(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EncryptionAdmin_EnableRaftEnvelope_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EncryptionAdminServer).EnableRaftEnvelope(ctx, req.(*EnableRaftEnvelopeRequest)) + } + return interceptor(ctx, in, info, handler) +} + // EncryptionAdmin_ServiceDesc is the grpc.ServiceDesc for EncryptionAdmin service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -425,6 +459,10 @@ var EncryptionAdmin_ServiceDesc = grpc.ServiceDesc{ MethodName: "EnableStorageEnvelope", Handler: _EncryptionAdmin_EnableStorageEnvelope_Handler, }, + { + MethodName: "EnableRaftEnvelope", + Handler: _EncryptionAdmin_EnableRaftEnvelope_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "encryption_admin.proto",