Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion crates/tinybus/src/attest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
106 changes: 89 additions & 17 deletions crates/tinybus/src/module/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ struct Activation {
lazy_init: bool,
lazy_load: bool,
descriptor_info: Option<DescriptorInfo>,
/// 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<String>,
}

impl PendingModule {
Expand Down Expand Up @@ -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<str>,
Expand All @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -371,6 +396,26 @@ impl ModuleHost {
&self,
path: impl AsRef<Path>,
config: serde_json::Value,
) -> Result<ModuleInfo> {
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<Path>,
config: serde_json::Value,
pinned_sha256: Option<String>,
) -> Result<ModuleInfo> {
let path = path.as_ref();
let result = (|| {
Expand All @@ -384,15 +429,15 @@ 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();
if let Err(error) = self.ensure_dependencies(&artifact.manifest, path) {
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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -641,6 +694,7 @@ impl ModuleHost {
path: &Path,
artifact: LoadedArtifact,
config: serde_json::Value,
pinned_sha256: Option<String>,
) -> Result<ModuleInfo> {
let _admission = self.inner.admission.lock().expect("module admission lock");
let manifest = artifact.manifest.clone();
Expand Down Expand Up @@ -697,6 +751,7 @@ impl ModuleHost {
lazy_init: artifact.manifest.lazy_init,
lazy_load: false,
descriptor_info: None,
pinned_sha256,
},
)
}
Expand All @@ -706,6 +761,7 @@ impl ModuleHost {
path: &Path,
manifest: ModuleManifest,
config: serde_json::Value,
pinned_sha256: Option<String>,
) -> Result<ModuleInfo> {
let _admission = self.inner.admission.lock().expect("module admission lock");
let admitted = provisional_info(path, &manifest).inspect_err(|error| {
Expand Down Expand Up @@ -768,6 +824,7 @@ impl ModuleHost {
lazy_init: true,
lazy_load: true,
descriptor_info: Some(descriptor_info),
pinned_sha256,
},
)
}
Expand Down Expand Up @@ -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 {
Expand Down
91 changes: 90 additions & 1 deletion crates/tinybus/src/module/host_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1321,3 +1323,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"
);
}
38 changes: 32 additions & 6 deletions docs/modules/attest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading