Skip to content
Open
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
15 changes: 9 additions & 6 deletions docs/design/2026_04_29_partial_data_at_rest_encryption.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Data-at-rest encryption for elastickv

Status: Partial — Stages 0–8, 9A–9B, and 9C-1 shipped (5E deferred); remaining Stage 9 work open
Status: Partial — Stages 0–8, 9A–9B, 9C-1, and 9C-2 shipped (5E deferred); remaining Stage 9 work open
Author: bootjp
Date: 2026-04-29

Expand Down Expand Up @@ -35,7 +35,8 @@ Date: 2026-04-29
| 9A | Compress-then-encrypt, authenticated compression flag, encrypted-store Pebble compression policy, storage benchmark (§6.4, §8.3) | shipped | `2026_07_18_implemented_9a_encryption_compression.md` |
| 9B | AWS KMS, GCP KMS, Vault Transit, and test/CI env KEK providers; mutually-exclusive source loader and loaded-provider mutator gate (§5.1, §6.1, §6.5) | shipped | `2026_07_18_implemented_9b_kek_providers.md` |
| 9C-1 | Storage-envelope observability: `decrypt_failures_total`, `writes_per_dek`, `value_overhead_bytes`, wired from the storage envelope path through `monitoring.Registry` (§9.2) | shipped | — |
| 9C+ | Rotation budget/rewrap/retire/rewrite, the remaining §9.2 metrics (`active_dek_id`, `last_proposed_index_per_raft_dek`, `kek_unwrap_seconds`, `sidecar_raft_index`), remaining benchmarks and encrypted Jepsen (§5.2, §5.4, §6.5, §8) | open | — |
| 9C-2 | Sidecar/KEK observability: `active_dek_id{purpose}`, `sidecar_raft_index`, `kek_unwrap_seconds` (§9.2) | shipped | — |
| 9C+ | Rotation budget/rewrap/retire/rewrite, `last_proposed_index_per_raft_dek` (needs the §5.4 raft-DEK Wrap path), remaining benchmarks and encrypted Jepsen (§5.2, §5.4, §6.5, §8) | open | — |

Stages 0–4 ship the entire byte-tag pipeline (storage envelope, raft
envelope, FSM dispatch, halt-on-error) but leave it **production
Expand Down Expand Up @@ -2527,10 +2528,12 @@ relevant flag and runbook section.

New metrics (Stage 9C-1 shipped the three emitted by the storage
envelope path — `decrypt_failures_total`, `writes_per_dek`, and
`value_overhead_bytes`. The remaining four are owned by the rotation /
sidecar / KEK subsystems and land with their milestones; they are
deliberately NOT registered yet, so an operator cannot mistake an
always-zero series for a healthy signal):
`value_overhead_bytes`. Stage 9C-2 shipped the three with live
sidecar/KEK sources — `active_dek_id`, `sidecar_raft_index`, and
`kek_unwrap_seconds`. Only `last_proposed_index_per_raft_dek` remains:
it needs the §5.4 raft-DEK Wrap path, so it is deliberately NOT
registered yet — an always-zero series is worse than an absent one,
because an operator could mistake it for a healthy signal):

- `elastickv_encryption_active_dek_id{purpose}` — gauge, label is
storage/raft.
Expand Down
88 changes: 64 additions & 24 deletions internal/encryption/applier.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ type StateCache struct {
// activeStorageDEKID and re-registers, which Registered()'s
// equality check handles without a reset.
registeredStorageDEKID atomic.Uint32
// activeRaftDEKID mirrors sidecar.Active.Raft, and
// sidecarRaftAppliedIndex mirrors sidecar.RaftAppliedIndex.
// Neither participates in a decision: they exist so the §9.2
// observability collector can read current sidecar state without
// doing file I/O on a metrics tick. They are refreshed by the
// same RefreshFromSidecar call that maintains the decision
// mirrors above, so they cannot drift from them.
activeRaftDEKID atomic.Uint32
sidecarRaftAppliedIndex atomic.Uint64
}

// NewStateCache returns a zero-initialised StateCache. The
Expand All @@ -250,6 +259,31 @@ func (c *StateCache) RefreshFromSidecar(sc *Sidecar) {
}
c.activeStorageDEKID.Store(sc.Active.Storage)
c.storageEnvelopeActive.Store(sc.StorageEnvelopeActive)
c.activeRaftDEKID.Store(sc.Active.Raft)
c.sidecarRaftAppliedIndex.Store(sc.RaftAppliedIndex)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh the cache after no-op sidecar writes

