Review 001 (consumer input from alkssh): VaultServiceHandle::decrypt returned a bare heap String — decrypted secret material crossed the API boundary with no zeroization guarantee, a window consumers could not close from the consumer seat. Resolution paths 1+2 of the review. - decrypt -> Result<Zeroizing<String>, VaultServiceError> (breaking; the unlock_new precedent for the same class of secret) - encryption::decrypt (module-internal) -> Zeroizing<String>, zeroizes the decrypted buffer on all paths including the UTF-8 failure path, which previously leaked unzeroized bytes inside the error value - rotate transits through the zeroizing decrypt output; no bare String during re-encryption - test assertions use .as_str() for the explicit PartialEq - docs: README example, service.md decrypt section, encryption.md internal signatures, review 001 closed as resolved - CHANGELOG.md established (house format); version 0.1.0 -> 0.2.0 Verification: cargo test --all-features (108) and cargo test (101) pass, clippy --all-features --all-targets clean, cargo doc clean, cargo publish --dry-run clean at 0.2.0.
4.8 KiB
status, last_updated
| status | last_updated |
|---|---|
| resolved | 2026-11-04 |
Review 001 — The decrypt plaintext window: consumer input from alkssh on zeroizing decryption output
Status
Resolved — filed 2026-10-31, resolved in alkvault 0.2.0 (2026-11-04).
Consumer input from alkssh 0.1.0 (published 2026-10-31), under the
family's upstream posture: file asks upstream, never absorb silently.
The ask was recorded alkssh-side (alkssh ADR-005 §3, "Zeroization
ownership (the known gap)") and is now fixed where it belongs: the
resolution took path 1 below verbatim — decrypt now returns
Zeroizing<String> (the unlock_new precedent), the in-crate
intermediates are zeroized (including the UTF-8 error path), and
rotate transits through the zeroizing return. alkssh's adapter-side
wrap can be dropped against this release; alkssh ADR-005 §3's pointer
update is the one-line doc touch noted below. Not blocking was
correct: this shipped as a regular release, not an expedited one.
The finding
VaultService::decrypt returns Result<String, VaultServiceError>
(src/service.rs:335; the plaintext materializes in
encryption::decrypt, src/encryption.rs:211). The decrypted
plaintext is a bare heap String from the moment decryption
succeeds inside the vault until the caller drops it — no zeroization,
nothing the caller can do about it at that layer's seam: the plaintext
has already been materialized when the call returns.
Contrast this crate's own established posture:
- The house principle is "zeroize everything sensitive" — the README's
zeroization section lists mnemonic, seed, private keys, encryption
keys, cached keys as
Zeroize+ZeroizeOnDrop, "secret material does not linger in freed heap memory". unlock_newalready returnsZeroizing<String>for exactly this reason (docs/architecture/service.md§unlock_new: the returned phrase is the root of trust, heap-allocated and zeroized on drop).decrypt's output is the same class of material with weaker handling than the mnemonic path — a decrypted secret blob is the customer's secret, and by construction (this vault holds the keys the family uses), frequently a private key's plaintext.
An in-crate instance of the same window: rotate decrypts to
plaintext and re-encrypts internally (src/service.rs:350); the
transient plaintext there is at least fully in-crate — decrypt
hands it across the API boundary into caller-controlled heap that the
vault's zeroization guarantees do not cover.
The consumer that surfaced it (alkssh's key-custody path)
alkssh's at-rest key custody (alkssh ADR-005, verified in POC-2):
vault.decrypt(blob) → the plaintext wrapped in Zeroizing<String>
at the adapter seam → one-time decode_secret_key → the decoded
PrivateKey (which ssh-key zeroizes on Drop). The wrap is
alkssh's contract-owning step and works — but from the vault's return
to the adapter's wrap, the plaintext sits in unfenced heap. That
window is unfixable from the consumer seat; only the vault's return
type can close it.
The alkssh-side resolution session (alkssh OQ-07, closed) established the division: the adapter owns the wrap as its contract; a zeroizing-out decrypt API is an alkvault ask, not an alkssh obligation. When it lands, alkssh ADR-005 §3's pointer updates as a one-line doc touch.
What a resolution decides (framing, not prescription)
- A decrypt-shaped surface whose output is zeroized on drop
(
Zeroizing<String>— theunlock_newprecedent — or an equivalent owned type). The exact shape is this crate's call; the consumer requirement is only: the returned type itself fences the plaintext, so the wrap no longer happens "on faith" downstream. - Whether
rotate's internal plaintext handling becomes part of the same resolution (in-crate hygiene; both paths materialize the same class of buffer). - Scope note: the
EncryptedDatawire format is frozen (ADR-018) and is not implicated — this is return-type API surface only. If alkssh's first consumer needs it before the next regular release, the semver class is this crate's decision; alkssh 0.1.0 pinsalkvault = "0.1"and rides either answer.
References
- ADR-018 (vault standalone; the
EncryptedDatawire lock — untouched here) - ADR-021 (key rotation via version-indexed derivation paths — the
rotateinternals noted above) docs/architecture/service.md— the zeroization posture;unlock_new'sZeroizing<String>precedent; the rotate contractsrc/service.rs(decrypt:335,rotate:350),src/encryption.rs(decrypt:211)- alkssh ADR-005 (
/workspace/@alkdev/alkssh/docs/architecture/ decisions/005-at-rest-key-custody.md) §2/§3 — the consuming contract; the "Zeroization ownership (the known gap)" paragraph - alkssh OQ-07 (closed — the adapter-convention-level resolution that produced this filing)