Files
alkssh/tasks/implementation/w1-host-key-policy.md
T
glm-5.3-flash 149f11d4b2 feat(host-key): land the HostKeyPolicy seam (OQ-05, policy-blind)
- HostKeyPolicy trait: async, dyn-compatible (Arc<dyn> handle, the
  alkcall injected-handle pattern) — decide(&self, &PresentedHostKey)
  -> bool with host/port/fingerprint as the decision input
- Verdict type pinned to the russh 0.65 check_server_key shape:
  bool is the trust decision; decline reasons stay with the policy
  (declines are dispositions, not errors). Flagged for the wave-1 gate
- Built-in default RejectAll: fail-closed reject-all (russh's default,
  kept); no accept-all option exists in the crate
- async-trait 0.1 added for the dyn-compatible async handle
  (alkcall-precedented; tree-deduped). Flagged for the wave-1 gate
- Tests: default rejects; custom verdict honored; both through the dyn
  handle; the whole presented context reaches the policy

Verified: cargo test (14), cargo test --all-features, cargo clippy
--all-targets -- -D warnings, cargo fmt --check, taskgraph validate
2026-10-28 06:40:40 +00:00

116 lines
5.6 KiB
Markdown

---
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<bool, Self::Error>`, 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<dyn
> 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 —
> 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<dyn Handle>`; 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.