When a stale or duplicate enable-storage-envelope/enable-raft-envelope entry is applied, the no-op branches in applyEnableStorageEnvelope and applyEnableRaftEnvelope advance and persist sc.RaftAppliedIndex but return without calling RefreshFromSidecar. Because the new sidecarRaftAppliedIndex mirror is the sole source for elastickv_encryption_sidecar_raft_index, the metric remains behind the actual persisted sidecar until another fresh encryption mutation or restart, potentially producing a false sidecar-divergence signal.

Useful? React with 👍 / 👎.

}

// ActiveRaftKeyID returns the current sidecar.Active.Raft DEK id.
// Observability only — the raft envelope path resolves its own key id
// through the raft envelope runtime, not through this mirror.
func (c *StateCache) ActiveRaftKeyID() (uint32, bool) {
if c == nil {
return 0, false
}
id := c.activeRaftDEKID.Load()
return id, id != 0
}

// SidecarRaftAppliedIndex returns the sidecar's last persisted
// raft_applied_index. §5.5 uses a persistent gap between this and the
// FSM applied index as the sidecar-divergence signal, and §9.2
// exposes it as elastickv_encryption_sidecar_raft_index so an
// operator can alert on that gap.
func (c *StateCache) SidecarRaftAppliedIndex() uint64 {
if c == nil {
return 0
}
return c.sidecarRaftAppliedIndex.Load()
}

// ActiveStorageKeyID returns the current sidecar.Active.Storage DEK
Expand Down Expand Up @@ -862,8 +896,26 @@ func (a *Applier) writeBootstrapSidecar(raftIdx uint64, p fsmwire.BootstrapPaylo
Created: createdAt,
LocalEpoch: 0,
}
if err := a.persistSidecar(sc, "bootstrap"); err != nil {
return err
}
return nil
}

// persistSidecar writes the sidecar and, on success, refreshes the
// StateCache mirrors from the same value.
//
// Every apply path that persists the sidecar must go through this.
// Pairing the two here rather than at each call site is deliberate:
// the no-op branches (stale DEKID, already-active cutover) advance and
// persist RaftAppliedIndex but have no other work to do, and each one
// that forgot to refresh left the cache — and therefore the §9.2
// elastickv_encryption_sidecar_raft_index gauge — behind the durable
// sidecar until the next fresh mutation or restart, which reads as a
// false sidecar-divergence signal.
func (a *Applier) persistSidecar(sc *Sidecar, context string) error {
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return errors.Wrap(err, "applier: write sidecar for bootstrap")
return errors.Wrapf(err, "applier: write sidecar for %s", context)
}
a.stateCache.RefreshFromSidecar(sc)
return nil
Expand Down Expand Up @@ -1089,10 +1141,7 @@ func (a *Applier) applyEnableStorageEnvelope(raftIdx uint64, p fsmwire.RotationP
// flipping the cutover fields.
if p.DEKID != sc.Active.Storage {
advanceRaftAppliedIndex(sc, raftIdx)
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return errors.Wrap(err, "applier: write sidecar for stale-dekid cutover no-op")
}
return nil
return a.persistSidecar(sc, "stale-dekid cutover no-op")
}
// §2.1 constraint #4 — idempotency. Preserve the original
// StorageEnvelopeCutoverIndex; only advance the generic
Expand All @@ -1109,10 +1158,7 @@ func (a *Applier) applyEnableStorageEnvelope(raftIdx uint64, p fsmwire.RotationP
// registration-before-sidecar reordering landed in 74a504c8.
if sc.StorageEnvelopeActive {
advanceRaftAppliedIndex(sc, raftIdx)
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return errors.Wrap(err, "applier: write sidecar for already-active cutover no-op")
}
return nil
return a.persistSidecar(sc, "already-active cutover no-op")
}
// Fresh successful apply.
//
Expand Down Expand Up @@ -1148,10 +1194,9 @@ func (a *Applier) applyEnableStorageEnvelope(raftIdx uint64, p fsmwire.RotationP
sc.StorageEnvelopeActive = true
sc.StorageEnvelopeCutoverIndex = raftIdx
advanceRaftAppliedIndex(sc, raftIdx)
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return errors.Wrap(err, "applier: write sidecar for cutover")
if err := a.persistSidecar(sc, "cutover"); err != nil {
return err
}
a.stateCache.RefreshFromSidecar(sc)
return nil
}

