Files
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
Renames the written-usage rule into practice across the contract surface:
docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files,
taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the
.opencode/agents/ prompts. Every replacement is a sense-preserving
rewrite (colons, semicolons, commas, parentheses, sentence splits, varied),
never a mechanical swap; no spaced hyphens were substituted. Enum fields,
frontmatter, tables, wire data, and cited identifiers survive verbatim;
en dashes inside numeric ranges keep their meaning.

Verification: taskgraph validate (43 tasks), repo grep clean (single
exempt hit: the verification command's own search pattern in AGENTS.md),
cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
2026-11-04 20:28:00 +00:00

131 lines
6.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: w1-key-sources
name: "Wave 1: vault-backed key sources (ADR-005 custody in code)"
status: completed
depends_on: [w1-manifest, w1-error-model]
scope: moderate
risk: medium
impact: project
level: implementation
---
## Description
Land the ADR-005 custody model in code: the key-source seam both
directions consume (client dial keys, server host keys):
- **The source seam**: the adapter takes *key sources*, not static
keys (the alkhttp ADR-014 pattern): a source trait the assembly
implements over an injected alkvault handle, `EncryptedData` opaque
to the crate (ADR-005 decision 5: the crate never interprets
`key_version` or record internals).
- **At-use lifecycle** (decision 2): `vault.decrypt(blob)` →
**`Zeroizing<String>` wrap at the adapter seam** (decision 3, the
known-gap contract; the vault-side improvement is upstream, not
here) → one-time `decode_secret_key(plain, None)` → the decoded
`PrivateKey` lives for the dial's lifetime. The vault is needed only
at dial time; locking mid-session keeps live sessions streaming.
- **Host-key load path** (decision 6): decrypt-at-construction
returning the `PrivateKey` set the server `Config::keys` will
consume (the `Config` construction itself is wave 2; locked vault →
the load fails → **no server is constructed**, POC-2 §5).
- **Failure mapping**: every vault failure mode maps to its distinct
`KeySourceError` variant (locked / absent / decryption / unknown
version / decode); never collapsed, never a fallback dial.
- **Plaintext never persists** (decision 7): no disk spill on this
path, ever; the `Zeroizing` wrap bounds the plaintext from the
adapter seam onward.
Inner format is OpenSSH private-key PEM, unencrypted (decision 1);
the source contract assumes it, and the decode call is the single
conversion.
## Acceptance Criteria
- [x] Source trait(s) take the vault handle injected; `EncryptedData`
opaque (no `key_version` interpretation anywhere in `src/`)
- [x] Decrypt → `Zeroizing<String>` → one-time `decode_secret_key`
chain in place; the plaintext never outlives the wrap
- [x] Host-key load path returns the decoded key set; locked vault →
`KeySourceError::Locked` (no server construction possible)
- [x] Round-trip test: encrypt(PEM) → source → decoded ed25519 key
usable by russh
- [x] Failure-mode tests: locked / absent / tampered ciphertext /
unknown version / undecodable plaintext each map to their
distinct variant
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check` pass
## References
- docs/architecture/decisions/005-at-rest-key-custody.md (decisions
1–7)
- docs/architecture/auth.md §Key custody
- docs/research/poc-2-vault-keys.md (§1 the 4-way table, §2 lifecycle
+ zeroization bounds, §3 failure matrix, §5 host keys end to end)
- docs/research/auth-bridge.md §The gap (the source-shape reasoning)
## Notes
> `src/key_source.rs` landed: two injected-handle seams and the
> crate-provided vault-backed implementations over them.
>
> - **Traits** (`async_trait`, the `HostKeyPolicy` handle shape):
> `KeySource::load() -> Result<PrivateKey, KeySourceError>` (the
> wave-4 dial-config carries `Arc<dyn KeySource>`) and
> `HostKeySource::load() -> Result<Vec<PrivateKey>,
> KeySourceError>` (wave-2 serve-config; decrypt-at-construction,
> a *set* because `Config::keys` takes multiple). An assembly may
> implement either trait itself; the crate ships the ADR-005
> implementations and the custody machinery.
> - **`VaultKeySource`**: decrypt-at-use over an injected clone of
> the assembly's `VaultServiceHandle` + one injected
> `Option<EncryptedData>` record (`None` → `Absent`, the POC-2 §1
> `KeySource { blob: Option<EncryptedData> }` shape).
> **`VaultHostKeySource`**: same model over a record set (empty set
> → `Absent`). Both are decrypt-at-use chains
> (`decrypt_and_decode`, shared): `vault.decrypt(blob)` →
> **`Zeroizing<String>` at the seam** → one-time
> `decode_secret_key(plain, None)`. Blobs are never interpreted in
> `src/`: no `key_version` reads, no record internals.
> - **Failure mapping** (`map_vault_error`): `VaultLocked` →
> `Locked`; `InvalidPath` → `UnknownKeyVersion`; `Encryption` →
> `Decryption`. The vault's decrypt-path-unreachable variants
> (`Derivation`/`Mnemonic`/`AlreadyUnlocked`/`UnsupportedKeyType`)
> land in the `Decryption` refuse-at-crypto class with honest
> message text; the five-variant matrix stays closed, nothing
> collapses a real custody condition into success.
> - **Tests** (12, all in-module with the real in-memory
> alkvault handle: `VaultServiceHandle::new()` is the in-memory
> construction, so no local vault double was needed):
> round-trip (encrypt PEM → source → fingerprint-identical decodable
> ed25519 key that signs), dyn-handle load, host-key set order,
> load-works-after-lock (POC-2 §2 lifecycle), locked / absent /
> tampered-ciphertext / wrong-mnemonic → `Decryption` (distinct
> from tampered only by message, both GCM-open) / version 0+1 →
> `UnknownKeyVersion` / non-key plaintext → `Decode`, locked
> host-bootstrap refusal, empty host set, v2→v3 rotation-preserves
> -identity through the source. Key fixtures: three committed
> throwaway ed25519 PEMs embedded as test constants
> (w1-test-doubles' committed-static-PEM posture; generated at
> test-fixture time with ssh-keygen, never deployed/registered).
>
> Interpretation pinned for test-doubles: alkvault 0.1 *does* ship
> the in-memory construction (`VaultServiceHandle`), so the double
> wraps the real handle; no local decrypt/decrypt double.
## Summary
> `src/key_source.rs` (lib re-exports `KeySource`, `HostKeySource`,
> `VaultKeySource`, `VaultHostKeySource`), and ADR-005 is in code:
> the
> injected-handle source seams both directions consume, the crate's
> vault-backed implementations carrying decrypt-at-use (vault.decrypt
> → `Zeroizing<String>` → one-time `decode_secret_key`), opaque
> `EncryptedData`, and the full five-variant failure mapping.
> 12 tests cover the round-trip (usable-by-russh via signing), the
> full failure matrix, the host-key decrypt-at-construction refusal
> (locked → no server constructed), lock-mid-session lifecycle, and
> rotation identity preservation. Verified: cargo test (23 + 4
> parity), cargo test --all-features, clippy -D warnings (default +
> all-features), fmt --check, taskgraph validate.