feat(security): zeroize long-lived engine key material on drop #238

Merged
KofTwentyTwo merged 1 commits from story/m6-zeroize-keys into dev 2026-07-30 03:10:02 +00:00
KofTwentyTwo commented 2026-07-30 03:08:50 +00:00 (Migrated from github.com)

Closes #199.

Summary

Wraps long-lived cryptographic key material in zeroize::Zeroizing<T> so it is
overwritten on drop instead of lingering as plain heap bytes for the rest of
the process lifetime. vault.rs's ZeroizingSecret was already a workspace
dependency but was decorative -- nothing wrapped real keys in it. This story
starts actually using zeroizing wrappers for real key material:

  • DatabaseEngine (crates/nuncio-store/src/db.rs): storage_key: Zeroizing<[u8; 32]>,
    worm_key: Zeroizing<Vec<u8>>, ledger_key: Zeroizing<Vec<u8>>. resolve_engine_keys
    wraps the raw bytes at construction; all existing call sites (PayloadCipher,
    HmacSha256, WORM chain verification) compile unchanged via deref coercion.
    The manual Debug impl still redacts key fields.
  • WebhookDispatcher (crates/nuncio-filter/src/webhook.rs): secret_key: Zeroizing<String>.
    Added zeroize.workspace = true to crates/nuncio-filter/Cargo.toml.
  • AccountsGrpcService::add_account (crates/nunciod/src/grpc.rs): the incoming
    plaintext password is moved into a Zeroizing<String> immediately after
    request.into_inner(), before it's passed to set_secret. Added
    zeroize.workspace = true to crates/nunciod/Cargo.toml. Did not touch the
    already-merged update_account rotation / keyring-rollback logic in the
    same file.

No encryption/HMAC/signing behavior changes -- this is purely additive
zeroize-on-drop.

Tests

  • db::tests::zeroize_mechanism_actually_wipes_key_bytes -- proves the
    mechanism directly: constructs Zeroizing<[u8; 32]> and Zeroizing<Vec<u8>>
    with known non-zero bytes, calls .zeroize() explicitly (safe, no
    unsafe), and asserts every byte is now zero. This is the "real" test --
    inspecting freed memory would require unsafe, which is forbidden, so this
    proves the same wipe that Zeroizing's Drop performs internally actually
    works, rather than a no-op drop test that asserts nothing.
  • db::tests::debug_impl_never_prints_raw_key_bytes -- asserts DatabaseEngine's
    Debug output never contains the key field names/values.
  • All existing nuncio-store (encryption roundtrip, WORM chain, ledger HMAC)
    and nuncio-filter webhook tests pass unchanged.

Gate output

cargo fmt --all -- --check   -> clean, no diff
cargo check-all              -> clippy --all-targets --workspace -D warnings: 0 warnings
cargo test-all                -> 421 passed; 0 failed; 0 ignored (across all crates + doctests)

Follow-ups

None found in scope. No real bugs discovered outside this story.

Closes #199. ## Summary Wraps long-lived cryptographic key material in `zeroize::Zeroizing<T>` so it is overwritten on drop instead of lingering as plain heap bytes for the rest of the process lifetime. `vault.rs`'s `ZeroizingSecret` was already a workspace dependency but was decorative -- nothing wrapped real keys in it. This story starts actually using zeroizing wrappers for real key material: - `DatabaseEngine` (`crates/nuncio-store/src/db.rs`): `storage_key: Zeroizing<[u8; 32]>`, `worm_key: Zeroizing<Vec<u8>>`, `ledger_key: Zeroizing<Vec<u8>>`. `resolve_engine_keys` wraps the raw bytes at construction; all existing call sites (`PayloadCipher`, `HmacSha256`, WORM chain verification) compile unchanged via deref coercion. The manual `Debug` impl still redacts key fields. - `WebhookDispatcher` (`crates/nuncio-filter/src/webhook.rs`): `secret_key: Zeroizing<String>`. Added `zeroize.workspace = true` to `crates/nuncio-filter/Cargo.toml`. - `AccountsGrpcService::add_account` (`crates/nunciod/src/grpc.rs`): the incoming plaintext password is moved into a `Zeroizing<String>` immediately after `request.into_inner()`, before it's passed to `set_secret`. Added `zeroize.workspace = true` to `crates/nunciod/Cargo.toml`. Did not touch the already-merged `update_account` rotation / keyring-rollback logic in the same file. No encryption/HMAC/signing behavior changes -- this is purely additive zeroize-on-drop. ## Tests - `db::tests::zeroize_mechanism_actually_wipes_key_bytes` -- proves the mechanism directly: constructs `Zeroizing<[u8; 32]>` and `Zeroizing<Vec<u8>>` with known non-zero bytes, calls `.zeroize()` explicitly (safe, no `unsafe`), and asserts every byte is now zero. This is the "real" test -- inspecting freed memory would require `unsafe`, which is forbidden, so this proves the same wipe that `Zeroizing`'s `Drop` performs internally actually works, rather than a no-op drop test that asserts nothing. - `db::tests::debug_impl_never_prints_raw_key_bytes` -- asserts `DatabaseEngine`'s `Debug` output never contains the key field names/values. - All existing `nuncio-store` (encryption roundtrip, WORM chain, ledger HMAC) and `nuncio-filter` webhook tests pass unchanged. ## Gate output ``` cargo fmt --all -- --check -> clean, no diff cargo check-all -> clippy --all-targets --workspace -D warnings: 0 warnings cargo test-all -> 421 passed; 0 failed; 0 ignored (across all crates + doctests) ``` ## Follow-ups None found in scope. No real bugs discovered outside this story.
Sign in to join this conversation.