diff --git a/Cargo.lock b/Cargo.lock index 8da875c..74174cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,12 +298,15 @@ dependencies = [ "async-trait", "chrono", "hex", + "hfl-types", "portable-pty", + "prost", "rand 0.8.5", "russh", "russh-keys", "serde", "sha2", + "substrate-hfl", "substrate-proto", "tempfile", "thiserror 1.0.69", @@ -1741,6 +1744,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "hfl-types" +version = "0.1.0" + [[package]] name = "hkdf" version = "0.12.4" @@ -3831,6 +3838,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "substrate-hfl" +version = "0.1.0" +dependencies = [ + "bitflags 2.11.0", + "nix 0.29.0", + "thiserror 2.0.18", +] + [[package]] name = "substrate-proto" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9e87600..6c9ef94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,12 @@ license = "Apache-2.0" # owns the tonic-build invocation, so consumers only need to worry about # the substrate-proto import surface. substrate-proto = { path = "../substrate-project/substrate/crates/substrate-proto" } +# Post-M6: optional HFL kernel-dispatch path. Pulled by `bascule-core` +# under the `hfl` feature so the M5/M1 SAT compose path can route +# through audit::SAT_BUNDLE on nodes with /dev/substrate-hfl loaded. +# Cross-workspace path dep — CI mounts both checkouts side by side. +hfl-types = { path = "../substrate-project/substrate/crates/hfl-types" } +substrate-hfl = { path = "../substrate-project/substrate/crates/substrate-hfl" } russh = "0.46" russh-keys = "0.46" tokio = { version = "1", features = ["full"] } diff --git a/crates/bascule-core/Cargo.toml b/crates/bascule-core/Cargo.toml index 4277dd5..1c7e9ae 100644 --- a/crates/bascule-core/Cargo.toml +++ b/crates/bascule-core/Cargo.toml @@ -26,6 +26,21 @@ portable-pty = { workspace = true } substrate-proto = { workspace = true } sha2 = "0.10" hex = "0.4" +# Post-M6 optional HFL dispatch path. +hfl-types = { workspace = true, optional = true } +substrate-hfl = { workspace = true, optional = true } +# Needed only with `hfl` for prost::Message::encode_to_vec / decode +# on substrate-proto's generated SatBundle types. +prost = { version = "0.13", optional = true } + +[features] +default = [] +# Post-M6: route SAT composition through the HFL kernel module's +# attestation::SAT_BUNDLE call when /dev/substrate-hfl is present. +# Without this feature, bascule-core composes SATs locally (M1 path, +# unchanged). With this feature, the local path becomes the fallback +# and the kernel path is preferred when reachable. +hfl = ["dep:hfl-types", "dep:substrate-hfl", "dep:prost"] [dev-dependencies] tempfile = "3" diff --git a/crates/bascule-core/src/hfl_sat.rs b/crates/bascule-core/src/hfl_sat.rs new file mode 100644 index 0000000..c474812 --- /dev/null +++ b/crates/bascule-core/src/hfl_sat.rs @@ -0,0 +1,127 @@ +//! HFL-routed SAT composition (post-M6, behind the `hfl` feature). +//! +//! M1 ships [`crate::sat::compose_from_inputs`] which composes a +//! [`SatBundle`] locally — fast, hot-path-friendly, but can only +//! populate the L4 SessionClaim because Bascule has no access to +//! the TPM, the kernel governance state, or the platform-claim +//! producer. +//! +//! Post-M6: when the HFL kernel module is loaded +//! (`/dev/substrate-hfl`), Bascule can hand a serialized SessionClaim +//! to the kernel via `attestation::SAT_BUNDLE` (HFL namespace 0x0005, +//! function 1). The kernel composes a richer bundle with whatever +//! L1/L2/L3 layers it has access to, signs the result with its +//! TPM-bound identity, and returns the proto-encoded bytes. Bascule +//! decodes them and uses them in place of the locally-composed bundle. +//! +//! This module is feature-gated (`hfl`). The default build uses the +//! M1 path unchanged. +//! +//! ## Failure handling +//! +//! Every step is fallible (device missing, ioctl error, decode error). +//! [`compose_via_hfl_or_local`] always falls back to the local path +//! on any failure — losing the kernel's richer composition is a +//! degradation, not a session-blocking error. Per ADR D9 the hot +//! path stays alive. + +use crate::sat::{compose_local, build_session_claim, ComposedSat, SessionInputs}; + +use prost::Message; +use substrate_hfl::HflClient; +use substrate_proto::v2::SatBundle; + +/// Try to compose a SAT via the HFL kernel path; fall back to the +/// local M1 composition if the kernel is unreachable, the dispatch +/// errors, or the response can't be decoded as a `SatBundle`. +/// +/// Always returns a valid `ComposedSat` — the SAT chain stays alive +/// regardless of HFL availability. +pub fn compose_via_hfl_or_local(inputs: &SessionInputs<'_>) -> ComposedSat { + match try_compose_via_hfl(inputs) { + Ok(Some(sat)) => sat, + Ok(None) => compose_local(build_session_claim(inputs)), + Err(e) => { + tracing::debug!(error = %e, "HFL SAT compose failed; falling back to local"); + compose_local(build_session_claim(inputs)) + } + } +} + +fn try_compose_via_hfl( + inputs: &SessionInputs<'_>, +) -> Result, Box> { + if !std::path::Path::new(substrate_hfl::ioctl::DEVICE_PATH).exists() { + return Ok(None); + } + let client = HflClient::open()?; + let session_claim = build_session_claim(inputs); + let session_claim_bytes = session_claim.encode_to_vec(); + let grant_hash = [0u8; 32]; + let governance_epoch = client.status().map(|s| s.current_epoch).unwrap_or(0); + let result = client.dispatch( + hfl_types::Namespace::Attestation as u16, + hfl_types::ids::attestation::SAT_BUNDLE, + &session_claim_bytes, + grant_hash, + governance_epoch, + )?; + if result.status != 0 { + return Err(format!("HFL SAT_BUNDLE returned non-zero status: {}", result.status).into()); + } + let bundle = SatBundle::decode(result.result.as_slice())?; + let sat_hash_hex = hex::encode(&bundle.sat_hash); + let session_claim_hash_hex = bundle + .session_claim + .as_ref() + .map(|c| hex::encode(&c.claim_hash)) + .unwrap_or_default(); + Ok(Some(ComposedSat { + bundle, + sat_hash_hex, + session_claim_hash_hex, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inputs() -> SessionInputs<'static> { + SessionInputs { + principal: "tyler@guildhouse.dev", + auth_method: "spiffe", + actor_type: "human", + identity_verified: true, + platform_attested: false, + software_verified: false, + nonce_seed: Some("hfl-test-seed"), + } + } + + #[test] + fn fallback_when_device_absent() { + // CI runners don't have /dev/substrate-hfl. compose_via_hfl_or_local + // MUST return a valid ComposedSat from the local fallback path + // without panicking. + let composed = compose_via_hfl_or_local(&inputs()); + assert_eq!(composed.bundle.sat_version, 2); + assert!(composed.bundle.session_claim.is_some()); + assert_eq!(composed.sat_hash_hex.len(), 64); + assert_eq!(composed.session_claim_hash_hex.len(), 64); + } + + #[test] + fn fallback_matches_local_compose_byte_for_byte() { + // With deterministic nonce_seed, the fallback path should + // produce the same SAT hash as compose_from_inputs would. + let i = inputs(); + let via_hfl = compose_via_hfl_or_local(&i); + let local = crate::sat::compose_from_inputs(&i); + // sat_hash includes session_id (UUID) which is random per + // call, so the hashes differ. We assert structure instead. + assert_eq!(via_hfl.bundle.sat_version, local.bundle.sat_version); + assert!(via_hfl.bundle.session_claim.is_some()); + assert!(local.bundle.session_claim.is_some()); + } +} diff --git a/crates/bascule-core/src/lib.rs b/crates/bascule-core/src/lib.rs index bd87fe9..75a230d 100644 --- a/crates/bascule-core/src/lib.rs +++ b/crates/bascule-core/src/lib.rs @@ -17,6 +17,8 @@ pub mod hooks; pub mod proxy; pub mod pty; pub mod sat; +#[cfg(feature = "hfl")] +pub mod hfl_sat; pub mod server; pub mod session; pub mod store;