Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 15 additions & 4 deletions docs/design/2026_05_18_partial_6d_enable_storage_envelope.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| Field | Value |
|---|---|
| Status | partial — 6D-1 (doc), 6D-2 (startup guards), 6D-3 (capability fan-out helper), 6D-4 (cutover wire + apply dispatch) shipped; 6D-5, 6D-6 remain |
| Status | partial — 6D-1 (doc), 6D-2 (startup guards), 6D-3 (capability fan-out helper), 6D-4 (cutover wire + apply dispatch), 6D-5 (storage-layer toggle) shipped; 6D-6 remains |
| Date | 2026-05-18 |
| Parent design | [`2026_04_29_partial_data_at_rest_encryption.md`](2026_04_29_partial_data_at_rest_encryption.md) |
| Blockers (now satisfied) | 6B (KEK plumbing), 6C-1 / 6C-2 (startup guards), 6C-2d (`ErrSidecarBehindRaftLog` wiring) |
Expand Down Expand Up @@ -31,11 +31,22 @@
already-active outcomes are benign no-ops that advance
`RaftAppliedIndex` without halting. Operator-inert until 6D-5
wires the §6.2 storage-layer toggle.
- **6D-5** (storage-layer toggle) —
`WithStorageEnvelopeGate(StorageEnvelopeActive)` PebbleStore
option (`store/encryption_glue.go`) consulted on every Put.
When wired AND returning false, `encryptForKey` forces cleartext
even with cipher + active DEK present — the §7.1 Phase 0 →
Phase 1 split. Read path is unchanged: on-disk
`encryption_state == 0b01` versions always go through the
cipher regardless of the gate, so mixed cleartext / encrypted
versions for the same key stay readable across the cutover.
Nil gate preserves the pre-6D-5 legacy posture (encrypt
whenever a DEK is active) so existing test fixtures and the
pre-6D-6 production wiring keep working unchanged. Operator-
inert until 6D-6 wires both the cipher and the gate in main.go
and exposes the cutover RPC.

## Open milestones

- **6D-5** — §6.2 storage-layer toggle (`PutAt` consults
`StorageEnvelopeActive`).
- **6D-6** — `EnableStorageEnvelope` admin RPC + CLI command +
integration test composing 6D-3 + 6D-4 + 6D-5.

Expand Down
64 changes: 64 additions & 0 deletions store/encryption_glue.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,28 @@ type NonceFactory interface {
// independently.
type ActiveStorageKeyID func() (uint32, bool)

// StorageEnvelopeActive reports whether the §7.1 Phase 1 cutover has
// fired on this node (the Stage 6D-4 sidecar field of the same name,
// surfaced to the storage layer via WithStorageEnvelopeGate). A
// return value of false forces every Put to write cleartext even
// when a cipher + active DEK are wired; true allows the existing
// activeStorageKeyID-driven envelope emit to proceed.
//
// The contract intentionally separates "encryption is provisioned"
// (ActiveStorageKeyID has a DEK to use) from "encryption is
// activated for new writes" (the cutover has fired). The two
// signals diverge during the §7.1 Phase 0 → Phase 1 window: every
// node in the cluster has the DEK installed, but the cluster has
// not yet quiesced the cutover Raft entry that flips the bit on
// every replica simultaneously.
//
// Reads ignore this gate — once an on-disk version's
// encryption_state bit is 0b01, the read path always invokes the
// cipher to unwrap regardless of the gate's current value. A
// cluster that flipped from active back to inactive (not a path
// the design supports) would still decrypt old envelopes correctly.
type StorageEnvelopeActive func() bool

// WithEncryption configures the pebble-backed store to wrap every
// committed value in the §4.1 storage envelope.
//
Expand All @@ -154,6 +176,34 @@ func WithEncryption(cipher *encryption.Cipher, nf NonceFactory, activeKeyID Acti
}
}

// WithStorageEnvelopeGate wires the Stage 6D-5 §6.2 cutover gate
// in front of the existing envelope-emit path. After this option,
// encryptForKey writes cleartext when active() returns false even
// if a cipher and active DEK are present — exactly the §7.1
// Phase 0 / Phase 1 split described in the parent encryption
// design doc.
//
// Passing nil is a no-op: the store stays in the pre-6D-5
// "encrypt whenever activeKeyID returns a DEK" posture used by
// existing test fixtures and by production deployments that have
// not yet run the EnableStorageEnvelope RPC (lands in 6D-6).
// Operators must thread this option in alongside WithEncryption
// to actually opt into the cutover semantics.
//
// The gate is consulted on every Put. Reads never consult it —
// on-disk versions with `encryption_state == 0b01` always go
// through the cipher regardless of the gate, so a cluster mid-
// cutover (some versions cleartext, others encrypted) stays
// readable.
func WithStorageEnvelopeGate(active StorageEnvelopeActive) PebbleStoreOption {
return func(s *pebbleStore) {
if active == nil {
return
}
s.storageEnvelopeActive = active
}
}

// encryptForKey wraps plaintext in the §4.1 storage envelope when an
// encryption key is active for the storage purpose. Returns
// (plaintext, encStateCleartext, nil) when encryption is disabled or
Expand Down Expand Up @@ -184,6 +234,20 @@ func (s *pebbleStore) encryptForKey(pebbleKey, plaintext []byte, expireAt uint64
if !ok {
return plaintext, encStateCleartext, nil
}
// Stage 6D-5 §6.2 cutover gate. When the gate is wired AND
// reports false (`sidecar.StorageEnvelopeActive == false`),
// fall through to the cleartext path even though a DEK is
// active. This is the only correctness-critical site for the
// §7.1 Phase 0 → Phase 1 split: a pre-cutover Put that emitted
// an envelope would advance the writer-registry nonce counter
// before every replica had agreed the cutover applied, opening
// the cross-replica nonce-divergence race the §6.4 atomicity
// contract is built to close. The gate stays unconsulted when
// not wired so existing fixtures + the pre-6D-6 production
// posture keep working unchanged.
if s.storageEnvelopeActive != nil && !s.storageEnvelopeActive() {
return plaintext, encStateCleartext, nil
}
nonceArr, err := s.nonceFactory.Next()
if err != nil {
return nil, 0, errors.Wrap(err, "store: nonce factory")
Expand Down
9 changes: 9 additions & 0 deletions store/lsm_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,15 @@ type pebbleStore struct {
cipher *encryption.Cipher
nonceFactory NonceFactory
activeStorageKeyID ActiveStorageKeyID
// storageEnvelopeActive is the Stage 6D-5 cutover gate (design
// doc §6.2). When wired, every write path that would otherwise
// emit a §4.1 envelope first consults this closure and falls
// back to cleartext when it returns false. A nil closure
// preserves the pre-6D-5 behaviour where activeStorageKeyID
// alone decides the encrypt/cleartext split — the legacy test
// fixtures depend on that posture and the 6D-6 production wiring
// in main.go is what flips the gate on.
storageEnvelopeActive StorageEnvelopeActive
}

// Ensure pebbleStore implements MVCCStore and RetentionController.
Expand Down
Loading
Loading