Files
alkvault/docs/reviews/001-decrypt-plaintext-window-alkssh-input.md
T
glm-5.3-flash bcfb368b7b decrypt returns Zeroizing<String>; close the plaintext window (review 001)
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.
2026-11-04 06:08:08 +00:00

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_new already returns Zeroizing<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)

  1. A decrypt-shaped surface whose output is zeroized on drop (Zeroizing<String> — the unlock_new precedent — 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.
  2. Whether rotate's internal plaintext handling becomes part of the same resolution (in-crate hygiene; both paths materialize the same class of buffer).
  3. Scope note: the EncryptedData wire 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 pins alkvault = "0.1" and rides either answer.

References

  • ADR-018 (vault standalone; the EncryptedData wire lock — untouched here)
  • ADR-021 (key rotation via version-indexed derivation paths — the rotate internals noted above)
  • docs/architecture/service.md — the zeroization posture; unlock_new's Zeroizing<String> precedent; the rotate contract
  • src/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)