From 149f11d4b204733b11dbcdd73717220f546ee407 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 28 Oct 2026 06:40:40 +0000 Subject: [PATCH] feat(host-key): land the HostKeyPolicy seam (OQ-05, policy-blind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HostKeyPolicy trait: async, dyn-compatible (Arc 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 --- Cargo.lock | 1 + Cargo.toml | 4 + src/host_key.rs | 182 +++++++++++++++++++++ src/lib.rs | 2 + tasks/implementation/w1-host-key-policy.md | 67 +++++++- 5 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 src/host_key.rs diff --git a/Cargo.lock b/Cargo.lock index 3f84762..ec55adc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,7 @@ version = "0.1.0" dependencies = [ "alkcall", "alkvault", + "async-trait", "parking_lot", "russh", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 09b39e4..971d9bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,10 @@ tunnel = [] sftp = [] [dependencies] +# async-trait (the alkcall precedent) makes the injected handles +# dyn-compatible (`Arc`) — the HostKeyPolicy dial handle needs +# that, and native AFIT traits are not object-safe at this toolchain. +async-trait = "0.1" # ADR-002: aws-lc-rs only; default features off — no flate2, no rsa # (ed25519-only; the tree stays RC-free except the ssh-key pin riding the # russh keys path). Never enable russh's `des`/`dsa` features. diff --git a/src/host_key.rs b/src/host_key.rs new file mode 100644 index 0000000..828b6a2 --- /dev/null +++ b/src/host_key.rs @@ -0,0 +1,182 @@ +//! The client-side host-key policy seam (OQ-05 resolution) — the +//! trust-decision contract the dial surface calls at russh's +//! `check_server_key` hook. +//! +//! Two cases, one seam — the crate is **policy-blind** +//! (`docs/architecture/auth.md` §Host-key policy): +//! +//! 1. **Known beforehand** (trust anchors exist; family-built runners +//! mint their host key at assembly time): assembly-supplied pins +//! with **hard decline on mismatch**. The crate never hard-codes +//! this — a pin policy is a [`HostKeyPolicy`] implementation the +//! assembly writes, backed by its own pin store (the host *pin* is +//! public config, ADR-005 decision 6; never vault material). +//! 2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no +//! anchor until first contact): prompt-and-record, +//! provider-metadata match, learn-and-record, or accept-and- +//! continue (the ephemeral-fleet shape) — the OpenSSH +//! "add to known_hosts?" moment is assembly policy, implemented in +//! its [`HostKeyPolicy`], never crate behavior. +//! +//! Crate rules: +//! +//! - The built-in default policy is **fail-closed**: [`RejectAll`] +//! rejects every presented key — russh's own reject-all default, +//! kept. There is **no accept-all built-in crate option**: a +//! permissive policy is the assembly's to write when its fleet +//! model calls for one. +//! - Every trust decision is the policy's; the crate never decides +//! trust and never hard-codes a trust model. +//! - The verdict type is the accept/reject `bool`, pinned to the +//! russh hook shape at this pin (`Handler::check_server_key -> +//! Result`): the bool *is* the trust decision, +//! and the handler's `Err` channel stays wire/IO-failure territory. +//! A decline is a disposition, not an error (the crate's +//! error-model contract) — decline reasons are the policy's own +//! record, not a typed crate surface. +//! - The [`PresentedHostKey`]'s fingerprint string is the contract- +//! invariant format of the crate's single fingerprint path +//! ([`crate::fingerprint`]) — the wave-4 dial wiring computes it +//! there and surfaces it here. +//! +//! This module is the seam and its default only: the russh +//! client-handler consumption (`check_server_key` wiring) is wave-4 +//! dial-surface material; no dialing happens on this path yet. + +use async_trait::async_trait; + +/// The presented host key — the decision input to +/// [`HostKeyPolicy::decide`]: the endpoint the dial contacted and the +/// key that endpoint presented. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresentedHostKey { + /// The hostname (or IP) the dial targeted — what the assembly's + /// pin store or prompt labels the key against. + pub host: String, + /// The TCP port the dial targeted. + pub port: u16, + /// The presented key's fingerprint — the single fingerprint + /// path's `SHA256:` format ([`crate::fingerprint`]); comparison + /// against assembly pins or recorded entries is exact string + /// equality by contract. + pub fingerprint: String, +} + +/// The trust-decision contract the dial surface calls (OQ-05): the +/// crate surfaces the presented host key (host, port, fingerprint) to +/// the assembly-provided policy, which decides accept or reject. The +/// crate is policy-blind — it never decides trust itself and carries +/// no trust model (see the module docs for the two-case model). +/// +/// The handle shape is the family's injected-handle pattern +/// (`Arc`, the alkcall `IdentityProvider` +/// precedent): object-safe via [`async_trait`], shared by reference — +/// learn-and-record policies hold their state by interior mutability +/// so the same handle serves the dial and any inspection. +/// +/// **Fail-closed posture**: when a policy cannot decide (its pin +/// store is unavailable, its prompt failed, its metadata source is +/// down), it must decline (`false`). The built-in default, +/// [`RejectAll`], embodies the same posture. +#[async_trait] +pub trait HostKeyPolicy: Send + Sync + 'static { + /// Decides the trust verdict for one presented host key: `true` + /// accepts the key for this dial, `false` declines it — the dial + /// connection aborts on decline (russh's `check_server_key` + /// `false` → `Error::UnknownKey`, a hard, clean decline; a + /// mismatched pin is a hard decline, never a soft failure). + async fn decide(&self, presented: &PresentedHostKey) -> bool; +} + +/// The built-in default host-key policy — **fail-closed**: reject +/// every presented key. +/// +/// russh's own reject-all default, kept as the crate's built-in +/// default policy (`docs/architecture/auth.md` §Host-key policy). A +/// dial that ships this policy can never connect — by design: trust +/// is the assembly's decision to make, and the crate's default must +/// never let a dial through on the crate's authority. There is no +/// accept-all counterpart in the crate; the permissive case-2 policy +/// (the ephemeral-fleet/rental shape) is the assembly's to write. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RejectAll; + +#[async_trait] +impl HostKeyPolicy for RejectAll { + async fn decide(&self, _: &PresentedHostKey) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::{HostKeyPolicy, PresentedHostKey, RejectAll}; + use std::sync::Arc; + + fn presented(fingerprint: &str) -> PresentedHostKey { + PresentedHostKey { + host: "runner.example".into(), + port: 2222, + fingerprint: fingerprint.into(), + } + } + + #[async_trait::async_trait] + impl HostKeyPolicy for YesAll { + async fn decide(&self, _: &PresentedHostKey) -> bool { + true + } + } + + struct YesAll; + + #[tokio::test] + async fn default_policy_rejects_everything() { + let policy = RejectAll; + assert!(!policy.decide(&presented("SHA256:unknown")).await); + } + + #[tokio::test] + async fn custom_policy_verdict_is_honored() { + let policy = YesAll; + assert!(policy.decide(&presented("SHA256:any")).await); + } + + #[tokio::test] + async fn dyn_handle_honors_both_verdicts() { + let reject: Arc = Arc::new(RejectAll); + let accept: Arc = Arc::new(YesAll); + assert!(!reject.decide(&presented("SHA256:x")).await); + assert!(accept.decide(&presented("SHA256:x")).await); + } + + #[tokio::test] + async fn policy_receives_the_whole_presented_context() { + struct Recording { + seen: parking_lot::Mutex>, + } + + #[async_trait::async_trait] + impl HostKeyPolicy for Recording { + async fn decide(&self, presented: &PresentedHostKey) -> bool { + self.seen.lock().push(presented.clone()); + false + } + } + + let policy = Arc::new(Recording { + seen: parking_lot::Mutex::new(Vec::new()), + }); + policy.decide(&presented("SHA256:abc123")).await; + let seen = policy.seen.lock().clone(); + assert_eq!(seen.len(), 1); + assert_eq!( + seen[0], + PresentedHostKey { + host: "runner.example".into(), + port: 2222, + fingerprint: "SHA256:abc123".into(), + } + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8304788..6f82379 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,8 @@ mod error; mod fingerprint; +mod host_key; pub use error::KeySourceError; pub use fingerprint::fingerprint; +pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll}; diff --git a/tasks/implementation/w1-host-key-policy.md b/tasks/implementation/w1-host-key-policy.md index 2ee9d46..c0085de 100644 --- a/tasks/implementation/w1-host-key-policy.md +++ b/tasks/implementation/w1-host-key-policy.md @@ -1,7 +1,7 @@ --- id: w1-host-key-policy name: Wave 1 — the HostKeyPolicy seam (OQ-05, policy-blind) -status: pending +status: completed depends_on: [w1-manifest] scope: narrow risk: low @@ -37,14 +37,14 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls: ## Acceptance Criteria -- [ ] `HostKeyPolicy` trait in place; the presented-key context +- [x] `HostKeyPolicy` trait in place; the presented-key context (host, port, fingerprint) is the decision input -- [ ] Built-in default policy rejects unknown keys (fail-closed); +- [x] Built-in default policy rejects unknown keys (fail-closed); no accept-all option exists in the crate -- [ ] Doc comments carry the two-case model and the policy-blind rule -- [ ] Unit tests: default policy rejects; a custom policy's verdict +- [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 -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -58,8 +58,59 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls: ## Notes -> To be filled by implementation agent +> 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 — +> 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 -> To be filled on completion \ No newline at end of file +> 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. \ No newline at end of file