Files
alkssh/tasks/implementation/w1-key-sources.md
T
glm-5.3-flash 4d529dbacf feat(key-source): land the vault-backed key sources (ADR-005 custody in code)
- Two injected-handle seams: KeySource (dial-config, decrypt-at-use ->
  one PrivateKey) and HostKeySource (serve-config, decrypt-at-construction
  -> the Config::keys set); async_trait object-safe, the HostKeyPolicy
  handle shape
- Crate-provided ADR-005 implementations: VaultKeySource (injected
  vault handle + Option<EncryptedData>, None -> Absent) and
  VaultHostKeySource (record set, empty -> Absent) over the shared
  decrypt_and_decode chain: vault.decrypt -> Zeroizing<String> seam
  wrap -> one-time decode_secret_key(plain, None)
- EncryptedData opaque in src/ (no key_version interpretation); vault
  error taxonomy reshaped into the five-variant KeySourceError matrix,
  never collapsed; plaintext never persists
- 12 in-module tests on the real in-memory alkvault handle: round-trip
  (fingerprint-identical ed25519 key that signs), full failure matrix,
  locked host-bootstrap refusal, lock-mid-session lifecycle,
  v2->v3 rotation identity; three committed throwaway PEM fixtures

Verified: cargo test (23 + 4 parity), cargo test --all-features,
clippy -D warnings (default + all-features), fmt --check, taskgraph
validate
2026-10-28 06:45:56 +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; 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) — ADR-005 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.