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
This commit is contained in:
glm-5.3-flash committed 2026-10-28 06:40:40 +00:00
1 parent 22b82af517
commit 149f11d4b2
5 files changed
+248 -8

No files matched your search

Generated
+1
View File
@@ -89,6 +89,7 @@ version = "0.1.0"
dependencies = [
"alkcall",
"alkvault",
"async-trait",
"parking_lot",
"russh",
"thiserror",
+4
View File
@@ -36,6 +36,10 @@ tunnel = []
sftp = []
[dependencies]
# async-trait (the alkcall precedent) makes the injected handles
# dyn-compatible (`Arc<dyn ...>`) — 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.
+182
View File
@@ -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<bool, Self::Error>`): 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<dyn HostKeyPolicy>`, 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<dyn HostKeyPolicy> = Arc::new(RejectAll);
let accept: Arc<dyn HostKeyPolicy> = 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<Vec<PresentedHostKey>>,
}
#[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(),
}
);
}
}
+2
View File
@@ -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};
+59 -8
View File
@@ -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<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
> To be filled on completion
> 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.