Expand Down Expand Up @@ -1277,8 +1322,8 @@ func (a *Applier) applyEnableRaftEnvelope(raftIdx uint64, p fsmwire.RotationPayl
// separate bool flag).
if sc.RaftEnvelopeCutoverIndex != 0 {
advanceRaftAppliedIndex(sc, raftIdx)
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return errors.Wrap(err, "applier: write sidecar for already-active raft-cutover no-op")
if err := a.persistSidecar(sc, "already-active raft-cutover no-op"); err != nil {
return err
}
// Installer takes the CURRENT sc.Active.Raft, NOT the
// replayed p.DEKID — the wrap closure must key to the
Expand All @@ -1295,10 +1340,7 @@ func (a *Applier) applyEnableRaftEnvelope(raftIdx uint64, p fsmwire.RotationPayl
// flipping the cutover field.
if p.DEKID != sc.Active.Raft {
advanceRaftAppliedIndex(sc, raftIdx)
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return errors.Wrap(err, "applier: write sidecar for stale-dekid raft-cutover no-op")
}
return nil
return a.persistSidecar(sc, "stale-dekid raft-cutover no-op")
}
// Fresh successful apply. Crash-recovery ordering follows
// the storage variant: ApplyRegistration runs BEFORE
Expand All @@ -1314,10 +1356,9 @@ func (a *Applier) applyEnableRaftEnvelope(raftIdx uint64, p fsmwire.RotationPayl
}
sc.RaftEnvelopeCutoverIndex = raftIdx
advanceRaftAppliedIndex(sc, raftIdx)
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return errors.Wrap(err, "applier: write sidecar for raft cutover")
if err := a.persistSidecar(sc, "raft cutover"); err != nil {
return err
}
a.stateCache.RefreshFromSidecar(sc)
// Stage 6E-2e-1 BLOCKER (b) — publish the wrap closure on every
// replica's local FSM apply so a follower that becomes leader
// post-cutover already has wrap active without needing the
Expand Down Expand Up @@ -1399,10 +1440,9 @@ func (a *Applier) writeRotationSidecar(raftIdx uint64, p fsmwire.RotationPayload
Created: a.now().UTC().Format(time.RFC3339),
LocalEpoch: keyLocalEpoch,
}
if err := WriteSidecar(a.sidecarPath, sc); err != nil {
return nil, errors.Wrap(err, "applier: write sidecar for rotation")
if err := a.persistSidecar(sc, "rotation"); err != nil {
return nil, err
}
a.stateCache.RefreshFromSidecar(sc)
return sc, nil
}

Expand Down
149 changes: 149 additions & 0 deletions internal/encryption/state_cache_observability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package encryption_test

import (
"path/filepath"
"testing"

"github.com/bootjp/elastickv/internal/encryption"
"github.com/bootjp/elastickv/internal/encryption/fsmwire"
etcdraftengine "github.com/bootjp/elastickv/internal/raftengine/etcd"
"github.com/stretchr/testify/require"
)

// TestStateCacheMirrorsRaftSlotAndSidecarIndex pins the §9.2
// observability mirrors. They are refreshed by the same
// RefreshFromSidecar call that maintains the decision mirrors, so a
// refresh that updated one and not the other would let the metrics
// report stale sidecar state indefinitely.
func TestStateCacheMirrorsRaftSlotAndSidecarIndex(t *testing.T) {
t.Parallel()

cache := encryption.NewStateCache()

// Pre-bootstrap posture.
id, ok := cache.ActiveRaftKeyID()
require.Zero(t, id)
require.False(t, ok)
require.Zero(t, cache.SidecarRaftAppliedIndex())

cache.RefreshFromSidecar(&encryption.Sidecar{
Version: 1,
RaftAppliedIndex: 9182,
Active: encryption.ActiveKeys{Storage: 7, Raft: 8},
})

id, ok = cache.ActiveRaftKeyID()
require.Equal(t, uint32(8), id)
require.True(t, ok)
require.Equal(t, uint64(9182), cache.SidecarRaftAppliedIndex())

// The storage mirror must still track its own slot.
storageID, ok := cache.ActiveStorageKeyID()
require.Equal(t, uint32(7), storageID)
require.True(t, ok)

// A later refresh must advance the index, not latch it.
cache.RefreshFromSidecar(&encryption.Sidecar{
Version: 1,
RaftAppliedIndex: 9200,
Active: encryption.ActiveKeys{Storage: 7, Raft: 8},
})
require.Equal(t, uint64(9200), cache.SidecarRaftAppliedIndex())
}

func TestStateCacheObservabilityMirrorsAreNilSafe(t *testing.T) {
t.Parallel()

var cache *encryption.StateCache
id, ok := cache.ActiveRaftKeyID()
require.Zero(t, id)
require.False(t, ok)
require.Zero(t, cache.SidecarRaftAppliedIndex())
}

// TestApplierRefreshesTheCacheOnNoOpSidecarWrites is the regression for
// the metric-lag defect: the idempotency and stale-DEKID branches
// advance and persist sc.RaftAppliedIndex but do no other work, so a
// branch that skipped RefreshFromSidecar left the cache — and the §9.2
// elastickv_encryption_sidecar_raft_index gauge — behind the durable
// sidecar until the next fresh mutation or a restart. That reads as a
// false sidecar-divergence signal.
func TestApplierRefreshesTheCacheOnNoOpSidecarWrites(t *testing.T) {
t.Parallel()

const storageDEK = uint32(7)
const raftDEK = uint32(8)
nodeID := etcdraftengine.DeriveNodeID("n1")

dir := t.TempDir()
sidecarPath := filepath.Join(dir, "keys.json")
cache := encryption.NewStateCache()
applier := newObservabilityApplier(t, sidecarPath, cache)

require.NoError(t, applier.ApplyBootstrap(1, fsmwire.BootstrapPayload{
StorageDEKID: storageDEK,
WrappedStorage: []byte("wrapped-storage-dek"),
RaftDEKID: raftDEK,
WrappedRaft: []byte("wrapped-raft-dek-distinct"),
BatchRegistry: []fsmwire.RegistrationPayload{
{DEKID: storageDEK, FullNodeID: nodeID, LocalEpoch: 0},
},
}))
require.Equal(t, uint64(1), cache.SidecarRaftAppliedIndex())

// Fresh cutover.
require.NoError(t, applier.ApplyRotation(2, fsmwire.RotationPayload{
SubTag: fsmwire.RotateSubEnableStorageEnvelope,
DEKID: storageDEK,
Purpose: fsmwire.PurposeStorage,
Wrapped: []byte{},
ProposerRegistration: fsmwire.RegistrationPayload{DEKID: storageDEK, FullNodeID: nodeID, LocalEpoch: 1},
}))
require.Equal(t, uint64(2), cache.SidecarRaftAppliedIndex())

// A DUPLICATE cutover entry: the already-active no-op branch. It
// persists the new applied index, so the cache must follow.
require.NoError(t, applier.ApplyRotation(3, fsmwire.RotationPayload{
SubTag: fsmwire.RotateSubEnableStorageEnvelope,
DEKID: storageDEK,
Purpose: fsmwire.PurposeStorage,
Wrapped: []byte{},
ProposerRegistration: fsmwire.RegistrationPayload{DEKID: storageDEK, FullNodeID: nodeID, LocalEpoch: 2},
}))
onDisk, err := encryption.ReadSidecar(sidecarPath)
require.NoError(t, err)
require.Equal(t, uint64(3), onDisk.RaftAppliedIndex, "the no-op branch must persist the index")
require.Equal(t, onDisk.RaftAppliedIndex, cache.SidecarRaftAppliedIndex(),
"the cache must never lag the durable sidecar after a no-op write")

// A STALE-DEKID cutover entry: the other no-op branch.
require.NoError(t, applier.ApplyRotation(4, fsmwire.RotationPayload{
SubTag: fsmwire.RotateSubEnableStorageEnvelope,
DEKID: storageDEK + 100, // no longer the active DEK
Purpose: fsmwire.PurposeStorage,
Wrapped: []byte{},
ProposerRegistration: fsmwire.RegistrationPayload{DEKID: storageDEK + 100, FullNodeID: nodeID, LocalEpoch: 3},
}))
onDisk, err = encryption.ReadSidecar(sidecarPath)
require.NoError(t, err)
require.Equal(t, uint64(4), onDisk.RaftAppliedIndex)
require.Equal(t, onDisk.RaftAppliedIndex, cache.SidecarRaftAppliedIndex(),
"the stale-DEKID no-op branch must refresh the cache too")
}

// newObservabilityApplier builds an applier wired to a real sidecar
// path and the supplied cache, matching the production topology in
// main_encryption_write_wiring.go.
func newObservabilityApplier(
t *testing.T, sidecarPath string, cache *encryption.StateCache,
) *encryption.Applier {
t.Helper()
app, err := encryption.NewApplier(newMapRegistryStore(),
encryption.WithKEK(&fakeKEK{}),
encryption.WithKeystore(encryption.NewKeystore()),
encryption.WithSidecarPath(sidecarPath),
encryption.WithStateCache(cache),
)
require.NoError(t, err)
return app
}
Loading
Loading