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:
1 parent
22b82af517
commit
149f11d4b2
5 files changed
+248
-8
No files matched your search
Generated
+1
@@ -89,6 +89,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"alkcall",
|
"alkcall",
|
||||||
"alkvault",
|
"alkvault",
|
||||||
|
"async-trait",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"russh",
|
"russh",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ tunnel = []
|
|||||||
sftp = []
|
sftp = []
|
||||||
|
|
||||||
[dependencies]
|
[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
|
# 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
|
# (ed25519-only; the tree stays RC-free except the ssh-key pin riding the
|
||||||
# russh keys path). Never enable russh's `des`/`dsa` features.
|
# russh keys path). Never enable russh's `des`/`dsa` features.
|
||||||
|
|||||||
+182
@@ -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(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
mod fingerprint;
|
mod fingerprint;
|
||||||
|
mod host_key;
|
||||||
|
|
||||||
pub use error::KeySourceError;
|
pub use error::KeySourceError;
|
||||||
pub use fingerprint::fingerprint;
|
pub use fingerprint::fingerprint;
|
||||||
|
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: w1-host-key-policy
|
id: w1-host-key-policy
|
||||||
name: Wave 1 — the HostKeyPolicy seam (OQ-05, policy-blind)
|
name: Wave 1 — the HostKeyPolicy seam (OQ-05, policy-blind)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [w1-manifest]
|
depends_on: [w1-manifest]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: low
|
risk: low
|
||||||
@@ -37,14 +37,14 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls:
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## 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
|
(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
|
no accept-all option exists in the crate
|
||||||
- [ ] Doc comments carry the two-case model and the policy-blind rule
|
- [x] Doc comments carry the two-case model and the policy-blind rule
|
||||||
- [ ] Unit tests: default policy rejects; a custom policy's verdict
|
- [x] Unit tests: default policy rejects; a custom policy's verdict
|
||||||
is honored
|
is honored
|
||||||
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||||
`cargo fmt --check` pass
|
`cargo fmt --check` pass
|
||||||
|
|
||||||
## References
|
## References
|
||||||
@@ -58,8 +58,59 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls:
|
|||||||
|
|
||||||
## Notes
|
## 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
|
## 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.
|
||||||
Reference in new issue
Block a user