Skip to content

encryption: add storage-envelope metrics (Stage 9C-1, §9.2) - #1221

Open
bootjp wants to merge 1 commit into
mainfrom
design/encryption-9c-metrics
Open

encryption: add storage-envelope metrics (Stage 9C-1, §9.2)#1221
bootjp wants to merge 1 commit into
mainfrom
design/encryption-9c-metrics

Conversation

@bootjp

@bootjp bootjp commented Sep 6, 2026

Copy link
Copy Markdown
Owner

What

Implements Stage 9C-1 of docs/design/2026_04_29_partial_data_at_rest_encryption.md — the three §9.2 metrics that the storage-envelope path can actually emit, wired end-to-end from store through monitoring.Registry to main.go.

Metric Type Source
elastickv_encryption_decrypt_failures_total{reason} counter envelope decode + GCM open failures
elastickv_encryption_writes_per_dek{key_id} counter every emitted envelope
elastickv_encryption_value_overhead_bytes histogram len(stored payload) - len(plaintext)

Design decisions worth reviewing

Classification lives with the sentinels. encryption.DecryptFailureReason maps errors → the doc's closed reason set, in internal/encryption next to the errors it classifies, so adding a decrypt-path error and its label is a single-file edit. monitoring re-normalizes on the way in: the label is derived from on-disk bytes an attacker can influence, so an unexpected string must collapse into unknown rather than mint a series.

A failure is never dropped. DecryptFailureReason returns unknown rather than "no label" for an unclassified error — a paging-grade counter that silently skips what it doesn't recognize is worse than one with an unknown bucket. It returns ok=false only for nil, so a success can never be counted.

The rebadge guard is deliberately unobserved. rejectRebadgedEnvelope trial-decrypts a cleartext body against every loaded DEK; a tag mismatch there is the healthy, expected result. Counting it would increment the paging-grade counter on essentially every cleartext read. This is pinned by a test and a comment at the site.

The other four §9.2 metrics are left unregistered. active_dek_id, last_proposed_index_per_raft_dek, kek_unwrap_seconds, and sidecar_raft_index are owned by the rotation / sidecar / KEK subsystems. Registering them now would expose always-zero series an operator could mistake for health, so they land with their milestones. The doc's §9.2 list is annotated to say exactly which three are live.

Hot-path cost. The per-key_id child counter is memoized behind an RWMutex, so the write path does not strconv-format the key_id on every write. Nil observer (metrics disabled) costs one nil check.

Behavior change / risk

Metrics-only. Nothing in the storage path reads an observation back, so a dropped or nil observation cannot change stored bytes, MVCC visibility, or apply determinism. WithEncryptionObserver(nil) is a no-op.

Two existing assertions on pebbleOptions() length moved 4 → 5.

Test evidence

  • go test . ./store/ ./monitoring/ ./internal/encryption/... -race -count=1 — all pass
  • golangci-lint run (full repo) — 0 issues, no //nolint added
  • Revert-checked (each fix removed → named test FAILS, restore verified byte-exact with diff -q):
    1. drop ObserveEncryptionWriteTestEncryptionObserverCountsEveryEmittedEnvelope FAILs
    2. drop the GCM-open observation → TestEncryptionObserverCountsTagMismatchOnTamperedEnvelope FAILs
    3. observe inside the rebadge trial-decrypt loop → TestEncryptionObserverIgnoresRebadgeGuardTrialDecrypts FAILs
    4. drop WithEncryptionObserver from production pebbleOptions()TestEncryptionObserverReachesStoreThroughProductionWiring FAILs

The store-level tests build their own option list, so they would stay green if production never wired the observer. TestEncryptionObserverReachesStoreThroughProductionWiring drives the real buildEncryptionWriteWiring → pebbleOptions() → NewPebbleStore topology through Bootstrap → cutover → Put, and is the test that catches that case (revert #4 above). A compile-time assertion also pins that monitoring's implementation satisfies store.EncryptionObserver.

Self-review (five passes)

  1. Data loss — No issues. No write path, encoding, or apply behavior changed; the observation is taken after env.Encode() succeeds and is discarded on any error path.
  2. Concurrency / distributed failures — No issues. The memo cache is double-checked under RWMutex and exercised from 16 goroutines under -race. No new locks are taken while holding a store lock; the observer never blocks.
  3. Performance — Hot path considered: per-key_id counter memoized to avoid a per-write strconv allocation; the histogram is unlabelled. Nil observer costs one nil check.
  4. Data consistency — No issues. Metrics-only, no read-back, no effect on MVCC visibility, OCC ordering, or apply determinism.
  5. Test coverage — 8 classifier cases (table-driven), 6 monitoring tests incl. cardinality-folding and a race test, 4 store-seam tests, 2 production-wiring tests. Four revert-checks.

https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE

Implements the three §9.2 metrics that the storage envelope path can
actually emit, and wires them from the store through main.go:

  elastickv_encryption_decrypt_failures_total{reason}
  elastickv_encryption_writes_per_dek{key_id}
  elastickv_encryption_value_overhead_bytes

The reason label set is closed (tag_mismatch, unknown_key_id,
truncated, bad_version, plus an `unknown` catch-all so a failure is
never silently dropped). Classification lives beside the sentinels it
maps, in internal/encryption, so a new decrypt-path error and its
label are edited in one place; monitoring re-normalizes on the way in
because the label is derived from attacker-influenceable on-disk bytes
and must not mint unbounded series.

The rebadge guard's trial-decrypt loop is deliberately NOT observed: it
decrypts a cleartext body against every loaded DEK, so a tag mismatch
there is the expected outcome and counting it would fire the
paging-grade counter on essentially every cleartext read.

The other four §9.2 metrics are owned by the rotation / sidecar / KEK
subsystems and are left unregistered rather than registered-and-zero,
so an operator cannot mistake an always-zero series for health.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

bootjp commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@bootjp

bootjp commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 40 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 77673b8c-9bb2-4c9f-b78c-15e479a95b6b

📥 Commits

Reviewing files that changed from the base of the PR and between cb3abbe and f51caaf.

📒 Files selected for processing (15)
  • docs/design/2026_04_29_partial_data_at_rest_encryption.md
  • internal/encryption/decrypt_failure_reason.go
  • internal/encryption/decrypt_failure_reason_test.go
  • main.go
  • main_encryption_e2e_test.go
  • main_encryption_kek_source_test.go
  • main_encryption_metrics_wiring_test.go
  • main_encryption_write_wiring.go
  • main_encryption_write_wiring_test.go
  • monitoring/encryption.go
  • monitoring/encryption_test.go
  • monitoring/registry.go
  • store/encryption_glue.go
  • store/lsm_store.go
  • store/lsm_store_encryption_metrics_test.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T06:56:46.140539Z f51caaf Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 3m 29s —— View job


Review

  • Gather context (PR body, changed files list)
  • Read design doc and changed files
  • Review classification logic (internal/encryption/decrypt_failure_reason.go)
  • Review monitoring wiring (monitoring/encryption.go, registry.go)
  • Review store glue (store/encryption_glue.go, lsm_store.go)
  • Review main.go wiring (main_encryption_write_wiring.go)
  • Review tests for coverage/edge cases
  • Post final review

Note: go build/go test/golangci-lint were blocked by the sandbox's command-approval gate in this environment, so I could not independently re-run the test evidence in the PR description — the review below is from reading the diff and the surrounding code paths it touches. If you'd like me to actually execute the test/lint suite, --allowedTools needs Bash(go:*) and Bash(golangci-lint:*) enabled for this trigger.

Overall this is a clean, well-scoped addition — the classifier, the observer interface split (store declares its own EncryptionObserver so store doesn't import monitoring), the label-cardinality guard (normalizeDecryptFailureReason folding unknown strings into unknown), and the deliberate non-observation of the rebadge-guard trial-decrypts are all correct and match what the PR body claims. I traced every decrypt call site (store/lsm_store.go:1371, :2762, store/lsm_migration.go:325) back through decryptForKey/authenticateForKeydecryptAuthenticatedForKey, and all of them route through the same observeDecryptFailure helper — no bypass path. The DecryptFailureReason mapping in internal/encryption/decrypt_failure_reason.go also matches the actual sentinels 1:1 (ErrIntegrity, ErrUnknownKeyID, ErrEnvelopeShort, ErrEnvelopeVersion/ErrEnvelopeFlag/ErrBadNonceSize/ErrReservedKeyID), confirmed against internal/encryption/errors.go and cipher.go.

One thing worth a second look:

elastickv_encryption_decrypt_failures_total doesn't cover the raft-envelope decrypt path. The design doc's §9.2 metric has no purpose label (unlike active_dek_id{purpose}), and the pre-existing doc text (git show origin/main:docs/design/...md) describes it as counting decrypt failures generically, not storage-only. There's a second live decrypt call site this PR doesn't touch: internal/raftengine/etcd/encryption.go:99 (unwrapRaftPayloadencryption.UnwrapRaftPayload), which can fail with the same ErrIntegrity/ErrUnknownKeyID/ErrEnvelopeVersion family but is only wrapped in ErrRaftUnwrapFailed for the apply loop — it never reaches ObserveEncryptionDecryptFailure. An operator alerting on this counter today would get zero signal from a tampered/corrupted raft-envelope entry. This is defensible as a Stage 9C-1 scope boundary (the PR title and doc row both say "storage-envelope"), but the 9C+ follow-up row in the doc (docs/design/2026_04_29_partial_data_at_rest_encryption.md:38) currently only lists active_dek_id, last_proposed_index_per_raft_dek, kek_unwrap_seconds, sidecar_raft_index as remaining — it doesn't call out "wire raft-envelope unwrap failures into decrypt_failures_total" as a remaining item, so that gap could get lost. Worth either a one-line doc addition to the 9C+ row, or a comment at unwrapRaftPayload pointing at the same counter for future wiring.

Everything else checked out:

  • Histogram buckets (-1048576 … 65536) are strictly increasing — no Prometheus panic risk.
  • The memoized per-key_id counter in monitoring/encryption.go uses correct double-checked RWMutex locking; the -race test with 16 goroutines exercises it.
  • WithEncryptionObserver(nil) and the nil-receiver EncryptionMetrics methods are genuinely no-ops (verified against TestEncryptionMetricsNilReceiverIsInert).
  • pebbleOptions() option-count assertions (4→5) in main_encryption_write_wiring_test.go and the new TestEncryptionObserverReachesStoreThroughProductionWiring are exactly the kind of "would this test have caught it" check the PR claims — the latter genuinely does exercise the real buildEncryptionWriteWiring → pebbleOptions → NewPebbleStore path rather than a store-only fixture.
  • Compile-time interface satisfaction between store.EncryptionObserver and monitoring.EncryptionMetrics is pinned by TestMetricsRegistryEncryptionObserverSatisfiesStoreInterface.
  • No changes to unrelated pre-existing tests (store/lsm_store_encryption_test.go, store/encryption_compression_test.go are untouched), consistent with "metrics-only, no behavior change."

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: f51caafe25

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant