Files
alkssh/tasks/implementation/w1-key-sources.md
T
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

6.4 KiB
Raw Blame History

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
w1-manifest
w1-error-model
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, 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

  • Source trait(s) take the vault handle injected; EncryptedData opaque (no key_version interpretation anywhere in src/)
  • Decrypt → Zeroizing<String> → one-time decode_secret_key chain 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 --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.