Implements §5 step 1 of the Action Receipt blueprint: the envelope spec plus a
signed conformance vector pack.
## The gap this closes
Three receipt families ship today, each holding one quarter of what an audit
asks for, and none binds to the others:
jcp-receipt authority grant chain, verdict, budget drawn
sandbox-proof environment attestation, isolation tier, substrate
JCR-1 work input hash, measured joules, provenance
(nothing) effect what changed, and whether it can be undone
So no single artifact answers "under what authority did this agent do that,
where did it run, what did it cost, and what did it change?" The encoding is
implemented six independent times across the tree — evidence the *encoding* is
portable, and that the *composition* was never specified. Two families already
gesture at the chain (the proof cell carries a receipt_id, the authority receipt
a payload_hash). The intent was there; the binding was not.
## Envelope, not replacement
AR-1 binds the three by content-id and supplies the fourth. Each family keeps
its own canonical encoding and signer; the envelope reaches into them and never
the reverse, so every existing verifier keeps working unchanged.
No new cryptography: canonical det-CBOR and the tagged COSE_Sign1 envelope are
reused verbatim from jcr1. The only primitive added is a canonical *decoder* —
jcr1 ships an encoder and none was needed downstream until now, and a verifier
needs to read a signed core back. It is the exact inverse of the encoder's
subset (maps, arrays, text, unsigned ints; no floats, bools, nulls, tags),
refuses non-canonical length encodings and trailing bytes, and is pinned to the
encoder by a round-trip property test. Taking a CBOR dependency instead would
have contradicted the crate's own claim.
## Refusal over record-keeping
A format that merely records claims is a log. Three shapes are refused at
verification, re-derived from the wire projection rather than a caller-supplied
struct, so a hand-forged receipt that signs correctly is still rejected:
1. effect `irreversible` REQUIRES a plan receipt — binding the cascade's
existing plan/commit split into the evidence.
2. egress `none` REQUIRES zero bytes — the claim is falsified by its own
byte count.
3. egress `attested_enclave` REQUIRES a peer attestation. An unattested
destination is `third_party` even when the operator believes it
trustworthy. Trust is not an egress class.
Two further invariants are normative: `grounding` is carried verbatim and never
upgraded from software to hardware, and the three energy provenances never
collapse into one number.
## Conformance pack
7 positive and 3 negative vectors under jcp/conformance/v1/, signed with a
published fixture key so third parties can verify. The negatives are the
substance: each carries a *genuine* signature and must still be refused, so an
implementation that checks signatures and skips the structural rules passes the
positives and fails the pack. That is precisely the failure mode the pack
exists to catch. The emitter asserts both properties before writing, so an
unverifiable vector cannot be published.
tests/conformance.rs consumes the published JSON from disk — the same bytes a
third party downloads — and checks that re-derived receipt ids and canonical
cores match byte-for-byte.
AR-1 does not solve effect revocation; nothing does in general. It makes the
distinction machine-readable at the moment of action, which is the precondition
for reasoning about it afterwards.
20 tests in the crate; jcp-rs workspace green at 258.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
(cherry picked from commit d3e1c2adda)
113 lines
4.4 KiB
Rust
113 lines
4.4 KiB
Rust
//! Consume the published AR-1 conformance pack.
|
|
//!
|
|
//! This is the test that makes the pack mean something. It reads
|
|
//! `jcp/conformance/v1/action_receipt.json` from disk — the same bytes a third
|
|
//! party would download — resolves the public key from the meta file, and
|
|
//! checks every vector against the stated expectation.
|
|
//!
|
|
//! Crucially it consumes the *published artifact*, not in-memory values. A pack
|
|
//! that only its own generator can read is not a conformance pack.
|
|
|
|
use ed25519_dalek::VerifyingKey;
|
|
use jcp_action_receipt::verify;
|
|
use serde_json::Value;
|
|
|
|
fn pack_dir() -> std::path::PathBuf {
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../../conformance/v1")
|
|
.canonicalize()
|
|
.expect("conformance dir must exist next to the spec")
|
|
}
|
|
|
|
fn load(name: &str) -> Value {
|
|
let p = pack_dir().join(name);
|
|
let bytes = std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()));
|
|
serde_json::from_slice(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", p.display()))
|
|
}
|
|
|
|
fn key_and_steward() -> (VerifyingKey, String) {
|
|
let meta = load("action_receipt_meta.json");
|
|
let hex_key = meta["pubkey_hex"].as_str().expect("meta.pubkey_hex");
|
|
let raw = hex::decode(hex_key).expect("pubkey hex");
|
|
let arr: [u8; 32] = raw.as_slice().try_into().expect("32-byte pubkey");
|
|
let steward = meta["steward"].as_str().expect("meta.steward").to_string();
|
|
(VerifyingKey::from_bytes(&arr).expect("valid pubkey"), steward)
|
|
}
|
|
|
|
#[test]
|
|
fn every_positive_vector_verifies() {
|
|
let (vk, steward) = key_and_steward();
|
|
let pack = load("action_receipt.json");
|
|
let vectors = pack["vectors"].as_array().expect("vectors array");
|
|
|
|
let mut checked = 0;
|
|
for v in vectors {
|
|
if v["expect"] != "verify" {
|
|
continue;
|
|
}
|
|
let name = v["name"].as_str().unwrap_or("<unnamed>");
|
|
let env = hex::decode(v["envelope_hex"].as_str().expect("envelope_hex"))
|
|
.unwrap_or_else(|e| panic!("{name}: envelope hex: {e}"));
|
|
|
|
let ok = verify(&env, &vk, &steward)
|
|
.unwrap_or_else(|e| panic!("positive vector `{name}` failed to verify: {e}"));
|
|
|
|
// The receipt id in the pack must match what a verifier re-derives from
|
|
// the bytes — otherwise the published id is decorative.
|
|
let want = v["id_hex"].as_str().expect("id_hex");
|
|
assert_eq!(ok.id.to_hex(), want, "{name}: id mismatch");
|
|
|
|
// And the canonical core must match byte-for-byte, which is what makes
|
|
// the content address reproducible on another implementation.
|
|
let want_core = v["core_hex"].as_str().expect("core_hex");
|
|
assert_eq!(hex::encode(&ok.core), want_core, "{name}: core mismatch");
|
|
|
|
checked += 1;
|
|
}
|
|
assert!(checked >= 7, "expected at least 7 positive vectors, saw {checked}");
|
|
}
|
|
|
|
#[test]
|
|
fn every_negative_vector_is_rejected_despite_a_valid_signature() {
|
|
let (vk, steward) = key_and_steward();
|
|
let pack = load("action_receipt.json");
|
|
let vectors = pack["vectors"].as_array().expect("vectors array");
|
|
|
|
let mut checked = 0;
|
|
for v in vectors {
|
|
let expect = v["expect"].as_str().unwrap_or("verify");
|
|
if expect == "verify" {
|
|
continue;
|
|
}
|
|
let name = v["name"].as_str().unwrap_or("<unnamed>");
|
|
let env = hex::decode(v["envelope_hex"].as_str().expect("envelope_hex"))
|
|
.unwrap_or_else(|e| panic!("{name}: envelope hex: {e}"));
|
|
|
|
// The signature is genuine — these vectors exist precisely because a
|
|
// signature-only verifier would accept them.
|
|
let sig_ok = jcp_receipt::jcr1::verify_cose_sign1(&env, &steward, vk.as_bytes());
|
|
assert!(
|
|
sig_ok.is_ok(),
|
|
"{name}: negative vector should carry a genuine signature"
|
|
);
|
|
|
|
// AR-1 must refuse it anyway.
|
|
assert!(
|
|
verify(&env, &vk, &steward).is_err(),
|
|
"negative vector `{name}` was accepted — the rule it pins is not enforced"
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert!(checked >= 3, "expected at least 3 negative vectors, saw {checked}");
|
|
}
|
|
|
|
#[test]
|
|
fn pack_declares_the_profile_this_build_implements() {
|
|
let meta = load("action_receipt_meta.json");
|
|
assert_eq!(
|
|
meta["profile"].as_str().expect("meta.profile"),
|
|
jcp_action_receipt::AR_PROFILE,
|
|
"vector pack was generated for a different profile than this build implements"
|
|
);
|
|
assert_eq!(meta["alg"].as_str().expect("meta.alg"), "EdDSA (COSE -8)");
|
|
}
|