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.
6.4 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| w1-key-sources | Wave 1: vault-backed key sources (ADR-005 custody in code) | completed |
|
moderate | medium | project | 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,
EncryptedDataopaque to the crate (ADR-005 decision 5: the crate never interpretskey_versionor 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-timedecode_secret_key(plain, None)→ the decodedPrivateKeylives 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
PrivateKeyset the serverConfig::keyswill consume (theConfigconstruction 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
KeySourceErrorvariant (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
Zeroizingwrap 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
- Source trait(s) take the vault handle injected;
EncryptedDataopaque (nokey_versioninterpretation anywhere insrc/) - Decrypt →
Zeroizing<String>→ one-timedecode_secret_keychain in place; the plaintext never outlives the wrap - Host-key load path returns the decoded key set; locked vault →
KeySourceError::Locked(no server construction possible) - Round-trip test: encrypt(PEM) → source → decoded ed25519 key usable by russh
- Failure-mode tests: locked / absent / tampered ciphertext / unknown version / undecodable plaintext each map to their distinct variant
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --checkpass
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.rslanded: two injected-handle seams and the crate-provided vault-backed implementations over them.
Traits (
async_trait, theHostKeyPolicyhandle shape):KeySource::load() -> Result<PrivateKey, KeySourceError>(the wave-4 dial-config carriesArc<dyn KeySource>) andHostKeySource::load() -> Result<Vec<PrivateKey>, KeySourceError>(wave-2 serve-config; decrypt-at-construction, a set becauseConfig::keystakes 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'sVaultServiceHandle+ one injectedOption<EncryptedData>record (None→Absent, the POC-2 §1KeySource { 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-timedecode_secret_key(plain, None). Blobs are never interpreted insrc/: nokey_versionreads, 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 theDecryptionrefuse-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-exportsKeySource,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-timedecode_secret_key), opaqueEncryptedData, 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.