From 999c78ef4caffd1c434050c80eb88cb9995dba860b65d37f6efbcbcefe3cde7a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 7 Apr 2026 14:38:20 -0400 Subject: [PATCH] feat(m1): bascule-shell composes a real SAT anchored on session_leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the opaque BASCULE_ATTESTATION_HASH (a SHA over a "pcrN:val;ima:hash;" evidence string) with a real proto-canonical SatBundle composed from the operator's identity + local platform attestation, anchored on the L4 SessionClaim. bascule-core::sat (NEW): pure composer module. - build_session_claim(SessionInputs) -> SessionClaim builds the L4 leaf from {principal, auth_method, actor_type, identity_verified, platform_attested, software_verified, nonce_seed}, computes posture per SAT-SPEC-0002 §7, and populates the L1/L2/L3 binding fields with zero-padded placeholders until upstream producers exist. - compose_local(SessionClaim) -> ComposedSat assembles the proto SatBundle via SatBundleBuilder. Hot path stays local per ADR D9 (zero network); QM's gRPC ComposeSat is the warm-path surface. - 7 unit tests cover layer/actor wiring, posture math at each evidence level, deterministic nonce, sat_hash uniqueness across principal changes. bascule-shell: composes the SAT in main() right before execvp of the inner shell — that's the OSS equivalent of an "Authenticated -> ShellActive" transition (the OSS Bascule has no russh state machine; it's a CLI wrapper). Exports the new env var surface: BASCULE_SAT_HASH hex of proto sat_hash (canonical) BASCULE_SESSION_CLAIM_HASH hex of L4 leaf hash BASCULE_SESSION_ID UUID from SessionClaim BASCULE_POSTURE_LEVEL SAT-SPEC-0002 §7 posture BASCULE_ATTESTATION_HASH retained as compat alias (gsh / dashboard consumers); now points at the proto sat_hash, not the old evidence-string SHA. Cross-workspace path dep: substrate-proto via ../substrate-project/substrate/crates/substrate-proto. CI mounts ~/projects as one volume so the path resolves. Switching to a git dep is post-MVP. Note: russh-keys pulls `home` which requires Rust 1.88; CI bumps the docker image accordingly. No code change. Tested: cargo build -p bascule-core -p bascule-shell clean cargo test -p bascule-core --lib sat 7/7 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Claude Code --- Cargo.lock | 365 +++++++++++++++++++++++++++++-- Cargo.toml | 6 + crates/bascule-core/Cargo.toml | 3 + crates/bascule-core/src/lib.rs | 1 + crates/bascule-core/src/sat.rs | 259 ++++++++++++++++++++++ crates/bascule-shell/Cargo.toml | 1 + crates/bascule-shell/src/main.rs | 38 +++- 7 files changed, 654 insertions(+), 19 deletions(-) create mode 100644 crates/bascule-core/src/sat.rs diff --git a/Cargo.lock b/Cargo.lock index 959ffa8..8da875c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,28 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -153,13 +175,40 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + [[package]] name = "axum" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "axum-core", + "axum-core 0.5.6", "bytes", "form_urlencoded", "futures-util", @@ -169,7 +218,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -180,12 +229,32 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -228,11 +297,14 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "hex", "portable-pty", "rand 0.8.5", "russh", "russh-keys", "serde", + "sha2", + "substrate-proto", "tempfile", "thiserror 1.0.69", "tokio", @@ -266,7 +338,7 @@ name = "bascule-server" version = "0.1.0" dependencies = [ "anyhow", - "axum", + "axum 0.8.8", "bascule-auth-agent-id", "bascule-core", "clap", @@ -282,6 +354,7 @@ name = "bascule-shell" version = "0.1.0" dependencies = [ "anyhow", + "bascule-core", "chrono", "clap", "dirs", @@ -1222,6 +1295,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1328,6 +1407,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1575,6 +1660,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1586,6 +1690,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1719,6 +1829,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1747,6 +1858,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1764,7 +1888,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1909,6 +2033,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1962,6 +2096,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2130,6 +2273,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -2194,6 +2343,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nix" version = "0.25.1" @@ -2437,6 +2592,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.13.1", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2615,6 +2780,58 @@ dependencies = [ "version_check", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2628,7 +2845,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2665,7 +2882,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2770,6 +2987,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2815,7 +3044,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -3483,6 +3712,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3572,6 +3811,36 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "substrate-common" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "prost", + "prost-types", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", + "tracing", + "uuid", +] + +[[package]] +name = "substrate-proto" +version = "0.1.0" +dependencies = [ + "prost", + "prost-types", + "substrate-common", + "tonic", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3757,7 +4026,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3835,7 +4104,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.13.1", "serde", "serde_spanned", "toml_datetime", @@ -3849,6 +4118,70 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -3887,7 +4220,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -4241,7 +4574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -4267,7 +4600,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.1", "semver", ] @@ -4713,7 +5046,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.1", "prettyplease", "syn", "wasm-metadata", @@ -4744,7 +5077,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -4763,7 +5096,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.1", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index f78be54..9e87600 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,12 @@ edition = "2021" license = "Apache-2.0" [workspace.dependencies] +# M1: depend on the canonical proto-generated SAT types from the substrate +# workspace. Path dep across workspaces; CI mounts both checkouts side by +# side. substrate-proto is itself a thin facade over substrate-common which +# 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" } 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 1cdd469..4277dd5 100644 --- a/crates/bascule-core/Cargo.toml +++ b/crates/bascule-core/Cargo.toml @@ -23,6 +23,9 @@ chrono = { workspace = true } uuid = { workspace = true } rand = { workspace = true } portable-pty = { workspace = true } +substrate-proto = { workspace = true } +sha2 = "0.10" +hex = "0.4" [dev-dependencies] tempfile = "3" diff --git a/crates/bascule-core/src/lib.rs b/crates/bascule-core/src/lib.rs index 480f0ca..bd87fe9 100644 --- a/crates/bascule-core/src/lib.rs +++ b/crates/bascule-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod handler; pub mod hooks; pub mod proxy; pub mod pty; +pub mod sat; pub mod server; pub mod session; pub mod store; diff --git a/crates/bascule-core/src/sat.rs b/crates/bascule-core/src/sat.rs new file mode 100644 index 0000000..5a06a6a --- /dev/null +++ b/crates/bascule-core/src/sat.rs @@ -0,0 +1,259 @@ +//! SAT composition — turn a Bascule session's identity + platform attestation +//! into a real proto-canonical `SatBundle` anchored on the L4 SessionClaim. +//! +//! M1 milestone: replaces the opaque `BASCULE_ATTESTATION_HASH` composite +//! with a real four-layer SAT (only L4 today; L1/L2/L3 join as the upstream +//! producers wire up). +//! +//! ## Why local composition first +//! +//! Per ADR D9 the hot path is local and zero-network. Bascule composes the +//! bundle in-process at session establish time so a single shell launch +//! doesn't pay an RTT to QM. The same `SatBundle` *can* be re-derived by +//! QM via the `ComposeSat` gRPC if a renegotiation needs the producer's +//! authoritative L1/L2/L3 layers (M4 work). +//! +//! ## Inputs +//! +//! Bascule itself is identity-agnostic; the `bascule-shell` binary detects +//! the operator identity (Entra/AZ-CLI/Kerberos/cached-OIDC/system) and the +//! local platform attestation (TPM/IMA/Keylime/Entra-device). Both come in +//! here as plain types so this module stays free of OS-detection logic. + +use sha2::{Digest, Sha256}; + +use substrate_proto::v2::builder::SatBundleBuilder; +use substrate_proto::v2::hashing::session_claim_hash; +use substrate_proto::v2::{ + ActorContext, PostureEvidence, PostureLevel, SatBundle, SessionClaim, +}; + +/// Inputs to SAT composition. Plain data so the caller can be `bascule-shell` +/// or a future server-side handler without dragging in OS detection. +#[derive(Debug, Clone)] +pub struct SessionInputs<'a> { + /// Operator principal — `tyler@guildhouse.dev`, SPIFFE ID, system user, etc. + pub principal: &'a str, + /// Bascule's classification of how `principal` was authenticated. + /// e.g. `oidc-entra`, `kerberos`, `oidc-cached`, `ssh-key`. + pub auth_method: &'a str, + /// `human` | `agent` | `system` | `node` per ActorContext schema. + pub actor_type: &'a str, + /// Did this principal acquire a fresh attestation token alongside the + /// shell session (Entra access token, Kerberos TGT)? + pub identity_verified: bool, + /// Was a TPM quote read at attestation time? + pub platform_attested: bool, + /// Did the local image hash match the expected/attested value? + /// Bascule today doesn't compute this; pass `false` until the loader + /// produces a verdict. + pub software_verified: bool, + /// Optional hex string seed used to derive a deterministic nonce. + /// Pass `None` for a random nonce. + pub nonce_seed: Option<&'a str>, +} + +/// Outcome of SAT composition. +pub struct ComposedSat { + /// The full proto bundle (only the L4 SessionClaim is populated today). + pub bundle: SatBundle, + /// Hex-encoded `bundle.sat_hash`. + pub sat_hash_hex: String, + /// Hex-encoded `session_claim.claim_hash`. + pub session_claim_hash_hex: String, +} + +/// Build a `SessionClaim` from session inputs. Pure function; no I/O. +pub fn build_session_claim(inputs: &SessionInputs<'_>) -> SessionClaim { + let evidence = PostureEvidence { + platform_attested: inputs.platform_attested, + platform_method: if inputs.platform_attested { + "tpm_quote".into() + } else { + "none".into() + }, + software_verified: inputs.software_verified, + software_method: if inputs.software_verified { + "hash_match".into() + } else { + "none".into() + }, + // Bascule does not bind to a Quartermaster-issued accord at session + // establish time; M4 will set this when the Accord verdict pipeline + // returns a permit. + governance_bound: false, + governance_method: "none".into(), + identity_verified: inputs.identity_verified, + identity_method: inputs.auth_method.to_string(), + }; + + let posture_level = compute_posture(&evidence); + + let nonce = match inputs.nonce_seed { + Some(seed) => Sha256::digest(seed.as_bytes()).to_vec(), + None => { + use rand::RngCore; + let mut buf = vec![0u8; 16]; + rand::thread_rng().fill_bytes(&mut buf); + buf + } + }; + + let mut claim = SessionClaim { + layer: 4, + session_id: uuid::Uuid::new_v4().to_string(), + actor: Some(ActorContext { + actor_id: inputs.principal.to_string(), + actor_type: inputs.actor_type.to_string(), + auth_method: inputs.auth_method.to_string(), + delegated_by: None, + delegation_id: None, + }), + posture_evidence: Some(evidence), + posture_level: posture_level as i32, + timestamp: chrono::Utc::now().to_rfc3339(), + nonce, + // L1/L2/L3 binding hashes are zero-padded until upstream producers + // exist. The L4 leaf hash is still verifiable on its own. + platform_claim_hash: vec![0u8; 32], + software_claim_hash: vec![0u8; 32], + governance_claim_hash: vec![0u8; 32], + claim_hash: Vec::new(), + }; + claim.claim_hash = session_claim_hash(&claim).to_vec(); + claim +} + +/// Compose a SAT bundle locally from a `SessionClaim`. The other layers are +/// `None` until the corresponding producers are wired in. The composite +/// `sat_hash` covers all four slots (zero-padded for absent layers), so the +/// hash is comparable to one assembled later by QM with full layers. +pub fn compose_local(claim: SessionClaim) -> ComposedSat { + let session_claim_hash_hex = hex::encode(&claim.claim_hash); + let bundle = SatBundleBuilder::new().session(claim).build(); + let sat_hash_hex = hex::encode(&bundle.sat_hash); + ComposedSat { + bundle, + sat_hash_hex, + session_claim_hash_hex, + } +} + +/// Convenience: build_session_claim + compose_local in one call. +pub fn compose_from_inputs(inputs: &SessionInputs<'_>) -> ComposedSat { + compose_local(build_session_claim(inputs)) +} + +/// SAT-SPEC-0002 §7 posture computation, mirrored from substrate-common's +/// `compute_posture` so we can call it without dragging the symbol through +/// the proto facade. Kept identical on purpose. +fn compute_posture(evidence: &PostureEvidence) -> PostureLevel { + if evidence.platform_attested + && evidence.software_verified + && evidence.governance_bound + && evidence.identity_verified + { + PostureLevel::Attested + } else if evidence.software_verified + && evidence.governance_bound + && evidence.identity_verified + { + PostureLevel::Governed + } else if evidence.software_verified && evidence.identity_verified { + PostureLevel::Verified + } else if evidence.identity_verified { + PostureLevel::Local + } else { + PostureLevel::None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inputs() -> SessionInputs<'static> { + SessionInputs { + principal: "tyler@guildhouse.dev", + auth_method: "oidc-entra", + actor_type: "human", + identity_verified: true, + platform_attested: false, + software_verified: false, + nonce_seed: Some("test-seed"), + } + } + + #[test] + fn session_claim_carries_actor_and_layer4() { + let claim = build_session_claim(&inputs()); + assert_eq!(claim.layer, 4); + let actor = claim.actor.as_ref().unwrap(); + assert_eq!(actor.actor_id, "tyler@guildhouse.dev"); + assert_eq!(actor.auth_method, "oidc-entra"); + assert_eq!(actor.actor_type, "human"); + assert!(!claim.claim_hash.is_empty()); + } + + #[test] + fn posture_identity_only_is_local() { + // identity verified but no platform/software/governance evidence + let claim = build_session_claim(&inputs()); + assert_eq!(claim.posture_level, PostureLevel::Local as i32); + } + + #[test] + fn posture_identity_plus_software_is_verified() { + let mut i = inputs(); + i.software_verified = true; + let claim = build_session_claim(&i); + assert_eq!(claim.posture_level, PostureLevel::Verified as i32); + } + + #[test] + fn posture_full_chain_is_attested_when_governance_bound() { + let mut i = inputs(); + i.platform_attested = true; + i.software_verified = true; + let mut claim = build_session_claim(&i); + // Bascule sets governance_bound=false until M4 — flip it manually + // to confirm the math. + let mut ev = claim.posture_evidence.take().unwrap(); + ev.governance_bound = true; + ev.governance_method = "qm_live".into(); + claim.posture_evidence = Some(ev); + let bumped = compute_posture(claim.posture_evidence.as_ref().unwrap()); + assert_eq!(bumped, PostureLevel::Attested); + } + + #[test] + fn nonce_seed_is_deterministic() { + let a = build_session_claim(&inputs()); + let b = build_session_claim(&inputs()); + assert_eq!(a.nonce, b.nonce); + // session_id and timestamp differ, so the leaf hashes still differ + // — that's intentional, the nonce is not the only freshness source. + } + + #[test] + fn compose_local_produces_nonempty_sat_hash() { + let composed = compose_from_inputs(&inputs()); + assert!(!composed.bundle.sat_hash.is_empty()); + assert_eq!(composed.bundle.sat_version, 2); + assert!(composed.bundle.session_claim.is_some()); + assert!(composed.bundle.platform_claim.is_none()); + assert!(composed.bundle.software_claim.is_none()); + assert!(composed.bundle.governance_claim.is_none()); + assert_eq!(composed.sat_hash_hex.len(), 64); // sha256 hex + assert_eq!(composed.session_claim_hash_hex.len(), 64); + } + + #[test] + fn sat_hash_changes_when_principal_changes() { + let a = compose_from_inputs(&inputs()); + let mut other = inputs(); + other.principal = "alice@guildhouse.dev"; + let b = compose_from_inputs(&other); + assert_ne!(a.sat_hash_hex, b.sat_hash_hex); + } +} diff --git a/crates/bascule-shell/Cargo.toml b/crates/bascule-shell/Cargo.toml index 2bcf39b..f6d225c 100644 --- a/crates/bascule-shell/Cargo.toml +++ b/crates/bascule-shell/Cargo.toml @@ -10,6 +10,7 @@ name = "bascule-shell" path = "src/main.rs" [dependencies] +bascule-core = { path = "../bascule-core" } clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/bascule-shell/src/main.rs b/crates/bascule-shell/src/main.rs index f57e334..f653155 100644 --- a/crates/bascule-shell/src/main.rs +++ b/crates/bascule-shell/src/main.rs @@ -71,8 +71,25 @@ fn main() -> Result<()> { banner::display(&id, &attest); } + // M1: compose a real proto SatBundle anchored on the L4 SessionClaim, + // built from the operator identity + local platform attestation. The + // composer lives in bascule_core::sat so server-side handlers can call + // it later without dragging in bascule-shell's OS-detection logic. + let nonce_seed = format!("{}|{}", id.principal, attest.composite_hash); + let composed = bascule_core::sat::compose_from_inputs(&bascule_core::sat::SessionInputs { + principal: &id.principal, + auth_method: &id.auth_method, + actor_type: "human", + identity_verified: id.has_token, + platform_attested: attest.tpm_available && !attest.pcr_values.is_empty(), + // M2 will flip this once gsh corpus check runs and the loader + // produces a software verdict. + software_verified: false, + nonce_seed: Some(&nonce_seed), + }); + // Set BASCULE_* env vars - set_env(&id, &attest); + set_env(&id, &attest, &composed); // Determine inner shell let shell = cli.shell.unwrap_or_else(|| config.inner_shell.clone()); @@ -96,13 +113,28 @@ fn main() -> Result<()> { anyhow::bail!("Failed to exec inner shell: {}", shell); } -fn set_env(id: &identity::Identity, attest: &attestation::Attestation) { +fn set_env( + id: &identity::Identity, + attest: &attestation::Attestation, + composed: &bascule_core::sat::ComposedSat, +) { std::env::set_var("BASCULE_PRINCIPAL", &id.principal); std::env::set_var("BASCULE_AUTH_METHOD", &id.auth_method); if let Some(ref domain) = id.domain { std::env::set_var("BASCULE_DOMAIN", domain); } - std::env::set_var("BASCULE_ATTESTATION_HASH", &attest.composite_hash); + // BASCULE_ATTESTATION_HASH was an opaque "evidence string" SHA. M1 + // replaces it with the proto SAT composite hash. Kept under the same + // env var name for backward compatibility with existing gsh consumers + // (and a NEW BASCULE_SAT_HASH alias for the renamed surface). + std::env::set_var("BASCULE_ATTESTATION_HASH", &composed.sat_hash_hex); + std::env::set_var("BASCULE_SAT_HASH", &composed.sat_hash_hex); + std::env::set_var("BASCULE_SESSION_CLAIM_HASH", &composed.session_claim_hash_hex); + if let Some(ref claim) = composed.bundle.session_claim { + std::env::set_var("BASCULE_SESSION_ID", &claim.session_id); + std::env::set_var("BASCULE_POSTURE_LEVEL", claim.posture_level.to_string()); + } + std::env::set_var("BASCULE_TPM_AVAILABLE", attest.tpm_available.to_string()); std::env::set_var("BASCULE_PCR_COUNT", attest.pcr_values.len().to_string()); if let Some(ref ima_hash) = attest.ima_log_hash {