Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a670591
encryption: project sidecar writer registry
bootjp Jul 10, 2026
9630091
encryption: classify missing registry node id
bootjp Jul 10, 2026
4e1931b
encryption: use default registry for sidecar projection
bootjp Jul 10, 2026
304df03
encryption: add compress-then-encrypt storage path
bootjp Jul 18, 2026
2daeafa
encryption: bound compressed read allocation
bootjp Jul 18, 2026
a6bfa76
store: remove benchmark lint suppression
bootjp Jul 18, 2026
8bacf37
encryption: harden recovery and compression compatibility
bootjp Jul 19, 2026
738b64f
store: preserve encryption policy in SST snapshots
bootjp Jul 19, 2026
e34b4de
encryption: gate compressed writes by cluster capability
bootjp Jul 19, 2026
a39527d
encryption: harden V2 membership capability gate
bootjp Jul 19, 2026
4fa5a51
encryption: add production KEK providers
bootjp Jul 18, 2026
df1c19f
Reuse GCP KMS checksum table
bootjp Jul 18, 2026
017ba51
encryption: harden KEK provider startup
bootjp Jul 19, 2026
46b533e
adapter: bound Lua cache test cost
bootjp Jul 19, 2026
12d34f7
encryption: harden Vault Transit key binding
bootjp Jul 19, 2026
2308534
proto: reserve removed raft status fields
bootjp Jul 23, 2026
8b0889c
Add production KEK providers (#1113)
bootjp Jul 23, 2026
45ef4ba
encryption: skip the V2 membership probe when encryption is off
bootjp Aug 22, 2026
748b078
store: defer decompression past the read visibility checks
bootjp Aug 22, 2026
8e96d83
encryption: treat an unset responder id as a capability mismatch
bootjp Aug 22, 2026
21720c4
encryption: wipe partial KEK bytes and name the key on V2 activation
bootjp Aug 22, 2026
c65ed5f
encryption: take a read-applied barrier before serving resync
bootjp Aug 24, 2026
3b57fbe
encryption: refuse Transit key upsert when wrapping a DEK
bootjp Aug 28, 2026
9a1fc80
encryption: release the capability monitor's connections
bootjp Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 134 additions & 28 deletions adapter/encryption_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
type EncryptionAdminServer struct {
sidecarPath string
fullNodeID uint64
writerRegistry encryption.WriterRegistryStore
buildSHA string
latestAppliedIndex func() uint64
// proposer is the raw raft proposer used for cleartext-only
Expand All @@ -45,6 +46,10 @@ type EncryptionAdminServer struct {
// the raft envelope.
postCutoverProposer raftengine.Proposer
leaderView raftengine.LeaderView
// recoveryLeaderView is the authority for sidecar/registry recovery.
// In multi-group deployments it points at the default group even when
// this server is registered on another group's listener.
recoveryLeaderView raftengine.LeaderView
// capabilityFanout, when wired, runs the §4 Voters ∪ Learners
// fan-out before the §7.1 Phase 1 cutover entry is proposed.
// A nil value short-circuits EnableStorageEnvelope with
Expand Down Expand Up @@ -182,6 +187,19 @@ func WithEncryptionAdminFullNodeID(id uint64) EncryptionAdminServerOption {
}
}

// WithEncryptionAdminWriterRegistry wires the §4.1 writer-registry
// store that read-only sidecar recovery RPCs use to project
// writer_registry_for_caller. A nil argument is a no-op so tests and
// encryption-disabled nodes keep the pre-Stage-7 empty-map posture.
func WithEncryptionAdminWriterRegistry(reg encryption.WriterRegistryStore) EncryptionAdminServerOption {
return func(s *EncryptionAdminServer) {
if reg == nil {
return
}
s.writerRegistry = reg
}
}

// WithEncryptionAdminBuildSHA overrides the auto-detected
// runtime/debug build SHA. Tests use this to pin a deterministic
// value; production wiring leaves it empty.
Expand Down Expand Up @@ -258,6 +276,17 @@ func WithEncryptionAdminLeaderView(v raftengine.LeaderView) EncryptionAdminServe
}
}

// WithEncryptionAdminRecoveryLeaderView registers the default-group
// leadership oracle used by ResyncSidecar. A nil value preserves the
// single-group fallback to leaderView.
func WithEncryptionAdminRecoveryLeaderView(v raftengine.LeaderView) EncryptionAdminServerOption {
return func(s *EncryptionAdminServer) {
if v != nil {
s.recoveryLeaderView = v
}
}
}

// WithEncryptionAdminCutoverBarrier wires the §7.1 quiescence
// barrier controller used by EnableRaftEnvelope. A nil argument is
// a no-op (the server stays in the cutover-disabled posture);
Expand Down Expand Up @@ -342,12 +371,16 @@ func (s *EncryptionAdminServer) Validate() error {
// case so the empty epoch never reaches the writer registry.
func (s *EncryptionAdminServer) GetCapability(_ context.Context, _ *pb.Empty) (*pb.CapabilityReport, error) {
if s.sidecarPath == "" {
return &pb.CapabilityReport{BuildSha: s.buildSHA}, nil
return &pb.CapabilityReport{
BuildSha: s.buildSHA,
StorageEnvelopeV2Capable: true,
}, nil
}
report := &pb.CapabilityReport{
EncryptionCapable: true,
BuildSha: s.buildSHA,
FullNodeId: s.fullNodeID,
EncryptionCapable: true,
StorageEnvelopeV2Capable: true,
BuildSha: s.buildSHA,
FullNodeId: s.fullNodeID,
// LocalEpoch stays at 0 until Stage 7 wires the §4.1
// writer-registry counter. The §5.6 step 1a pre-check
// happens before any DEK exists, so 0 is the correct
Expand All @@ -374,10 +407,10 @@ func (s *EncryptionAdminServer) GetCapability(_ context.Context, _ *pb.Empty) (*
// pointers; the wrapped material is leakage-safe because it is
// KEK-wrapped, which is the same property the on-disk sidecar has.
//
// The writer_registry_for_caller map is empty until Stage 7 wires
// the registry. Callers in the §7.1 cutover path tolerate an empty
// map because the §5.6 step 1a batch is sourced from the
// GetCapability fan-out, not from this RPC.
// When the writer registry is wired, writer_registry_for_caller
// carries this node's recorded last_seen_local_epoch per sidecar DEK.
// Unwired tests and encryption-disabled nodes keep the historical
// empty non-nil map.
func (s *EncryptionAdminServer) GetSidecarState(_ context.Context, _ *pb.Empty) (*pb.SidecarStateReport, error) {
if s.sidecarPath == "" {
return nil, grpcStatusError(codes.FailedPrecondition, "encryption: sidecar path is not configured on this node")
Expand All @@ -386,14 +419,18 @@ func (s *EncryptionAdminServer) GetSidecarState(_ context.Context, _ *pb.Empty)
if err != nil {
return nil, statusFromSidecarErr(err)
}
writerRegistry, err := s.writerRegistryForCaller(sc, s.fullNodeID, codes.Internal)
if err != nil {
return nil, err
}
resp := &pb.SidecarStateReport{
ActiveStorageId: sc.Active.Storage,
ActiveRaftId: sc.Active.Raft,
StorageEnvelopeActive: sc.StorageEnvelopeActive,
RaftEnvelopeCutoverIndex: sc.RaftEnvelopeCutoverIndex,
LatestAppliedIndex: s.appliedIndex(sc.RaftAppliedIndex),
WrappedDeksById: wrappedDEKMap(sc),
WriterRegistryForCaller: map[uint32]uint32{},
WriterRegistryForCaller: writerRegistry,
}
return resp, nil
}
Expand All @@ -410,15 +447,7 @@ func (s *EncryptionAdminServer) GetSidecarState(_ context.Context, _ *pb.Empty)
// state to a recovering follower and silently overwrite recent
// rotations.
func (s *EncryptionAdminServer) ResyncSidecar(ctx context.Context, req *pb.ResyncSidecarRequest) (*pb.ResyncSidecarResponse, error) {
// req.CallerFullNodeId is intentionally unused for the
// recovery payload itself in PR-B; Stage 7 will use it to
// scope the writer-registry projection to that specific
// caller per §5.5. Recording it here keeps the field on the
// hot path so a future leader-side audit log can correlate
// resyncs to the requesting member without a wire-format
// change.
_ = req
if err := s.requireLeader(ctx); err != nil {
if err := s.requireRecoveryLeader(ctx); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for default-group apply before serving resync

When the default-group leader has committed a newer writer registration but its FSM is still applying it, this check succeeds and the following registry read can return the older last_seen_local_epoch, causing a recovering caller with a rolled-back sidecar to select an epoch that is too low. Fresh evidence beyond the earlier leadership-wiring issue is that the production Engine.VerifyLeader implementation calls submitRead(ctx, false), and handleReadStates completes such requests immediately without waiting for applied >= ReadIndex; using the default-group view therefore confirms leadership but not freshness. Use a linearizable/read-applied barrier before projecting the sidecar and registry state.

Useful? React with 👍 / 👎.

return nil, err
}
if s.sidecarPath == "" {
Expand All @@ -428,20 +457,62 @@ func (s *EncryptionAdminServer) ResyncSidecar(ctx context.Context, req *pb.Resyn
if err != nil {
return nil, statusFromSidecarErr(err)
}
var callerFullNodeID uint64
if req != nil {
callerFullNodeID = req.GetCallerFullNodeId()
}
writerRegistry, err := s.writerRegistryForCaller(sc, callerFullNodeID, codes.InvalidArgument)
if err != nil {
return nil, err
}
return &pb.ResyncSidecarResponse{
WrappedDeksById: wrappedDEKMap(sc),
ActiveStorageId: sc.Active.Storage,
ActiveRaftId: sc.Active.Raft,
LeaderLatestAppliedIndex: s.appliedIndex(sc.RaftAppliedIndex),
// §5.5 follower-repair: leader's recorded
// last_seen_local_epoch per (dek_id, caller). Stage 7
// fills this from the writer registry. PR-A returns an
// empty non-nil map because a node recovering before
// the registry exists has nothing to re-derive.
WriterRegistryForCaller: map[uint32]uint32{},
WriterRegistryForCaller: writerRegistry,
}, nil
}

func (s *EncryptionAdminServer) writerRegistryForCaller(sc *encryption.Sidecar, fullNodeID uint64, missingIDCode codes.Code) (map[uint32]uint32, error) {
out := map[uint32]uint32{}
if s.writerRegistry == nil {
return out, nil
}
if fullNodeID == 0 {
return nil, grpcStatusError(missingIDCode,
"encryption: full_node_id is required to project writer_registry_for_caller")
}
Comment thread
bootjp marked this conversation as resolved.
nodeID16 := encryption.NodeID16(fullNodeID)
for idStr := range sc.Keys {
dekID, err := parseSidecarKeyID(idStr)
if err != nil {
return nil, grpcStatusErrorf(codes.Internal,
"encryption: sidecar key id %q could not be projected into writer registry: %v", idStr, err)
}
raw, ok, err := s.writerRegistry.GetRegistryRow(encryption.RegistryKey(dekID, nodeID16))
if err != nil {
return nil, grpcStatusErrorf(codes.Internal,
"encryption: read writer registry row for dek_id=%d full_node_id=%#x: %v", dekID, fullNodeID, err)
}
if !ok {
continue
}
row, err := encryption.DecodeRegistryValue(raw)
if err != nil {
return nil, grpcStatusErrorf(codes.Internal,
"encryption: decode writer registry row for dek_id=%d full_node_id=%#x: %v", dekID, fullNodeID, err)
}
if row.FullNodeID != fullNodeID {
return nil, grpcStatusErrorf(codes.Internal,
"encryption: writer registry node_id collision for dek_id=%d caller_full_node_id=%#x registry_full_node_id=%#x",
dekID, fullNodeID, row.FullNodeID)
}
out[dekID] = uint32(row.LastSeenLocalEpoch)
}
return out, nil
}

func (s *EncryptionAdminServer) appliedIndex(sidecarValue uint64) uint64 {
if s.latestAppliedIndex == nil {
return sidecarValue
Expand Down Expand Up @@ -1860,19 +1931,54 @@ func proposeErrorToStatus(err error, opcode byte) error {
// a follower's recovery flow could pull an outdated DEK
// set from a stranded leader and miss recent rotations.
func (s *EncryptionAdminServer) requireLeader(ctx context.Context) error {
if s.leaderView == nil {
return requireEncryptionLeader(ctx, s.leaderView)
}

func (s *EncryptionAdminServer) requireRecoveryLeader(ctx context.Context) error {
view := s.recoveryLeaderView
if view == nil {
view = s.leaderView
}
if err := requireEncryptionLeader(ctx, view); err != nil {
return err
}
if view == nil {
return nil
}
// Leadership is not enough here. VerifyLeader confirms quorum through a
// ReadIndex round-trip but submits it with waitApplied=false, and
// handleReadStates completes such a request as soon as the index is known
// -- it never waits for this node's FSM to reach it. A leader that has
// committed a newer writer registration but has not applied it yet
// therefore passes, and the registry read below returns the older
// last_seen_local_epoch. A recovering caller with a rolled-back sidecar
// would then choose an epoch that is too low.
//
// LinearizableRead blocks until the returned index is safe to read from the
// local FSM, which is the barrier this projection needs. It is applied only
// to the recovery path; the other mutator RPCs keep the VerifyLeader-only
// posture through requireEncryptionLeader.
if _, err := view.LinearizableRead(ctx); err != nil {
return verifyLeaderErrorToStatus(err)
}

return nil
}

func requireEncryptionLeader(ctx context.Context, view raftengine.LeaderView) error {
if view == nil {
return nil
}
if s.leaderView.State() != raftengine.StateLeader {
leader := s.leaderView.Leader()
if view.State() != raftengine.StateLeader {
leader := view.Leader()
if leader.ID == "" && leader.Address == "" {
return grpcStatusError(codes.FailedPrecondition, "encryption: not leader (no known leader)")
}
return grpcStatusErrorf(codes.FailedPrecondition,
"encryption: not leader (current leader id=%q address=%q)",
leader.ID, leader.Address)
}
if err := s.leaderView.VerifyLeader(ctx); err != nil {
if err := view.VerifyLeader(ctx); err != nil {
return verifyLeaderErrorToStatus(err)
}
return nil
Expand Down
Loading