docs(review): first review — alkssh consumer input on the decrypt plaintext window (establishes docs/reviews/)

This commit is contained in:
glm-5.3-flash committed 2026-10-31 12:56:05 +00:00
1 parent 5462a9e560
commit 2a15bf2bc4
1 file changed
+93
@@ -0,0 +1,93 @@
# Review 001 — The decrypt plaintext window: consumer input from alkssh on zeroizing decryption output
## Status
Open — filed 2026-10-31. 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 filed where the
fix belongs. This is the first review in this crate's series; the
numbering follows the alkcall/alktty/alktunnels house pattern
(`docs/reviews/00N-name.md`, numbering independent per crate).
Not blocking: the consuming contract treats the adapter-side wrap as
the shipped answer. The ask is an API-hygiene improvement that removes
a window a consumer cannot otherwise close.
## 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)