--- id: w1-host-key-policy name: "Wave 1: the HostKeyPolicy seam (OQ-05, policy-blind)" status: completed depends_on: [w1-manifest] scope: narrow risk: low impact: project level: implementation --- ## Description Land the client-side host-key policy seam (`auth.md` §Host-key policy, OQ-05 resolution), the trust-decision contract the dial surface calls: - **The `HostKeyPolicy` trait**: the crate surfaces the presented host key (host, port, fingerprint) to an assembly-provided policy that decides accept/reject. The two OQ-05 cases are the *implementor's*: case-1 pins (known beforehand, hard-decline on mismatch) and case-2 policies (unknown beforehand: prompt-and-record, learn-and-record, provider-metadata match, accept-and-continue for ephemeral fleets). The crate is **policy-blind**: it never decides trust, never hard-codes a trust model. - **The crate default is fail-closed**: reject unknown (russh's own reject-all default, kept as the built-in default policy). **No accept-all built-in crate option**: a permissive case-2 policy is the assembly's to write. - The trait receives the fingerprint string (the wave-1 fingerprint computation is the caller's input path, wave-4 dial); the exact decision type (bool vs a richer verdict carrying a reason) is the task's to pin against the russh `check_server_key` hook shape at this pin (flagged for the wave-1 gate). - The russh client-handler consumption (`check_server_key` wiring) is wave-4 dial-surface material; this task lands the seam and its default only. ## Acceptance Criteria - [x] `HostKeyPolicy` trait in place; the presented-key context (host, port, fingerprint) is the decision input - [x] Built-in default policy rejects unknown keys (fail-closed); no accept-all option exists in the crate - [x] Doc comments carry the two-case model and the policy-blind rule - [x] Unit tests: default policy rejects; a custom policy's verdict is honored - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/architecture/auth.md §Host-key policy (OQ-05 resolution) - docs/architecture/client.md §The dial surface, §What the crate does not include (trust decisions) - docs/architecture/decisions/005-at-rest-key-custody.md (decision 6: the client host-*pin* is public config, never vault material) - docs/research/auth-bridge.md §Host keys and `check_server_key` ## Notes > Landed `src/host_key.rs` (`HostKeyPolicy`, `PresentedHostKey`, > `RejectAll`) + lib.rs re-exports. Design decisions pinned against > the russh 0.65 hook shape (`Handler::check_server_key -> > Result`, async via `impl Future`, default > `Ok(false)`, verified in the russh 0.65.0 source): > > - **The verdict type is `bool`** (the flagged pin): the hook's bool > is the trust decision and its `Err` channel is the handler's > wire/IO-failure territory; a trust decline has no typed reason > channel on this surface. A decline is a disposition, not an > error (`src/error.rs` contract); decline reasons are the > policy's own record (case-2 policies log their decisions), not a > crate surface. Wave-4 wiring maps `false` → russh's hard, > clean `Error::UnknownKey` decline (mismatch = hard decline, > per auth.md). > - **The trait is async and dyn-compatible**: `#[async_trait]`, > `decide(&self, &PresentedHostKey) -> bool`, `Send + Sync + > 'static`, the family injected-handle pattern (`Arc HostKeyPolicy>`, the alkcall `IdentityProvider`/`OperationEnv` > precedent), which w1-config's dial-config handle and > w1-test-doubles' inspectable double both require. State-holding > (learn-and-record) policies record by interior mutability > behind the shared `&self` handle. > - **New dep `async-trait = "0.1"`** (MIT OR Apache-2.0; the only > way to get a dyn-compatible async trait at this toolchain, as > native AFIT is not object-safe, and russh 0.65 removed its own > `async-trait` feature, per ADR-002's context). Precedented and > deduped: alkcall already carries `async-trait 0.1`, so the tree > adds no new transitive package. **Flagged for the wave-1 gate** > (manifest addition outside w1-manifest, same class as the > verdict-type pin). > - **`RejectAll` is the built-in default** (russh's reject-all > default, kept); doc comments carry the two-case model, the > policy-blind rule, the no-accept-all rule, the fail-closed > disposition (an undecided policy declines), and the host-pin-is- > public-config note (ADR-005 decision 6). > - The `PresentedHostKey.fingerprint` doc pins exact string > equality against assembly pins; the string is the single > fingerprint path's format, computed at wave-4 wiring. ## Summary > Seam landed: `src/host_key.rs` with the `HostKeyPolicy` trait > (`async fn decide(&self, &PresentedHostKey) -> bool`, > dyn-compatible via `#[async_trait]`), the `PresentedHostKey` > (host/port/fingerprint) decision input, and the fail-closed > `RejectAll` built-in default; no accept-all option exists in the > crate. Verdict type pinned to russh 0.65's `check_server_key` > shape (bool = the trust decision; decline reasons live with the > policy, not the crate). `async-trait = "0.1"` added for the > dyn-compatible async handle (alkcall-precedented, tree-deduped). > Tests: default rejects; custom verdict honored; both honored > through an `Arc`; the whole presented context reaches > the policy. Verified: `cargo test` (14), `cargo test > --all-features`, `cargo clippy --all-targets -- -D warnings`, > `cargo fmt --check` all pass. `taskgraph validate` clean.