feat(hfl): bascule-core SAT compose routes through HFL when available
Post-M6 enhancement: when /dev/substrate-hfl is loaded and the
bascule-core `hfl` feature is enabled, the new
compose_via_hfl_or_local() entry point hands a serialized
SessionClaim to the kernel's attestation::SAT_BUNDLE function and
uses the kernel-composed SatBundle (proto-encoded) in place of the
locally-composed M1 bundle. Kernel composition has access to TPM
state, governance state, and platform-claim producers Bascule can't
reach from userspace.
Without the `hfl` feature: M1 path unchanged.
With the `hfl` feature but no kernel module: graceful fallback to
the M1 local compose path. Per ADR D9, the SAT chain stays alive
regardless of HFL availability.
bascule-core::hfl_sat (NEW, behind --features hfl):
- compose_via_hfl_or_local(inputs) tries the kernel path first.
On any failure (device missing, ioctl error, decode error)
it logs at debug and returns the local M1 compose result.
- try_compose_via_hfl() encodes the SessionClaim with prost,
dispatches via HflClient::dispatch(0x0005, 1, claim_bytes,
[0u8;32], current_epoch), and decodes the result as a
proto SatBundle.
- 2 unit tests cover the device-absent fallback path (+ structure
equivalence with the M1 local compose).
Cargo.toml:
- Workspace deps: hfl-types + substrate-hfl as path deps to the
substrate workspace (cross-workspace, CI mounts both checkouts
side by side).
- bascule-core gains a `hfl` feature gating hfl-types +
substrate-hfl + prost (the last for SessionClaim::encode_to_vec
on the substrate-proto-generated types).
Tested (Docker rust:1.88-bookworm):
cargo build -p bascule-core clean
cargo test -p bascule-core --lib sat 7/7 (M1 regression)
cargo build -p bascule-core --features hfl clean
cargo test -p bascule-core --features hfl --lib 26/26
+2 hfl_sat tests on top of the existing bascule-core suite
Branched off main (post-merge of the M1..M5 stack).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
This commit is contained in:
parent
2520525ec6
commit
33f6bf729a
5 changed files with 166 additions and 0 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
127
crates/bascule-core/src/hfl_sat.rs
Normal file
127
crates/bascule-core/src/hfl_sat.rs
Normal file
|
|
@ -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<Option<ComposedSat>, Box<dyn std::error::Error>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue