diff --git a/Cargo.lock b/Cargo.lock index fd1d7e6..b52332c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "verified-canonical" -version = "0.1.5" +version = "0.2.0" dependencies = [ "proptest", "sha2", @@ -1787,7 +1787,7 @@ dependencies = [ [[package]] name = "verified-cli" -version = "0.1.5" +version = "0.2.0" dependencies = [ "anstream", "anstyle", @@ -1808,14 +1808,14 @@ dependencies = [ [[package]] name = "verified-contract" -version = "0.1.5" +version = "0.2.0" dependencies = [ "verified-canonical", ] [[package]] name = "verified-derivation" -version = "0.1.5" +version = "0.2.0" dependencies = [ "cedar-policy", "serde_json", @@ -1827,7 +1827,7 @@ dependencies = [ [[package]] name = "verified-history" -version = "0.1.5" +version = "0.2.0" dependencies = [ "base64", "ed25519-dalek", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "verified-protocol" -version = "0.1.5" +version = "0.2.0" dependencies = [ "thiserror", "verified-canonical", @@ -1846,7 +1846,7 @@ dependencies = [ [[package]] name = "verified-verifier" -version = "0.1.5" +version = "0.2.0" dependencies = [ "base64", "ed25519-dalek", @@ -1859,7 +1859,7 @@ dependencies = [ [[package]] name = "verified-work-unit" -version = "0.1.5" +version = "0.2.0" dependencies = [ "hmac", "sha2", @@ -1870,7 +1870,7 @@ dependencies = [ [[package]] name = "verified-workspace" -version = "0.1.5" +version = "0.2.0" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 72d83c5..394673e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "3" members = ["crates/*"] [workspace.package] -version = "0.1.5" +version = "0.2.0" edition = "2024" rust-version = "1.99" repository = "https://github.com/dekrops/verified-cli" @@ -35,14 +35,14 @@ codegen-units = 1 strip = "symbols" [workspace.dependencies] -verified-canonical = { path = "crates/verified-canonical", version = "0.1.5" } -verified-contract = { path = "crates/verified-contract", version = "0.1.5" } -verified-derivation = { path = "crates/verified-derivation", version = "0.1.5" } -verified-history = { path = "crates/verified-history", version = "0.1.5" } -verified-protocol = { path = "crates/verified-protocol", version = "0.1.5" } -verified-verifier = { path = "crates/verified-verifier", version = "0.1.5" } -verified-work-unit = { path = "crates/verified-work-unit", version = "0.1.5" } -verified-workspace = { path = "crates/verified-workspace", version = "0.1.5" } +verified-canonical = { path = "crates/verified-canonical", version = "0.2.0" } +verified-contract = { path = "crates/verified-contract", version = "0.2.0" } +verified-derivation = { path = "crates/verified-derivation", version = "0.2.0" } +verified-history = { path = "crates/verified-history", version = "0.2.0" } +verified-protocol = { path = "crates/verified-protocol", version = "0.2.0" } +verified-verifier = { path = "crates/verified-verifier", version = "0.2.0" } +verified-work-unit = { path = "crates/verified-work-unit", version = "0.2.0" } +verified-workspace = { path = "crates/verified-workspace", version = "0.2.0" } anstream = "1.0.0" anstyle = "1.0.14" diff --git a/README.md b/README.md index 6275dfb..75e6ed7 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ ```console $ verified verify examples/invoice-posting/supported.json \ + --expect-contract sha-256:6a2c3b0bf5814aacd8c699f430db0a11b50de812d3dd86f30823aa98df46b550 \ --trust examples/invoice-posting/trust.json Work Verification Protocol package (wvp-0.2, draft) package wvp:sha-256:d0982b73f310… @@ -34,6 +35,7 @@ decision reproduced SUPPORTED, matches the package POSTED TRUE issuer signature absent source truthfulness NOT established: no check can establish it +expectations met (contract) complete: every protocol check ran; the decision SUPPORTED was reproduced ``` @@ -69,6 +71,12 @@ that: - the decision (`SUPPORTED`, `CONTRADICTED` or `UNDETERMINED`) is what the contract yields from that evidence, recomputed on your machine. +The contract travels in the package, chosen by whoever made it. A contract +that requires nothing reproduces as `SUPPORTED` like any other, so the +decision is about the contract you agreed only when you pin it: +`--expect-contract` with its digest. Without a pin, the report says +`contract pinned NO`. + It cannot establish that a source told the truth, that a completeness statement was honest, or that the package holds everything the sources know. A valid signature proves who said something, not that it is so. And a @@ -115,7 +123,8 @@ $ echo $? | --- | --- | | `--trust ` | Your trust registry: the keys you accept, for which issuer, tenant, sources and period. Keys inside a package are never trusted. | | `--expect-tenant`, `--expect-contract`, `--expect-claim` | Refuse a `wvp-0.2` package that verifies but is about another tenant, contract (by digest) or claim than you meant. | -| `--require-decision ` | Exit 0 only if this decision (`SUPPORTED`, `CONTRADICTED` or `UNDETERMINED`) was reproduced; otherwise exit 4. Use it in scripts. | +| `--require-decision ` | Exit 0 only if this decision (`SUPPORTED`, `CONTRADICTED` or `UNDETERMINED`) was reproduced, and for `wvp-0.2` under a contract pinned with `--expect-contract`; otherwise exit 4. Use it in scripts. | +| `--any-contract` | With `--require-decision`: accept the decision under whatever contract a `wvp-0.2` package carries. Say so only if any contract will do. | | `--protocol ` | Require a protocol version instead of detecting it. | | `--json` | The same result as structured data, with full identifiers. | | `--quiet` | Print nothing; rely on the exit code. | @@ -127,7 +136,7 @@ $ echo $? | 1 | Refused by a protocol check. `fsck`: damage found. `diff`: the packages differ. `replay`: a package was refused or is damaged. | | 2 | The command could not run (usage, unreadable input, unusable registry, resource limit). | | 3 | `verify`: not refused, but not every check ran (no registry). | -| 4 | `--require-decision` (`verify`, `replay`): every check ran, but a reproduced decision is not the required one. | +| 4 | `--require-decision` (`verify`, `replay`): every check ran, but a reproduced decision is not the required one, or a `wvp-0.2` contract was not pinned. | ### Gating a script on the decision @@ -137,15 +146,21 @@ A script that acts on the exit code alone must therefore say which decision it needs: ```sh -verified verify invoice.json --trust trust.json --require-decision SUPPORTED \ +verified verify invoice.json --trust trust.json \ + --expect-contract sha-256: \ + --require-decision SUPPORTED \ && pay_invoice ``` With `--require-decision`, exit 0 means every check ran **and** that decision -was reproduced on this machine. Any other reproduced decision exits 4. The -decision a package merely carries never satisfies the requirement: without a -registry nothing is reproduced and the exit code is 3. The flag is opt-in; -without it the behaviour is unchanged. +was reproduced on this machine, under the contract you pinned. Any other +reproduced decision exits 4, and so does a `wvp-0.2` package whose contract +you did not pin: its producer chose that contract, and one that requires +nothing reproduces as `SUPPORTED`. `--any-contract` accepts the contract the +package carries, when that is really what you mean. The decision a package +merely carries never satisfies the requirement: without a registry nothing is +reproduced and the exit code is 3. Without `--require-decision` the exit code +is unchanged; the report says whether the contract was pinned. A trust registry is a JSON file: @@ -330,7 +345,7 @@ no damage found. Every stored package is the content its names commit to; that s | --- | --- | | `verified export [-o FILE]` | Writes a stored package out byte for byte, to standard output or to a file that must not exist. Nothing is added: no signature, no result, no claim of this tool's own. | | `verified diff ` | Compares two packages (files or stored ones): which components differ and what the two state differently. Both need integrity; nothing else is checked. Exits 0 if the texts are identical, 1 if not. | -| `verified replay --trust REGISTRY [id…]` | Verifies stored packages again, now, under the registry given now. Nothing from an earlier run is reused. Exits 1 if any is refused or damaged; with `--require-decision`, 4 if any reproduces another decision. | +| `verified replay --trust REGISTRY [id…]` | Verifies stored packages again, now, under the registry given now. Nothing from an earlier run is reused. Exits 1 if any is refused or damaged; with `--require-decision`, 4 if any reproduces another decision or, unless `--any-contract`, is a `wvp-0.2` package whose contract `--expect-contract` did not pin. | | `verified verify --record FILE` | Refuses (`RECORD_MISMATCH`) a `wvp-0.2` package that a work record you hold is not about, naming every member that differs. | A **work record** states one verification of one package: the claim, the diff --git a/crates/verified-cli/src/cli.rs b/crates/verified-cli/src/cli.rs index 434e78b..9f2a8e1 100644 --- a/crates/verified-cli/src/cli.rs +++ b/crates/verified-cli/src/cli.rs @@ -43,7 +43,8 @@ Exit codes: 3 verify: the package was not refused, but not every check ran registration log: the log does not reach the entry you hold 4 --require-decision: every check ran, but a reproduced decision is not - the required one + the required one, or a wvp-0.2 contract was not pinned + (--expect-contract; --any-contract accepts it unpinned) The Work Verification Protocol is a draft. No check establishes that a source told the truth. @@ -630,9 +631,21 @@ pub(crate) struct Replay { /// Your trust registry #[arg(long, value_name = "REGISTRY")] pub(crate) trust: PathBuf, - /// Exit 4 unless every package reproduces this decision + /// Exit 4 unless every package reproduces this decision under a pinned + /// contract #[arg(long, value_name = "DECISION", value_parser = decision)] pub(crate) require_decision: Option, + /// Refuse a wvp-0.2 package about another contract, by digest + #[arg(long, value_name = "SHA-256", value_parser = digest)] + pub(crate) expect_contract: Option, + /// With --require-decision: accept the decision under whatever contract + /// each wvp-0.2 package carries, unpinned + #[arg( + long, + requires = "require_decision", + conflicts_with = "expect_contract" + )] + pub(crate) any_contract: bool, /// The packages to verify (default: every stored package) pub(crate) packages: Vec, } @@ -684,7 +697,8 @@ pub(crate) struct Verify { #[arg(long, value_name = "VERSION", value_parser = protocol)] pub(crate) protocol: Option, /// Exit 0 only if this decision was reproduced: SUPPORTED, CONTRADICTED - /// or UNDETERMINED. Another reproduced decision exits 4 + /// or UNDETERMINED. Another reproduced decision exits 4, and so does a + /// wvp-0.2 package whose contract you did not pin with --expect-contract #[arg(long, value_name = "DECISION", value_parser = decision)] pub(crate) require_decision: Option, /// A work record you hold, as a JSON file: refuse a package the record @@ -697,6 +711,15 @@ pub(crate) struct Verify { /// Refuse a package about another contract, by digest (wvp-0.2) #[arg(long, value_name = "SHA-256", value_parser = digest)] pub(crate) expect_contract: Option, + /// With --require-decision: accept the decision under whatever contract + /// the package carries, unpinned (wvp-0.2). The package's producer chose + /// that contract, and one that requires nothing is SUPPORTED + #[arg( + long, + requires = "require_decision", + conflicts_with = "expect_contract" + )] + pub(crate) any_contract: bool, /// Refuse a package carrying another claim, by identifier (wvp-0.2) #[arg(long, value_name = "CLAIM")] pub(crate) expect_claim: Option, diff --git a/crates/verified-cli/src/exit.rs b/crates/verified-cli/src/exit.rs index b21269a..c4effdd 100644 --- a/crates/verified-cli/src/exit.rs +++ b/crates/verified-cli/src/exit.rs @@ -22,7 +22,8 @@ pub(crate) enum Exit { /// registry's log does not reach an entry held against it. Incomplete = 3, /// Every check ran and the package was not refused, but the decision it - /// reproduced is not the one `--require-decision` asked for. + /// reproduced is not the one `--require-decision` asked for, or it was + /// reproduced under a `wvp-0.2` contract the caller did not pin. DecisionNotRequired = 4, } diff --git a/crates/verified-cli/src/portable.rs b/crates/verified-cli/src/portable.rs index f094999..0244fc6 100644 --- a/crates/verified-cli/src/portable.rs +++ b/crates/verified-cli/src/portable.rs @@ -11,7 +11,9 @@ use std::path::Path; use serde_json::json; use verified_canonical::Limits; use verified_protocol::Decision; -use verified_verifier::{DecisionFinding, Inspection, Options, Rejection, inspect, verify}; +use verified_verifier::{ + DecisionFinding, Expectations, Inspection, Options, Rejection, inspect, verify, +}; use verified_workspace::{Stored, Workspace}; use crate::cli::{Diff, Export, Replay}; @@ -318,7 +320,8 @@ pub(crate) fn diff(command: &Diff, out: &mut Output) -> Exit { /// What verifying one stored package now concluded. enum Replayed { - Reproduced(Decision), + /// The decision reproduced, and whether its contract was pinned. + Reproduced(Decision, bool), Refused(String), Damaged(String), } @@ -329,9 +332,9 @@ fn replay_one(workspace: &Workspace, stored: &Stored, options: &Options<'_>) -> Err(error) => return Replayed::Damaged(error.to_string()), }; match verify(&bytes, options) { - Ok(report) => match report.decision { + Ok(report) => match &report.decision { DecisionFinding::Reproduced(reproduced) if report.is_complete() => { - Replayed::Reproduced(reproduced.decision) + Replayed::Reproduced(reproduced.decision, report.contract_pinned()) } // A registry was supplied, so a report is complete; anything // else is not something to call a success. @@ -342,6 +345,27 @@ fn replay_one(workspace: &Workspace, stored: &Stored, options: &Options<'_>) -> } } +/// One package's line in `replay --json`. +fn replayed_json(stored: &Stored, result: &Replayed) -> serde_json::Value { + let (status, detail, pinned) = match result { + Replayed::Reproduced(decision, pinned) => { + ("reproduced", decision.to_string(), Some(*pinned)) + } + Replayed::Refused(code) => ("refused", code.clone(), None), + Replayed::Damaged(detail) => ("damaged", detail.clone(), None), + }; + let mut entry = json!({ + "package": stored.package.to_string(), + "object": stored.object.to_string(), + "status": status, + "detail": detail, + }); + if let Some(pinned) = pinned { + entry["contractPinned"] = json!(pinned); + } + entry +} + pub(crate) fn replay(command: &Replay, out: &mut Output) -> Exit { let limits = Limits::DEFAULT; let trust = match registry(&command.trust, &limits) { @@ -368,6 +392,10 @@ pub(crate) fn replay(command: &Replay, out: &mut Output) -> Exit { let options = Options { limits, trust: Some(&trust), + expectations: Expectations { + contract: command.expect_contract, + ..Expectations::default() + }, ..Options::default() }; let results: Vec<(Stored, Replayed)> = selected @@ -377,31 +405,22 @@ pub(crate) fn replay(command: &Replay, out: &mut Output) -> Exit { let failed_count = results .iter() - .filter(|(_, r)| !matches!(r, Replayed::Reproduced(_))) + .filter(|(_, r)| !matches!(r, Replayed::Reproduced(..))) .count(); let unmet = command.require_decision.map_or(0, |required| { results .iter() - .filter(|(_, r)| matches!(r, Replayed::Reproduced(d) if *d != required)) + .filter(|(_, r)| { + matches!(r, Replayed::Reproduced(d, pinned) + if *d != required || !(*pinned || command.any_contract)) + }) .count() }); match out.mode { Mode::Quiet => {} Mode::Json => out.json(&json!({ "outcome": if failed_count > 0 { "failed" } else if unmet > 0 { "decision_not_required" } else { "replayed" }, - "packages": results.iter().map(|(stored, result)| { - let (status, detail) = match result { - Replayed::Reproduced(decision) => ("reproduced", decision.to_string()), - Replayed::Refused(code) => ("refused", code.clone()), - Replayed::Damaged(detail) => ("damaged", detail.clone()), - }; - json!({ - "package": stored.package.to_string(), - "object": stored.object.to_string(), - "status": status, - "detail": detail, - }) - }).collect::>(), + "packages": results.iter().map(|(stored, result)| replayed_json(stored, result)).collect::>(), "requiredDecision": command.require_decision.map(Decision::as_str), "note": "every result was computed in this run under the supplied registry; nothing was reused", })), @@ -409,8 +428,9 @@ pub(crate) fn replay(command: &Replay, out: &mut Output) -> Exit { for (stored, result) in &results { let id = format!("wvp:sha-256:{}", out.short(&stored.package.commitment().to_hex())); let (tone, text) = match result { - Replayed::Reproduced(Decision::Supported) => (Tone::Established, "SUPPORTED reproduced".to_owned()), - Replayed::Reproduced(decision) => (Tone::Unknown, format!("{decision} reproduced")), + Replayed::Reproduced(Decision::Supported, true) => (Tone::Established, "SUPPORTED reproduced".to_owned()), + Replayed::Reproduced(decision, true) => (Tone::Unknown, format!("{decision} reproduced")), + Replayed::Reproduced(decision, false) => (Tone::Unknown, format!("{decision} reproduced, contract not pinned")), Replayed::Refused(code) => (Tone::Negative, format!("refused: {code}")), Replayed::Damaged(detail) => (Tone::Negative, format!("damaged: {detail}")), }; @@ -423,7 +443,9 @@ pub(crate) fn replay(command: &Replay, out: &mut Output) -> Exit { results.len() )); if let Some(required) = command.require_decision { - out.line(&format!("required decision {required}: {unmet} package(s) reproduced another")); + out.line(&format!( + "required decision {required}: {unmet} package(s) reproduced another, or under a contract not pinned" + )); } } } diff --git a/crates/verified-cli/src/verify.rs b/crates/verified-cli/src/verify.rs index 42f4e23..b4babea 100644 --- a/crates/verified-cli/src/verify.rs +++ b/crates/verified-cli/src/verify.rs @@ -75,7 +75,7 @@ pub(crate) fn run(command: &Verify, out: &mut Output) -> Exit { }, }; match verified_verifier::verify(package_text(&bytes), &options) { - Ok(report) => reported(out, &report, command.require_decision), + Ok(report) => reported(out, &report, command.require_decision, command.any_contract), Err(Rejection::Refused(refusal)) => refused(out, &refusal), Err(Rejection::Limit(limit)) => not_examined(out, limit), Err(error @ Rejection::ExpectationsUndefined(_)) => { @@ -298,7 +298,12 @@ pub(crate) fn issuer_row(issuer: IssuerSignature) -> Row { /// The package was not refused. Whether every check ran is the report's to /// say, and decides the exit code. -fn reported(out: &mut Output, report: &Report, required: Option) -> Exit { +fn reported( + out: &mut Output, + report: &Report, + required: Option, + any_contract: bool, +) -> Exit { let complete = report.is_complete(); // A requirement is met only by a decision this run reproduced. The // decision a package carries never meets one. @@ -306,25 +311,44 @@ fn reported(out: &mut Output, report: &Report, required: Option) -> Ex DecisionFinding::Reproduced(reproduced) => Some(reproduced.decision), _ => None, }; - let met = required.map(|required| reproduced == Some(required)); + // Nor does one reproduced under a contract the caller did not pin, + // unless the caller said that any contract will do. + let pinned = report.contract_pinned() || any_contract; + let met = required.map(|required| reproduced == Some(required) && pinned); match out.mode { Mode::Quiet => {} Mode::Json => { let mut result = report_json(report, complete); if let (Some(required), Some(met)) = (required, met) { - result["requiredDecision"] = json!({ "decision": required.as_str(), "met": met }); + result["requiredDecision"] = json!({ + "decision": required.as_str(), + "met": met, + "anyContract": any_contract, + }); } out.json(&result); } Mode::Human => { human(out, report, complete); match (required, met, reproduced) { - (Some(required), Some(false), Some(reproduced)) => out.line(&format!( - "required decision {required} not met: the reproduced decision is {reproduced}" + (Some(required), Some(false), Some(reproduced)) if reproduced != required => { + out.line(&format!( + "required decision {required} not met: the reproduced decision is {reproduced}" + )); + } + (Some(required), Some(false), Some(_)) => out.line(&format!( + "required decision {required} not met: the contract is not pinned; give \ + --expect-contract with the digest you agreed, or --any-contract" )), (Some(required), Some(false), None) => out.line(&format!( "required decision {required} not met: no decision was reproduced" )), + (Some(required), Some(true), _) if !report.contract_pinned() => { + out.line(&format!( + "required decision {required} met, under the contract the package carries \ + (--any-contract)" + )); + } (Some(required), Some(true), _) => { out.line(&format!("required decision {required} met")); } @@ -352,6 +376,14 @@ fn binding_rows(report: &Report) -> Vec { "the supplied record is about this package", )); } + if !report.contract_pinned() { + rows.push(Row::new( + "contract pinned", + Tone::Unknown, + "NO: the decision is about the contract the package carries; \ + pin yours with --expect-contract", + )); + } if !report.expectations_met.is_empty() { let met = &report.expectations_met; let given: Vec<&str> = [ @@ -443,9 +475,19 @@ pub(crate) fn human(out: &mut Output, report: &Report, complete: bool) { .. }), true, - ) => { + ) if report.contract_pinned() => { out.line("complete: every protocol check ran; the decision SUPPORTED was reproduced"); } + ( + DecisionFinding::Reproduced(Reproduced { + decision: Decision::Supported, + .. + }), + true, + ) => out.line( + "complete: every protocol check ran; the decision SUPPORTED was reproduced under \ + the contract the package carries, which you did not pin", + ), (DecisionFinding::Reproduced(Reproduced { decision, .. }), true) => { out.line(&format!( "complete: every protocol check ran; the reproduced decision is {decision}, \ diff --git a/crates/verified-cli/tests/portable.rs b/crates/verified-cli/tests/portable.rs index efae79c..b5baeb0 100644 --- a/crates/verified-cli/tests/portable.rs +++ b/crates/verified-cli/tests/portable.rs @@ -311,8 +311,14 @@ fn replay_verifies_stored_packages_now_and_reuses_nothing() { let output = scratch.run(&["replay", "--trust", "trust.json"]); let text = stdout(&output); assert_eq!(output.status.code(), Some(0), "{}", stderr(&output)); - assert!(text.contains("SUPPORTED reproduced\n"), "{text}"); - assert!(text.contains("UNDETERMINED reproduced\n"), "{text}"); + assert!( + text.contains("SUPPORTED reproduced, contract not pinned\n"), + "{text}" + ); + assert!( + text.contains("UNDETERMINED reproduced, contract not pinned\n"), + "{text}" + ); assert!( text.contains( "2 package(s) verified now under the supplied registry; 0 refused or damaged." @@ -331,17 +337,38 @@ fn replay_verifies_stored_packages_now_and_reuses_nothing() { ]); assert_eq!(output.status.code(), Some(4)); assert_eq!(json(&output)["outcome"], "decision_not_required"); - // Named packages only. - let output = scratch.run(&[ - "replay", - "--quiet", - "--trust", - "trust.json", - "--require-decision", - "SUPPORTED", - &posted, - ]); + // Named packages only, under the contract the relying party agreed. + let pin = format!("sha-256:{}", Scenario::posted().contract.digest().to_hex()); + let required = |extra: &[&str]| { + let mut args = vec![ + "replay", + "--json", + "--trust", + "trust.json", + "--require-decision", + "SUPPORTED", + ]; + args.extend_from_slice(extra); + args.push(&posted); + scratch.run(&args) + }; + let output = required(&["--expect-contract", &pin]); assert_eq!(output.status.code(), Some(0)); + assert_eq!(json(&output)["packages"][0]["contractPinned"], true); + // Unpinned, SUPPORTED does not meet the requirement, unless the caller + // accepts any contract. + let output = required(&[]); + assert_eq!(output.status.code(), Some(4)); + assert_eq!(json(&output)["packages"][0]["contractPinned"], false); + assert_eq!(required(&["--any-contract"]).status.code(), Some(0)); + // A package about another contract is refused, not counted. + let other = format!("sha-256:{}", "0".repeat(64)); + let output = required(&["--expect-contract", &other]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + json(&output)["packages"][0]["detail"], + "CONTRACT_NOT_EXPECTED" + ); // The same packages under a registry that does not hold their signer. let output = scratch.run(&["replay", "--json", "--trust", "other.json"]); diff --git a/crates/verified-cli/tests/verify.rs b/crates/verified-cli/tests/verify.rs index 6b226f7..e8d2a2e 100644 --- a/crates/verified-cli/tests/verify.rs +++ b/crates/verified-cli/tests/verify.rs @@ -112,6 +112,8 @@ signer authority not checked: no trust registry supplied decision reproduced not checked: no trust registry supplied issuer signature not checked: no trust registry supplied (0 carried) source truthfulness NOT established: no check can establish it +contract pinned NO: the decision is about the contract the package carries; pin yours \ +with --expect-contract incomplete: no trust registry was supplied (--trust), so no signature was checked and the \ decision was not reproduced; the decision the package carries is its issuer's claim @@ -149,8 +151,11 @@ decision reproduced SUPPORTED, matches the package {:<28}TRUE issuer signature absent source truthfulness NOT established: no check can establish it +contract pinned NO: the decision is about the contract the package carries; pin yours \ +with --expect-contract -complete: every protocol check ran; the decision SUPPORTED was reproduced +complete: every protocol check ran; the decision SUPPORTED was reproduced under the contract \ +the package carries, which you did not pin ", &commitment[..12], uuid(0x500), @@ -163,11 +168,9 @@ complete: every protocol check ran; the decision SUPPORTED was reproduced verify_with(&trust, &["--quiet"], &package).status.code(), Some(0) ); - let output = verify_with(&trust, &["--require-decision", "SUPPORTED"], &package); - assert_eq!(output.status.code(), Some(0)); - assert!(stdout(&output).ends_with("required decision SUPPORTED met\n")); let result = result_json(&verify_with(&trust, &["--json"], &package)); assert!(result.get("requiredDecision").is_none()); + assert_eq!(result["contractPinned"], false); // A file ends with a line end; the package inside it does not. for ending in ["\n", "\r\n"] { @@ -205,6 +208,89 @@ complete: every protocol check ran; the decision SUPPORTED was reproduced ); } +#[test] +fn a_required_decision_needs_the_contract_the_relying_party_pinned() { + let scratch = Scratch::new("pinned"); + let draft = Scenario::posted().draft(); + let package = scratch.file("package.json", draft.text().as_bytes()); + let trust = trust_file(&scratch, &[&evidence_key()]); + let contract = Scenario::posted().contract.digest().to_hex(); + + // A required decision is met only under the contract the relying party + // agreed. The package's producer chose the one it carries, and a contract + // that requires nothing reproduces as SUPPORTED (review F-1). + let output = verify_with(&trust, &["--require-decision", "SUPPORTED"], &package); + assert_eq!(output.status.code(), Some(4)); + assert!(stdout(&output).ends_with( + "required decision SUPPORTED not met: the contract is not pinned; give --expect-contract \ + with the digest you agreed, or --any-contract\n" + )); + let output = verify_with( + &trust, + &["--json", "--require-decision", "SUPPORTED"], + &package, + ); + assert_eq!(output.status.code(), Some(4)); + let result = result_json(&output); + assert_eq!(result["outcome"], "complete"); + assert_eq!(result["requiredDecision"]["met"], false); + assert_eq!(result["requiredDecision"]["anyContract"], false); + + let pin = format!("sha-256:{contract}"); + let output = verify_with( + &trust, + &["--require-decision", "SUPPORTED", "--expect-contract", &pin], + &package, + ); + let text = stdout(&output); + assert_eq!(output.status.code(), Some(0), "{text}"); + assert!( + text.contains("expectations met (contract)\n"), + "{text}" + ); + assert!(!text.contains("contract pinned"), "{text}"); + assert!( + text.contains( + "complete: every protocol check ran; the decision SUPPORTED was reproduced\n" + ), + "{text}" + ); + assert!( + text.ends_with("required decision SUPPORTED met\n"), + "{text}" + ); + let result = result_json(&verify_with( + &trust, + &["--json", "--expect-contract", &pin], + &package, + )); + assert_eq!(result["contractPinned"], true); + + // Accepting any contract is the caller's explicit choice, and is said. + let output = verify_with( + &trust, + &["--require-decision", "SUPPORTED", "--any-contract"], + &package, + ); + assert_eq!(output.status.code(), Some(0)); + assert!(stdout(&output).ends_with( + "required decision SUPPORTED met, under the contract the package carries (--any-contract)\n" + )); + // It is a choice about a required decision, and never one about a pin. + for args in [ + &["--any-contract"][..], + &[ + "--require-decision", + "SUPPORTED", + "--any-contract", + "--expect-contract", + &pin, + ][..], + ] { + assert_eq!(verify_with(&trust, args, &package).status.code(), Some(2)); + } +} + #[test] fn a_reproduced_decision_that_is_not_supported_exits_0_and_says_so() { let scratch = Scratch::new("undetermined"); @@ -264,7 +350,17 @@ fn a_reproduced_decision_that_is_not_supported_exits_0_and_says_so() { assert_eq!(result["requiredDecision"]["decision"], "SUPPORTED"); assert_eq!(result["requiredDecision"]["met"], false); // The decision that was reproduced can be required too. - let output = verify_with(&trust, &["--require-decision", "UNDETERMINED"], &package); + let pin = format!("sha-256:{}", scenario.contract.digest().to_hex()); + let output = verify_with( + &trust, + &[ + "--require-decision", + "UNDETERMINED", + "--expect-contract", + &pin, + ], + &package, + ); assert_eq!(output.status.code(), Some(0)); assert!(stdout(&output).ends_with("required decision UNDETERMINED met\n")); // Without a registry nothing is reproduced: incomplete, never 0 or 4, @@ -810,6 +906,7 @@ fn the_checked_in_examples_exit_as_their_readme_says() { ("refused-decision-mismatch.json", 1, 1), ("refused-component-digest.json", 1, 1), ]; + let pin = "sha-256:6a2c3b0bf5814aacd8c699f430db0a11b50de812d3dd86f30823aa98df46b550"; for (file, plain, required) in cases { let package = dir.join(file); assert_eq!( @@ -820,7 +917,13 @@ fn the_checked_in_examples_exit_as_their_readme_says() { assert_eq!( verify_with( &trust, - &["--quiet", "--require-decision", "SUPPORTED"], + &[ + "--quiet", + "--require-decision", + "SUPPORTED", + "--expect-contract", + pin + ], &package ) .status @@ -828,6 +931,18 @@ fn the_checked_in_examples_exit_as_their_readme_says() { Some(required), "{file} with a required decision" ); + // Unpinned, no decision meets a requirement; a refusal stays one. + assert_eq!( + verify_with( + &trust, + &["--quiet", "--require-decision", "SUPPORTED"], + &package + ) + .status + .code(), + Some(if plain == 1 { 1 } else { 4 }), + "{file} with a required decision, unpinned" + ); // Without a registry: integrity only, or the integrity refusal. let expected = if file == "refused-component-digest.json" { 1 @@ -858,6 +973,10 @@ fn the_readme_shows_what_verify_prints() { let output = verified() .current_dir(&root) .args(["verify", "examples/invoice-posting/supported.json"]) + .args([ + "--expect-contract", + "sha-256:6a2c3b0bf5814aacd8c699f430db0a11b50de812d3dd86f30823aa98df46b550", + ]) .args(["--trust", "examples/invoice-posting/trust.json"]) .output() .expect("run verified"); diff --git a/crates/verified-verifier/src/report.rs b/crates/verified-verifier/src/report.rs index 0bda6c0..5c19397 100644 --- a/crates/verified-verifier/src/report.rs +++ b/crates/verified-verifier/src/report.rs @@ -222,4 +222,16 @@ impl Report { IssuerSignature::Established { .. } | IssuerSignature::Absent ) } + + /// `wvp-0.2`: whether the contract was pinned, that is, whether the + /// relying party gave the contract it agreed by digest and the package + /// met it. Without a pin the decision is about the contract the package + /// carries, which its producer chose: a contract that requires nothing + /// is reproduced as `SUPPORTED` like any other. A `wvp-0.1` contract + /// names an evaluator the verifier implements, so there is nothing to + /// pin, and this is `true`. + #[must_use] + pub const fn contract_pinned(&self) -> bool { + !matches!(self.protocol, ProtocolVersion::V0_2) || self.expectations_met.contract.is_some() + } } diff --git a/crates/verified-verifier/src/report_value.rs b/crates/verified-verifier/src/report_value.rs index cc9f267..aafed7a 100644 --- a/crates/verified-verifier/src/report_value.rs +++ b/crates/verified-verifier/src/report_value.rs @@ -7,6 +7,7 @@ //! nothing here decides anything. use verified_canonical::{Integer, Object, Value}; +use verified_protocol::ProtocolVersion; use crate::json::{digest, object, text}; use crate::report::{ @@ -128,8 +129,8 @@ impl Report { /// The report as the `verify` command's JSON output states it /// (`docs/sdk/json-output.md`): `outcome` (`complete` or `incomplete`), /// the package's identity, `components`, `properties`, - /// `expectationsMet`, and for `wvp-0.2` `contract` and, when computed, - /// `workRecord` and `recordMatched`. + /// `expectationsMet`, and for `wvp-0.2` `contractPinned`, `contract` + /// and, when computed, `workRecord` and `recordMatched`. /// /// Members only the command line adds (`note`, `requiredDecision`) are /// not here. `properties.sourceTruthfulness` always is, and is never @@ -185,6 +186,9 @@ impl Report { ("tenant", optional(met.tenant.as_deref())), ]), ); + if self.protocol == ProtocolVersion::V0_2 { + members.insert("contractPinned", Value::Bool(self.contract_pinned())); + } if let Some(contract) = &self.contract { members.insert( "contract", diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e051f1b..3c762d4 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -126,7 +126,7 @@ first is a statement about the package. | 1 | Refused by a protocol check. `fsck`: damage found. `diff`: the packages differ. `replay`: a package was refused or is damaged. | | 2 | The command could not run: usage, unreadable input, unusable registry, resource limit. | | 3 | Not refused, but not every check ran. | -| 4 | `--require-decision` (`verify`, `replay`): every check ran, but a reproduced decision is not the required one. | +| 4 | `--require-decision` (`verify`, `replay`): every check ran, but a reproduced decision is not the required one, or a `wvp-0.2` contract was not pinned. | Code 3 is where upstream's CLI and this one differ on purpose: upstream exits 0 for integrity-only verification without a registry. Here, "not everything @@ -139,7 +139,10 @@ opt-in answer: with it, exit 0 also means that decision was reproduced, and any other reproduced decision is exit 4. The default is unchanged so that the tool keeps the protocol's meaning of success. Only a decision this run reproduced can meet the requirement; without a registry the exit code stays -3. +3. And for `wvp-0.2` only one reproduced under a contract the caller pinned +(`--expect-contract`): the contract travels in the package, chosen by its +producer, and one that requires nothing reproduces as `SUPPORTED`. +`--any-contract` is the explicit way to accept it unpinned. ## Not built yet diff --git a/docs/sdk/json-output.md b/docs/sdk/json-output.md index eca99d4..ddf84c5 100644 --- a/docs/sdk/json-output.md +++ b/docs/sdk/json-output.md @@ -62,12 +62,14 @@ mapping. | `properties.sourceTruthfulness` | Always `{ "status": "not_established", … }` | | `expectationsMet` | The expectations that were given, each `null` if not given | | `workRecord`, `recordMatched` | `wvp-0.2` only: the record computed from the package, and whether a supplied one was compared (it matched, or the outcome would be `refused`) | -| `requiredDecision` | Only with `--require-decision`: `decision` and `met` | +| `contractPinned` | `wvp-0.2` only: whether `--expect-contract` pinned the contract. When `false`, the decision is about the contract the package carries, which its producer chose | +| `requiredDecision` | Only with `--require-decision`: `decision`, `met` and `anyContract`. For `wvp-0.2`, `met` needs a pinned contract unless `anyContract` | | `note` | Only when `incomplete`: why | -To act on a package: require `outcome == "complete"` **and** -`properties.decisionReproduced.decision` equal to the decision you need, or -pass `--require-decision` and require exit code 0. +To act on a package: require `outcome == "complete"`, +`properties.decisionReproduced.decision` equal to the decision you need **and** +`contractPinned` (for `wvp-0.2`), or pass `--expect-contract` and +`--require-decision` and require exit code 0. ## Workspace commands diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 695c556..93fab4b 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -65,6 +65,12 @@ relying party's own trust registry. decision can be `CONTRADICTED` or `UNDETERMINED`. A script that gates on the exit code must pass `--require-decision SUPPORTED`, or read the decision from `--json`. +- **A contract chosen by the package's producer.** The contract travels in + the package. One that requires nothing is reproduced as `SUPPORTED`, with + no evidence at all. Only `--expect-contract`, with the digest the relying + party agreed, ties the decision to that agreement. The report says + `contract pinned NO` without it, and `--require-decision` is not met + without it unless `--any-contract` is given. - **Chains of statements** are not verified when a package is: its statements are checked individually. The library verifies a chain of statements held outside a package (`verified_verifier::evidence`, diff --git a/examples/invoice-posting/README.md b/examples/invoice-posting/README.md index a6432ca..b1984a7 100644 --- a/examples/invoice-posting/README.md +++ b/examples/invoice-posting/README.md @@ -18,6 +18,10 @@ agreeing with them shows nothing about conformance. | `refused-decision-mismatch.json` | Refused, `DECISION_MISMATCH`: the package records `SUPPORTED`, which its evidence does not yield. Its issuer signed that decision anyway | 1 | | `refused-component-digest.json` | Refused, `COMPONENT_DIGEST`: a record was changed after the package was assembled | 1 | +With `--require-decision SUPPORTED`, the two `supported` files exit 0 only +when the contract is pinned too (`--expect-contract`, digest below); without +the pin they exit 4. + Without `--trust`, every file that is not refused for its integrity exits 3: nothing was checked beyond integrity, whatever decision the package carries. @@ -25,12 +29,13 @@ nothing was checked beyond integrity, whatever decision the package carries. verified verify examples/invoice-posting/supported.json \ --trust examples/invoice-posting/trust.json verified verify examples/invoice-posting/contradicted.json \ - --trust examples/invoice-posting/trust.json --require-decision SUPPORTED + --trust examples/invoice-posting/trust.json --require-decision SUPPORTED \ + --expect-contract sha-256:6a2c3b0bf5814aacd8c699f430db0a11b50de812d3dd86f30823aa98df46b550 ``` ## What is in them -One contract, `posting-test 1.0.0`, with one requirement: a posting record for +One contract, `posting-test 1.0.0` (`sha-256:6a2c3b0bf5814aacd8c699f430db0a11b50de812d3dd86f30823aa98df46b550`), with one requirement: a posting record for the claimed invoice exists, from the source `erp`. The claim is about invoice `INV-1`. The evidence key (`test-erp-gateway`) may speak for `erp`; the issuer key (`test-decision-issuer`) may sign decisions. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index e96e8ae..58a97cb 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1529,7 +1529,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "verified-canonical" -version = "0.1.5" +version = "0.2.0" dependencies = [ "sha2", "thiserror", @@ -1537,14 +1537,14 @@ dependencies = [ [[package]] name = "verified-contract" -version = "0.1.5" +version = "0.2.0" dependencies = [ "verified-canonical", ] [[package]] name = "verified-derivation" -version = "0.1.5" +version = "0.2.0" dependencies = [ "cedar-policy", "serde_json", @@ -1571,7 +1571,7 @@ dependencies = [ [[package]] name = "verified-protocol" -version = "0.1.5" +version = "0.2.0" dependencies = [ "thiserror", "verified-canonical", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "verified-verifier" -version = "0.1.5" +version = "0.2.0" dependencies = [ "base64", "ed25519-dalek", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "verified-work-unit" -version = "0.1.5" +version = "0.2.0" dependencies = [ "hmac", "sha2",