From 098a5bd121ab074095781c41b24a743d0160bad5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 14 Aug 2026 09:24:20 +0300 Subject: [PATCH 1/2] feat(attest): carry a pinned release digest into the attestation A module loaded from a GitHub release extracts into a fresh temporary directory holding no `modules.toml`, so `allowlisted_hash` found nothing to compare against and the module was never attested. A host could pin a digest, have it checked against the release's own checksum manifest and against the downloaded bytes, and still have every confidential call to that module refused with NotAttested. Carry the verified pin down to the attestation instead of discarding it. Passing `expected_sha256` is now what makes a release-loaded module an attested recipient; omitting it leaves the publisher vouching for itself, and the module loads but stays ineligible for secrets. The recorded digest is the release archive's, not a re-hash of the extracted library: the archive is the artifact the operator named, and hashing the extracted file would report a number nobody vouched for, computed by the same code that would then trust it. Co-authored-by: Medulla --- AGENTS.md | 6 +- crates/tinybus/src/attest.rs | 14 +++- crates/tinybus/src/module/host.rs | 106 +++++++++++++++++++++---- crates/tinybus/src/module/host_test.rs | 87 ++++++++++++++++++++ docs/modules/attest/README.md | 38 +++++++-- 5 files changed, 225 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2156245..bdcde75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,8 +104,10 @@ discussion in the pull request: - **Every call has a deadline.** It cannot be disabled. A call with no deadline reintroduces the hang that motivated the project. - **A confidential message goes to a loaded, hash-verified module or to - nobody.** Only a module whose artifact the host hashed against its allowlist - before `dlopen` may receive one; a peer reached across a transport never can, + nobody.** Only a module whose artifact the host hashed against a digest an + operator asserted — a `modules.toml` beside the file, or one compiled into the + host and checked against the release manifest before extraction — may receive + one; a peer reached across a transport never can, by design rather than by omission. The message is never fanned out to a subscriber, never printed by `monitor`, and never carried by a signal. This is admission control, not isolation — a loaded module is already inside the trust diff --git a/crates/tinybus/src/attest.rs b/crates/tinybus/src/attest.rs index ccca75e..3804a5a 100644 --- a/crates/tinybus/src/attest.rs +++ b/crates/tinybus/src/attest.rs @@ -71,7 +71,19 @@ pub struct Attestation { /// is whether the code answering to `…Wallet` is what the operator /// allowlisted for `…Wallet`. pub name: BusName, - /// Lowercase hex SHA-256 of the artifact the host read at load time. + /// Lowercase hex SHA-256 of the bytes the operator vouched for. + /// + /// For a module loaded from disk that is the library file itself, hashed + /// against a `modules.toml` beside it. For one loaded from a pinned GitHub + /// release it is the release *archive* the library was extracted from — + /// the artifact named by the digest the host compiled in, and the only + /// value in that path any operator ever asserted. Hashing the extracted + /// library instead would report a number nobody had vouched for, computed + /// by the same code that would then be trusting it. + /// + /// A sender that pinned the digest itself can therefore compare this + /// against its own copy before parting with a secret, rather than taking + /// the host's word that some check happened. pub sha256: String, } diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index fd30d0a..c4ea59d 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -111,6 +111,14 @@ struct Activation { lazy_init: bool, lazy_load: bool, descriptor_info: Option, + /// The digest a caller pinned for this artifact, where the artifact did not + /// come from a directory carrying a `modules.toml`. + /// + /// Set only by the release path, which has already checked these bytes + /// twice — against the release's own checksum manifest and against the + /// value the caller compiled in. `None` everywhere else, which leaves the + /// on-disk allowlist as the sole source of attestation exactly as before. + pinned_sha256: Option, } impl PendingModule { @@ -297,6 +305,16 @@ impl ModuleHost { /// The release must publish a `checksum.toml` or `checksum.json` asset /// containing the SHA-256 for `asset_name`. When supplied, the host's /// expected digest must agree with the release manifest as well. + /// + /// Supplying `expected_sha256` is what makes the module an **attested + /// recipient**, eligible to be sent a confidential message. A host that + /// compiles a digest in has made the same statement an operator makes by + /// writing one into `modules.toml`, and it is a stronger one: the value + /// cannot be edited on the machine running it. Omitting the argument + /// leaves the release's own checksum manifest as the only claim about + /// these bytes — which is the publisher vouching for itself, not an + /// operator vouching for the publisher — so the module loads and is + /// refused secrets. pub fn load_github_release( &self, release_url: impl AsRef, @@ -309,7 +327,11 @@ impl ModuleHost { asset_name.as_ref(), expected_sha256, )?; - let info = self.load_file_with_config(&module, config)?; + let info = self.load_file_pinned( + &module, + config, + expected_sha256.map(str::to_ascii_lowercase), + )?; self.inner .artifacts .lock() @@ -360,7 +382,10 @@ impl ModuleHost { manifest, init, }; - self.activate(file.as_ref(), artifact, config) + // No pin: the caller handed over an already-resolved artifact rather + // than bytes this host read and hashed, so there is nothing to vouch + // for. Attestation, if any, comes from an allowlist beside the file. + self.activate(file.as_ref(), artifact, config, None) } /// Load one module and pass JSON configuration to its setup function. @@ -371,6 +396,26 @@ impl ModuleHost { &self, path: impl AsRef, config: serde_json::Value, + ) -> Result { + self.load_file_pinned(path, config, None) + } + + /// [`ModuleHost::load_file_with_config`], carrying a digest the caller has + /// already verified for an artifact that has no `modules.toml` beside it. + /// + /// `pinned_sha256` is recorded as the attestation without being re-checked + /// here, because there is nothing left on disk to re-check it against — the + /// bytes it names are the archive, which was verified and then extracted. + /// The verification therefore lives entirely in the caller, and this stays + /// private for that reason: exposing it would let a caller declare an + /// artifact attested without anyone having hashed anything. The only + /// caller is [`ModuleHost::load_github_release`]; keep it that way, or move + /// the check down here first. + fn load_file_pinned( + &self, + path: impl AsRef, + config: serde_json::Value, + pinned_sha256: Option, ) -> Result { let path = path.as_ref(); let result = (|| { @@ -384,7 +429,7 @@ impl ModuleHost { check_file(path)?; if let Some(manifest) = read_lazy_manifest(path)? { self.ensure_dependencies(&manifest, path)?; - return self.register_lazy(path, manifest, config); + return self.register_lazy(path, manifest, config, pinned_sha256.clone()); } let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; let rejected_manifest = artifact.manifest.clone(); @@ -392,7 +437,7 @@ impl ModuleHost { self.record_manifest_rejection(&error, rejected_manifest, RefusalClass::Unresolved); return Err(error); } - self.activate(path, artifact, config) + self.activate(path, artifact, config, pinned_sha256) })(); if let Err(error) = &result { self.record_rejection(error); @@ -430,7 +475,9 @@ impl ModuleHost { } check_file(path)?; self.ensure_dependencies(&manifest, path)?; - self.register_lazy(path, manifest, config) + // No pin: a caller naming a path on disk vouches for it with the + // `modules.toml` beside it, if at all. + self.register_lazy(path, manifest, config, None) } /// Discover and load every platform library in a private directory. @@ -516,9 +563,15 @@ impl ModuleHost { RefusalClass::Unresolved, ); }) + // No pin: these came from scanning a directory, so the + // `modules.toml` in it is the operator's statement about them. .and_then(|()| match module { - PendingModule::Loaded(artifact) => self.activate(&path, *artifact, config), - PendingModule::Lazy(manifest) => self.register_lazy(&path, *manifest, config), + PendingModule::Loaded(artifact) => { + self.activate(&path, *artifact, config, None) + } + PendingModule::Lazy(manifest) => { + self.register_lazy(&path, *manifest, config, None) + } }); outcomes.push(result); } @@ -641,6 +694,7 @@ impl ModuleHost { path: &Path, artifact: LoadedArtifact, config: serde_json::Value, + pinned_sha256: Option, ) -> Result { let _admission = self.inner.admission.lock().expect("module admission lock"); let manifest = artifact.manifest.clone(); @@ -697,6 +751,7 @@ impl ModuleHost { lazy_init: artifact.manifest.lazy_init, lazy_load: false, descriptor_info: None, + pinned_sha256, }, ) } @@ -706,6 +761,7 @@ impl ModuleHost { path: &Path, manifest: ModuleManifest, config: serde_json::Value, + pinned_sha256: Option, ) -> Result { let _admission = self.inner.admission.lock().expect("module admission lock"); let admitted = provisional_info(path, &manifest).inspect_err(|error| { @@ -768,6 +824,7 @@ impl ModuleHost { lazy_init: true, lazy_load: true, descriptor_info: Some(descriptor_info), + pinned_sha256, }, ) } @@ -795,16 +852,31 @@ impl ModuleHost { return Err(error); } }; - // A module that matched `modules.toml` is an attested recipient: the - // host hashed its artifact against a list the operator installed, which - // is the same fact the trust store asserts about an out-of-process peer. - // Re-read rather than plumbed down from the gate, and fails closed — - // an artifact that changed underneath us no longer matches, so it does - // not become attested. - if let Ok(Some(sha256)) = std::fs::File::open(path) - .map_err(Error::from) - .and_then(|file| allowlisted_hash(path, file)) - { + // A module whose bytes an operator vouched for is an attested + // recipient. There are two ways to vouch, and they differ only in where + // the operator wrote the digest down. + // + // On disk, it is `modules.toml` beside the artifact, re-read here + // rather than plumbed down from the gate so that an artifact which + // changed underneath us no longer matches and does not become attested. + // + // From a pinned release, it is the digest the caller compiled in, which + // `acquire` checked against the release's own checksum manifest and + // against the downloaded bytes before extracting anything. There is no + // `modules.toml` to re-read in that case — the artifact lives in a + // private temporary directory this host created moments ago — so the + // value is carried down instead. Both paths fail closed: no allowlist + // and no pin means no attestation, and a slim build that cannot load a + // module at all reaches neither. + let vouched = match activation.pinned_sha256 { + Some(pinned) => Some(pinned), + None => std::fs::File::open(path) + .map_err(Error::from) + .and_then(|file| allowlisted_hash(path, file)) + .ok() + .flatten(), + }; + if let Some(sha256) = vouched { self.inner.broker.attest_module( &unique, crate::attest::Attestation { diff --git a/crates/tinybus/src/module/host_test.rs b/crates/tinybus/src/module/host_test.rs index 25f3f83..03af014 100644 --- a/crates/tinybus/src/module/host_test.rs +++ b/crates/tinybus/src/module/host_test.rs @@ -1321,3 +1321,90 @@ async fn a_module_whose_artifact_does_not_match_the_allowlist_never_loads_at_all let error = host.load_file(&staged).unwrap_err(); assert!(error.to_string().contains("allowlist"), "{error}"); } + +/// Copy `artifact` into a fresh directory carrying no allowlist at all. +/// +/// This is the shape of a release download: the host extracted the archive +/// into a private directory it just created, so there is no `modules.toml` +/// beside the library and nothing on disk to re-read a digest from. +#[cfg(unix)] +fn staged_module_without_allowlist(artifact: &Path) -> (tempfile::TempDir, PathBuf) { + let root = artifact.parent().expect("artifact has a parent directory"); + let dir = tempfile::tempdir_in(root).unwrap(); + let staged = dir.path().join(artifact.file_name().unwrap()); + std::fs::copy(artifact, &staged).unwrap(); + (dir, staged) +} + +#[cfg(unix)] +#[tokio::test] +#[ignore = "requires TINYBUS_TEST_MODULE to point at the built cdylib"] +async fn a_module_from_a_pinned_release_becomes_an_attested_recipient_without_an_allowlist_file() { + // The seam a host that loads from a release actually travels. `acquire` + // has already checked the archive against the release manifest and against + // the caller's compiled-in digest; what is proven here is that the fact + // survives into an `Attestation` instead of being dropped on the floor + // because no `modules.toml` happened to sit beside the extracted library. + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").unwrap()); + let (_dir, staged) = staged_module_without_allowlist(&artifact); + let pinned = "b".repeat(64); + + let bus = MemoryBus::new(); + let broker = Broker::new(); + broker.spawn(bus.clone()); + let host = ModuleHost::new(broker.clone()); + let info = host + .load_file_pinned(&staged, serde_json::json!({}), Some(pinned.clone())) + .unwrap(); + + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let attestation = client + .attestation(info.manifest.bus_name.clone()) + .await + .unwrap() + .expect("a module loaded against a pinned digest is attested"); + assert_eq!(attestation.name, info.manifest.bus_name); + + // Recorded verbatim, and deliberately *not* the hash of the library file. + // The pin names the release archive the library was extracted from, which + // is the only artifact any operator asserted anything about. Re-hashing + // the extracted file here would replace a checked fact with a number this + // code computed and then trusted itself for, so the two must differ. + assert_eq!(attestation.sha256, pinned); + let library_hash = + crate::module::hash::file_hex(std::fs::File::open(&staged).unwrap()).unwrap(); + assert_ne!(attestation.sha256, library_hash); +} + +#[cfg(unix)] +#[tokio::test] +#[ignore = "requires TINYBUS_TEST_MODULE to point at the built cdylib"] +async fn a_module_with_neither_a_pin_nor_an_allowlist_is_loaded_but_never_attested() { + // The regression this change exists to fix, asserted from the other side: + // before it, every release-loaded module looked exactly like this, so a + // confidential call to one was refused no matter how carefully the host + // had pinned the digest. Loading must still succeed — an unattested module + // is ineligible for secrets, not inadmissible. + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").unwrap()); + let (_dir, staged) = staged_module_without_allowlist(&artifact); + + let bus = MemoryBus::new(); + let broker = Broker::new(); + broker.spawn(bus.clone()); + let host = ModuleHost::new(broker.clone()); + let info = host.load_file(&staged).unwrap(); + + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + assert!( + client + .attestation(info.manifest.bus_name.clone()) + .await + .unwrap() + .is_none(), + "a module nobody vouched for must not be eligible to receive a secret" + ); +} diff --git a/docs/modules/attest/README.md b/docs/modules/attest/README.md index 4ed4333..a16c1cc 100644 --- a/docs/modules/attest/README.md +++ b/docs/modules/attest/README.md @@ -78,12 +78,38 @@ travel. Confidential bulk transfer is its own piece of work; what this closes is the silent version of the gap. See [the protocol's `confidential` section](../../protocol.md#confidential). -A third case worth naming explicitly: a module loaded from a GitHub release -extracts into a fresh temporary directory that holds no `modules.toml`, so -`allowlisted_hash` finds nothing to compare against and the module is never -attested — this is the fail-closed default working as intended, not a bug, but -it means a GitHub-loaded module can never be a confidential recipient until the -operator also places its digest in the local allowlist beside it. +## Two ways to vouch for an artifact + +An operator asserts "these bytes are the ones I meant" in one of two places, and +attestation accepts either. + +**A digest on disk.** `modules.toml` beside the library, which the host re-reads +at load time rather than carrying the value down from the admission gate, so an +artifact that changed underneath the check no longer matches and does not become +attested. + +**A digest compiled into the host**, passed as `expected_sha256` to +`load_github_release`. Before extracting anything, `acquire` fetches the +release's own `checksum.toml`, refuses a disagreement between it and the caller's +value, downloads the archive, and hashes the bytes it actually received. Only +then is the library extracted and loaded. The attestation records the digest of +that **archive** — the artifact the operator named — not a hash of the extracted +`.so`, which nobody vouched for and which the host would only be computing in +order to trust itself for it. + +A pinned digest is the stronger of the two statements, because it cannot be +edited on the machine that runs it. + +Omitting `expected_sha256` leaves the release's own checksum manifest as the only +claim about the bytes, which is a publisher vouching for itself rather than an +operator vouching for the publisher. Such a module loads and is refused secrets. +Loading from a directory with no allowlist behaves the same way: admissible, and +ineligible. + +> Earlier revisions of this document described the release path as never +> attestable — correct when written, and the reason a host could pin a digest +> with great care and still have every confidential call refused. The pin was +> verified twice and then discarded. It is now carried through. ## Not a signature, yet From 004d7dd013d14ad71f1efdabae1d226a99766980 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 14 Aug 2026 09:29:25 +0300 Subject: [PATCH 2/2] chore: files changed crates/tinybus/src/module/host_test.rs Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinybus/src/module/host_test.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinybus/src/module/host_test.rs b/crates/tinybus/src/module/host_test.rs index 03af014..f1df4fa 100644 --- a/crates/tinybus/src/module/host_test.rs +++ b/crates/tinybus/src/module/host_test.rs @@ -219,7 +219,9 @@ async fn a_lazy_manifest_registers_an_unmapped_library_and_the_first_call_loads_ // rejected before discovery, so exercise the same sidecar seam directly; // the dedicated loader job covers a CI-provisioned private directory. let discovered = read_lazy_manifest(&artifact).unwrap().unwrap(); - host.register_lazy(&artifact, discovered, serde_json::json!({})) + // No pin: this stands in for a directory scan, which vouches for an + // artifact with the `modules.toml` beside it or not at all. + host.register_lazy(&artifact, discovered, serde_json::json!({}), None) .unwrap() }; assert_eq!(info.state, ModuleState::Resolved);