From 774cf06c65faa0208d302f2a8b556ddb3415d1f9 Mon Sep 17 00:00:00 2001 From: samjanny Date: Wed, 3 Jun 2026 16:46:33 +0200 Subject: [PATCH] verify: report E_SIG_INVALID_KEY when no runtime key is given Verifying a content or transaction without --expected-runtime-pubkey fabricated an all-zero RuntimePubkey and let the signature check fail, so the reject surfaced as E_SIG_VERIFICATION - indistinguishable from a genuinely bad signature. A missing authorizing key is not a signature failure: report it as E_SIG_INVALID_KEY before attempting verification, matching the entangled-core reference runner. Automation can now tell 'no manifest context' apart from 'bad signature'. Add an integration test asserting the diagnostic, and update the README and example wording. --- README.md | 2 +- examples/blog/README.md | 10 ++++++---- src/commands/verify.rs | 44 +++++++++++++++++++++++------------------ tests/exit_code.rs | 20 +++++++++++++++++++ 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1a12c97..b6f4a36 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ A manifest is driven through the full chain: signature (Stages 2-6), canary (Sta - `--now` sets the verified-time reference for the canary and origin-expiry checks (defaults to the current system clock). - `--fetched-onion` is the onion address the manifest was fetched from; with it, Stage 9 origin binding runs. Omit to skip Stage 9. - `--content-index` is the served `/content_index.json`; when the manifest declares `content_root`, Stage 9b verifies it (and its absence with a declared `content_root` surfaces the fetch failure). -- `--expected-runtime-pubkey` is the manifest's `canary.runtime_pubkey`; for a content or transaction document, it is the key the signature is checked against. Without it, a content/transaction has no authorized key and reports a signature rejection. +- `--expected-runtime-pubkey` is the manifest's `canary.runtime_pubkey`; for a content or transaction document, it is the key the signature is checked against. Without it, there is no authorized key and the document is rejected with `E_SIG_INVALID_KEY` (distinct from `E_SIG_VERIFICATION`, a bad signature). Skipped stages are reported, so an accept is never mistaken for a full-pipeline pass. diff --git a/examples/blog/README.md b/examples/blog/README.md index 1b6d9f6..b73a812 100644 --- a/examples/blog/README.md +++ b/examples/blog/README.md @@ -75,10 +75,12 @@ Steps 5 and 6 both report `accept`; step 5 also prints `canary_state: Fresh`. A content document carries no key of its own: it is signed by a runtime key, and only the manifest says which runtime key is authorized (its `canary.runtime_pubkey`). So verifying `post.json` standalone, without -`--expected-runtime-pubkey`, reports a signature rejection - there is no -authorized key to check against. That is the whole point of the manifest: -identity and authorization live there, not in the individual document. This is -also where the canary, origin binding, and content-index checks run. +`--expected-runtime-pubkey`, is rejected with `E_SIG_INVALID_KEY` - there is no +authorized key to check against. (This is distinct from `E_SIG_VERIFICATION`, an +actual bad signature, so automation can tell the two apart.) That is the whole +point of the manifest: identity and authorization live there, not in the +individual document. This is also where the canary, origin binding, and +content-index checks run. ## What gets rejected diff --git a/src/commands/verify.rs b/src/commands/verify.rs index 72557c8..211dff8 100644 --- a/src/commands/verify.rs +++ b/src/commands/verify.rs @@ -19,7 +19,7 @@ use entangled_core::document::{ use entangled_core::types::keys::RuntimePubkey; use entangled_core::types::manifest::OnionAddress; use entangled_core::types::timestamp::EntangledTimestamp; -use entangled_core::validation::Diagnostic; +use entangled_core::validation::{Diagnostic, DiagnosticCode, DocumentKindLabel}; use crate::cli::VerifyArgs; use crate::commands::{Error, Outcome}; @@ -133,11 +133,13 @@ fn verify_manifest( } fn verify_content(args: &VerifyArgs, bytes: &[u8]) -> Result { - let (runtime_pk, has_key) = runtime_key(args)?; + let runtime_pk = match runtime_key(args)? { + Some(k) => k, + None => return report_reject(&no_runtime_key(DocumentKindLabel::Content)), + }; match parse_and_verify_content(bytes, &runtime_pk) { Ok(_) => { println!("verdict: accept"); - print_runtime_note(has_key); Ok(Outcome::Success) } Err(d) => report_reject(&d), @@ -145,39 +147,43 @@ fn verify_content(args: &VerifyArgs, bytes: &[u8]) -> Result { } fn verify_transaction(args: &VerifyArgs, bytes: &[u8]) -> Result { - let (runtime_pk, has_key) = runtime_key(args)?; + let runtime_pk = match runtime_key(args)? { + Some(k) => k, + None => return report_reject(&no_runtime_key(DocumentKindLabel::Transaction)), + }; match parse_and_verify_transaction(bytes, &runtime_pk, None) { Ok(_) => { println!("verdict: accept"); - print_runtime_note(has_key); Ok(Outcome::Success) } Err(d) => report_reject(&d), } } -/// Resolve the runtime key to verify a content/transaction signature against: -/// the manifest-authorized key from `--expected-runtime-pubkey` when given, or -/// a placeholder otherwise (in which case the signature check has no authorized -/// key and will reject). Returns the key and whether a real one was supplied. -fn runtime_key(args: &VerifyArgs) -> Result<(RuntimePubkey, bool), Error> { +/// The manifest-authorized runtime key from `--expected-runtime-pubkey`, or +/// `None` when it was not supplied. `None` is not a signature failure: there is +/// no key to verify against, reported as `E_SIG_INVALID_KEY` so automation can +/// tell "no manifest context" apart from "bad signature". +fn runtime_key(args: &VerifyArgs) -> Result, Error> { match args.expected_runtime_pubkey.as_deref() { Some(b64) => { let key = RuntimePubkey::try_from(b64) .map_err(|e| format!("--expected-runtime-pubkey is invalid: {e}"))?; - Ok((key, true)) + Ok(Some(key)) } - None => Ok((RuntimePubkey::from_bytes([0u8; 32]), false)), + None => Ok(None), } } -fn print_runtime_note(has_key: bool) { - if !has_key { - println!( - "note: no authorizing runtime key given; pass --expected-runtime-pubkey \ - (the manifest's canary.runtime_pubkey) to verify against the manifest" - ); - } +/// The diagnostic for "no authorized runtime key available", per section 11: +/// distinct from a signature that decoded but failed to verify. +fn no_runtime_key(kind: DocumentKindLabel) -> Diagnostic { + Diagnostic::new( + DiagnosticCode::ESigInvalidKey, + kind, + "no authorizing runtime key available; pass --expected-runtime-pubkey \ + (the manifest's canary.runtime_pubkey) to verify against the manifest", + ) } fn report_reject(diag: &Diagnostic) -> Result { diff --git a/tests/exit_code.rs b/tests/exit_code.rs index 9922397..58da1d0 100644 --- a/tests/exit_code.rs +++ b/tests/exit_code.rs @@ -39,3 +39,23 @@ fn verify_accept_exits_zero() { .expect("run verify"); assert!(status.success(), "an accepted document must exit 0"); } + +/// Without an authorizing runtime key, the reject must be E_SIG_INVALID_KEY +/// ("no manifest context"), not E_SIG_VERIFICATION ("bad signature"), so +/// automation can tell the two apart. +#[test] +fn verify_no_key_reports_invalid_key_not_sig_failure() { + let out = tool() + .args(["verify", "--input", POST]) + .output() + .expect("run verify"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("E_SIG_INVALID_KEY"), + "expected E_SIG_INVALID_KEY, got:\n{stdout}" + ); + assert!( + !stdout.contains("E_SIG_VERIFICATION"), + "missing key must not surface as a signature failure:\n{stdout}" + ); +}