- 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
116 lines
5.6 KiB
Markdown
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. |