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.
131 lines
6.4 KiB
Markdown
131 lines
6.4 KiB
Markdown
---
|
||
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. |