From 00113c08264af5f017a01f2d36691b95fd77aa81 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 08:12:16 -0700 Subject: [PATCH 01/17] chore(2712): open lane for anonymous read path Co-Authored-By: Claude From 91a0c97c547d377a320b1526598f9fd566c175f1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 10:23:17 -0700 Subject: [PATCH 02/17] refactor(host): make the store identity representable as absent HostDeps, HostKeys and AttestationBackend carry Option instead of a substituted key, and UnavailableAttestationBackend refuses to attest rather than signing under a borrowed one. HostKeys.bls_secret is removed: it had two writes and no reads. Refs DIG-Network/dig_ecosystem#2712 Co-Authored-By: Claude --- crates/digstore-cli/src/error.rs | 33 +++++++++++++++++ crates/digstore-host/src/imports.rs | 15 +++++++- crates/digstore-host/src/lib.rs | 4 +- crates/digstore-host/src/runtime.rs | 49 ++++++++++++++++++------- crates/digstore-host/src/serve_blind.rs | 7 +++- crates/digstore-host/src/state.rs | 18 +++++---- crates/digstore-host/src/teehook.rs | 49 +++++++++++++++++++++---- 7 files changed, 142 insertions(+), 33 deletions(-) diff --git a/crates/digstore-cli/src/error.rs b/crates/digstore-cli/src/error.rs index 75023e74..14f89122 100644 --- a/crates/digstore-cli/src/error.rs +++ b/crates/digstore-cli/src/error.rs @@ -91,6 +91,21 @@ pub enum CliError { /// message so the user knows WHICH command to retry. operation: String, }, + /// The store's own identity material (`signing_key.bin` / + /// `trusted_keys.json`) could not be read or is malformed. + /// + /// Raised ONLY by the paths that genuinely consume an identity — signing a + /// proof, pushing. Reading committed content does not consume one and never + /// raises this: the guest's content path does not consult the host identity, + /// so refusing a read over a missing key would cost availability and buy no + /// security. + /// + /// Nothing branches on this variant for control flow. It exists so an + /// operator is told WHICH file is unreadable instead of "the system cannot + /// find the file specified", and so a §6.2 machine consumer can classify the + /// failure without matching on prose. + #[error("store identity unavailable at {path}: {detail}")] + IdentityUnavailable { path: String, detail: String }, #[error(transparent)] Other(#[from] anyhow::Error), } @@ -119,6 +134,7 @@ impl CliError { CliError::TooLarge(_) => 17, CliError::NeedsConsolidation { .. } => 18, CliError::NoLocalNode { .. } => 19, + CliError::IdentityUnavailable { .. } => 20, CliError::Other(_) => 1, } } @@ -148,6 +164,7 @@ impl CliError { CliError::TooLarge(_) => "TOO_LARGE", CliError::NeedsConsolidation { .. } => "NEEDS_CONSOLIDATION", CliError::NoLocalNode { .. } => "NO_LOCAL_NODE", + CliError::IdentityUnavailable { .. } => "IDENTITY_UNAVAILABLE", CliError::Other(_) => "ERROR", } } @@ -218,6 +235,11 @@ impl CliError { 19, "no DIG node is running on this machine and this operation needs one; start or install dig-node", ), + ( + "IDENTITY_UNAVAILABLE", + 20, + "the store's own identity file could not be read; re-create the store identity (reading committed content does not need one)", + ), ] } @@ -229,6 +251,9 @@ impl CliError { // instructions in full; a hint here would only repeat them. CliError::NoLocalNode { .. } => Some("run `dig-node status` to check your node".into()), CliError::NonFastForward => Some("run `digstore pull` first, then push".into()), + CliError::IdentityUnavailable { path, .. } => Some(format!( + "check that {path} exists and is readable by this user; reading committed content does not need it, only signing does" + )), CliError::Unauthorized(_) => Some("check your credentials / store signing key".into()), CliError::NotFound(_) => Some("run `digstore log` to list capsules and keys".into()), CliError::NoSeed => Some("run `digstore seed import` to set up your seed".into()), @@ -343,6 +368,10 @@ mod tests { required: 50, cap: 50, }, + CliError::IdentityUnavailable { + path: "signing_key.bin".into(), + detail: "x".into(), + }, ]; let mut codes: Vec = errs.iter().map(|e| e.exit_code()).collect(); let n = codes.len(); @@ -389,6 +418,10 @@ mod tests { required: 50, cap: 50, }, + CliError::IdentityUnavailable { + path: "signing_key.bin".into(), + detail: "x".into(), + }, ]; let mut codes: Vec<&str> = errs.iter().map(|e| e.code()).collect(); let n = codes.len(); diff --git a/crates/digstore-host/src/imports.rs b/crates/digstore-host/src/imports.rs index 652b0768..71eb4183 100644 --- a/crates/digstore-host/src/imports.rs +++ b/crates/digstore-host/src/imports.rs @@ -120,7 +120,13 @@ pub fn register(linker: &mut Linker) -> Result<(), HostError> { m, "host_get_public_key", |mut caller: Caller<'_, RuntimeState>| -> i32 { - let pk = caller.data().host.keys.bls_public.0; // [u8; 48] + // An anonymous host has no key to return. NotFound is the honest + // answer; writing 48 zero bytes would hand the guest a key-shaped + // value it could not distinguish from a real one. + let pk = match caller.data().host.keys.bls_public { + Some(pk) => pk.0, // [u8; 48] + None => return ErrorCode::NotFound as i32, + }; match caller.data_mut().host.return_buffer.set(&pk) { Ok(n) => n as i32, Err(_) => ErrorCode::GeneralError as i32, @@ -160,7 +166,12 @@ pub fn register(linker: &mut Linker) -> Result<(), HostError> { Ok(s) => s, Err(_) => return ErrorCode::AttestationFailed as i32, }; - let pk = state.attestation.public_key(); + // A backend that signed must also be able to name itself; a + // signature attributed to no key is unverifiable by construction. + let pk = match state.attestation.public_key() { + Some(pk) => pk, + None => return ErrorCode::AttestationFailed as i32, + }; let mut resp = Vec::with_capacity(48 + 32 + 96); resp.extend_from_slice(&pk.0); resp.extend_from_slice(&state.instance_id.0); diff --git a/crates/digstore-host/src/lib.rs b/crates/digstore-host/src/lib.rs index 6ea49798..f9c0865c 100644 --- a/crates/digstore-host/src/lib.rs +++ b/crates/digstore-host/src/lib.rs @@ -28,4 +28,6 @@ pub use serve_blind::{ }; pub use session::{Session, SessionTable}; pub use state::{HostKeys, HostState, ReturnBuffer}; -pub use teehook::{AttestationBackend, BlsAttestationBackend, SharedBackend}; +pub use teehook::{ + AttestationBackend, BlsAttestationBackend, SharedBackend, UnavailableAttestationBackend, +}; diff --git a/crates/digstore-host/src/runtime.rs b/crates/digstore-host/src/runtime.rs index a46ec1d6..33a2333a 100644 --- a/crates/digstore-host/src/runtime.rs +++ b/crates/digstore-host/src/runtime.rs @@ -7,7 +7,7 @@ use crate::memory::read_bytes; use crate::random::HostRng; use crate::session::SessionTable; use crate::state::{HostKeys, HostState, ReturnBuffer}; -use crate::teehook::{BlsAttestationBackend, SharedBackend}; +use crate::teehook::{BlsAttestationBackend, SharedBackend, UnavailableAttestationBackend}; use digstore_core::abi::{is_error, unpack_ptr_len}; use digstore_core::config::HostImportsConfig; use digstore_core::types::{Bytes32, Bytes48}; @@ -57,8 +57,14 @@ impl Drop for EpochTicker { /// Dependencies injected into a runtime: BLS keys, clock, chain, prover, rng. pub struct HostDeps { pub store_id: Bytes32, - pub bls_secret: BlsSecretKey, - pub bls_public: Bytes48, + /// The host's BLS identity, or `None` for an ANONYMOUS host. + /// + /// Both halves are supplied together or not at all: a secret with no public + /// half cannot be attributed, and a public half with no secret cannot sign. + /// An anonymous host still serves committed content — the guest's content + /// path does not consult the host identity — it simply cannot attest. + pub bls_secret: Option, + pub bls_public: Option, pub clock: Arc, pub chain: Arc, pub prover: Arc, @@ -89,6 +95,13 @@ pub struct HostRuntime { /// proves nothing about the deps the production call site constructs. See /// [`HostRuntime::rng_is_deterministic`]. rng_seeded: bool, + /// Whether this runtime was built with a host public identity + /// ([`HostDeps::bls_public`] was `Some`). Recorded for the same reason as + /// [`Self::rng_seeded`]: the identity is not observable through any export + /// on the content path, so a caller that wants to assert what the PRODUCTION + /// call site actually built has nothing else to ask. See + /// [`HostRuntime::has_host_public_key`]. + host_pubkey_present: bool, } impl HostRuntime { @@ -132,20 +145,23 @@ impl HostRuntime { Module::new(&engine, module_bytes).map_err(|e| HostError::Wasmtime(e.to_string()))?; let rng_seeded = deps.rng_seed.is_some(); + let host_pubkey_present = deps.bls_public.is_some(); let rng = match deps.rng_seed { Some(s) => HostRng::from_seed(s), None => HostRng::from_entropy(), }; - // The BLS secret is not `Clone`, so share it (Arc) between HostKeys and - // the default attestation backend (§13.6 default = BLS backend). - let shared_secret = Arc::new(deps.bls_secret); - let attestation: SharedBackend = match deps.attestation { - Some(b) => b, - None => Arc::new(BlsAttestationBackend::from_shared( - shared_secret.clone(), - deps.bls_public, - )), + // §13.6 default backend = BLS, but ONLY when this host actually has an + // identity. An anonymous host gets a backend that refuses, never one + // built from a stand-in key: a placeholder would make every attestation + // it produced attributable to a key nobody controls. + let attestation: SharedBackend = match (deps.attestation, deps.bls_secret, deps.bls_public) + { + (Some(b), _, _) => b, + (None, Some(secret), Some(public)) => { + Arc::new(BlsAttestationBackend::new(secret, public)) + } + (None, _, _) => Arc::new(UnavailableAttestationBackend), }; let host = HostState { @@ -153,7 +169,6 @@ impl HostRuntime { config: config.clone(), return_buffer: ReturnBuffer::new(&config), keys: Arc::new(HostKeys { - bls_secret: shared_secret, bls_public: deps.bls_public, }), attestation, @@ -233,6 +248,7 @@ impl HostRuntime { limits_cfg: limits, _ticker: ticker, rng_seeded, + host_pubkey_present, }) } @@ -244,6 +260,13 @@ impl HostRuntime { self.rng_seeded } + /// `true` when this runtime carries a host public identity. Read paths build + /// runtimes for which this is `false`: serving committed content consults no + /// identity, so carrying one there is surface with no purpose. + pub fn has_host_public_key(&self) -> bool { + self.host_pubkey_present + } + /// Set the per-export-call fuel budget. Epoch deadline is added in Task 12. /// NOTE: bounds are armed PER export call (alloc, serve, dealloc each get /// their own budget); the serve flow is not a single combined budget (§18.2). diff --git a/crates/digstore-host/src/serve_blind.rs b/crates/digstore-host/src/serve_blind.rs index 118a82b3..0cd11ee3 100644 --- a/crates/digstore-host/src/serve_blind.rs +++ b/crates/digstore-host/src/serve_blind.rs @@ -183,8 +183,11 @@ impl BlindServeDeps { fn host_deps(cfg: BlindServeConfig, deps: BlindServeDeps) -> HostDeps { HostDeps { store_id: cfg.store_id, - bls_secret: cfg.bls_secret, - bls_public: cfg.bls_public, + // `BlindServeConfig` REQUIRES an identity, so this path is never + // anonymous: the blind serve exists to prove the host's key is in the + // module's trusted set. + bls_secret: Some(cfg.bls_secret), + bls_public: Some(cfg.bls_public), clock: deps.clock, chain: deps.chain, prover: deps.prover, diff --git a/crates/digstore-host/src/state.rs b/crates/digstore-host/src/state.rs index 9740a253..041da5a3 100644 --- a/crates/digstore-host/src/state.rs +++ b/crates/digstore-host/src/state.rs @@ -7,7 +7,6 @@ use crate::session::SessionTable; use crate::teehook::SharedBackend; use digstore_core::config::HostImportsConfig; use digstore_core::types::{Bytes32, Bytes48, Bytes96}; -use digstore_crypto::bls::BlsSecretKey; use digstore_prover::{ChainSource, Prover}; use std::sync::Arc; @@ -45,15 +44,18 @@ impl ReturnBuffer { } } -/// Host BLS key material used for attestation and node-proof signing (§12). +/// The host's PUBLIC BLS identity, as answered to the guest by +/// `host_get_public_key` (§12). /// -/// DEVIATION: `digstore_crypto::bls::BlsSecretKey` is not `Clone`, and the -/// default `BlsAttestationBackend` must sign with the same key. The secret is -/// therefore held behind an `Arc` so it can be shared between this keystore and -/// the default attestation backend without duplicating the (un-clonable) key. +/// `None` means this host is anonymous: it holds no identity, so there is no key +/// to hand out. Absence is representable rather than substituted — see +/// [`crate::teehook::AttestationBackend::public_key`]. +/// +/// The secret half lives ONLY in the attestation backend that signs with it. It +/// was previously mirrored here too and read by nothing, which put un-clonable +/// key material one field access away from every import handler for no purpose. pub struct HostKeys { - pub bls_secret: Arc, - pub bls_public: Bytes48, + pub bls_public: Option, } /// State threaded through every `dig_host` import call (§18.3). diff --git a/crates/digstore-host/src/teehook.rs b/crates/digstore-host/src/teehook.rs index 95da87be..64c281bc 100644 --- a/crates/digstore-host/src/teehook.rs +++ b/crates/digstore-host/src/teehook.rs @@ -15,8 +15,14 @@ use std::sync::Arc; pub trait AttestationBackend: Send + Sync + 'static { /// Produce a 96-byte attestation signature over the challenge bytes. fn attest(&self, challenge: &[u8]) -> Result; - /// The attesting public key (48-byte BLS G1 for the BLS backend). - fn public_key(&self) -> Bytes48; + /// The attesting public key (48-byte BLS G1 for the BLS backend), or `None` + /// when this host holds no identity to attest with. + /// + /// `None` is the HONEST answer for an anonymous host and is deliberately not + /// representable as a placeholder key: an all-zero `Bytes48` is not a weaker + /// identity, it is a nonexistent one wearing the shape of a real one, and a + /// caller cannot tell the two apart. + fn public_key(&self) -> Option; } /// Shared backend handle carried on `HostState`. @@ -55,8 +61,27 @@ impl AttestationBackend for BlsAttestationBackend { challenge, )) } - fn public_key(&self) -> Bytes48 { - self.public + fn public_key(&self) -> Option { + Some(self.public) + } +} + +/// The backend installed when the host carries NO identity (§13.6). +/// +/// An anonymous host can still SERVE committed content — the guest's content +/// path does not consult the host's identity — but it cannot speak for anyone, +/// so every attestation attempt fails rather than producing a signature under a +/// borrowed or invented key. +pub struct UnavailableAttestationBackend; + +impl AttestationBackend for UnavailableAttestationBackend { + fn attest(&self, _challenge: &[u8]) -> Result { + Err(HostError::GuestError( + digstore_core::ErrorCode::NotFound, + )) + } + fn public_key(&self) -> Option { + None } } @@ -70,8 +95,8 @@ mod tests { fn attest(&self, _challenge: &[u8]) -> Result { Ok(Bytes96([0x5Au8; 96])) } - fn public_key(&self) -> Bytes48 { - Bytes48([0x11u8; 48]) + fn public_key(&self) -> Option { + Some(Bytes48([0x11u8; 48])) } } @@ -80,6 +105,16 @@ mod tests { let b = ConstBackend; let sig = b.attest(b"challenge").unwrap(); assert_eq!(sig.0, [0x5Au8; 96]); - assert_eq!(b.public_key().0, [0x11u8; 48]); + assert_eq!(b.public_key().unwrap().0, [0x11u8; 48]); + } + + /// An identity-less host must REFUSE to attest, not attest as nobody. Both + /// halves matter: a backend that returned `Ok` with a placeholder signature + /// would let a verifier believe a host had spoken. + #[test] + fn the_unavailable_backend_has_no_key_and_cannot_attest() { + let b = UnavailableAttestationBackend; + assert!(b.public_key().is_none()); + assert!(b.attest(b"challenge").is_err()); } } From ba00ed3f1e6af8692af5fd322addd5cf1c9645f5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 10:55:56 -0700 Subject: [PATCH 03/17] fix(serve): carry no store identity on the read path Reading committed content consumes no identity, so the read path no longer loads one. A store whose signing_key.bin / trusted_keys.json is missing or unreadable now serves its content instead of aborting, which also unbreaks checkout, dev, deploy --preview and compute_status -- none of which has a network ladder to fall through to. serve_proof keeps its fail-closed load: signing IS attribution. Refs DIG-Network/dig_ecosystem#2712 Co-Authored-By: Claude --- .gitnexus/lbug | Bin 0 -> 4096 bytes .gitnexus/lbug.wal | Bin 0 -> 25374 bytes crates/digstore-cli/src/ops/serve.rs | 244 +++++++++++++----- .../tests/adv_delegated_host_key.rs | 4 +- crates/digstore-cli/tests/adv_self_serve.rs | 4 +- crates/digstore-cli/tests/ops_roundtrip.rs | 4 +- crates/digstore-compiler/tests/auth_policy.rs | 4 +- .../tests/large_data_section.rs | 4 +- .../digstore-compiler/tests/self_serving.rs | 4 +- crates/digstore-host/tests/common/mod.rs | 4 +- 10 files changed, 188 insertions(+), 84 deletions(-) create mode 100644 .gitnexus/lbug create mode 100644 .gitnexus/lbug.wal diff --git a/.gitnexus/lbug b/.gitnexus/lbug new file mode 100644 index 0000000000000000000000000000000000000000..d9c28a62dbccf891aa0a8be5eb8c5f29aa798077 GIT binary patch literal 4096 zcmb`JKWG$D5XRps>e`*r!t#v&Ql`8{IBX0U^%MllR3ajJl?D`Yis1PN|6+0~!U+}u zo0OJUiXcP;Ik2&e7LrN?3*C3~{r0j$mP?T>^4;uynR##CH#2Yg=-Fcv!JdEF6MunY zJAYk&TfNr)c=Gbk=dZ`_Y>6Lt|Gj!C`;lZnBX$q|*d8(LvG+ONy^^wXYx4O^#^~CcSdGYD*H{ObP9^uu+ zt4JRn;Vq7vzqWWD*+=t$@s`B%$Uai(uP>fQ;+E>v>7`VEYj~fMu&Yd0=!Sii>1C(` zJLfVT4A1xTo8NQUf#`fcR8((=4Y7&4`tatU{&?ScU~#a?-?>Lc-h})$g!t=;hn_A- zpV%S1*%9l#b-bvn$Ks){5Nsb)CyKgyFCO|Dh%fuIG;KWe^j$n`czeY|Pw9QzFLqFW z`L4L=X;M6Fc(W%hE_ymA9(Ify_gis&ttDRG2em-@Uqd=t6c3xY^fh#}EFL!drC*_= zKjLABaAKer`Vcy*N(b2NgZ_d(W)7LI(#OR<)SkaJIA_k&BYL54a84eRaPeZyAN_#5 zdPJw2SHG5*(e-QRyLt6%{(SwCXYx+|nhTa^-dA)UZ6UnA+|}=CN4Vl>&H%1N9@Dv z7@N50+3ypZa~r*1)63?~H>=}~%(t(ZUN%x6Q8A=$?A;$oZD`-J!oU xlS`mJs2h(-uzpq6pF^j(U+ReZ^2qwi?m3OzpXr%8%Iiz_N^J61*6+LW_a7t11I_>d literal 0 HcmV?d00001 diff --git a/.gitnexus/lbug.wal b/.gitnexus/lbug.wal new file mode 100644 index 0000000000000000000000000000000000000000..3270ff4a541b9931a46b74452de70d1d6024bd12 GIT binary patch literal 25374 zcmeHQX^a#_6do2lIAjrJXT@EDcrIczc%#P1$|7=zxE@3e_DW4-D6y2qxR-JV_}4An_(_k7j$y6V+e zuc}^E_YUs6>52!3&z|2o#rpn^g(Gh0_4DoP){Jc*c~GxC7k*XKxnJohm2wT+LUKPb-GBfPH|@M+?kkFtbv4JGWLPX&Q@&O5c#m8Sg-g!9$7_*j5zNvn z5wRQ3LqCxZBRg(R#&%q$*Yzr&5gzYlgqV*URM;jdd4G`uon=#wks%I5Z5H5=khC2o z(FkD^so)f-1Skb%C7>1m;T;e4No^hUS3=tKNdz6urk2f?Ht-?}&X#5_ctF%5lFDJh z{!b&UcuSSLr`lGm8RE2MIlLafT%@rb) z&E=Zy!sf0R(K&kI+(n1WxJyatn9SS`TZaYqF#eaiGX#LUw$at&61SmH6j4>g9Vobw z(HuUZ;a0HO&RK4K@A4xb9`B8Oj`(dY*-(s~9OmX6t2LjAJT-y@P?f|}Aj(qc?!X21 znsP-UpgjNK9Wx)lruq0=tXE`>3PZ4FTW8};mgci7v@;}EU4o|xze{LnO2uwjo`|Yy zSw3I*MWt+(u=lQG3y0*!uOBaCFNQPCb~>H23^$KSiJuoVkDeQfqvJ8PHfBa0%TxJa zfjU^uF|w|anpBA-vJWWKES8^bJ9_q*x)}lSwOaWkjE#U z{ooO2Y;Nk5@fft7>ewlk&4zRFA6VcWh{fPw#w&a!oheZnwjk@WOyqhebs!%pVN47b zu|W}s2MFfye$>fNwGGRi5ZUWWY{2k<4^YyErJ2#Cgh|wcRGxp-qUoDPj$bUJGDZ*Q zxK1wRqEdubyf#YZ1LG+oF#1ug29*gj*qzfg(pVT8cPt@95QU-D3Vm`^cW4DMdIaQx zODi5xnHTT@5)wZz9a8_t#vk%B#(@CitXvw+#!m>YmF6jszw7ZW*`&qgn-Eded>ga+ z_f@YR+W3TwZ|FKFYbnP@b5Vp#BuZBj{mIg>LhQ+s5K+~HYn}1JjFUfUuw;aT?V^#9 z&e}1=P?Y-@%BeopTjQbzLtQ$BpwIJZbI-iB%j~5`e=egOY}~mPpNH+`ClME_Q!OgO zw{eYywz!?D5K+}Ot~OV5$4DK4>cS6)5m zv!RDwe6NgjuyU=AX1KLJ)5L`$oFZHWgsaWHz4_56e!NyjI80S1q%-Cq#W&w~Bb_-k zG_kn7st{4tz3P~)4}Z8(zx*c|-_Wbl%;7ZC(6W0g(se<)9^GPxnOoKQ<8C-%V&>ru z6Yr9d4!vr#ZDrlTig4u#C)Td0X8>EG?&%aFs@lr21#9Mx{&W5|8R3E@2aT_$?xPr| z7}oZdg<7 z#e5;6su{Q7%=@n$aQg#ylYznW%2&dej{F5|MB8hTW0Do zjhJlfIg{0rF%GWub6D}q5)EO>krhNhT(Egy z*V`66w9p>6V-+HZaunz=*=5Y+yQEsYpTS;z>3XjaK*j>qSK+!DCCBh8#y)m*mB@Wm zr!r-OLO?7?O58fHrvEWDBWq>ch4!Y|IhQs1NyG)I2LW-x19n#CVo$5XH-GVELpG7+ zBffk?3usvGW{zV!(M6ErF&9i5x^A-Bn!}>KR7X`)dUTgFK^6L`+0zG}HRpoI?v`-` z8%bJi+Z^Vn5Em4QfVe>Z&PtIUXwc9al=5$t6DSLJCT`2!$qxDDQ*-;u@NWrSOdCF< zz^mb^k`G3~peW~7H@gJo-0IfYdpqY=h>Gf`(ty%{(ty%{(ty%{(ty%{(ty%{(ty%H zYz@F!_l%H)JyKZ8eKv$+*WTg~4v*(^0>{1=;Li&jI}?DvAaFbn_>vF~*T;(?oL+FE zW2tvaKn}{aEQG@~vOI(%-Af@HF1eROIJ`Npgm5fJz8b>u;>wB;4u9Rs5Dq=P7Q*q? z)$1V~ubXy;a4c565yDZAt3o&&nQsalJRvOfg_*5-xWCO5BPfmhkk*-FL3Au_y+<~p9mapbproX;CL?u_-6vg+XKKq7dUo!1OGzc-~sS21r9p^{40T@T?7AG z;NUCp4FU&$fPW)!_?dxk6gbukfPX7+IEjIOC-B6j3s#QZvSjg??H!u~6Hs>XDDYc@wS-E( z(q12TEsf`aL{OtggJsbFvB|^%1Grn+Q+11Xk}s|O^VVVtQZ3r9SY10q%SQgE3@}@G qI?1L$x~D)Iq(Ekf0+oaUJ)T0q5B|eDPQ3ZPM}Pf8lWZXHsrfgSBom Vec { out } -fn host_deps(store_id: Bytes32, pubkey: Bytes48, secret: BlsSecretKey) -> HostDeps { +/// Dependencies for an ANONYMOUS read runtime: no host identity at all. +/// +/// Reading committed content consumes no identity, so this path carries none. +/// That is not a relaxed check but the absence of a subject to check: the guest's +/// content path builds its gate with `require_attestation: false` +/// (`digstore-guest/src/content.rs`), so nothing ever asks this host who it is. +/// Supplying a key here would be surface with no consumer; supplying a *stand-in* +/// key would be worse, because a store whose identity had been destroyed would +/// look healthy. +/// +/// [`serve_proof`] is the sibling that genuinely does consume an identity, and it +/// loads one itself. See §13.6. +fn host_deps(store_id: Bytes32) -> HostDeps { let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); let block = ChiaBlockRef { @@ -103,8 +115,9 @@ fn host_deps(store_id: Bytes32, pubkey: Bytes48, secret: BlsSecretKey) -> HostDe let prover = MockProver::new(prover_sk, prover_pk, block); HostDeps { store_id, - bls_secret: secret, - bls_public: pubkey, + // ANONYMOUS: see this function's doc comment. Absence, not a placeholder. + bls_secret: None, + bls_public: None, clock: Arc::new(FixedClock::new(1_700_000_000)), chain: Arc::new(chain), prover: Arc::new(prover), @@ -128,33 +141,28 @@ fn host_deps(store_id: Bytes32, pubkey: Bytes48, secret: BlsSecretKey) -> HostDe /// Instantiate the real host runtime over `module_path` (real wasmtime load / /// validate / instantiate — this is how a corrupted CODE section surfaces). -fn instantiate_host( - ctx: &CliContext, - module_path: &Path, - store_id: Bytes32, - pubkey: Bytes48, -) -> Result { +/// +/// The runtime is ANONYMOUS by construction: it reads no `signing_key.bin` and no +/// `trusted_keys.json`, so a store whose identity files are missing, unreadable, +/// or malformed still serves its committed content. Reading DIG content never +/// needs an account or a key (§5.3), and the two files are not inputs to it. +/// +/// This deliberately reverses the earlier fail-closed load here. That load +/// misread the fix it belonged to: the real defect was SUBSTITUTING a stand-in +/// identity (an all-zero G1, a `from_seed(&[42u8; 32])` seed), and the cure for a +/// substituted key is to stop substituting, not to refuse the read. Refusing cost +/// availability on every read while buying nothing, because the guest never +/// consults the value — and it broke far more than `cat`: `checkout`, `dev`, +/// `deploy --preview` and `compute_status` reach this function too and have no +/// network ladder to fall through to. +fn instantiate_host(module_path: &Path, store_id: Bytes32) -> Result { let module_bytes = std::fs::read(module_path) .map_err(|_| CliError::NotFound(module_path.display().to_string()))?; - // §12.2: the host attests with the store's host signing key — the same key - // whose public half the compiler embedded as the trusted key. Load the - // persisted seed that `init` wrote to `signing_key.bin`. - // - // Note what this key does NOT do on the read path: `digstore-guest`'s content - // path hardcodes `require_attestation: false`, so the guest does not verify - // this host and would not serve decoys if the key were wrong. The key is - // genuinely consumed by `serve_proof` below (§13.7 "one key for both roles"). - // - // FAIL CLOSED anyway: no fallback key. A hardcoded seed is reproducible by - // anyone reading this source, so a host holding it carries no identity at - // all — surface the missing key instead of degrading into an anonymous host, - // and surface it HERE rather than in the proof path that is harder to reach. - let secret = store_ops::load_signing_key(ctx)?; HostRuntime::new( &module_bytes, HostImportsConfig::default(), ExecutionLimits::default(), - host_deps(store_id, pubkey, secret), + host_deps(store_id), ) .map_err(|e| CliError::VerificationFailed(format!("module load/instantiate failed: {e:?}"))) } @@ -175,22 +183,11 @@ pub fn serve_content_raw( urn: &Urn, ) -> Result, CliError> { let store_id = urn.store_id; - // FAIL CLOSED, same reason as the signing key one line below: a store that - // cannot produce its own host identity is a broken store, and an all-zero G1 - // is not a weaker identity but a nonexistent one. - // - // Do NOT weaken this on the theory that the old fallback failed closed - // downstream anyway. It did not, and the earlier version of this comment - // claiming otherwise was the most dangerous line in the file: it read as a - // license to restore `unwrap_or(Bytes48([0u8; 48]))`. The guest's content - // path hardcodes `require_attestation: false` (see `instantiate_host`), so - // the host pubkey is never read here and never checked against any trusted - // set. Measured against the released 0.23.0 binary: with `trusted_keys.json` - // deleted it exits 0 and serves the content, under an identity that exists - // nowhere. Refusing here IS the fix, not a diagnostic nicety layered over a - // default that was already safe. - let pubkey = store_ops::load_host_pubkey(ctx)?; - let mut rt = instantiate_host(ctx, module_path, store_id, pubkey)?; + // No identity is loaded, and none is substituted for one — see + // [`instantiate_host`]. `ctx` is still threaded through for the callers' + // benefit, not for an identity read. + let _ = ctx; + let mut rt = instantiate_host(module_path, store_id)?; // Drive the module's own serve flow. The request carries the ROOT-INDEPENDENT // retrieval key (matching the compiler's `static_key`) so the guest finds the @@ -292,8 +289,15 @@ pub fn serve_proof( // attestation signing key (init wrote `signing_key.bin`) rather than minting // an independent prover key, so node attribution is bound to the attestation // identity by construction. - // FAIL CLOSED (see `instantiate_host`): a proof signed by a world-known - // fallback key attributes serving work to nobody. + // FAIL CLOSED, and note that this is the OPPOSITE of the serve path above, + // deliberately. Serving content consumes no identity, so it carries none; + // signing a proof IS an act of attribution, so an absent or unreadable key + // must stop it. Neither substituting a stand-in key (a proof attributed to a + // world-known seed attributes serving work to nobody) nor proceeding without + // one is available here — a proof needs a signer. + // + // Do not "simplify" this to match `instantiate_host`. The asymmetry is the + // fix: identity became optional on the READ path only. let node_sk = store_ops::load_signing_key(ctx)?; let node_pk = node_sk.public_key(); let block = ChiaBlockRef { @@ -376,10 +380,9 @@ mod tests { /// whatever the RNG does). #[test] fn the_host_instantiate_host_builds_draws_real_entropy() { - let (_td, ctx, store_id, module_path) = committed_store(); - let pubkey = store_ops::load_host_pubkey(&ctx).unwrap(); + let (_td, _ctx, store_id, module_path) = committed_store(); - let rt = instantiate_host(&ctx, &module_path, store_id, pubkey) + let rt = instantiate_host(&module_path, store_id) .expect("an initialized store instantiates"); assert!( @@ -389,22 +392,83 @@ mod tests { ); } - /// FAIL CLOSED on a missing host PUBLIC key, the sibling of the signing-key - /// load one line away in the same function. + /// Delete every file that carries the store's identity. + fn destroy_identity(ctx: &CliContext) { + for f in ["signing_key.bin", "trusted_keys.json"] { + let p = ctx.dig_dir.join(f); + if p.exists() { + std::fs::remove_file(&p).unwrap(); + } + } + } + + /// A store with NO identity still serves its committed content (#2712). /// - /// Before this, `load_host_pubkey` fell back to an all-zero `Bytes48`, so a - /// store whose `trusted_keys.json` had gone missing served on with a host - /// identity that does not exist — and it SUCCEEDED. The pre-fix outcome was - /// not "an error, misattributed a few layers away"; it was a 200-equivalent. - /// Attestation is hardcoded off on the content path, so the zero key was - /// never read, let alone rejected. Confirmed against the released 0.23.0 - /// binary, which serves the file with `trusted_keys.json` deleted. + /// Asserting `Ok` here would be a false green: the miss path returns a DECOY + /// through the same `Ok` (§14.2), so a runtime that had silently stopped + /// finding the resource would satisfy a bare success check. The assertion is + /// therefore on the decrypted, merkle-verified PLAINTEXT — the one outcome a + /// decoy cannot produce, because its proof does not verify against the + /// trusted root. /// - /// That is why this has to be a call-site test: the difference it detects is - /// served-content versus refusal, which no assertion on `load_host_pubkey`'s - /// return value alone would show. + /// Fixture design: exactly one actor varies (the identity files), and the + /// intact store is kept as a truthful control, so a store that had stopped + /// serving for some unrelated reason could not read as a pass. #[test] - fn a_missing_trusted_key_file_refuses_to_serve() { + fn a_store_with_no_identity_still_serves_committed_content() { + let (_td, ctx, _store_id, _module_path) = committed_store(); + let cfg = ctx.load_config().unwrap(); + let root = store_ops::current_root(&ctx).unwrap().unwrap(); + + // Control: the intact store returns the real bytes. + let intact = read_resource_plaintext(&ctx, &cfg, &root, "hello") + .expect("an intact store serves its own content"); + assert_eq!(intact, b"hello serve"); + + destroy_identity(&ctx); + + let anonymous = read_resource_plaintext(&ctx, &cfg, &root, "hello").expect( + "reading committed content consumes no identity, so a store whose \ + identity files are gone must still serve it", + ); + assert_eq!( + anonymous, intact, + "an anonymous read must return the SAME real plaintext, not a decoy" + ); + } + + /// The read runtime the PRODUCTION path builds carries no host identity. + /// + /// Anchored at the call site for the same reason as the RNG test above: an + /// assertion on `host_deps(..)`'s return value is defeated by inlining a + /// `HostDeps { bls_public: Some(..), .. }` literal into `instantiate_host`. + /// The identity is not observable through any export on the content path, so + /// `HostRuntime::has_host_public_key` exists to answer this. + #[test] + fn the_read_runtime_carries_no_host_identity() { + let (_td, _ctx, store_id, module_path) = committed_store(); + + let rt = instantiate_host(&module_path, store_id).expect("an initialized store serves"); + + assert!( + !rt.has_host_public_key(), + "the read path must carry NO host identity; carrying one re-couples \ + every read to files it does not consume" + ); + } + + /// The control that keeps the relaxation honest: identity became optional on + /// the READ path ONLY. + /// + /// `serve_proof` signs, and signing is an act of attribution, so it must still + /// refuse when the signing key is gone. Without this test the suite above is + /// satisfied equally by "identity optional on reads" and by "identity optional + /// everywhere" — the second being the security regression this change must not + /// become. The message must also name the missing file, because a bare io + /// error ("the system cannot find the file specified") fails just as closed + /// and tells an operator nothing. + #[test] + fn serve_proof_still_refuses_without_a_signing_key() { let (_td, ctx, store_id, module_path) = committed_store(); let urn = Urn { chain: "chia".into(), @@ -412,22 +476,62 @@ mod tests { root_hash: None, resource_key: Some("hello".into()), }; - // Control: the intact store serves. - serve_content_raw(&ctx, &module_path, &urn).expect("an intact store serves"); + let root = store_ops::current_root(&ctx).unwrap().unwrap(); + + // Control: with the identity intact, a proof is produced. + serve_proof(&ctx, &module_path, &urn, root).expect("an intact store can sign a proof"); - std::fs::remove_file(ctx.dig_dir.join("trusted_keys.json")).unwrap(); + destroy_identity(&ctx); - let err = serve_content_raw(&ctx, &module_path, &urn) - .expect_err("a store with no host identity must refuse to serve"); - // The refusal is only half the value; the other half is that the message - // names the file that is gone. A bare io error ("the system cannot find - // the file specified") fails just as closed and tells an operator - // nothing, so assert the subject, not merely the failure. + let err = serve_proof(&ctx, &module_path, &urn, root) + .expect_err("signing a proof REQUIRES an identity and must refuse without one"); let msg = format!("{err:?}"); assert!( - msg.contains("trusted_keys.json"), - "the error must name the missing identity file, not a downstream \ - symptom or a bare io error: {msg}" + msg.contains("signing_key.bin"), + "the refusal must name the missing identity file: {msg}" ); } + + /// Revert-proof, two legs chained in ONE test because either alone is + /// defeatable. + /// + /// `has_host_public_key` (the behavioural leg) is green both when the read + /// path carries NOTHING and — crucially — it is NOT green when a stand-in key + /// is substituted, so it catches a restored `unwrap_or(Bytes48([0u8; 48]))`. + /// What it CANNOT catch is a re-added *refusal*: a runtime that never gets + /// built because the read aborted earlier still trivially "carries no + /// identity". This leg catches that by asserting the read path never reaches + /// for the identity in the first place. + /// + /// The banned tokens are the identity-carrying positions specifically, not + /// seeds in general: `host_deps`'s MOCK PROVER key is a legitimate + /// `from_seed(&[7u8; 32])` and has nothing to do with the host identity, so a + /// blanket seed ban here would be a false failure that invites deletion. + #[test] + fn the_read_path_never_reaches_for_the_store_identity() { + let src = include_str!("serve.rs"); + // Cut the test module off so this file's own doc comments and fixtures do + // not match themselves. + let production = src + .split("#[cfg(test)]") + .next() + .expect("this file has a test module"); + + // `load_signing_key` is deliberately absent from this list: `serve_proof` + // still calls it, and must. + for banned in [ + "load_host_pubkey", + "bls_public: Some(", + "bls_secret: Some(", + "[0u8; 48]", + ] { + assert!( + !production.contains(banned), + "`{banned}` reappeared in this file's read path. Reading committed \ + content consumes no identity: it must neither load one (which \ + costs availability, #2712) nor substitute one (which fakes an \ + identity nobody controls, #2553)." + ); + } + } } diff --git a/crates/digstore-cli/tests/adv_delegated_host_key.rs b/crates/digstore-cli/tests/adv_delegated_host_key.rs index 6e55c212..e99c9f50 100644 --- a/crates/digstore-cli/tests/adv_delegated_host_key.rs +++ b/crates/digstore-cli/tests/adv_delegated_host_key.rs @@ -35,8 +35,8 @@ fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { let prover = MockProver::new(prover_sk, prover_pk, block); HostDeps { store_id, - bls_secret: sk, - bls_public: pk, + bls_secret: Some(sk), + bls_public: Some(pk), clock: Arc::new(FixedClock::new(1_700_000_000)), chain: Arc::new(chain), prover: Arc::new(prover), diff --git a/crates/digstore-cli/tests/adv_self_serve.rs b/crates/digstore-cli/tests/adv_self_serve.rs index 10e949cc..d1e078c9 100644 --- a/crates/digstore-cli/tests/adv_self_serve.rs +++ b/crates/digstore-cli/tests/adv_self_serve.rs @@ -39,8 +39,8 @@ fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { let prover = MockProver::new(prover_sk, prover_pk, block); HostDeps { store_id, - bls_secret: sk, - bls_public: pk, + bls_secret: Some(sk), + bls_public: Some(pk), clock: Arc::new(FixedClock::new(1_700_000_000)), chain: Arc::new(chain), prover: Arc::new(prover), diff --git a/crates/digstore-cli/tests/ops_roundtrip.rs b/crates/digstore-cli/tests/ops_roundtrip.rs index eaa09fd0..437132e3 100644 --- a/crates/digstore-cli/tests/ops_roundtrip.rs +++ b/crates/digstore-cli/tests/ops_roundtrip.rs @@ -87,8 +87,8 @@ fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { let prover = MockProver::new(prover_sk, prover_pk, block); HostDeps { store_id, - bls_secret: sk, - bls_public: pk, + bls_secret: Some(sk), + bls_public: Some(pk), clock: Arc::new(FixedClock::new(1_700_000_000)), chain: Arc::new(chain), prover: Arc::new(prover), diff --git a/crates/digstore-compiler/tests/auth_policy.rs b/crates/digstore-compiler/tests/auth_policy.rs index bec2e13a..43b5ab4d 100644 --- a/crates/digstore-compiler/tests/auth_policy.rs +++ b/crates/digstore-compiler/tests/auth_policy.rs @@ -40,8 +40,8 @@ fn host_deps(store_id: Bytes32) -> HostDeps { let prover = MockProver::new(prover_sk, prover_pk, block); HostDeps { store_id, - bls_secret: sk, - bls_public: pk, + bls_secret: Some(sk), + bls_public: Some(pk), clock: Arc::new(FixedClock::new(1_700_000_000)), chain: Arc::new(chain), prover: Arc::new(prover), diff --git a/crates/digstore-compiler/tests/large_data_section.rs b/crates/digstore-compiler/tests/large_data_section.rs index 7625c28d..c5d7a177 100644 --- a/crates/digstore-compiler/tests/large_data_section.rs +++ b/crates/digstore-compiler/tests/large_data_section.rs @@ -88,8 +88,8 @@ fn host_deps(store_id: Bytes32) -> HostDeps { let prover = MockProver::new(prover_sk, prover_pk, block); HostDeps { store_id, - bls_secret: sk, - bls_public: pk, + bls_secret: Some(sk), + bls_public: Some(pk), clock: Arc::new(FixedClock::new(1_700_000_000)), chain: Arc::new(chain), prover: Arc::new(prover), diff --git a/crates/digstore-compiler/tests/self_serving.rs b/crates/digstore-compiler/tests/self_serving.rs index c0c3cc57..afda1c0b 100644 --- a/crates/digstore-compiler/tests/self_serving.rs +++ b/crates/digstore-compiler/tests/self_serving.rs @@ -93,8 +93,8 @@ fn host_deps(store_id: Bytes32) -> HostDeps { let prover = MockProver::new(prover_sk, prover_pk, block); HostDeps { store_id, - bls_secret: sk, - bls_public: pk, + bls_secret: Some(sk), + bls_public: Some(pk), clock: Arc::new(FixedClock::new(1_700_000_000)), chain: Arc::new(chain), prover: Arc::new(prover), diff --git a/crates/digstore-host/tests/common/mod.rs b/crates/digstore-host/tests/common/mod.rs index 2d7fdcdc..5a0cb23e 100644 --- a/crates/digstore-host/tests/common/mod.rs +++ b/crates/digstore-host/tests/common/mod.rs @@ -26,8 +26,8 @@ pub fn test_deps(clock: FixedClock) -> HostDeps { HostDeps { store_id: Bytes32([0u8; 32]), - bls_secret: sk, - bls_public: pk, + bls_secret: Some(sk), + bls_public: Some(pk), clock: Arc::new(clock), chain: Arc::new(chain), prover: Arc::new(prover), From d7b74bf29c1c7ace19f32829694ce9960a6205cc Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 11:00:43 -0700 Subject: [PATCH 04/17] test(guest): an anonymous host cannot serve attestation-gated content Proves the optional host identity is not a hole: where the content gate DOES require attestation, a host holding no identity gets a Decoy. Widens the SigningHost double with an anonymous mode mirroring digstore-host's UnavailableAttestationBackend, and keeps the identified host as the control on the same fixture. Refs DIG-Network/dig_ecosystem#2712 Co-Authored-By: Claude --- crates/digstore-guest/tests/content_proof.rs | 90 ++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/digstore-guest/tests/content_proof.rs b/crates/digstore-guest/tests/content_proof.rs index 144b035f..65f753e1 100644 --- a/crates/digstore-guest/tests/content_proof.rs +++ b/crates/digstore-guest/tests/content_proof.rs @@ -183,6 +183,15 @@ struct SigningHost { time: u64, rand: std::cell::Cell, corrupt_sig: bool, + /// Behave as an ANONYMOUS host: hold no identity, so both identity imports + /// fail. This mirrors what `digstore-host` actually does for a runtime built + /// with `bls_public: None` — `host_get_public_key` returns `NotFound` and the + /// `UnavailableAttestationBackend` refuses to sign. + /// + /// The double is widened rather than the test weakened: a double that can only + /// vary `corrupt_sig` cannot express "the host has nothing to sign with", + /// which is a different lie from "the host signed badly". + anonymous: bool, } impl SigningHost { @@ -195,15 +204,30 @@ impl SigningHost { time: 1_700_000_000, rand: std::cell::Cell::new(0), corrupt_sig: false, + anonymous: false, + } + } + + /// The same host, stripped of its identity. + fn anonymous(seed: &[u8; 32]) -> Self { + SigningHost { + anonymous: true, + ..SigningHost::new(seed) } } } impl digstore_guest::host::DigHost for SigningHost { fn get_public_key(&self) -> digstore_guest::host::HostResult { + if self.anonymous { + return Err(digstore_core::ErrorCode::NotFound); + } Ok(self.pubkey.to_vec()) } fn create_attestation(&self, challenge: &[u8]) -> digstore_guest::host::HostResult { + if self.anonymous { + return Err(digstore_core::ErrorCode::NotFound); + } // Sign the EXACT challenge bytes the gate handed us (AugScheme). let mut sig = digstore_crypto::bls::bls_sign(&self.secret, challenge).0; if self.corrupt_sig { @@ -281,6 +305,72 @@ fn valid_attestation_from_trusted_key_returns_real() { ); } +/// An ANONYMOUS host must NOT be able to serve a module that genuinely requires +/// attestation (#2712). +/// +/// This is the test that proves making the host identity optional is not a hole. +/// The read path in `digstore-cli` now builds its runtime with no identity at all, +/// which is safe only because the content gate does not ask for one +/// (`require_attestation: false`). This asserts the converse directly: where the +/// gate DOES ask, an identity-less host gets a **Decoy**, never real content. +/// +/// Fixture design — exactly one actor varies. The trusted set still contains a +/// real, valid key and the gate is still enabled; the only difference from +/// `valid_attestation_from_trusted_key_returns_real` (the control, immediately +/// above) is that this host holds nothing to sign with. Removing the trusted key +/// instead would have proved a different, already-covered thing +/// (`attestation_with_no_embedded_trusted_set_returns_decoy`) and would have made +/// the fixture unable to see the property under test, because no honest signer +/// would remain. +#[test] +fn an_anonymous_host_cannot_serve_content_that_requires_attestation() { + let key = Bytes32([0x11; 32]); + let entry = KeyTableEntry { + static_key: key, + generation: Bytes32([0xBB; 32]), + chunk_indices: vec![0], + total_size: 5, + }; + let table = encode_key_table(&[entry]); + let pool = fixtures::pack_pool(&[b"alpha"]); + + // The trusted set holds the key this host WOULD have signed with, so the only + // thing standing between the request and real content is the missing identity. + let identified = SigningHost::new(&[42u8; 32]); + let blob = section_with_trusted([0xAA; 32], [0xBB; 32], &table, &pool, &[identified.pubkey]); + let ds = DataSection::parse(&blob).unwrap(); + + let mut gc = gate_config(); + gc.require_attestation = true; + let req = ContentRequest { + retrieval_key: key, + root_hash: None, + range: None, + jwt: None, + window: None, + }; + + // Control: the SAME fixture releases real content to a host that can attest. + assert!( + matches!( + serve_content(&identified, &ds, &req, &gc), + ContentOutcome::Real(_) + ), + "control: an identified, trusted host must get real content from this \ + fixture, otherwise the assertion below proves nothing" + ); + + let anonymous = SigningHost::anonymous(&[42u8; 32]); + assert!( + matches!( + serve_content(&anonymous, &ds, &req, &gc), + ContentOutcome::Decoy(_) + ), + "a host with NO identity must fail the attestation gate closed and get a \ + Decoy; making the identity optional must not make the gate optional" + ); +} + #[test] fn attestation_from_untrusted_key_returns_decoy() { // The host signs with a real key, but that key is NOT in the embedded From 8a2fa41b925deb92bd43346c480c86b14bf5df9f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 11:13:13 -0700 Subject: [PATCH 05/17] docs(spec): state that serving committed content consults no identity SPEC 13.6 banned substituting an identity but never said what a path that does not need one should do, which left "refuse the read" readable as the stricter option. It is not stricter: it withholds content whose integrity does not depend on the host, while leaving every signing path exactly as safe. Also bumps digstore-host 0.2.0 -> 0.3.0 (breaking Rust API: HostDeps and HostKeys identity fields became Option, AttestationBackend::public_key returns Option) and the workspace 0.24.0 -> 0.25.0. Refs DIG-Network/dig_ecosystem#2712 Co-Authored-By: Claude --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- SPEC.md | 21 +++++++++++++++++++-- crates/digstore-cli/src/error.rs | 3 ++- crates/digstore-cli/src/ops/serve.rs | 4 ++-- crates/digstore-host/Cargo.toml | 2 +- crates/digstore-host/src/teehook.rs | 4 +--- 7 files changed, 33 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71ff99ad..edc9d907 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2354,7 +2354,7 @@ dependencies = [ [[package]] name = "digstore-chain" -version = "0.24.0" +version = "0.25.0" dependencies = [ "aes-gcm", "anyhow", @@ -2392,7 +2392,7 @@ dependencies = [ [[package]] name = "digstore-chunker" -version = "0.24.0" +version = "0.25.0" dependencies = [ "digstore-core", "hex", @@ -2402,7 +2402,7 @@ dependencies = [ [[package]] name = "digstore-cli" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anstream 0.6.21", "anstyle", @@ -2471,7 +2471,7 @@ dependencies = [ [[package]] name = "digstore-core" -version = "0.24.0" +version = "0.25.0" dependencies = [ "aes-gcm-siv", "hex", @@ -2526,7 +2526,7 @@ dependencies = [ [[package]] name = "digstore-host" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -2568,7 +2568,7 @@ dependencies = [ [[package]] name = "digstore-remote" -version = "0.24.0" +version = "0.25.0" dependencies = [ "async-trait", "axum", @@ -2628,7 +2628,7 @@ dependencies = [ [[package]] name = "digstore-subscription" -version = "0.24.0" +version = "0.25.0" dependencies = [ "async-trait", "digstore-core", diff --git a/Cargo.toml b/Cargo.toml index ac9c2b8b..e71ecd85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["crates/digstore-prover/guest", "crates/dig-client-wasm"] [workspace.package] edition = "2021" -version = "0.24.0" +version = "0.25.0" license = "GPL-2.0-only" [workspace.dependencies] diff --git a/SPEC.md b/SPEC.md index 2f19d01a..5bdb3971 100644 --- a/SPEC.md +++ b/SPEC.md @@ -700,7 +700,7 @@ bound-induced export failure (timeout vs fuel exhaustion), not as an opaque engi Each export call is armed with its own fresh budget; a serve sequence (alloc → call → read → dealloc) is deliberately NOT a single combined budget. -### 13.6 Host identity: never substituted, and required to attest, sign, or push +### 13.6 Host identity: never substituted, required to attest, sign, or push, and not consulted to read A store's host identity is its BLS signing key (`signing_key.bin`) and the trusted host keys (`trusted_keys.json`) persisted at init. @@ -710,6 +710,17 @@ A store's host identity is its BLS signing key (`signing_key.bin`) and the trust carries no identity at all rather than a weaker one, and an all-zero public key is a nonexistent identity rather than a weak one. - A host MUST refuse to attest, sign, or push when the identity is absent or unreadable. +- Serving committed content consumes NO host identity. A host MUST carry none on that path, MUST NOT + read the identity files to take it, and MUST NOT refuse a read because the identity is absent, + unreadable, or malformed. Reading DIG content requires no account and no key (§14), so a missing + identity is not a reason to withhold content that is already committed and merkle-verifiable + against its trusted root. +- Where a host does carry no identity, that absence MUST be representable as absence rather than + encoded as a placeholder value, so that a path which later begins consuming the identity fails + closed instead of accepting a key nobody controls. +- Making the identity optional MUST NOT make any gate optional. Where the content gate does require + attestation (§12.2), a host holding no identity MUST fail that gate closed and return a decoy, + exactly as a host presenting an untrusted or invalid key does. - The signing key MUST be exactly 32 bytes. A shorter or longer file is malformed and MUST be reported as a corrupt-identity error — never truncated, never padded, and never handed to key derivation, which is permitted to abort the process on a short seed. @@ -720,7 +731,13 @@ The substitution ban holds regardless of whether a given path currently verifies loads. A path that does not consume the identity today MUST NOT be treated as licence to supply a placeholder, because the placeholder becomes forgeable identity the moment any consumer begins verifying it. Where a path genuinely does not need an identity, the correct expression is to carry -no identity at all — not to carry a fabricated one. +no identity at all — not to carry a fabricated one, and not to refuse the operation. + +These two rules are one rule seen from both sides, and neither implies the other. Refusing a read +over a missing identity is not a stricter form of not substituting one: it withholds content whose +integrity does not depend on the host at all, while leaving every path that DOES consume an identity +exactly as safe as it was. Conversely, tolerating a missing identity on the read path grants nothing +to the signing paths, which continue to require one. ## 14. Client → node resolution (the origin) diff --git a/crates/digstore-cli/src/error.rs b/crates/digstore-cli/src/error.rs index 14f89122..88953815 100644 --- a/crates/digstore-cli/src/error.rs +++ b/crates/digstore-cli/src/error.rs @@ -252,7 +252,8 @@ impl CliError { CliError::NoLocalNode { .. } => Some("run `dig-node status` to check your node".into()), CliError::NonFastForward => Some("run `digstore pull` first, then push".into()), CliError::IdentityUnavailable { path, .. } => Some(format!( - "check that {path} exists and is readable by this user; reading committed content does not need it, only signing does" + "check that {path} exists and is readable by this user; reading committed \ + content does not need it, only signing does" )), CliError::Unauthorized(_) => Some("check your credentials / store signing key".into()), CliError::NotFound(_) => Some("run `digstore log` to list capsules and keys".into()), diff --git a/crates/digstore-cli/src/ops/serve.rs b/crates/digstore-cli/src/ops/serve.rs index c326cecf..0a80cc5c 100644 --- a/crates/digstore-cli/src/ops/serve.rs +++ b/crates/digstore-cli/src/ops/serve.rs @@ -382,8 +382,8 @@ mod tests { fn the_host_instantiate_host_builds_draws_real_entropy() { let (_td, _ctx, store_id, module_path) = committed_store(); - let rt = instantiate_host(&module_path, store_id) - .expect("an initialized store instantiates"); + let rt = + instantiate_host(&module_path, store_id).expect("an initialized store instantiates"); assert!( !rt.rng_is_deterministic(), diff --git a/crates/digstore-host/Cargo.toml b/crates/digstore-host/Cargo.toml index 24b21bb7..5017a39b 100644 --- a/crates/digstore-host/Cargo.toml +++ b/crates/digstore-host/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "digstore-host" license = "GPL-2.0-only" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "wasmtime runtime for serving compiled Digstore WASM modules (paper 6.3, 6.4, 18)." diff --git a/crates/digstore-host/src/teehook.rs b/crates/digstore-host/src/teehook.rs index 64c281bc..385ea2f5 100644 --- a/crates/digstore-host/src/teehook.rs +++ b/crates/digstore-host/src/teehook.rs @@ -76,9 +76,7 @@ pub struct UnavailableAttestationBackend; impl AttestationBackend for UnavailableAttestationBackend { fn attest(&self, _challenge: &[u8]) -> Result { - Err(HostError::GuestError( - digstore_core::ErrorCode::NotFound, - )) + Err(HostError::GuestError(digstore_core::ErrorCode::NotFound)) } fn public_key(&self) -> Option { None From ddc15587d758f316401a8f0be14107c2700dc902 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 11:31:21 -0700 Subject: [PATCH 06/17] test(cli): pin `cat` reading without a store identity Command-level twins of the ops::serve unit tests: `cat` serves after the identity files are destroyed, and `cat --verify-proof` still refuses, naming the missing file. The second is the control that keeps the relaxation scoped to reads. Refs DIG-Network/dig_ecosystem#2712 Co-Authored-By: Claude --- .../digstore-cli/tests/cli_cat_no_identity.rs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 crates/digstore-cli/tests/cli_cat_no_identity.rs diff --git a/crates/digstore-cli/tests/cli_cat_no_identity.rs b/crates/digstore-cli/tests/cli_cat_no_identity.rs new file mode 100644 index 00000000..8c770ce6 --- /dev/null +++ b/crates/digstore-cli/tests/cli_cat_no_identity.rs @@ -0,0 +1,127 @@ +//! `cat` reads without a store identity; signing still requires one (#2712). +//! +//! Reading DIG content never needs an account or a key (`SPEC.md` §13.6/§14), so a +//! store whose `signing_key.bin` / `trusted_keys.json` is missing or unreadable +//! must still serve the content it has already committed. Before this, the read +//! path loaded both files and aborted, which also pre-empted the client→node +//! ladder: `cat`'s local leg returned `Err` rather than the "not here, try the +//! network" signal, so the ladder was never reached. +//! +//! These are the command-level twins of the unit tests in `ops::serve::tests`. +//! They exist separately because the unit tests drive `serve_content_raw` +//! directly, and the property users actually depend on is the exit status of a +//! whole `cat` invocation. + +mod common; +use common::{dig, store_id_and_root, tmp_dig}; + +use std::path::Path; + +/// Delete every file carrying the store's identity, and assert they were really +/// there — a fixture that silently deleted nothing would make every assertion +/// below vacuous. +fn destroy_identity(dig_dir: &Path) { + let store_dir = dig_dir.join("stores").join("default"); + let mut removed = 0; + for name in ["signing_key.bin", "trusted_keys.json"] { + let p = store_dir.join(name); + if p.exists() { + std::fs::remove_file(&p).unwrap(); + removed += 1; + } + } + assert_eq!( + removed, + 2, + "fixture must actually remove BOTH identity files from {}; if the layout \ + moved, this test is no longer exercising a store without an identity", + store_dir.display() + ); +} + +/// Commit a one-file store and return its URN. +fn committed_store(dir: &tempfile::TempDir, content: &[u8]) -> String { + let f = dir.path().join("doc.txt"); + std::fs::write(&f, content).unwrap(); + dig(dir).arg("init").assert().success(); + dig(dir) + .args(["add"]) + .arg(&f) + .args(["--key", "doc"]) + .assert() + .success(); + dig(dir).args(["commit"]).assert().success(); + let (store_id, root) = store_id_and_root(dir); + format!("urn:dig:chia:{store_id}:{root}/doc") +} + +#[test] +fn cat_serves_committed_content_after_the_store_identity_is_destroyed() { + let dir = tmp_dig(); + let content = b"readable without an identity"; + let urn = committed_store(&dir, content); + + // Control: the intact store returns the plaintext. Without this, a store that + // had stopped serving for an unrelated reason could not be told apart from the + // regression under test. + let intact = dig(&dir).args(["cat", &urn]).output().unwrap(); + assert!(intact.status.success()); + assert_eq!(intact.stdout, content); + + destroy_identity(&dir.path().join(".dig")); + + let out = dig(&dir).args(["cat", &urn]).output().unwrap(); + assert!( + out.status.success(), + "cat must still serve when the store has no identity; it consumes none. \ + stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Assert the PLAINTEXT, not merely a zero exit. A retrieval miss returns a + // decoy through the same success path (§14.2), so a runtime that had quietly + // stopped finding the resource would satisfy an exit-status-only check. + assert_eq!( + out.stdout, content, + "an identity-less read must return the real plaintext, never a decoy" + ); +} + +#[test] +fn cat_verify_proof_still_refuses_without_a_signing_key() { + let dir = tmp_dig(); + let urn = committed_store(&dir, b"proof needs a signer"); + + // Control: with the identity intact, the proof verifies. + dig(&dir) + .args(["cat", "--verify-proof", &urn]) + .assert() + .success(); + + destroy_identity(&dir.path().join(".dig")); + + // This is the control that keeps the relaxation honest. Identity became + // optional on the READ path only: `--verify-proof` signs an execution proof, + // and signing is an act of attribution, so it must refuse. Without this + // assertion the test above is satisfied equally by "identity optional on + // reads" and by "identity optional everywhere" — the second being a security + // regression. + let out = dig(&dir) + .args(["cat", "--verify-proof", &urn]) + .output() + .unwrap(); + assert!( + !out.status.success(), + "signing a proof REQUIRES an identity and must refuse without one" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + // The refusal must name the file that is gone. The released 0.23.0 binary + // instead substituted a world-known fallback key and failed several layers + // later with `NodeKeyNotAttested(b145dfcb…)` plus a hint blaming the CONTENT + // ("the store data was tampered with") — a true statement about the wrong + // subject, which sends an operator looking for corruption that is not there. + assert!( + stderr.contains("signing_key.bin"), + "the refusal must name the missing identity file rather than blame the \ + content: {stderr}" + ); +} From ad9355aa39ebb428a3766d69e16372e77452ca84 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 11:36:43 -0700 Subject: [PATCH 07/17] feat(cli): classify an absent store identity as IDENTITY_UNAVAILABLE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CliError variant existed but was constructed nowhere. Wire it into load_signing_key so a store that cannot sign reports a stable code (IDENTITY_UNAVAILABLE, exit 20) instead of the catch-all exit 1, and so a §6.2 machine consumer can branch on the class rather than on prose. The corrupt-length branches stay InvalidArgument: a truncated or overlong key is a different problem with a different remedy from an absent one. Refs DIG-Network/dig_ecosystem#2712 Co-Authored-By: Claude --- crates/digstore-cli/src/ops/store_ops.rs | 58 ++++++++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/digstore-cli/src/ops/store_ops.rs b/crates/digstore-cli/src/ops/store_ops.rs index 61718b82..5f8fa8c9 100644 --- a/crates/digstore-cli/src/ops/store_ops.rs +++ b/crates/digstore-cli/src/ops/store_ops.rs @@ -1468,10 +1468,17 @@ pub(crate) fn load_host_pubkey(ctx: &CliContext) -> Result { /// Load the host BLS signing key (seed) persisted at init (§12.2). /// -/// The error names the file and the likely cause, because every caller of this -/// is a serve path that must now REFUSE to run without it: an operator seeing a -/// bare io error would reasonably look for a content problem instead of a -/// missing store identity. +/// Every remaining caller is a path that genuinely CONSUMES the identity — +/// signing a proof, pushing — so the failure is classified as +/// [`CliError::IdentityUnavailable`] (`IDENTITY_UNAVAILABLE`, exit 20) rather +/// than a generic error. Reading committed content does not call this at all +/// (§13.6): it consumes no identity, so a missing key is not a reason to refuse +/// a read. +/// +/// The error names the file and the likely cause because an operator seeing a +/// bare io error would reasonably look for a content problem instead of a missing +/// store identity — and a §6.2 machine consumer can branch on the stable code +/// instead of matching prose. /// /// The length is validated before the key is derived — `SecretKey::from_seed` /// asserts `len >= 32` and therefore ABORTS THE PROCESS on a short seed. A @@ -1483,12 +1490,12 @@ pub(crate) fn load_signing_key( ctx: &CliContext, ) -> Result { let path = ctx.dig_dir.join("signing_key.bin"); - let bytes = fs::read(&path).map_err(|e| { - CliError::Other(anyhow::anyhow!( - "cannot read the host signing key at {} ({e}) — the store may not have been \ - initialized (`dig init`), or the key file was removed or is unreadable", - path.display() - )) + let bytes = fs::read(&path).map_err(|e| CliError::IdentityUnavailable { + path: path.display().to_string(), + detail: format!( + "cannot read the host signing key ({e}) — the store may not have been \ + initialized (`dig init`), or the key file was removed or is unreadable" + ), })?; let seed: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { CliError::InvalidArgument(format!( @@ -1905,6 +1912,37 @@ mod tests { } } + /// An ABSENT key file is classified as `IDENTITY_UNAVAILABLE`, distinctly from + /// the corrupt-length cases above. + /// + /// A §6.2 machine consumer must be able to tell "this store has no identity, + /// so it cannot sign" from "something went wrong", because the two have + /// different remedies (re-create the identity vs investigate). Before this, + /// both arrived as the catch-all `ERROR`/exit 1. The distinction matters more + /// now that a missing identity no longer stops a READ (§13.6): the only + /// operations that surface it are the ones that genuinely need a signer. + #[test] + fn an_absent_signing_key_is_classified_as_identity_unavailable() { + let (_td, ctx) = ctx(false); + fs::remove_file(ctx.dig_dir.join("signing_key.bin")).unwrap(); + + let err = load_signing_key(&ctx) + .err() + .expect("an absent key cannot be loaded"); + assert!( + matches!(err, CliError::IdentityUnavailable { .. }), + "an absent identity must be its own class, not the catch-all: {err:?}" + ); + assert_eq!(err.code(), "IDENTITY_UNAVAILABLE"); + assert_eq!(err.exit_code(), 20); + // The path must reach the operator: "store identity unavailable" without + // naming the file leaves them guessing which of the two files it was. + assert!( + format!("{err}").contains("signing_key.bin"), + "the message must name the file: {err}" + ); + } + /// An OVERLONG key file is equally corrupt. `from_seed` accepts `len >= 32`, /// so without an exact-length check a 64-byte file would silently derive a /// key from bytes nothing wrote — a different identity than the store's, with From 92c9ee6f9263b1b508192fb8ccddf2c313662be4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 11:37:55 -0700 Subject: [PATCH 08/17] chore: gitignore the per-worktree GitNexus index The index is a regenerable per-worktree artifact (52-299 MB when it builds) and must never enter the tree. Two stub files from a failed analyze run had been committed by a `git add -A`. Co-Authored-By: Claude --- .gitignore | 4 +++- .gitnexus/lbug | Bin 4096 -> 0 bytes .gitnexus/lbug.wal | Bin 25374 -> 0 bytes 3 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 .gitnexus/lbug delete mode 100644 .gitnexus/lbug.wal diff --git a/.gitignore b/.gitignore index f5cbad43..83d2f593 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ # Rendered whitepaper HTML intermediate (the .md source and .pdf are kept). /docs/whitepaper/digstore-whitepaper.html .testcredentials -.claude/worktrees/ \ No newline at end of file +.claude/worktrees/ +# GitNexus code-intelligence index (per-worktree, regenerable) +.gitnexus/ diff --git a/.gitnexus/lbug b/.gitnexus/lbug deleted file mode 100644 index d9c28a62dbccf891aa0a8be5eb8c5f29aa798077..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmb`JKWG$D5XRps>e`*r!t#v&Ql`8{IBX0U^%MllR3ajJl?D`Yis1PN|6+0~!U+}u zo0OJUiXcP;Ik2&e7LrN?3*C3~{r0j$mP?T>^4;uynR##CH#2Yg=-Fcv!JdEF6MunY zJAYk&TfNr)c=Gbk=dZ`_Y>6Lt|Gj!C`;lZnBX$q|*d8(LvG+ONy^^wXYx4O^#^~CcSdGYD*H{ObP9^uu+ zt4JRn;Vq7vzqWWD*+=t$@s`B%$Uai(uP>fQ;+E>v>7`VEYj~fMu&Yd0=!Sii>1C(` zJLfVT4A1xTo8NQUf#`fcR8((=4Y7&4`tatU{&?ScU~#a?-?>Lc-h})$g!t=;hn_A- zpV%S1*%9l#b-bvn$Ks){5Nsb)CyKgyFCO|Dh%fuIG;KWe^j$n`czeY|Pw9QzFLqFW z`L4L=X;M6Fc(W%hE_ymA9(Ify_gis&ttDRG2em-@Uqd=t6c3xY^fh#}EFL!drC*_= zKjLABaAKer`Vcy*N(b2NgZ_d(W)7LI(#OR<)SkaJIA_k&BYL54a84eRaPeZyAN_#5 zdPJw2SHG5*(e-QRyLt6%{(SwCXYx+|nhTa^-dA)UZ6UnA+|}=CN4Vl>&H%1N9@Dv z7@N50+3ypZa~r*1)63?~H>=}~%(t(ZUN%x6Q8A=$?A;$oZD`-J!oU xlS`mJs2h(-uzpq6pF^j(U+ReZ^2qwi?m3OzpXr%8%Iiz_N^J61*6+LW_a7t11I_>d diff --git a/.gitnexus/lbug.wal b/.gitnexus/lbug.wal deleted file mode 100644 index 3270ff4a541b9931a46b74452de70d1d6024bd12..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25374 zcmeHQX^a#_6do2lIAjrJXT@EDcrIczc%#P1$|7=zxE@3e_DW4-D6y2qxR-JV_}4An_(_k7j$y6V+e zuc}^E_YUs6>52!3&z|2o#rpn^g(Gh0_4DoP){Jc*c~GxC7k*XKxnJohm2wT+LUKPb-GBfPH|@M+?kkFtbv4JGWLPX&Q@&O5c#m8Sg-g!9$7_*j5zNvn z5wRQ3LqCxZBRg(R#&%q$*Yzr&5gzYlgqV*URM;jdd4G`uon=#wks%I5Z5H5=khC2o z(FkD^so)f-1Skb%C7>1m;T;e4No^hUS3=tKNdz6urk2f?Ht-?}&X#5_ctF%5lFDJh z{!b&UcuSSLr`lGm8RE2MIlLafT%@rb) z&E=Zy!sf0R(K&kI+(n1WxJyatn9SS`TZaYqF#eaiGX#LUw$at&61SmH6j4>g9Vobw z(HuUZ;a0HO&RK4K@A4xb9`B8Oj`(dY*-(s~9OmX6t2LjAJT-y@P?f|}Aj(qc?!X21 znsP-UpgjNK9Wx)lruq0=tXE`>3PZ4FTW8};mgci7v@;}EU4o|xze{LnO2uwjo`|Yy zSw3I*MWt+(u=lQG3y0*!uOBaCFNQPCb~>H23^$KSiJuoVkDeQfqvJ8PHfBa0%TxJa zfjU^uF|w|anpBA-vJWWKES8^bJ9_q*x)}lSwOaWkjE#U z{ooO2Y;Nk5@fft7>ewlk&4zRFA6VcWh{fPw#w&a!oheZnwjk@WOyqhebs!%pVN47b zu|W}s2MFfye$>fNwGGRi5ZUWWY{2k<4^YyErJ2#Cgh|wcRGxp-qUoDPj$bUJGDZ*Q zxK1wRqEdubyf#YZ1LG+oF#1ug29*gj*qzfg(pVT8cPt@95QU-D3Vm`^cW4DMdIaQx zODi5xnHTT@5)wZz9a8_t#vk%B#(@CitXvw+#!m>YmF6jszw7ZW*`&qgn-Eded>ga+ z_f@YR+W3TwZ|FKFYbnP@b5Vp#BuZBj{mIg>LhQ+s5K+~HYn}1JjFUfUuw;aT?V^#9 z&e}1=P?Y-@%BeopTjQbzLtQ$BpwIJZbI-iB%j~5`e=egOY}~mPpNH+`ClME_Q!OgO zw{eYywz!?D5K+}Ot~OV5$4DK4>cS6)5m zv!RDwe6NgjuyU=AX1KLJ)5L`$oFZHWgsaWHz4_56e!NyjI80S1q%-Cq#W&w~Bb_-k zG_kn7st{4tz3P~)4}Z8(zx*c|-_Wbl%;7ZC(6W0g(se<)9^GPxnOoKQ<8C-%V&>ru z6Yr9d4!vr#ZDrlTig4u#C)Td0X8>EG?&%aFs@lr21#9Mx{&W5|8R3E@2aT_$?xPr| z7}oZdg<7 z#e5;6su{Q7%=@n$aQg#ylYznW%2&dej{F5|MB8hTW0Do zjhJlfIg{0rF%GWub6D}q5)EO>krhNhT(Egy z*V`66w9p>6V-+HZaunz=*=5Y+yQEsYpTS;z>3XjaK*j>qSK+!DCCBh8#y)m*mB@Wm zr!r-OLO?7?O58fHrvEWDBWq>ch4!Y|IhQs1NyG)I2LW-x19n#CVo$5XH-GVELpG7+ zBffk?3usvGW{zV!(M6ErF&9i5x^A-Bn!}>KR7X`)dUTgFK^6L`+0zG}HRpoI?v`-` z8%bJi+Z^Vn5Em4QfVe>Z&PtIUXwc9al=5$t6DSLJCT`2!$qxDDQ*-;u@NWrSOdCF< zz^mb^k`G3~peW~7H@gJo-0IfYdpqY=h>Gf`(ty%{(ty%{(ty%{(ty%{(ty%{(ty%H zYz@F!_l%H)JyKZ8eKv$+*WTg~4v*(^0>{1=;Li&jI}?DvAaFbn_>vF~*T;(?oL+FE zW2tvaKn}{aEQG@~vOI(%-Af@HF1eROIJ`Npgm5fJz8b>u;>wB;4u9Rs5Dq=P7Q*q? z)$1V~ubXy;a4c565yDZAt3o&&nQsalJRvOfg_*5-xWCO5BPfmhkk*-FL3Au_y+<~p9mapbproX;CL?u_-6vg+XKKq7dUo!1OGzc-~sS21r9p^{40T@T?7AG z;NUCp4FU&$fPW)!_?dxk6gbukfPX7+IEjIOC-B6j3s#QZvSjg??H!u~6Hs>XDDYc@wS-E( z(q12TEsf`aL{OtggJsbFvB|^%1Grn+Q+11Xk}s|O^VVVtQZ3r9SY10q%SQgE3@}@G qI?1L$x~D)Iq(Ekf0+oaUJ)T0q5B|eDPQ3ZPM}Pf8lWZXHsrfgSBom Date: Tue, 11 Aug 2026 14:43:17 -0700 Subject: [PATCH 09/17] test(serve): pin that serving survives a missing host key while signing fails closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from a lane that died at a session end with this uncommitted. UNVERIFIED: not compiled, not run since the edits. Covers the #2712 reversal of SPEC 13.6's 'MUST NOT serve' — reading is anonymous and must not require a host identity, but a proof must still refuse to be signed by the world-known fallback key. Co-Authored-By: Claude --- .../digstore-cli/tests/serve_fails_closed.rs | 122 ++++++++++++++---- 1 file changed, 100 insertions(+), 22 deletions(-) diff --git a/crates/digstore-cli/tests/serve_fails_closed.rs b/crates/digstore-cli/tests/serve_fails_closed.rs index 8e4cf838..0315be29 100644 --- a/crates/digstore-cli/tests/serve_fails_closed.rs +++ b/crates/digstore-cli/tests/serve_fails_closed.rs @@ -1,36 +1,56 @@ -//! The serve path must FAIL CLOSED, not fall back to a world-known default. +//! The serve path must never use a world-known identity (#2553) — and must not +//! demand an identity it does not consume (#2712). //! -//! Two independent fail-open sites lived in `ops::serve` (issue #2553). -//! This file covers the first: a missing host signing key silently became -//! `BlsSecretKey::from_seed(&[42u8; 32])`, a value anyone can reproduce from the -//! source. It is exercised against a REAL store built by the genuine `init` + +//! These two rules are one rule seen from both sides. #2553's defect was that a +//! missing host signing key silently became `BlsSecretKey::from_seed(&[42u8; 32])`, +//! a value anyone can reproduce from this source. The cure is to stop +//! substituting, NOT to refuse the read: serving committed content consumes no +//! identity (`SPEC.md` §13.6), so refusing cost availability while buying nothing. +//! +//! This file therefore asserts the surviving, positive form of #2553 — the signer +//! is the store's OWN key and never the world-known one — plus #2712's +//! availability rule and the signing-path control that keeps it scoped to reads. +//! Everything is exercised against a REAL store built by the genuine `init` + //! `add` + `commit` machinery, so the assertions observe the shipped code path //! rather than a hand-built fixture. //! -//! The second — the pinned host RNG seed — is asserted in `ops::serve`'s own -//! unit test instead, because neither consumer of that RNG is observable from +//! The pinned host RNG seed, the third #2553 site, is asserted in `ops::serve`'s +//! own unit test instead, because neither consumer of that RNG is observable from //! serve output. use digstore_cli::context::CliContext; use digstore_cli::ops::{serve, store_ops}; use digstore_core::Urn; +/// The world-known fallback that #2553 removed. Reconstructed here so the test +/// can assert its ABSENCE from real output — a literal comparison against the +/// actual bad value, rather than a proxy for it. +fn world_known_fallback_pubkey() -> [u8; 48] { + digstore_crypto::bls::SecretKey::from_seed(&[42u8; 32]) + .public_key() + .to_bytes() + .0 +} + /// A real committed store: returns its context, the compiled module path, and a /// URN for the one resource it holds. struct Fixture { _td: tempfile::TempDir, ctx: CliContext, module_path: std::path::PathBuf, + root: digstore_core::Bytes32, urn: Urn, } +const PAYLOAD: &[u8] = b"fail-closed fixture payload 0123456789"; + fn committed_store() -> Fixture { let td = tempfile::tempdir().unwrap(); let ctx = CliContext::resolve(Some(td.path().to_path_buf()), false, false); store_ops::init_store(&ctx, false, None, None, None, None, None, None).unwrap(); let f = td.path().join("known.txt"); - std::fs::write(&f, b"fail-closed fixture payload 0123456789").unwrap(); + std::fs::write(&f, PAYLOAD).unwrap(); store_ops::add_path(&ctx, &f, Some("known".into())).unwrap(); let res = store_ops::commit(&ctx, None, serve::empty_manifest()).unwrap(); @@ -40,6 +60,7 @@ fn committed_store() -> Fixture { _td: td, ctx, module_path: res.output_path, + root: res.roothash, urn: Urn { chain: "chia".into(), store_id, @@ -50,8 +71,8 @@ fn committed_store() -> Fixture { } /// CONTROL: with the signing key present the serve path succeeds. Without this -/// the "missing key fails" test below could pass for an unrelated reason (a -/// broken fixture, an uncommitted resource) and prove nothing. +/// the tests below could pass for an unrelated reason (a broken fixture, an +/// uncommitted resource) and prove nothing. #[test] fn serving_succeeds_while_the_host_signing_key_is_present() { let fx = committed_store(); @@ -63,22 +84,79 @@ fn serving_succeeds_while_the_host_signing_key_is_present() { .expect("a store with its signing key serves normally"); } -/// FAIL CLOSED: with `signing_key.bin` removed, serving must ERROR rather than -/// silently attest with a world-known key baked into the source. The fallback -/// key is reproducible by anyone reading this repository, so a host using it -/// carries no identity at all — the operator must learn that the key is gone, -/// not be handed a degraded serve that looks like a content problem. +/// #2712: with `signing_key.bin` removed, serving must SUCCEED — the read path +/// consumes no identity, so there is nothing to fail closed on. +/// +/// This replaces an earlier assertion that serving must ERROR here. That was the +/// wrong expression of #2553: it withheld content whose integrity does not depend +/// on the host at all, and it broke `checkout`, `dev`, `deploy --preview` and +/// `compute_status`, none of which has a network ladder to fall through to. +#[test] +fn serving_succeeds_when_the_host_signing_key_is_missing() { + let fx = committed_store(); + std::fs::remove_file(fx.ctx.dig_dir.join("signing_key.bin")).unwrap(); + + let resp = serve::serve_content(&fx.ctx, &fx.module_path, &fx.urn, fx.root) + .expect("reading committed content consumes no identity"); + // A retrieval miss returns a DECOY through this same success path (§14.2), so + // a bare `expect` would be satisfied by a runtime that had silently stopped + // finding the resource. Assert the ciphertext is the resource's, by checking + // the plaintext recovered from it. + let chunk_lens = store_ops::resource_chunk_lens(&fx.ctx, &fx.root, "known").unwrap_or_default(); + let plaintext = digstore_cli::ops::client_crypto::decrypt_and_verify( + &resp, + &fx.urn, + None, + &fx.root, + &chunk_lens, + ) + .expect("the served bytes must be the real resource, not a decoy"); + assert_eq!(plaintext, PAYLOAD); +} + +/// #2553, in the form that survives #2712: a proof produced by an intact store is +/// signed by the store's OWN key and NOT by the world-known fallback. +/// +/// This is a stronger statement than the refusal it replaces. A refusal only +/// proves the code noticed something was missing; this pins the actual property +/// #2553 cared about — that no output is ever attributed to a key anyone can +/// reproduce from this repository — and it keeps holding on the happy path, where +/// a substituted key would otherwise go unnoticed. +#[test] +fn a_proof_is_signed_by_the_stores_own_key_never_the_world_known_fallback() { + let fx = committed_store(); + + let (proof, _root) = serve::serve_proof(&fx.ctx, &fx.module_path, &fx.urn, fx.root) + .expect("an intact store can sign a proof"); + + let expected = store_ops::load_host_pubkey(&fx.ctx).expect("the store has a host key"); + assert_eq!( + proof.node_pubkey.0, expected.0, + "the proof must be signed by the store's own host key" + ); + assert_ne!( + proof.node_pubkey.0, + world_known_fallback_pubkey(), + "the proof must NEVER be signed by from_seed(&[42u8; 32]), which anyone \ + reading this source can reproduce" + ); +} + +/// The signing path still FAILS CLOSED, which is what keeps #2712 scoped to reads. +/// +/// Signing a proof is an act of attribution, so an absent key must stop it — +/// neither substituting a stand-in nor proceeding without one is available. The +/// error must name the file: an operator handed a bare io error would reasonably +/// go looking for a content problem. #[test] -fn serving_fails_closed_when_the_host_signing_key_is_missing() { +fn signing_a_proof_fails_closed_when_the_host_signing_key_is_missing() { let fx = committed_store(); - let key_path = fx.ctx.dig_dir.join("signing_key.bin"); - std::fs::remove_file(&key_path).unwrap(); + std::fs::remove_file(fx.ctx.dig_dir.join("signing_key.bin")).unwrap(); - let err = serve::serve_content_raw(&fx.ctx, &fx.module_path, &fx.urn) - .expect_err("a host with no signing key must refuse to serve"); + let err = serve::serve_proof(&fx.ctx, &fx.module_path, &fx.urn, fx.root) + .err() + .expect("a host with no signing key must refuse to SIGN"); - // Observe the REASON, not merely the failure: the error must name the - // signing key, otherwise this test would also pass on an unrelated break. let msg = err.to_string(); assert!( msg.contains("signing key"), From ff24818c16b987269129bf0fc95c7b836ad5e87c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 14:51:11 -0700 Subject: [PATCH 10/17] test(serve): derive the expected signer key without a crate-private loader `store_ops::load_host_pubkey` is `pub(crate)`, so the integration test could not name it and the crate failed to compile on both CI runners (E0603). Re-derive the expected public key in the test from the seed bytes on disk instead of widening the crate's public API. This is also the stronger oracle: reading the expectation back through the crate's own loader would be circular, because a loader that substituted a stand-in would hand the test the same stand-in the signer used and the comparison would still pass. `from_seed` is a crypto primitive rather than the code under test, so re-deriving through it is independent of the loading path this suite exists to police. Co-Authored-By: Claude --- crates/digstore-cli/tests/serve_fails_closed.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/digstore-cli/tests/serve_fails_closed.rs b/crates/digstore-cli/tests/serve_fails_closed.rs index 0315be29..5428c095 100644 --- a/crates/digstore-cli/tests/serve_fails_closed.rs +++ b/crates/digstore-cli/tests/serve_fails_closed.rs @@ -122,6 +122,13 @@ fn serving_succeeds_when_the_host_signing_key_is_missing() { /// #2553 cared about — that no output is ever attributed to a key anyone can /// reproduce from this repository — and it keeps holding on the happy path, where /// a substituted key would otherwise go unnoticed. +/// +/// The expected key is derived HERE from the seed bytes on disk rather than read +/// back through the crate's own `load_host_pubkey`. Asking production code what +/// the key should be would make the assertion circular: a loader that substituted +/// a stand-in would hand the test the same stand-in the signer used, and the +/// comparison would pass. `from_seed` is a crypto primitive, not the code under +/// test, so re-deriving through it is an independent oracle. #[test] fn a_proof_is_signed_by_the_stores_own_key_never_the_world_known_fallback() { let fx = committed_store(); @@ -129,9 +136,14 @@ fn a_proof_is_signed_by_the_stores_own_key_never_the_world_known_fallback() { let (proof, _root) = serve::serve_proof(&fx.ctx, &fx.module_path, &fx.urn, fx.root) .expect("an intact store can sign a proof"); - let expected = store_ops::load_host_pubkey(&fx.ctx).expect("the store has a host key"); + let seed = std::fs::read(fx.ctx.dig_dir.join("signing_key.bin")) + .expect("init must persist the host signing key"); + let expected = digstore_crypto::bls::SecretKey::from_seed(&seed) + .public_key() + .to_bytes() + .0; assert_eq!( - proof.node_pubkey.0, expected.0, + proof.node_pubkey.0, expected, "the proof must be signed by the store's own host key" ); assert_ne!( From ef7138ca004879055c052709d589a367476b8f0a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 14:54:24 -0700 Subject: [PATCH 11/17] test(serve): use expect_err for the fail-closed signing assertion `clippy::err_expect` is denied workspace-wide, so `.err().expect(..)` failed the lint gate. This defect was latent behind the E0603 fixed in the previous commit: compilation aborts at the first error, so CI reported only that one and this never surfaced. Co-Authored-By: Claude --- crates/digstore-cli/tests/serve_fails_closed.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/digstore-cli/tests/serve_fails_closed.rs b/crates/digstore-cli/tests/serve_fails_closed.rs index 5428c095..b740ca0a 100644 --- a/crates/digstore-cli/tests/serve_fails_closed.rs +++ b/crates/digstore-cli/tests/serve_fails_closed.rs @@ -166,8 +166,7 @@ fn signing_a_proof_fails_closed_when_the_host_signing_key_is_missing() { std::fs::remove_file(fx.ctx.dig_dir.join("signing_key.bin")).unwrap(); let err = serve::serve_proof(&fx.ctx, &fx.module_path, &fx.urn, fx.root) - .err() - .expect("a host with no signing key must refuse to SIGN"); + .expect_err("a host with no signing key must refuse to SIGN"); let msg = err.to_string(); assert!( From fd18e905e28a52e201699188c266ca3277039a28 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 15:02:46 -0700 Subject: [PATCH 12/17] fix(host): derive has_host_public_key from installed state, not a deps mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accessor recorded `deps.bls_public.is_some()` into a bool field at construction and returned that, so it answered "what did the caller pass" rather than "what did this runtime install". That is the wrong question for the one job the accessor has. Every revert-proof in the read path is built on it, and a substitution reintroduced INSIDE this constructor — #2553's defect, one crate below the call site it is policing — leaves the mirror `false` while the guest is handed a key-shaped value through `host_get_public_key`. The guard would stay green through exactly the regression it exists to catch. Reading `store.data().host.keys.bls_public` observes the key the runtime actually installed, so the guard now fails on that substitution. Co-Authored-By: Claude --- crates/digstore-host/src/runtime.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/digstore-host/src/runtime.rs b/crates/digstore-host/src/runtime.rs index 33a2333a..552e314d 100644 --- a/crates/digstore-host/src/runtime.rs +++ b/crates/digstore-host/src/runtime.rs @@ -95,13 +95,6 @@ pub struct HostRuntime { /// proves nothing about the deps the production call site constructs. See /// [`HostRuntime::rng_is_deterministic`]. rng_seeded: bool, - /// Whether this runtime was built with a host public identity - /// ([`HostDeps::bls_public`] was `Some`). Recorded for the same reason as - /// [`Self::rng_seeded`]: the identity is not observable through any export - /// on the content path, so a caller that wants to assert what the PRODUCTION - /// call site actually built has nothing else to ask. See - /// [`HostRuntime::has_host_public_key`]. - host_pubkey_present: bool, } impl HostRuntime { @@ -145,7 +138,6 @@ impl HostRuntime { Module::new(&engine, module_bytes).map_err(|e| HostError::Wasmtime(e.to_string()))?; let rng_seeded = deps.rng_seed.is_some(); - let host_pubkey_present = deps.bls_public.is_some(); let rng = match deps.rng_seed { Some(s) => HostRng::from_seed(s), None => HostRng::from_entropy(), @@ -248,7 +240,6 @@ impl HostRuntime { limits_cfg: limits, _ticker: ticker, rng_seeded, - host_pubkey_present, }) } @@ -263,8 +254,17 @@ impl HostRuntime { /// `true` when this runtime carries a host public identity. Read paths build /// runtimes for which this is `false`: serving committed content consults no /// identity, so carrying one there is surface with no purpose. + /// + /// This reads the key the runtime ACTUALLY INSTALLED, not a bool mirrored + /// from the incoming [`HostDeps`]. The distinction is the whole value of the + /// accessor: a mirror answers "what did the caller pass", so a substitution + /// reintroduced inside this constructor — the #2553 defect, one crate below + /// the call site — would leave the mirror `false` while the guest was handed + /// a key-shaped value through `host_get_public_key`. Every revert-proof built + /// on this accessor would stay green through exactly the regression it + /// exists to catch. pub fn has_host_public_key(&self) -> bool { - self.host_pubkey_present + self.store.data().host.keys.bls_public.is_some() } /// Set the per-export-call fuel budget. Epoch deadline is added in Task 12. From e4c9f888c4f4d8685afa1d378407652e018d90ac Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 15:14:34 -0700 Subject: [PATCH 13/17] refactor(host)!: make a host identity whole-or-absent, and test the anonymous arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HostDeps.bls_secret`/`bls_public` were two independent `Option`s, so a half-present identity was representable: `Some(secret)` + `None` public silently discarded the secret and downgraded the host to anonymous, and `None` + `Some(public)` advertised a key it could not sign for. Replace both with `identity: Option`, a struct that derives its public half from its secret — half-present and mismatched pairs are now unrepresentable. `HostDeps` becomes `#[non_exhaustive]` with a `new` + `with_*` builder. `new` yields an ANONYMOUS host, so an identity is acquired by asking for one rather than by forgetting a field. Test the `(None, None)` backend-selection arm on observable behaviour of a real `HostRuntime`: an anonymous runtime answers `host_get_public_key` with `NotFound` and refuses to attest. Substituting a `BlsAttestationBackend` built from `from_seed(&[42u8; 32])` into that arm — #2553's world-known key, one crate below the call site — left every existing test green, because `teehook`'s test drives the backend in isolation and `has_host_public_key` reads a field the arm does not set. BREAKING CHANGE: `HostDeps` is `#[non_exhaustive]`; construct it with `HostDeps::new(..)`. `bls_secret`/`bls_public` are replaced by `identity`. `BlindServeConfig` is deliberately UNCHANGED and still requires both halves: the network-facing blind-serve path must not become anonymizable. Co-Authored-By: Claude --- crates/digstore-cli/src/ops/serve.rs | 92 ++++++++++----- .../tests/adv_delegated_host_key.rs | 21 ++-- crates/digstore-cli/tests/adv_self_serve.rs | 21 ++-- crates/digstore-cli/tests/ops_roundtrip.rs | 21 ++-- crates/digstore-compiler/tests/auth_policy.rs | 21 ++-- .../tests/large_data_section.rs | 21 ++-- .../digstore-compiler/tests/self_serving.rs | 21 ++-- crates/digstore-host/src/lib.rs | 2 +- crates/digstore-host/src/runtime.rs | 109 ++++++++++++++++-- crates/digstore-host/src/serve_blind.rs | 44 +++---- crates/digstore-host/tests/common/mod.rs | 32 ++--- crates/digstore-host/tests/imports_unit.rs | 74 +++++++++++- 12 files changed, 329 insertions(+), 150 deletions(-) diff --git a/crates/digstore-cli/src/ops/serve.rs b/crates/digstore-cli/src/ops/serve.rs index 0a80cc5c..378a8475 100644 --- a/crates/digstore-cli/src/ops/serve.rs +++ b/crates/digstore-cli/src/ops/serve.rs @@ -113,30 +113,29 @@ fn host_deps(store_id: Bytes32) -> HostDeps { }; let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { + // ANONYMOUS: see this function's doc comment. `HostDeps::new` carries no + // identity, and this path never calls `with_identity` — absence, not a + // placeholder. + // + // The RNG is left at its default of real OS entropy rather than a constant + // seed, converging on the convention `digstore_host::serve_blind` follows. + // + // SCOPE, stated honestly: this RNG backs `host_random_bytes`, whose only + // live consumer is the guest's oblivious-access cover traffic. The §12 + // attestation nonce draw is unreachable here — `digstore-guest`'s content + // path hardcodes `require_attestation: false` (dighub content is public + // and must be servable by any node), so no module this CLI compiles takes + // that branch. Nor is this closing a third-party attack: the party the + // cover-traffic shuffle hides access patterns from is the HOST, and the + // host is what supplies this randomness. The change removes a constant + // seed that had no business outside a test fixture. + HostDeps::new( store_id, - // ANONYMOUS: see this function's doc comment. Absence, not a placeholder. - bls_secret: None, - bls_public: None, - clock: Arc::new(FixedClock::new(1_700_000_000)), - chain: Arc::new(chain), - prover: Arc::new(prover), - // Draw real OS entropy rather than a constant seed, converging on the - // convention `digstore_host::serve_blind` already follows. - // - // SCOPE, stated honestly: this RNG backs `host_random_bytes`, whose only - // live consumer is the guest's oblivious-access cover traffic. The §12 - // attestation nonce draw is unreachable here — `digstore-guest`'s content - // path hardcodes `require_attestation: false` (dighub content is public - // and must be servable by any node), so no module this CLI compiles takes - // that branch. Nor is this closing a third-party attack: the party the - // cover-traffic shuffle hides access patterns from is the HOST, and the - // host is what supplies this randomness. The change removes a constant - // seed that had no business outside a test fixture. - rng_seed: None, - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + Arc::new(FixedClock::new(1_700_000_000)), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) } /// Instantiate the real host runtime over `module_path` (real wasmtime load / @@ -441,7 +440,7 @@ mod tests { /// /// Anchored at the call site for the same reason as the RNG test above: an /// assertion on `host_deps(..)`'s return value is defeated by inlining a - /// `HostDeps { bls_public: Some(..), .. }` literal into `instantiate_host`. + /// `.with_identity(..)` call into `instantiate_host`. /// The identity is not observable through any export on the content path, so /// `HostRuntime::has_host_public_key` exists to answer this. #[test] @@ -500,8 +499,18 @@ mod tests { /// is substituted, so it catches a restored `unwrap_or(Bytes48([0u8; 48]))`. /// What it CANNOT catch is a re-added *refusal*: a runtime that never gets /// built because the read aborted earlier still trivially "carries no - /// identity". This leg catches that by asserting the read path never reaches - /// for the identity in the first place. + /// identity". + /// + /// SCOPE, stated exactly, because the previous wording overstated it: this + /// leg catches a re-added refusal only in the `load_host_pubkey` form. It + /// cannot catch the `load_signing_key` form — that token is deliberately + /// ALLOWED below, since `serve_proof` lives in this same file and must keep + /// calling it. The test that actually covers a refusal of any shape is the + /// behavioural + /// [`a_store_with_no_identity_still_serves_committed_content`], which deletes + /// the identity files and demands real plaintext back; its control against + /// over-relaxation is + /// [`serve_proof_still_refuses_without_a_signing_key`]. /// /// The banned tokens are the identity-carrying positions specifically, not /// seeds in general: `host_deps`'s MOCK PROVER key is a legitimate @@ -509,20 +518,45 @@ mod tests { /// blanket seed ban here would be a false failure that invites deletion. #[test] fn the_read_path_never_reaches_for_the_store_identity() { + // ONE literal, used for both the count assertion and the split, so that + // naming the marker here does not itself change the count. + const TEST_MODULE_MARKER: &str = "#[cfg(test)]"; + let src = include_str!("serve.rs"); + + // The split below assumes the FIRST marker opens the test module, so the + // prefix it keeps is the whole production half. A new `#[cfg(test)]` + // helper added ABOVE the read path would silently move that boundary and + // narrow the guard to a prefix no longer containing the code it polices — + // a guard that passes by scanning the wrong text. Fail loudly instead. + let marker_count = src.matches(TEST_MODULE_MARKER).count(); + assert_eq!( + marker_count, 2, + "expected exactly 2 `{TEST_MODULE_MARKER}` occurrences in serve.rs \ + (the test-module attribute + this test's own marker literal), found \ + {marker_count}. A new one was added: re-check that the split below \ + still yields the WHOLE production half, then update this count." + ); + // Cut the test module off so this file's own doc comments and fixtures do // not match themselves. let production = src - .split("#[cfg(test)]") + .split(TEST_MODULE_MARKER) .next() .expect("this file has a test module"); // `load_signing_key` is deliberately absent from this list: `serve_proof` // still calls it, and must. + // + // `identity: Some(` and `with_identity(` are the identity-carrying + // positions under the `HostDeps { identity: Option }` shape. + // They replaced `bls_public: Some(` / `bls_secret: Some(`, which those + // fields no longer exist to produce — leaving those tokens here would be a + // ban that can never fire, green by construction. for banned in [ "load_host_pubkey", - "bls_public: Some(", - "bls_secret: Some(", + "identity: Some(", + "with_identity(", "[0u8; 48]", ] { assert!( diff --git a/crates/digstore-cli/tests/adv_delegated_host_key.rs b/crates/digstore-cli/tests/adv_delegated_host_key.rs index e99c9f50..0dc3982e 100644 --- a/crates/digstore-cli/tests/adv_delegated_host_key.rs +++ b/crates/digstore-cli/tests/adv_delegated_host_key.rs @@ -18,12 +18,11 @@ use digstore_cli::ops::{serve, store_ops}; use digstore_core::config::HostImportsConfig; use digstore_core::{Bytes32, ChiaBlockRef, ContentResponse, Decode, Decoder, Urn}; use digstore_crypto::bls::BlsSecretKey; -use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostRuntime}; +use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostIdentity, HostRuntime}; use digstore_prover::{MockChainSource, MockProver}; fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { let sk = BlsSecretKey::from_seed(signing_seed); - let pk = sk.public_key().to_bytes(); let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); let block = ChiaBlockRef { @@ -33,17 +32,15 @@ fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { }; let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { + HostDeps::new( store_id, - bls_secret: Some(sk), - bls_public: Some(pk), - clock: Arc::new(FixedClock::new(1_700_000_000)), - chain: Arc::new(chain), - prover: Arc::new(prover), - rng_seed: Some([99u8; 32]), - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + Arc::new(FixedClock::new(1_700_000_000)), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) + .with_identity(HostIdentity::new(sk)) + .with_rng_seed([99u8; 32]) } /// Serve `req` from `module` using a host whose BLS identity is derived from `signing_seed`, and diff --git a/crates/digstore-cli/tests/adv_self_serve.rs b/crates/digstore-cli/tests/adv_self_serve.rs index d1e078c9..e2352536 100644 --- a/crates/digstore-cli/tests/adv_self_serve.rs +++ b/crates/digstore-cli/tests/adv_self_serve.rs @@ -18,7 +18,7 @@ use digstore_cli::ops::{serve, store_ops}; use digstore_core::config::HostImportsConfig; use digstore_core::{Bytes32, ChiaBlockRef, ContentResponse, Decode, Decoder, Urn}; use digstore_crypto::bls::BlsSecretKey; -use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostRuntime}; +use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostIdentity, HostRuntime}; use digstore_prover::{MockChainSource, MockProver}; fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { @@ -27,7 +27,6 @@ fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { // it from the persisted seed (`signing_key.bin`) so the guest's attestation // verification accepts this host (otherwise it serves decoys, correctly). let sk = BlsSecretKey::from_seed(signing_seed); - let pk = sk.public_key().to_bytes(); let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); let block = ChiaBlockRef { @@ -37,17 +36,15 @@ fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { }; let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { + HostDeps::new( store_id, - bls_secret: Some(sk), - bls_public: Some(pk), - clock: Arc::new(FixedClock::new(1_700_000_000)), - chain: Arc::new(chain), - prover: Arc::new(prover), - rng_seed: Some([99u8; 32]), - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + Arc::new(FixedClock::new(1_700_000_000)), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) + .with_identity(HostIdentity::new(sk)) + .with_rng_seed([99u8; 32]) } /// The whole D6 promise in one test, with loud printed evidence. diff --git a/crates/digstore-cli/tests/ops_roundtrip.rs b/crates/digstore-cli/tests/ops_roundtrip.rs index 437132e3..c522054c 100644 --- a/crates/digstore-cli/tests/ops_roundtrip.rs +++ b/crates/digstore-cli/tests/ops_roundtrip.rs @@ -7,7 +7,7 @@ use digstore_cli::ops::{client_crypto, serve, store_ops}; use digstore_core::config::HostImportsConfig; use digstore_core::{Bytes32, ChiaBlockRef, ContentResponse, Decode, Decoder, Urn}; use digstore_crypto::bls::BlsSecretKey; -use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostRuntime}; +use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostIdentity, HostRuntime}; use digstore_prover::{MockChainSource, MockProver}; fn setup() -> (tempfile::TempDir, CliContext) { @@ -75,7 +75,6 @@ fn multi_chunk_round_trip() { /// verification accepts this host; otherwise it would (correctly) serve decoys. fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { let sk = BlsSecretKey::from_seed(signing_seed); - let pk = sk.public_key().to_bytes(); let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); let block = ChiaBlockRef { @@ -85,17 +84,15 @@ fn host_deps(store_id: Bytes32, signing_seed: &[u8]) -> HostDeps { }; let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { + HostDeps::new( store_id, - bls_secret: Some(sk), - bls_public: Some(pk), - clock: Arc::new(FixedClock::new(1_700_000_000)), - chain: Arc::new(chain), - prover: Arc::new(prover), - rng_seed: Some([99u8; 32]), - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + Arc::new(FixedClock::new(1_700_000_000)), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) + .with_identity(HostIdentity::new(sk)) + .with_rng_seed([99u8; 32]) } /// D6: the REAL module a `commit` produces MUST serve itself through diff --git a/crates/digstore-compiler/tests/auth_policy.rs b/crates/digstore-compiler/tests/auth_policy.rs index 43b5ab4d..f3d8c619 100644 --- a/crates/digstore-compiler/tests/auth_policy.rs +++ b/crates/digstore-compiler/tests/auth_policy.rs @@ -16,7 +16,7 @@ use digstore_compiler::{Compiler, CompilerConfig}; use digstore_core::config::HostImportsConfig; use digstore_core::{AuthenticationInfo, Bytes32, ChiaBlockRef, Decode, Decoder}; use digstore_crypto::bls::BlsSecretKey; -use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostRuntime}; +use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostIdentity, HostRuntime}; use digstore_prover::{MockChainSource, MockProver}; use std::sync::Arc; @@ -28,7 +28,6 @@ const GUEST_WASM: &str = concat!( fn host_deps(store_id: Bytes32) -> HostDeps { // The embedded trusted key is the public half of seed [42u8;32] (common.rs). let sk = BlsSecretKey::from_seed(&[42u8; 32]); - let pk = sk.public_key().to_bytes(); let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); let block = ChiaBlockRef { @@ -38,17 +37,15 @@ fn host_deps(store_id: Bytes32) -> HostDeps { }; let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { + HostDeps::new( store_id, - bls_secret: Some(sk), - bls_public: Some(pk), - clock: Arc::new(FixedClock::new(1_700_000_000)), - chain: Arc::new(chain), - prover: Arc::new(prover), - rng_seed: Some([99u8; 32]), - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + Arc::new(FixedClock::new(1_700_000_000)), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) + .with_identity(HostIdentity::new(sk)) + .with_rng_seed([99u8; 32]) } /// Compile a real module embedding `auth`, then read back the guest's diff --git a/crates/digstore-compiler/tests/large_data_section.rs b/crates/digstore-compiler/tests/large_data_section.rs index c5d7a177..78fe63f4 100644 --- a/crates/digstore-compiler/tests/large_data_section.rs +++ b/crates/digstore-compiler/tests/large_data_section.rs @@ -25,7 +25,7 @@ use digstore_core::serving::concat_output; use digstore_core::{Bytes32, Bytes48, ChiaBlockRef, ContentResponse, Decode, Decoder, Urn}; use digstore_crypto::bls::BlsSecretKey; use digstore_crypto::{derive_decryption_key, encrypt_chunk}; -use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostRuntime}; +use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostIdentity, HostRuntime}; use digstore_prover::{MockChainSource, MockProver}; use sha2::{Digest, Sha256}; use std::sync::Arc; @@ -76,7 +76,6 @@ impl<'a> ResourceView for FixtureResourceRef<'a> { fn host_deps(store_id: Bytes32) -> HostDeps { let sk = BlsSecretKey::from_seed(&[42u8; 32]); - let pk: Bytes48 = sk.public_key().to_bytes(); let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); let block = ChiaBlockRef { @@ -86,17 +85,15 @@ fn host_deps(store_id: Bytes32) -> HostDeps { }; let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { + HostDeps::new( store_id, - bls_secret: Some(sk), - bls_public: Some(pk), - clock: Arc::new(FixedClock::new(1_700_000_000)), - chain: Arc::new(chain), - prover: Arc::new(prover), - rng_seed: Some([99u8; 32]), - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + Arc::new(FixedClock::new(1_700_000_000)), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) + .with_identity(HostIdentity::new(sk)) + .with_rng_seed([99u8; 32]) } fn host_cfg() -> HostImportsConfig { diff --git a/crates/digstore-compiler/tests/self_serving.rs b/crates/digstore-compiler/tests/self_serving.rs index afda1c0b..03c05f7a 100644 --- a/crates/digstore-compiler/tests/self_serving.rs +++ b/crates/digstore-compiler/tests/self_serving.rs @@ -22,7 +22,7 @@ use digstore_core::serving::concat_output; use digstore_core::{Bytes32, Bytes48, ChiaBlockRef, ContentResponse, Decode, Decoder, Urn}; use digstore_crypto::bls::BlsSecretKey; use digstore_crypto::{derive_decryption_key, encrypt_chunk}; -use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostRuntime}; +use digstore_host::{ExecutionLimits, FixedClock, HostDeps, HostIdentity, HostRuntime}; use digstore_prover::{MockChainSource, MockProver}; use sha2::{Digest, Sha256}; use std::sync::Arc; @@ -81,7 +81,6 @@ impl<'a> ResourceView for FixtureResourceRef<'a> { fn host_deps(store_id: Bytes32) -> HostDeps { let sk = BlsSecretKey::from_seed(&[42u8; 32]); - let pk: Bytes48 = sk.public_key().to_bytes(); let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); let block = ChiaBlockRef { @@ -91,17 +90,15 @@ fn host_deps(store_id: Bytes32) -> HostDeps { }; let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { + HostDeps::new( store_id, - bls_secret: Some(sk), - bls_public: Some(pk), - clock: Arc::new(FixedClock::new(1_700_000_000)), - chain: Arc::new(chain), - prover: Arc::new(prover), - rng_seed: Some([99u8; 32]), - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + Arc::new(FixedClock::new(1_700_000_000)), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) + .with_identity(HostIdentity::new(sk)) + .with_rng_seed([99u8; 32]) } fn host_cfg() -> HostImportsConfig { diff --git a/crates/digstore-host/src/lib.rs b/crates/digstore-host/src/lib.rs index f9c0865c..f370bfbd 100644 --- a/crates/digstore-host/src/lib.rs +++ b/crates/digstore-host/src/lib.rs @@ -22,7 +22,7 @@ pub use clock::{Clock, FixedClock, SystemClock}; pub use config::{ExecutionLimits, MAX_MEMORY_BYTES, WASM_PAGE_SIZE}; pub use error::HostError; pub use random::HostRng; -pub use runtime::{HostDeps, HostRuntime, RuntimeState}; +pub use runtime::{HostDeps, HostIdentity, HostRuntime, RuntimeState}; pub use serve_blind::{ request_for_retrieval_key, serve_blind, serve_blind_with, BlindServeConfig, BlindServeDeps, }; diff --git a/crates/digstore-host/src/runtime.rs b/crates/digstore-host/src/runtime.rs index 552e314d..34ea8686 100644 --- a/crates/digstore-host/src/runtime.rs +++ b/crates/digstore-host/src/runtime.rs @@ -54,27 +54,111 @@ impl Drop for EpochTicker { } } -/// Dependencies injected into a runtime: BLS keys, clock, chain, prover, rng. +/// A host's BLS identity: the signing half plus the public half it advertises. +/// +/// The halves are held together and the public one is DERIVED from the secret, +/// so the three ways an identity can be wrong are all unrepresentable: a secret +/// with no public half (signs, but nothing can attribute the signature), a +/// public half with no secret (advertises a key it cannot sign for), and a +/// mismatched pair (advertises the wrong one). [`HostDeps::identity`] is +/// therefore an `Option` with exactly two meanings — +/// identity-bearing, or anonymous. +pub struct HostIdentity { + secret: BlsSecretKey, + public: Bytes48, +} + +impl HostIdentity { + /// Build an identity from its signing key, deriving the public half. + pub fn new(secret: BlsSecretKey) -> Self { + let public = secret.public_key().to_bytes(); + HostIdentity { secret, public } + } + + /// Build an identity from a 32-byte BLS seed (the `signing_key.bin` written + /// by `digstore init`). + pub fn from_seed(seed: &[u8]) -> Self { + HostIdentity::new(BlsSecretKey::from_seed(seed)) + } + + /// The public half this identity advertises. + pub fn public(&self) -> Bytes48 { + self.public + } + + /// Consume the identity into its two halves, for a backend that needs both. + pub fn into_parts(self) -> (BlsSecretKey, Bytes48) { + (self.secret, self.public) + } +} + +/// Dependencies injected into a runtime: identity, clock, chain, prover, rng. +/// +/// Construct with [`HostDeps::new`], which yields an ANONYMOUS host, then opt +/// into the non-default pieces with the `with_*` builders. Anonymity is the +/// default deliberately: an identity should be acquired by asking for one, never +/// by forgetting a field. +#[non_exhaustive] pub struct HostDeps { pub store_id: Bytes32, /// The host's BLS identity, or `None` for an ANONYMOUS host. /// - /// Both halves are supplied together or not at all: a secret with no public - /// half cannot be attributed, and a public half with no secret cannot sign. /// An anonymous host still serves committed content — the guest's content /// path does not consult the host identity — it simply cannot attest. - pub bls_secret: Option, - pub bls_public: Option, + pub identity: Option, pub clock: Arc, pub chain: Arc, pub prover: Arc, /// `Some(seed)` => deterministic rng (tests); `None` => OS entropy. pub rng_seed: Option<[u8; 32]>, pub instance_id: Bytes32, - /// `None` => default BLS attestation backend built from the BLS keys (§13.6). + /// `None` => default BLS attestation backend built from `identity` (§13.6), + /// or a refusing backend when there is no identity. pub attestation: Option, } +impl HostDeps { + /// Dependencies for an ANONYMOUS host: no identity, OS entropy, and the + /// default attestation backend (which refuses, having nobody to speak for). + pub fn new( + store_id: Bytes32, + clock: Arc, + chain: Arc, + prover: Arc, + instance_id: Bytes32, + ) -> Self { + HostDeps { + store_id, + identity: None, + clock, + chain, + prover, + rng_seed: None, + instance_id, + attestation: None, + } + } + + /// Give this host a BLS identity, so it can attest and sign. + pub fn with_identity(mut self, identity: HostIdentity) -> Self { + self.identity = Some(identity); + self + } + + /// Pin the host RNG to a fixed seed. Deterministic-fixture tests ONLY: a + /// seeded RNG makes every `host_random_bytes` draw reproducible. + pub fn with_rng_seed(mut self, seed: [u8; 32]) -> Self { + self.rng_seed = Some(seed); + self + } + + /// Replace the attestation backend (§13.6 TEE hook, or a test double). + pub fn with_attestation(mut self, backend: SharedBackend) -> Self { + self.attestation = Some(backend); + self + } +} + /// Combined per-store host state, including the wasmtime resource limiter that /// enforces the outer memory ceiling (§18.2). pub struct RuntimeState { @@ -147,13 +231,14 @@ impl HostRuntime { // identity. An anonymous host gets a backend that refuses, never one // built from a stand-in key: a placeholder would make every attestation // it produced attributable to a key nobody controls. - let attestation: SharedBackend = match (deps.attestation, deps.bls_secret, deps.bls_public) - { - (Some(b), _, _) => b, - (None, Some(secret), Some(public)) => { + let host_public = deps.identity.as_ref().map(HostIdentity::public); + let attestation: SharedBackend = match (deps.attestation, deps.identity) { + (Some(backend), _) => backend, + (None, Some(identity)) => { + let (secret, public) = identity.into_parts(); Arc::new(BlsAttestationBackend::new(secret, public)) } - (None, _, _) => Arc::new(UnavailableAttestationBackend), + (None, None) => Arc::new(UnavailableAttestationBackend), }; let host = HostState { @@ -161,7 +246,7 @@ impl HostRuntime { config: config.clone(), return_buffer: ReturnBuffer::new(&config), keys: Arc::new(HostKeys { - bls_public: deps.bls_public, + bls_public: host_public, }), attestation, clock: deps.clock, diff --git a/crates/digstore-host/src/serve_blind.rs b/crates/digstore-host/src/serve_blind.rs index 0cd11ee3..d1da914c 100644 --- a/crates/digstore-host/src/serve_blind.rs +++ b/crates/digstore-host/src/serve_blind.rs @@ -37,7 +37,7 @@ use digstore_prover::{ChainSource, MockChainSource, MockProver, Prover}; use crate::clock::{Clock, FixedClock}; use crate::config::ExecutionLimits; use crate::error::HostError; -use crate::runtime::{HostDeps, HostRuntime}; +use crate::runtime::{HostDeps, HostIdentity, HostRuntime}; /// Fixed deterministic mock-chain block used by the default blind serve path. /// The host never consults a live chain to serve content with the mock trio; @@ -180,25 +180,29 @@ impl BlindServeDeps { /// Build [`HostDeps`] for the blind serve path from the store identity in `cfg` /// and the injected proof backend / chain / clock in `deps`, wiring the host's /// BLS identity in so attestation passes iff that key is trusted by the module. -fn host_deps(cfg: BlindServeConfig, deps: BlindServeDeps) -> HostDeps { - HostDeps { - store_id: cfg.store_id, - // `BlindServeConfig` REQUIRES an identity, so this path is never - // anonymous: the blind serve exists to prove the host's key is in the - // module's trusted set. - bls_secret: Some(cfg.bls_secret), - bls_public: Some(cfg.bls_public), - clock: deps.clock, - chain: deps.chain, - prover: deps.prover, - // SECURITY: use real OS entropy, not a hardcoded seed. The host RNG seeds - // attestation challenge nonces and the indistinguishable decoys returned - // on a retrieval miss; a predictable seed would let an observer tell a - // decoy from real content, defeating oblivious serving. - rng_seed: None, - instance_id: Bytes32([1u8; 32]), - attestation: None, +/// +/// Errors when `cfg`'s two identity halves disagree. `BlindServeConfig` carries +/// them as separate public fields, so a caller CAN pair a secret with a public +/// half that does not belong to it; deriving the public half here would silently +/// serve under a different key than the one the caller believes it advertised. +/// +/// SECURITY: the host RNG draws real OS entropy rather than a fixed seed. It +/// seeds attestation challenge nonces and the indistinguishable decoys returned +/// on a retrieval miss; a predictable seed would let an observer tell a decoy +/// from real content, defeating oblivious serving. +fn host_deps(cfg: BlindServeConfig, deps: BlindServeDeps) -> Result { + // `BlindServeConfig` REQUIRES an identity, so this path is never anonymous: + // the blind serve exists to prove the host's key is in the module's trusted set. + let identity = HostIdentity::new(cfg.bls_secret); + if identity.public() != cfg.bls_public { + return Err(HostError::Validation( + "BlindServeConfig public half does not belong to its secret half".to_string(), + )); } + Ok( + HostDeps::new(cfg.store_id, deps.clock, deps.chain, deps.prover, Bytes32([1u8; 32])) + .with_identity(identity), + ) } /// Instantiate the REAL compiled module from `module_bytes` and drive its own @@ -233,7 +237,7 @@ pub fn serve_blind_with( module_bytes, HostImportsConfig::default(), ExecutionLimits::default(), - host_deps(cfg, deps), + host_deps(cfg, deps)?, )?; let request = request_for_retrieval_key(retrieval_key); rt.serve_content(&request) diff --git a/crates/digstore-host/tests/common/mod.rs b/crates/digstore-host/tests/common/mod.rs index 5a0cb23e..f60bcaff 100644 --- a/crates/digstore-host/tests/common/mod.rs +++ b/crates/digstore-host/tests/common/mod.rs @@ -1,18 +1,23 @@ //! Shared test helpers for digstore-host integration tests. -use digstore_core::types::{Bytes32, Bytes48}; +use digstore_core::types::Bytes32; use digstore_core::ChiaBlockRef; use digstore_crypto::bls::BlsSecretKey; -use digstore_host::{FixedClock, HostDeps}; +use digstore_host::{FixedClock, HostDeps, HostIdentity}; use digstore_prover::{MockChainSource, MockProver}; use std::sync::Arc; /// Build HostDeps with a deterministic BLS key, mock chain, and mock prover. /// `clock` is shared (FixedClock clones share their counter) so tests can advance it. pub fn test_deps(clock: FixedClock) -> HostDeps { - let sk = BlsSecretKey::from_seed(&[42u8; 32]); - let pk: Bytes48 = sk.public_key().to_bytes(); + anonymous_test_deps(clock).with_identity(HostIdentity::from_seed(&[42u8; 32])) +} +/// The same deps with NO host identity: the shape the CLI's read path builds. +/// +/// Everything except the identity is identical to [`test_deps`], so a test that +/// swaps one for the other varies exactly one thing. +pub fn anonymous_test_deps(clock: FixedClock) -> HostDeps { // A separate (deterministic) key + a known chain block back the mock prover. let prover_sk = BlsSecretKey::from_seed(&[7u8; 32]); let prover_pk = prover_sk.public_key(); @@ -24,15 +29,12 @@ pub fn test_deps(clock: FixedClock) -> HostDeps { let chain = MockChainSource::new(vec![block.clone()], 1_700_000_000); let prover = MockProver::new(prover_sk, prover_pk, block); - HostDeps { - store_id: Bytes32([0u8; 32]), - bls_secret: Some(sk), - bls_public: Some(pk), - clock: Arc::new(clock), - chain: Arc::new(chain), - prover: Arc::new(prover), - rng_seed: Some([99u8; 32]), - instance_id: Bytes32([1u8; 32]), - attestation: None, - } + HostDeps::new( + Bytes32([0u8; 32]), + Arc::new(clock), + Arc::new(chain), + Arc::new(prover), + Bytes32([1u8; 32]), + ) + .with_rng_seed([99u8; 32]) } diff --git a/crates/digstore-host/tests/imports_unit.rs b/crates/digstore-host/tests/imports_unit.rs index e0e9f2a6..23c73ef9 100644 --- a/crates/digstore-host/tests/imports_unit.rs +++ b/crates/digstore-host/tests/imports_unit.rs @@ -1,8 +1,9 @@ use digstore_core::config::HostImportsConfig; +use digstore_core::ErrorCode; use digstore_host::{ExecutionLimits, FixedClock, HostError, HostRuntime}; mod common; -use common::test_deps; +use common::{anonymous_test_deps, test_deps}; #[test] fn missing_data_exports_report_missing_export() { @@ -110,6 +111,77 @@ fn host_public_key_returns_48_bytes() { assert_eq!(n, 48); } +/// A host built with NO identity must answer the guest with an absence, from a +/// REAL `HostRuntime` — the arm `HostRuntime::new` selects when `identity` is +/// `None`. +/// +/// Why it is asserted here and on observable behaviour rather than on the +/// backend's type: substituting +/// `BlsAttestationBackend::new(BlsSecretKey::from_seed(&[42u8; 32]), ..)` for +/// `UnavailableAttestationBackend` inside that arm reconstitutes #2553's +/// world-known key one crate below the call site, and every other test in the +/// repo stays green through it. `teehook`'s test drives the backend in +/// isolation, the guest proof test drives a double, and +/// `has_host_public_key` reads `HostKeys::bls_public`, which this arm does not +/// set. Only an attestation actually produced by an anonymous runtime can tell +/// the two apart — a substituted key SIGNS, so this test goes red. +/// +/// Both halves matter: the pubkey leg catches a key handed out for free, and the +/// attest leg catches a signature produced under a key nobody controls. +#[test] +fn an_anonymous_runtime_hands_out_no_key_and_signs_nothing() { + let module_bytes = wat::parse_str(include_str!("fixtures/wat/import_probe.wat")).unwrap(); + let mut rt = HostRuntime::new( + &module_bytes, + cfg(), + ExecutionLimits::default(), + anonymous_test_deps(FixedClock::new(1_700_000_000)), + ) + .expect("an anonymous host still instantiates: reading consumes no identity"); + + assert!( + !rt.has_host_public_key(), + "an anonymous runtime must install no public key" + ); + + // No key to hand out. NotFound, never 48 bytes of anything. + let pubkey_result = rt.call_i32_export("probe_pubkey").unwrap(); + assert_eq!( + pubkey_result, + ErrorCode::NotFound as i32, + "an anonymous host must report NotFound, not return key-shaped bytes" + ); + + // Nobody to speak for. The attestation must FAIL rather than be signed under + // a stand-in key: a length here is a produced, attributable signature. + write_challenge(&mut rt, 4096); + let attest_result = rt.call_i32_export_1("probe_attest", 4096).unwrap(); + assert_eq!( + attest_result, + ErrorCode::AttestationFailed as i32, + "an anonymous host must refuse to attest; a non-negative result means it \ + signed the challenge under some key, which it does not have" + ); +} + +/// The control for the test above: the SAME runtime, varying only the identity, +/// does produce a key and an attestation. Without it, an anonymous-host test is +/// equally satisfied by a runtime that attests for nobody and by one that is +/// simply broken. +#[test] +fn the_identity_bearing_control_does_produce_a_key_and_an_attestation() { + let mut rt = probe_runtime(FixedClock::new(1_700_000_000)); + + assert!(rt.has_host_public_key()); + assert_eq!(rt.call_i32_export("probe_pubkey").unwrap(), 48); + + write_challenge(&mut rt, 4096); + assert_eq!( + rt.call_i32_export_1("probe_attest", 4096).unwrap() as usize, + ATTESTATION_LEN + ); +} + #[test] fn clock_advance_is_observed_by_guest() { let clock = FixedClock::new(1_000); From 10cb32fef1597be04c19b3a3ff028fbb2ca90370 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 15:20:21 -0700 Subject: [PATCH 14/17] test(serve): re-point the source-scan ban at the live tokens, and cover load_host_pubkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity-rename makes two banned tokens unreachable: `bls_public: Some(` and `bls_secret: Some(` name fields that no longer exist, so the guard would have passed by construction while appearing to protect the read path. Ban the positions that are actually reachable under the new shape — `identity: Some(` and `with_identity(`. `load_signing_key` stays deliberately absent: `serve_proof` lives in this file and must keep calling it. Correct that test's doc, which claimed the scan catches a re-added refusal. It catches only the `load_host_pubkey` form; the `load_signing_key` form — PR #40's exact shape — is covered by the behavioural `a_store_with_no_identity_still_serves_committed_content` and its control `serve_proof_still_refuses_without_a_signing_key`. Name those instead. Make the scan's scope assumption loud: it keeps the prefix before the FIRST `#[cfg(test)]`, so a new gated helper above the read path would silently narrow it to text that no longer contains the code it polices. Assert the expected occurrence count so that addition fails with an instruction. Add a store_ops-level successor to the deleted `a_missing_trusted_key_file_refuses_to_serve`: `load_host_pubkey` must error both when `trusted_keys.json` is absent and when it holds an empty array. The code is CORRECT today and the hypothetical regression fails closed downstream (an all-zero 48 bytes is not a canonical G1 point), so this closes a hole in the guards rather than a live defect. Co-Authored-By: Claude --- crates/digstore-cli/src/ops/serve.rs | 2 +- crates/digstore-cli/src/ops/store_ops.rs | 44 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/digstore-cli/src/ops/serve.rs b/crates/digstore-cli/src/ops/serve.rs index 378a8475..0ed7030f 100644 --- a/crates/digstore-cli/src/ops/serve.rs +++ b/crates/digstore-cli/src/ops/serve.rs @@ -525,7 +525,7 @@ mod tests { let src = include_str!("serve.rs"); // The split below assumes the FIRST marker opens the test module, so the - // prefix it keeps is the whole production half. A new `#[cfg(test)]` + // prefix it keeps is the whole production half. A new `cfg(test)`-gated // helper added ABOVE the read path would silently move that boundary and // narrow the guard to a prefix no longer containing the code it polices — // a guard that passes by scanning the wrong text. Fail loudly instead. diff --git a/crates/digstore-cli/src/ops/store_ops.rs b/crates/digstore-cli/src/ops/store_ops.rs index 5f8fa8c9..955f5330 100644 --- a/crates/digstore-cli/src/ops/store_ops.rs +++ b/crates/digstore-cli/src/ops/store_ops.rs @@ -1581,6 +1581,50 @@ mod tests { assert_ne!(res.store_id, Bytes32([0u8; 32])); } + /// `load_host_pubkey` must ERROR on both shapes of a missing trusted key — + /// never return one. + /// + /// This is the successor to `serve_fails_closed`'s deleted + /// `a_missing_trusted_key_file_refuses_to_serve`, which covered #2553's + /// pubkey leg from the read path. The read path no longer loads trusted keys + /// at all, so that test could not survive; without a replacement, a + /// reacquired `unwrap_or(Bytes48([0u8; 48]))` here would turn nothing red. + /// + /// The code is CORRECT today and even the hypothetical regression fails + /// closed downstream (an all-zero 48 bytes is not a canonical G1 point, so + /// `verify_head_signature` rejects it at clone time). This closes a hole in + /// the GUARDS, not a live defect. + /// + /// The assertion is "returns an error", deliberately not "is not the all-zero + /// key": the latter is satisfied by ANY substituted key, which is the same + /// class of lie with different bytes. + #[test] + fn load_host_pubkey_errors_when_there_is_no_trusted_key() { + let td = tempdir().unwrap(); + let ctx = CliContext::workspace_only(td.path().to_path_buf(), false, false); + init_store(&ctx, false, None, None, None, None, None, None).unwrap(); + let path = td.path().join("trusted_keys.json"); + + // Control: an initialized store DOES yield a key, so the two failures + // below are the absence of a key and not a broken fixture. + load_host_pubkey(&ctx).expect("an initialized store has a trusted host key"); + + // Shape 1: the file is gone. + std::fs::remove_file(&path).unwrap(); + assert!( + load_host_pubkey(&ctx).is_err(), + "a missing trusted_keys.json must refuse, never yield a key" + ); + + // Shape 2: the file parses but holds no key. `.first()` on an empty list + // is exactly where a `unwrap_or(default)` would substitute one. + std::fs::write(&path, "[]").unwrap(); + assert!( + load_host_pubkey(&ctx).is_err(), + "an empty trusted-key list must refuse, never yield a key" + ); + } + #[test] fn init_store_id_is_sha256_of_pubkey() { let td = tempdir().unwrap(); From ea6e73605b66836f84ef8f1861c86ae0e1acf40f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 15:30:45 -0700 Subject: [PATCH 15/17] docs(guest): name the field the anonymous-host double mirrors `bls_public` no longer exists on `HostDeps`; the anonymous state is `identity: None`. Co-Authored-By: Claude --- crates/digstore-guest/tests/content_proof.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/digstore-guest/tests/content_proof.rs b/crates/digstore-guest/tests/content_proof.rs index 65f753e1..7ffbe7b5 100644 --- a/crates/digstore-guest/tests/content_proof.rs +++ b/crates/digstore-guest/tests/content_proof.rs @@ -185,7 +185,7 @@ struct SigningHost { corrupt_sig: bool, /// Behave as an ANONYMOUS host: hold no identity, so both identity imports /// fail. This mirrors what `digstore-host` actually does for a runtime built - /// with `bls_public: None` — `host_get_public_key` returns `NotFound` and the + /// with `identity: None` — `host_get_public_key` returns `NotFound` and the /// `UnavailableAttestationBackend` refuses to sign. /// /// The double is widened rather than the test weakened: a double that can only From 71866433a97a15a87684e39e7406cfbce2af8420 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 11 Aug 2026 15:36:46 -0700 Subject: [PATCH 16/17] style(host): rustfmt the host_deps result expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI `Format check` step runs BEFORE clippy and the test suite, so this failure skipped every later step on the ubuntu runner — the red job said nothing about whether the code builds or passes, only that it was unformatted. Co-Authored-By: Claude --- crates/digstore-host/src/serve_blind.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/digstore-host/src/serve_blind.rs b/crates/digstore-host/src/serve_blind.rs index d1da914c..9cfcb049 100644 --- a/crates/digstore-host/src/serve_blind.rs +++ b/crates/digstore-host/src/serve_blind.rs @@ -199,10 +199,14 @@ fn host_deps(cfg: BlindServeConfig, deps: BlindServeDeps) -> Result Date: Tue, 11 Aug 2026 15:47:47 -0700 Subject: [PATCH 17/17] test(host): cover the blind-serve identity-mismatch refusal, and ban the reachable token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `host_deps` guard that refuses a `BlindServeConfig` whose `bls_public` does not belong to its `bls_secret` went in without a test. It is a new error branch on the network-facing path, so it gets one: a mismatched pair is refused with `HostError::Validation`, and the correctly-derived pair serves real bytes as the control. `from_seed` derives both halves and cannot express the mismatch, so the public field is assigned directly. Drop `identity: Some(` from the read-path token ban and add `.identity = Some(`. The literal form is a compile error from digstore-cli — `HostDeps` is `#[non_exhaustive]` and this is a different crate — so that entry could never fire. Field assignment is the form that IS reachable, since `#[non_exhaustive]` restricts construction rather than assignment, and it matched none of the four tokens. The behavioural `the_read_runtime_carries_no_host_identity` already catches that bypass; the scan is the cheap second leg. Co-Authored-By: Claude --- crates/digstore-cli/src/ops/serve.rs | 19 +++++--- crates/digstore-host/tests/dighost_serve.rs | 48 ++++++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/digstore-cli/src/ops/serve.rs b/crates/digstore-cli/src/ops/serve.rs index 0ed7030f..5bc60603 100644 --- a/crates/digstore-cli/src/ops/serve.rs +++ b/crates/digstore-cli/src/ops/serve.rs @@ -548,14 +548,21 @@ mod tests { // `load_signing_key` is deliberately absent from this list: `serve_proof` // still calls it, and must. // - // `identity: Some(` and `with_identity(` are the identity-carrying - // positions under the `HostDeps { identity: Option }` shape. - // They replaced `bls_public: Some(` / `bls_secret: Some(`, which those - // fields no longer exist to produce — leaving those tokens here would be a - // ban that can never fire, green by construction. + // The two identity-carrying positions reachable from THIS crate are the + // builder call and field assignment. A `HostDeps { identity: Some(..) }` + // literal is not one of them — `HostDeps` is `#[non_exhaustive]` and this + // is a different crate, so that form is a compile error here and banning + // it would be a ban on an unproducible string. + // + // Field assignment needs its own token because `#[non_exhaustive]` + // restricts construction, not assignment: `d.identity = Some(..)` on an + // existing value is legal from here and matches neither the builder token + // nor the literal one. It is not a live hole either way — the behavioural + // `the_read_runtime_carries_no_host_identity` catches it — this scan is + // the cheap second leg. for banned in [ "load_host_pubkey", - "identity: Some(", + ".identity = Some(", "with_identity(", "[0u8; 48]", ] { diff --git a/crates/digstore-host/tests/dighost_serve.rs b/crates/digstore-host/tests/dighost_serve.rs index bd4d00fb..379e0c18 100644 --- a/crates/digstore-host/tests/dighost_serve.rs +++ b/crates/digstore-host/tests/dighost_serve.rs @@ -17,7 +17,8 @@ use std::sync::Arc; use digstore_cli::context::CliContext; use digstore_cli::ops::store_ops; use digstore_core::{ContentResponse, Decode, Decoder, Urn}; -use digstore_host::{serve_blind, BlindServeConfig}; +use digstore_crypto::bls::BlsSecretKey; +use digstore_host::{serve_blind, BlindServeConfig, HostError}; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; use object_store::path::Path as ObjPath; @@ -253,3 +254,48 @@ fn s3_url_path_routes_through_object_store() { assert!(resp.merkle_proof.verify()); assert_eq!(resp.merkle_proof.root, fx.trusted_root); } + +/// A `BlindServeConfig` whose two identity halves disagree must be REFUSED, not +/// quietly served under the derived half. +/// +/// `BlindServeConfig` exposes `bls_secret` and `bls_public` as separate public +/// fields, so a caller can pair a secret with a public half that does not belong +/// to it. Deriving the public half and ignoring the supplied one would serve +/// under a different key than the caller believes it advertised — on the +/// network-facing path, where the whole point of the blind serve is proving the +/// host's key is in the module's trusted set. +/// +/// `from_seed` derives both halves and so cannot express the mismatch; the +/// public field is assigned directly to build it. +#[test] +fn a_blind_serve_config_whose_identity_halves_disagree_is_refused() { + let (_td, fx) = build_fixture(); + + // Control: the correctly-derived pair serves real content. Without it, the + // refusal below is equally satisfied by a fixture that cannot serve at all. + let ok_cfg = BlindServeConfig::from_seed(fx.store_id, &fx.seed); + let served = serve_blind(&fx.module, &fx.retrieval_key, ok_cfg) + .expect("a config whose halves agree must serve"); + assert!(!served.is_empty(), "the control must serve real bytes"); + + // Vary exactly ONE thing: the advertised public half now belongs to a + // different secret. + let mut bad_cfg = BlindServeConfig::from_seed(fx.store_id, &fx.seed); + let foreign = BlsSecretKey::from_seed(&[0xABu8; 32]) + .public_key() + .to_bytes(); + assert!( + foreign != bad_cfg.bls_public, + "the fixture must actually differ, or this test proves nothing" + ); + bad_cfg.bls_public = foreign; + + let err = serve_blind(&fx.module, &fx.retrieval_key, bad_cfg).expect_err( + "a public half that does not belong to the secret must be refused, not \ + silently replaced by the derived one", + ); + assert!( + matches!(err, HostError::Validation(_)), + "the refusal must be a validation error naming the disagreement: {err:?}" + ); +}