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/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 75023e74..88953815 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,10 @@ 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 +369,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 +419,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-cli/src/ops/serve.rs b/crates/digstore-cli/src/ops/serve.rs index f09d27bf..5bc60603 100644 --- a/crates/digstore-cli/src/ops/serve.rs +++ b/crates/digstore-cli/src/ops/serve.rs @@ -91,7 +91,19 @@ pub fn request_for(urn: &Urn) -> 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 { @@ -101,60 +113,55 @@ fn host_deps(store_id: Bytes32, pubkey: Bytes48, secret: BlsSecretKey) -> HostDe }; 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, - bls_secret: secret, - bls_public: pubkey, - 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 / /// 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 +182,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 +288,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,11 +379,10 @@ 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) - .expect("an initialized store instantiates"); + let rt = + instantiate_host(&module_path, store_id).expect("an initialized store instantiates"); assert!( !rt.rng_is_deterministic(), @@ -389,22 +391,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 + /// `.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] + 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 +475,104 @@ 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". + /// + /// 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 + /// `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() { + // 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)`-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. + 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(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. + // + // 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(", + "with_identity(", + "[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/src/ops/store_ops.rs b/crates/digstore-cli/src/ops/store_ops.rs index 61718b82..955f5330 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!( @@ -1574,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(); @@ -1905,6 +1956,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 diff --git a/crates/digstore-cli/tests/adv_delegated_host_key.rs b/crates/digstore-cli/tests/adv_delegated_host_key.rs index 6e55c212..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: sk, - bls_public: 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 10e949cc..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: sk, - bls_public: 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/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}" + ); +} diff --git a/crates/digstore-cli/tests/ops_roundtrip.rs b/crates/digstore-cli/tests/ops_roundtrip.rs index eaa09fd0..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: sk, - bls_public: 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-cli/tests/serve_fails_closed.rs b/crates/digstore-cli/tests/serve_fails_closed.rs index 8e4cf838..b740ca0a 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,90 @@ 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. +/// +/// 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(); + + let (proof, _root) = serve::serve_proof(&fx.ctx, &fx.module_path, &fx.urn, fx.root) + .expect("an intact store can sign a proof"); + + 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, + "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) + .expect_err("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"), diff --git a/crates/digstore-compiler/tests/auth_policy.rs b/crates/digstore-compiler/tests/auth_policy.rs index bec2e13a..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: sk, - bls_public: 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 7625c28d..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: sk, - bls_public: 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 c0c3cc57..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: sk, - bls_public: 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-guest/tests/content_proof.rs b/crates/digstore-guest/tests/content_proof.rs index 144b035f..7ffbe7b5 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 `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 + /// 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 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/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..f370bfbd 100644 --- a/crates/digstore-host/src/lib.rs +++ b/crates/digstore-host/src/lib.rs @@ -22,10 +22,12 @@ 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, }; 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..34ea8686 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}; @@ -54,21 +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, - pub bls_secret: BlsSecretKey, - pub bls_public: Bytes48, + /// The host's BLS identity, or `None` for an ANONYMOUS host. + /// + /// An anonymous host still serves committed content — the guest's content + /// path does not consult the host identity — it simply cannot attest. + 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 { @@ -137,15 +227,18 @@ impl HostRuntime { 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 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, None) => Arc::new(UnavailableAttestationBackend), }; let host = HostState { @@ -153,8 +246,7 @@ impl HostRuntime { config: config.clone(), return_buffer: ReturnBuffer::new(&config), keys: Arc::new(HostKeys { - bls_secret: shared_secret, - bls_public: deps.bls_public, + bls_public: host_public, }), attestation, clock: deps.clock, @@ -244,6 +336,22 @@ 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. + /// + /// 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.store.data().host.keys.bls_public.is_some() + } + /// 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..9cfcb049 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,22 +180,33 @@ 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, - bls_secret: cfg.bls_secret, - bls_public: 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 @@ -230,7 +241,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/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..385ea2f5 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,25 @@ 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 +93,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 +103,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()); } } diff --git a/crates/digstore-host/tests/common/mod.rs b/crates/digstore-host/tests/common/mod.rs index 2d7fdcdc..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: sk, - bls_public: 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/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:?}" + ); +} 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);