feat(m1): bascule-shell composes a real SAT anchored on session_leaf

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) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
This commit is contained in:
Claude Code 2026-04-07 14:38:20 -04:00
parent 6eb2de5dc0
commit 999c78ef4c
7 changed files with 654 additions and 19 deletions

365
Cargo.lock generated
View file

@ -130,6 +130,28 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@ -153,13 +175,40 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "axum" name = "axum"
version = "0.8.8" version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [ dependencies = [
"axum-core", "axum-core 0.5.6",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -169,7 +218,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"itoa", "itoa",
"matchit", "matchit 0.8.4",
"memchr", "memchr",
"mime", "mime",
"percent-encoding", "percent-encoding",
@ -180,12 +229,32 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tower", "tower 0.5.3",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "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]] [[package]]
name = "axum-core" name = "axum-core"
version = "0.5.6" version = "0.5.6"
@ -228,11 +297,14 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
"hex",
"portable-pty", "portable-pty",
"rand 0.8.5", "rand 0.8.5",
"russh", "russh",
"russh-keys", "russh-keys",
"serde", "serde",
"sha2",
"substrate-proto",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
@ -266,7 +338,7 @@ name = "bascule-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum 0.8.8",
"bascule-auth-agent-id", "bascule-auth-agent-id",
"bascule-core", "bascule-core",
"clap", "clap",
@ -282,6 +354,7 @@ name = "bascule-shell"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bascule-core",
"chrono", "chrono",
"clap", "clap",
"dirs", "dirs",
@ -1222,6 +1295,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "elliptic-curve" name = "elliptic-curve"
version = "0.13.8" version = "0.13.8"
@ -1328,6 +1407,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@ -1575,6 +1660,25 @@ dependencies = [
"subtle", "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]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@ -1586,6 +1690,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@ -1719,6 +1829,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@ -1747,6 +1858,19 @@ dependencies = [
"webpki-roots", "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]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@ -1764,7 +1888,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2 0.6.3",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@ -1909,6 +2033,16 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.1" version = "2.13.1"
@ -1962,6 +2096,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -2130,6 +2273,12 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@ -2194,6 +2343,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nix" name = "nix"
version = "0.25.1" version = "0.25.1"
@ -2437,6 +2592,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.11" version = "1.1.11"
@ -2615,6 +2780,58 @@ dependencies = [
"version_check", "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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@ -2628,7 +2845,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash 2.1.2", "rustc-hash 2.1.2",
"rustls", "rustls",
"socket2", "socket2 0.6.3",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@ -2665,7 +2882,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2", "socket2 0.6.3",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@ -2770,6 +2987,18 @@ dependencies = [
"thiserror 1.0.69", "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]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@ -2815,7 +3044,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower", "tower 0.5.3",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
@ -3483,6 +3712,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 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]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.3"
@ -3572,6 +3811,36 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -3757,7 +4026,7 @@ dependencies = [
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2 0.6.3",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -3835,7 +4104,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.13.1",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
@ -3849,6 +4118,70 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@ -3887,7 +4220,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower", "tower 0.5.3",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@ -4241,7 +4574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap", "indexmap 2.13.1",
"wasm-encoder", "wasm-encoder",
"wasmparser", "wasmparser",
] ]
@ -4267,7 +4600,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap 2.13.1",
"semver", "semver",
] ]
@ -4713,7 +5046,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck",
"indexmap", "indexmap 2.13.1",
"prettyplease", "prettyplease",
"syn", "syn",
"wasm-metadata", "wasm-metadata",
@ -4744,7 +5077,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.11.0", "bitflags 2.11.0",
"indexmap", "indexmap 2.13.1",
"log", "log",
"serde", "serde",
"serde_derive", "serde_derive",
@ -4763,7 +5096,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"id-arena", "id-arena",
"indexmap", "indexmap 2.13.1",
"log", "log",
"semver", "semver",
"serde", "serde",

View file

@ -15,6 +15,12 @@ edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
[workspace.dependencies] [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 = "0.46"
russh-keys = "0.46" russh-keys = "0.46"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

View file

@ -23,6 +23,9 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
portable-pty = { workspace = true } portable-pty = { workspace = true }
substrate-proto = { workspace = true }
sha2 = "0.10"
hex = "0.4"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -16,6 +16,7 @@ pub mod handler;
pub mod hooks; pub mod hooks;
pub mod proxy; pub mod proxy;
pub mod pty; pub mod pty;
pub mod sat;
pub mod server; pub mod server;
pub mod session; pub mod session;
pub mod store; pub mod store;

View file

@ -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);
}
}

View file

@ -10,6 +10,7 @@ name = "bascule-shell"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
bascule-core = { path = "../bascule-core" }
clap = { workspace = true } clap = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View file

@ -71,8 +71,25 @@ fn main() -> Result<()> {
banner::display(&id, &attest); 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 BASCULE_* env vars
set_env(&id, &attest); set_env(&id, &attest, &composed);
// Determine inner shell // Determine inner shell
let shell = cli.shell.unwrap_or_else(|| config.inner_shell.clone()); 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); 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_PRINCIPAL", &id.principal);
std::env::set_var("BASCULE_AUTH_METHOD", &id.auth_method); std::env::set_var("BASCULE_AUTH_METHOD", &id.auth_method);
if let Some(ref domain) = id.domain { if let Some(ref domain) = id.domain {
std::env::set_var("BASCULE_DOMAIN", 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_TPM_AVAILABLE", attest.tpm_available.to_string());
std::env::set_var("BASCULE_PCR_COUNT", attest.pcr_values.len().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 { if let Some(ref ima_hash) = attest.ima_log_hash {