Story for milestone M6. Read docs/STORY-WORKFLOW.md first — it defines the process, gates, and patterns; this issue defines only the specific work. crates/nuncio-store/src/db.rs::resolve_engine_keys (db.rs:100-116) provisions three cryptographic keys from the vault (crate::vault::SecretManager::get_or_create_key_bytes). Verified in the tree: only the storage key is length-validated —
letstorage_key_bytes=secrets.get_or_create_key_bytes(crate::vault::STORAGE_KEY_ACCOUNT,32).map_err(|e|DatabaseError::KeyProvisioning(e.to_string()))?;letstorage_key: [u8;32]=storage_key_bytes.try_into().map_err(|_|{DatabaseError::KeyProvisioning("storage key material must be exactly 32 bytes".to_string())})?;letworm_key=secrets.get_or_create_key_bytes(crate::vault::WORM_KEY_ACCOUNT,32).map_err(|e|DatabaseError::KeyProvisioning(e.to_string()))?;letledger_key=secrets.get_or_create_key_bytes(crate::vault::LEDGER_KEY_ACCOUNT,32).map_err(|e|DatabaseError::KeyProvisioning(e.to_string()))?;Ok((storage_key,worm_key,ledger_key))
(db.rs:103-115). worm_key and ledger_key are left as untyped Vec<u8> with no length check at all — if the vault ever returns a key of the wrong byte length (a hand-edited/corrupted OS keyring entry, a downgrade from a future format, etc.), it is silently accepted and used as the HMAC-SHA256 signing key for the WORM audit log (crates/nuncio-core/src/worm_audit.rs::WormAuditRecord::compute_hmac) and the filter execution ledger, weakening the tamper-evidence guarantee those logs exist to provide (reconciles #164).
Goal
Make WORM and ledger key provisioning fail closed on any length other than exactly 32 bytes, exactly like the storage key already does, so a corrupted or malformed vault entry can never silently become a weak/malformed signing key.
Files to touch / patterns to mirror
crates/nuncio-store/src/db.rs:79-85 (DatabaseEngine struct) — change worm_key: Vec<u8> and ledger_key: Vec<u8> to worm_key: [u8; 32] and ledger_key: [u8; 32], mirroring the existing storage_key: [u8; 32] field.
crates/nuncio-store/src/db.rs:99-116 (resolve_engine_keys) — apply the exact try_into().map_err(...) pattern already used for storage_key (db.rs:106-108) to worm_key and ledger_key as well. Update the function's return type from Result<([u8; 32], Vec<u8>, Vec<u8>), DatabaseError> to Result<([u8; 32], [u8; 32], [u8; 32]), DatabaseError> (the #[allow(clippy::type_complexity)] on db.rs:99 may become unnecessary once all three are the same simple array type — remove it only if cargo check-all confirms clippy no longer flags it).
crates/nuncio-store/src/db.rs:132-190-ish (DatabaseEngine::connect_file / connect_ephemeral or wherever resolve_engine_keys(..) is destructured, db.rs:142) — no signature change needed at the call site beyond the tuple type now being all-[u8; 32].
Every usage site of self.worm_key / self.ledger_key (db.rs:1262, 1354, 1525, 1585, 1603) passes the field as &self.worm_key / &self.ledger_key into functions expecting &[u8] (HmacSha256::new_from_slice, nuncio_core::verify_worm_chain) — these compile unchanged via deref coercion (&[u8; 32] coerces to &[u8]), so no call-site changes are required there.
Implementation steps
Change worm_key/ledger_key field types in DatabaseEngine to [u8; 32].
In resolve_engine_keys, add the same try_into().map_err(|_| DatabaseError::KeyProvisioning("<name> key material must be exactly 32 bytes".to_string()))? conversion for worm_key and ledger_key that storage_key already has, using a distinct error message per key (e.g. "WORM audit key material must be exactly 32 bytes", "ledger key material must be exactly 32 bytes") so a failure is diagnosable.
Update the function's return type and any type annotations that reference the old Vec<u8> shape.
Confirm cargo check-all compiles clean at every downstream usage site listed above (no code changes should be needed there beyond the type change itself, but verify).
Add tests per Acceptance Criteria, mirroring the existing get_or_create_key_bytes_fails_closed_on_corrupt_material test in crates/nuncio-store/src/vault.rs:342-353 (which tests SecretManager directly) but at the DatabaseEngine/resolve_engine_keys layer, since that vault-level test does not exercise the (currently missing) WORM/ledger length check at all.
Acceptance criteria
resolve_engine_keys fails closed (Err(DatabaseError::KeyProvisioning(_))) when the vault returns a WORM key of any length other than 32 bytes, and likewise for the ledger key — mirror the existing storage-key behavior exactly.
A new test (in crates/nuncio-store/src/db.rs's #[cfg(test)] mod tests) seeds a SecretManager::mock() with a wrong-length WORM key (e.g. via manager.set_secret(WORM_KEY_ACCOUNT, &hex::encode([0u8; 16])) — a 16-byte key hex-encoded, valid hex but wrong length) and asserts DatabaseEngine::connect_ephemeral-style construction (or whatever the real constructor path is) returns Err rather than silently proceeding with a 16-byte HMAC key. Repeat for the ledger key.
Existing tests (vault.rs:342-353's storage-key equivalent, plus all nuncio-store WORM-chain and ledger-HMAC tests) continue to pass unchanged.
No fabricated success: a malformed/corrupt vault key entry must never be silently accepted as valid key material.
Follow docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in .rs/.proto); mock-only tests. Branch story/m6-vault-key-length off dev; PR to dev with the gate output pasted as evidence; the PO reviews before the issue is closed.
Dependencies / notes
Not a [PROTO] story. Reconciles #164. Touches crates/nuncio-store/src/db.rs, which M6-S1 and M6-S2 also touch — coordinate landing order with the PO to avoid merge conflicts (this story's diff is small and localized to resolve_engine_keys + the DatabaseEngine field types, so it should be easy to rebase around). Size: S.
## Context
Story for milestone M6. **Read [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md) first** — it defines the process, gates, and patterns; this issue defines only the specific work. `crates/nuncio-store/src/db.rs::resolve_engine_keys` (`db.rs:100-116`) provisions three cryptographic keys from the vault (`crate::vault::SecretManager::get_or_create_key_bytes`). Verified in the tree: only the storage key is length-validated —
```rust
let storage_key_bytes = secrets
.get_or_create_key_bytes(crate::vault::STORAGE_KEY_ACCOUNT, 32)
.map_err(|e| DatabaseError::KeyProvisioning(e.to_string()))?;
let storage_key: [u8; 32] = storage_key_bytes.try_into().map_err(|_| {
DatabaseError::KeyProvisioning("storage key material must be exactly 32 bytes".to_string())
})?;
let worm_key = secrets
.get_or_create_key_bytes(crate::vault::WORM_KEY_ACCOUNT, 32)
.map_err(|e| DatabaseError::KeyProvisioning(e.to_string()))?;
let ledger_key = secrets
.get_or_create_key_bytes(crate::vault::LEDGER_KEY_ACCOUNT, 32)
.map_err(|e| DatabaseError::KeyProvisioning(e.to_string()))?;
Ok((storage_key, worm_key, ledger_key))
```
(`db.rs:103-115`). `worm_key` and `ledger_key` are left as untyped `Vec<u8>` with no length check at all — if the vault ever returns a key of the wrong byte length (a hand-edited/corrupted OS keyring entry, a downgrade from a future format, etc.), it is silently accepted and used as the HMAC-SHA256 signing key for the WORM audit log (`crates/nuncio-core/src/worm_audit.rs::WormAuditRecord::compute_hmac`) and the filter execution ledger, weakening the tamper-evidence guarantee those logs exist to provide (reconciles #164).
## Goal
Make WORM and ledger key provisioning fail closed on any length other than exactly 32 bytes, exactly like the storage key already does, so a corrupted or malformed vault entry can never silently become a weak/malformed signing key.
## Files to touch / patterns to mirror
- `crates/nuncio-store/src/db.rs:79-85` (`DatabaseEngine` struct) — change `worm_key: Vec<u8>` and `ledger_key: Vec<u8>` to `worm_key: [u8; 32]` and `ledger_key: [u8; 32]`, mirroring the existing `storage_key: [u8; 32]` field.
- `crates/nuncio-store/src/db.rs:99-116` (`resolve_engine_keys`) — apply the exact `try_into().map_err(...)` pattern already used for `storage_key` (`db.rs:106-108`) to `worm_key` and `ledger_key` as well. Update the function's return type from `Result<([u8; 32], Vec<u8>, Vec<u8>), DatabaseError>` to `Result<([u8; 32], [u8; 32], [u8; 32]), DatabaseError>` (the `#[allow(clippy::type_complexity)]` on `db.rs:99` may become unnecessary once all three are the same simple array type — remove it only if `cargo check-all` confirms clippy no longer flags it).
- `crates/nuncio-store/src/db.rs:132-190`-ish (`DatabaseEngine::connect_file` / `connect_ephemeral` or wherever `resolve_engine_keys(..)` is destructured, `db.rs:142`) — no signature change needed at the call site beyond the tuple type now being all-`[u8; 32]`.
- Every usage site of `self.worm_key` / `self.ledger_key` (`db.rs:1262`, `1354`, `1525`, `1585`, `1603`) passes the field as `&self.worm_key` / `&self.ledger_key` into functions expecting `&[u8]` (`HmacSha256::new_from_slice`, `nuncio_core::verify_worm_chain`) — these compile unchanged via deref coercion (`&[u8; 32]` coerces to `&[u8]`), so no call-site changes are required there.
## Implementation steps
1. Change `worm_key`/`ledger_key` field types in `DatabaseEngine` to `[u8; 32]`.
2. In `resolve_engine_keys`, add the same `try_into().map_err(|_| DatabaseError::KeyProvisioning("<name> key material must be exactly 32 bytes".to_string()))?` conversion for `worm_key` and `ledger_key` that `storage_key` already has, using a distinct error message per key (e.g. `"WORM audit key material must be exactly 32 bytes"`, `"ledger key material must be exactly 32 bytes"`) so a failure is diagnosable.
3. Update the function's return type and any type annotations that reference the old `Vec<u8>` shape.
4. Confirm `cargo check-all` compiles clean at every downstream usage site listed above (no code changes should be needed there beyond the type change itself, but verify).
5. Add tests per Acceptance Criteria, mirroring the existing `get_or_create_key_bytes_fails_closed_on_corrupt_material` test in `crates/nuncio-store/src/vault.rs:342-353` (which tests `SecretManager` directly) but at the `DatabaseEngine`/`resolve_engine_keys` layer, since that vault-level test does not exercise the (currently missing) WORM/ledger length check at all.
## Acceptance criteria
- [ ] `resolve_engine_keys` fails closed (`Err(DatabaseError::KeyProvisioning(_))`) when the vault returns a WORM key of any length other than 32 bytes, and likewise for the ledger key — mirror the existing storage-key behavior exactly.
- [ ] A new test (in `crates/nuncio-store/src/db.rs`'s `#[cfg(test)] mod tests`) seeds a `SecretManager::mock()` with a wrong-length WORM key (e.g. via `manager.set_secret(WORM_KEY_ACCOUNT, &hex::encode([0u8; 16]))` — a 16-byte key hex-encoded, valid hex but wrong length) and asserts `DatabaseEngine::connect_ephemeral`-style construction (or whatever the real constructor path is) returns `Err` rather than silently proceeding with a 16-byte HMAC key. Repeat for the ledger key.
- [ ] Existing tests (`vault.rs:342-353`'s storage-key equivalent, plus all `nuncio-store` WORM-chain and ledger-HMAC tests) continue to pass unchanged.
- [ ] No fabricated success: a malformed/corrupt vault key entry must never be silently accepted as valid key material.
- [ ] `cargo verify` green (fmt + clippy `-D warnings` all-targets + test-all), 0 ignored.
## Definition of done & process
Follow [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md): no fabricated data; comment convention (intent-only, no story/GH/phase refs in `.rs`/`.proto`); mock-only tests. Branch `story/m6-vault-key-length` off `dev`; PR to `dev` with the gate output pasted as evidence; the PO reviews before the issue is closed.
## Dependencies / notes
Not a [PROTO] story. Reconciles #164. Touches `crates/nuncio-store/src/db.rs`, which M6-S1 and M6-S2 also touch — coordinate landing order with the PO to avoid merge conflicts (this story's diff is small and localized to `resolve_engine_keys` + the `DatabaseEngine` field types, so it should be easy to rebase around). Size: S.
Delivered via PR #239 (squash-merged to dev). resolve_engine_keys now length-validates the WORM audit key and the ledger key with the same try_into fail-closed pattern as the storage key (distinct KeyProvisioning messages) — a wrong-length vault entry can no longer become a weak/malformed HMAC signing key. All three key fields are Zeroizing<[u8;32]> (validated AND zeroized — no regression of #199). Two fail-closed tests (16-byte WORM/ledger keys → Err). Reconciles #164.
Delivered via PR #239 (squash-merged to dev). resolve_engine_keys now length-validates the WORM audit key and the ledger key with the same try_into fail-closed pattern as the storage key (distinct KeyProvisioning messages) — a wrong-length vault entry can no longer become a weak/malformed HMAC signing key. All three key fields are Zeroizing<[u8;32]> (validated AND zeroized — no regression of #199). Two fail-closed tests (16-byte WORM/ledger keys → Err). Reconciles #164.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Context
Story for milestone M6. Read
docs/STORY-WORKFLOW.mdfirst — it defines the process, gates, and patterns; this issue defines only the specific work.crates/nuncio-store/src/db.rs::resolve_engine_keys(db.rs:100-116) provisions three cryptographic keys from the vault (crate::vault::SecretManager::get_or_create_key_bytes). Verified in the tree: only the storage key is length-validated —(
db.rs:103-115).worm_keyandledger_keyare left as untypedVec<u8>with no length check at all — if the vault ever returns a key of the wrong byte length (a hand-edited/corrupted OS keyring entry, a downgrade from a future format, etc.), it is silently accepted and used as the HMAC-SHA256 signing key for the WORM audit log (crates/nuncio-core/src/worm_audit.rs::WormAuditRecord::compute_hmac) and the filter execution ledger, weakening the tamper-evidence guarantee those logs exist to provide (reconciles #164).Goal
Make WORM and ledger key provisioning fail closed on any length other than exactly 32 bytes, exactly like the storage key already does, so a corrupted or malformed vault entry can never silently become a weak/malformed signing key.
Files to touch / patterns to mirror
crates/nuncio-store/src/db.rs:79-85(DatabaseEnginestruct) — changeworm_key: Vec<u8>andledger_key: Vec<u8>toworm_key: [u8; 32]andledger_key: [u8; 32], mirroring the existingstorage_key: [u8; 32]field.crates/nuncio-store/src/db.rs:99-116(resolve_engine_keys) — apply the exacttry_into().map_err(...)pattern already used forstorage_key(db.rs:106-108) toworm_keyandledger_keyas well. Update the function's return type fromResult<([u8; 32], Vec<u8>, Vec<u8>), DatabaseError>toResult<([u8; 32], [u8; 32], [u8; 32]), DatabaseError>(the#[allow(clippy::type_complexity)]ondb.rs:99may become unnecessary once all three are the same simple array type — remove it only ifcargo check-allconfirms clippy no longer flags it).crates/nuncio-store/src/db.rs:132-190-ish (DatabaseEngine::connect_file/connect_ephemeralor whereverresolve_engine_keys(..)is destructured,db.rs:142) — no signature change needed at the call site beyond the tuple type now being all-[u8; 32].self.worm_key/self.ledger_key(db.rs:1262,1354,1525,1585,1603) passes the field as&self.worm_key/&self.ledger_keyinto functions expecting&[u8](HmacSha256::new_from_slice,nuncio_core::verify_worm_chain) — these compile unchanged via deref coercion (&[u8; 32]coerces to&[u8]), so no call-site changes are required there.Implementation steps
worm_key/ledger_keyfield types inDatabaseEngineto[u8; 32].resolve_engine_keys, add the sametry_into().map_err(|_| DatabaseError::KeyProvisioning("<name> key material must be exactly 32 bytes".to_string()))?conversion forworm_keyandledger_keythatstorage_keyalready has, using a distinct error message per key (e.g."WORM audit key material must be exactly 32 bytes","ledger key material must be exactly 32 bytes") so a failure is diagnosable.Vec<u8>shape.cargo check-allcompiles clean at every downstream usage site listed above (no code changes should be needed there beyond the type change itself, but verify).get_or_create_key_bytes_fails_closed_on_corrupt_materialtest incrates/nuncio-store/src/vault.rs:342-353(which testsSecretManagerdirectly) but at theDatabaseEngine/resolve_engine_keyslayer, since that vault-level test does not exercise the (currently missing) WORM/ledger length check at all.Acceptance criteria
resolve_engine_keysfails closed (Err(DatabaseError::KeyProvisioning(_))) when the vault returns a WORM key of any length other than 32 bytes, and likewise for the ledger key — mirror the existing storage-key behavior exactly.crates/nuncio-store/src/db.rs's#[cfg(test)] mod tests) seeds aSecretManager::mock()with a wrong-length WORM key (e.g. viamanager.set_secret(WORM_KEY_ACCOUNT, &hex::encode([0u8; 16]))— a 16-byte key hex-encoded, valid hex but wrong length) and assertsDatabaseEngine::connect_ephemeral-style construction (or whatever the real constructor path is) returnsErrrather than silently proceeding with a 16-byte HMAC key. Repeat for the ledger key.vault.rs:342-353's storage-key equivalent, plus allnuncio-storeWORM-chain and ledger-HMAC tests) continue to pass unchanged.cargo verifygreen (fmt + clippy-D warningsall-targets + test-all), 0 ignored.Definition of done & process
Follow
docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in.rs/.proto); mock-only tests. Branchstory/m6-vault-key-lengthoffdev; PR todevwith the gate output pasted as evidence; the PO reviews before the issue is closed.Dependencies / notes
Not a [PROTO] story. Reconciles #164. Touches
crates/nuncio-store/src/db.rs, which M6-S1 and M6-S2 also touch — coordinate landing order with the PO to avoid merge conflicts (this story's diff is small and localized toresolve_engine_keys+ theDatabaseEnginefield types, so it should be easy to rebase around). Size: S.Delivered via PR #239 (squash-merged to dev). resolve_engine_keys now length-validates the WORM audit key and the ledger key with the same try_into fail-closed pattern as the storage key (distinct KeyProvisioning messages) — a wrong-length vault entry can no longer become a weak/malformed HMAC signing key. All three key fields are Zeroizing<[u8;32]> (validated AND zeroized — no regression of #199). Two fail-closed tests (16-byte WORM/ledger keys → Err). Reconciles #164.