diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20439e1..eb9ebfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,31 @@ jobs: # nor the cross-language vectors the SDK suites reconcile against. - run: cargo test -p contextgraph-types --features attestation + features: + name: contextgraph-types feature matrix + runs-on: ubuntu-latest + # `test` above builds with default features, and every feature this crate + # has is off by default — so until this job existed, the attestation and + # record-hashing code, and the vectors that pin their wire format, compiled + # nowhere in CI. A cryptographic surface no job builds is a surface nothing + # defends. + # + # Each combination is built on its own rather than only `--all-features`: + # the point of the split is that a consumer of one layer does not drag in + # the other's dependencies, and only a build with exactly one feature on can + # catch a `cfg` that silently relies on the other being enabled too. + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy -p contextgraph-types --all-features --all-targets -- -D warnings + - run: cargo test -p contextgraph-types --all-features + - run: cargo test -p contextgraph-types --features attestation + - run: cargo test -p contextgraph-types --features record-hash + - run: cargo test -p contextgraph-types --features record-attestation + msrv: name: msrv (rust-version from Cargo.toml) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ec63c..f25fc23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,49 @@ text lands without a human merge. ## [Unreleased] ### Added +- **Record content addressing and record attestation, implemented (lifecycle + profile `LH1`/`LC3`; + [ADR 0017](./docs/adr/0017-record-hash-and-record-attestation.md)).** The + profile has always defined `record_hash` as `sha256:` over the RFC 8785 + (JCS) canonicalization of a record with its own `record_hash` removed, and + `RecordAttestation` as a detached Ed25519 signature over it. Both were prose + and a struct: the only hashing code in the workspace was a private helper + inside the conformance suite, so the suite proved the fixtures agreed with the + suite. `contextgraph_types::record_attest` makes the rule callable — + `record_hash`, `record_hash_preimage` (the exact canonical bytes, because a + digest cannot say *where* two implementations diverged), `record_hash_of` for + a typed record, and signing and verification for the attestation. + Verification recomputes the record's hash rather than reading the stored + member, so editing a record and then rewriting its `record_hash` to match is + caught as a mismatch instead of passing. +- **`contextgraph-types` gains `record-hash` and `record-attestation` + features.** `record-hash` adds `serde_json` and `serde_json_canonicalizer` + (RFC 8785, delegated rather than hand-rolled — JCS number serialization is + ECMAScript `Number::toString`, and its exponent thresholds are exactly where + reimplementations diverge in silence). `record-attestation` adds Ed25519 on + top. Both off by default, so a frame-only consumer never pays for a JSON + canonicalizer and the crate's zero-dependency promise is untouched. +- **The record attestation signs a domain-separated message.** A frame + commitment is domain-bound by construction; a `record_hash` is a plain + SHA-256 over a JSON document that any number of unrelated systems also + compute. The signed bytes are therefore `"contextgraph/attest/1/record"` + followed by the digest's 32 raw bytes, so a signature from another layer + cannot be presented as a record attestation. Additive to `LC3`, which named + no preimage because nothing had implemented signing. +- **Golden vectors that can be reproduced and refuted (`LF1`).** + `tests/fixtures/record-hash-vectors.json` publishes the exact JCS preimage + text of every record fixture beside its hash; + `tests/fixtures/record-attestation.json` carries a real Ed25519 signature in + place of the 49 bytes of DER-shaped filler it used to carry; and + `tests/fixtures/record-attestation-key.json` publishes the test key that + signs it. `contextgraph-types/tests/record_vectors.rs` carries the same + values inline so they travel inside the published crate. The record hashes + themselves are unchanged — the library reproduces the rule the fixtures + already followed. +- **RFC 8785 conformance is checked against the RFC's own vectors.** §3.2.4's + hexadecimal byte listing, §3.2.3's property-sorting data, and Appendix B's + table of IEEE 754 bit patterns and their required ECMAScript text, including + the `-0` case and the `1e+21` / `0.000001` exponent thresholds. - **Provenance attestation (`SPEC.md` §6.5, F6–F9; [ADR 0010](./docs/adr/0010-provenance-attestation.md)).** A digest is tamper-evident only to someone who already trusts whoever recorded it; the diff --git a/Cargo.lock b/Cargo.lock index c465aea..c098116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,6 +322,7 @@ dependencies = [ "ed25519-dalek", "serde", "serde_json", + "serde_json_canonicalizer", "sha2", ] diff --git a/Cargo.toml b/Cargo.toml index a0c7532..6722edb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,10 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus futures-util = "0.3" sha2 = "0.10" ed25519-dalek = "2" -clap = { version = "4", features = ["derive", "env"] } +# RFC 8785 (JCS) canonicalization, used by the record layer's `record_hash` and +# by the conformance suite's golden vectors. One pin here so the library and the +# suite that checks it can never canonicalize with two different versions. +serde_json_canonicalizer = "0.3.2" +clap ={ version = "4", features = ["derive", "env"] } colored = "3" libc = "0.2" diff --git a/contextgraph-conformance/Cargo.toml b/contextgraph-conformance/Cargo.toml index 14483d8..c107492 100644 --- a/contextgraph-conformance/Cargo.toml +++ b/contextgraph-conformance/Cargo.toml @@ -48,4 +48,11 @@ name = "contextgraph-example-docs" path = "src/bin/contextgraph-example-docs.rs" [dev-dependencies] -serde_json_canonicalizer = "0.3.2" +serde_json_canonicalizer.workspace = true +# The lifecycle-profile suite checks the *library's* record hashing and record +# attestation rather than a private copy of the rule, so a fixture and +# `contextgraph_types::record_attest` can never describe two different hashes +# (profile LF3). Repeating the dependency here adds only the feature. +contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0", features = [ + "record-attestation", +] } diff --git a/contextgraph-conformance/tests/lifecycle_profile_examples.rs b/contextgraph-conformance/tests/lifecycle_profile_examples.rs index c9ac4d1..85fad91 100644 --- a/contextgraph-conformance/tests/lifecycle_profile_examples.rs +++ b/contextgraph-conformance/tests/lifecycle_profile_examples.rs @@ -19,20 +19,40 @@ //! with its own `record_hash` member removed, and must match the stored //! value. This is what makes the fixtures a golden vector for the hashing //! rule rather than a hash a fixture merely asserts about itself. +//! 4. **Canonical bytes.** `record-hash-vectors.json` pins the exact JCS +//! preimage of every record fixture, recomputed here. A hash that +//! disagrees between two implementations says nothing about *where* they +//! diverged; a byte diff of the preimage says it immediately, which is why +//! the vectors carry the text and not only the digest (profile LF1). +//! 5. **A verifiable attestation.** `record-attestation.json` carries a real +//! detached Ed25519 signature, and this suite verifies it against the +//! public key published beside it in `record-attestation-key.json`. It +//! used to carry a placeholder no code could check — a shape example +//! standing in for a vector. +//! +//! Every recomputation here calls the **library** — +//! [`contextgraph_types::record_attest`] — rather than a copy of the rule kept +//! in the test. A second implementation living in the suite that checks the +//! first is how a fixture set ends up agreeing with nothing that ships. //! //! `tests/fixtures/` is the **canonical home** for lifecycle-profile example //! records (resolving the draft's open "which repo owns the vectors" question). //! -//! Regenerating the hashes: `REGENERATE_LIFECYCLE_HASHES=1 cargo test -p +//! Regenerating: `REGENERATE_LIFECYCLE_HASHES=1 cargo test -p //! contextgraph-conformance --test lifecycle_profile_examples` rewrites each -//! fixture's `record_hash` (and the attestation's `signed_record_hash`) to the -//! recomputed value, preserving the file's field order. +//! fixture's `record_hash`, the attestation's `signed_record_hash` and +//! signature, and the whole vector file, then re-run without the env var to +//! verify. use std::collections::BTreeSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use contextgraph_types::{ContextRecord, LIFECYCLE_SCHEMA_VERSION, RecordAttestation}; -use sha2::{Digest, Sha256}; +use contextgraph_types::record_attest::{ + record_hash, record_hash_preimage, sign_record_attestation, verify_signed_record_hash, +}; +use contextgraph_types::{ + AttestationVerdict, ContextRecord, LIFECYCLE_SCHEMA_VERSION, RecordAttestation, +}; /// The 12 portable record kinds the profile defines (reconciliation row D1). const EXPECTED_KINDS: [&str; 12] = [ @@ -50,7 +70,23 @@ const EXPECTED_KINDS: [&str; 12] = [ "context_use_feedback", ]; +/// The detached `RecordAttestation` example (profile LC3). const ATTESTATION_FIXTURE: &str = "record-attestation.json"; +/// The published test key the attestation example is signed under. +const ATTESTATION_KEY_FIXTURE: &str = "record-attestation-key.json"; +/// The canonical JCS preimage and hash of every record fixture (profile LF1). +const HASH_VECTORS_FIXTURE: &str = "record-hash-vectors.json"; + +/// The fixtures in `tests/fixtures/` that are **not** lifecycle records. +/// +/// An explicit list rather than a filename convention: every other file in the +/// directory is named for its `record_kind`, and a new non-record fixture must +/// be a deliberate entry here rather than something a glob quietly swallows. +const NON_RECORD_FIXTURES: [&str; 3] = [ + ATTESTATION_FIXTURE, + ATTESTATION_KEY_FIXTURE, + HASH_VECTORS_FIXTURE, +]; fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -60,7 +96,7 @@ fn fixtures_dir() -> PathBuf { .join("fixtures") } -/// Every `*.json` fixture except the detached attestation — i.e. the record +/// Every `*.json` fixture except the non-record ones — i.e. the record /// fixtures, one per `record_kind`. fn record_fixture_paths() -> Vec { let mut paths: Vec = std::fs::read_dir(fixtures_dir()) @@ -68,8 +104,11 @@ fn record_fixture_paths() -> Vec { .map(|entry| entry.expect("dir entry").path()) .filter(|path| path.extension().is_some_and(|ext| ext == "json")) .filter(|path| { - path.file_name() - .is_some_and(|name| name != ATTESTATION_FIXTURE) + path.file_name().is_some_and(|name| { + !NON_RECORD_FIXTURES + .iter() + .any(|excluded| name == std::ffi::OsStr::new(excluded)) + }) }) .collect(); paths.sort(); @@ -81,22 +120,18 @@ fn record_fixture_paths() -> Vec { paths } -/// The content-addressed `record_hash`: `sha256:` over the RFC 8785 (JCS) -/// canonicalization of the record with `record_hash` (or, for the detached -/// attestation, `signed_record_hash`) omitted from the preimage. -fn compute_hash(value: &serde_json::Value, hash_member: &str) -> String { - let mut preimage = value.clone(); - preimage - .as_object_mut() - .expect("a record is a JSON object") - .remove(hash_member); - let canonical = - serde_json_canonicalizer::to_vec(&preimage).expect("record canonicalizes under JCS"); - let hex: String = Sha256::digest(&canonical) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - format!("sha256:{hex}") +/// The content-addressed `record_hash` — the library's rule, not a copy of it +/// (profile LH1, LF3). +fn compute_hash(value: &serde_json::Value) -> String { + record_hash(value).expect("record canonicalizes under JCS") +} + +/// Read and parse a fixture, naming it in the panic so a failure says which. +fn read_json(path: &Path) -> serde_json::Value { + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("could not read {}: {e}", path.display())); + serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", path.display())) } fn regenerating() -> bool { @@ -194,7 +229,7 @@ fn record_hash_is_the_jcs_sha256_of_the_hashless_record() { .as_str() .expect("record_hash present") .to_string(); - let expected = compute_hash(&value, "record_hash"); + let expected = compute_hash(&value); if regenerate { if stored != expected { @@ -214,6 +249,79 @@ fn record_hash_is_the_jcs_sha256_of_the_hashless_record() { } } +/// The canonical bytes every record fixture hashes over (profile LF1). +/// +/// The digest alone is a poor interop artifact: when a third-party CEP computes +/// a different one, the digest cannot say whether the difference is in a number, +/// a member order, or an escape. The preimage text can, so it is what the +/// vectors carry. +#[test] +fn the_hash_vectors_pin_the_canonical_preimage_of_every_record_fixture() { + let vectors_path = fixtures_dir().join(HASH_VECTORS_FIXTURE); + let mut vectors = Vec::new(); + for path in record_fixture_paths() { + let value = read_json(&path); + let preimage = record_hash_preimage(&value).expect("record canonicalizes under JCS"); + let jcs = String::from_utf8(preimage).expect("JCS output is UTF-8 by definition"); + vectors.push(serde_json::json!({ + "record_file": path.file_name().unwrap().to_string_lossy(), + "jcs_utf8": jcs, + "record_hash": compute_hash(&value), + })); + } + + let rebuilt = serde_json::json!({ + "note": "Golden RFC 8785 (JCS) preimages and record_hash values for the \ + lifecycle-profile record fixtures beside this file. Recomputed by \ + contextgraph-conformance's lifecycle_profile_examples suite; \ + regenerate with REGENERATE_LIFECYCLE_HASHES=1.", + "rule": "record_hash = \"sha256:\" + hex(sha256(JCS(record with its top-level \ + record_hash member removed)))", + "vectors": vectors, + }); + + if regenerating() { + let mut text = serde_json::to_string_pretty(&rebuilt).expect("vectors serialize"); + text.push('\n'); + std::fs::write(&vectors_path, text).expect("write vectors"); + eprintln!("regenerated {}", vectors_path.display()); + return; + } + + let stored = read_json(&vectors_path); + assert_eq!( + stored["vectors"], + rebuilt["vectors"], + "{} no longer matches the canonicalization of the fixtures beside it \ + (run with REGENERATE_LIFECYCLE_HASHES=1 to refresh)", + vectors_path.display() + ); +} + +#[test] +fn every_hash_vector_names_a_fixture_that_exists() { + let stored = read_json(&fixtures_dir().join(HASH_VECTORS_FIXTURE)); + let named: BTreeSet = stored["vectors"] + .as_array() + .expect("vectors is an array") + .iter() + .map(|vector| { + vector["record_file"] + .as_str() + .expect("record_file is a string") + .to_string() + }) + .collect(); + let present: BTreeSet = record_fixture_paths() + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + assert_eq!( + named, present, + "the vector file and the fixture directory must cover the same records" + ); +} + #[test] fn the_detached_attestation_round_trips_and_signs_the_observation_record() { let attestation_path = fixtures_dir().join(ATTESTATION_FIXTURE); @@ -225,17 +333,16 @@ fn the_detached_attestation_round_trips_and_signs_the_observation_record() { let reencoded = serde_json::to_value(&attestation).expect("re-serializes"); let back: RecordAttestation = serde_json::from_value(reencoded).expect("re-parses"); assert_eq!(back, attestation); + assert!(attestation.uses_known_algorithm()); + assert!(attestation.has_well_formed_signed_record_hash()); + assert!(attestation.has_well_formed_issued_at()); // It signs the observation record's hash — a coherent, cross-linked fixture // set. The attestation is detached: it is validated on its own, never as a // member of a ContextRecord. // Compute the observation hash directly (not by reading its stored field) so // this test never races the fixture that rewrites observation.json. - let observation: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(fixtures_dir().join("observation.json")).expect("readable"), - ) - .expect("valid JSON"); - let observation_hash = compute_hash(&observation, "record_hash"); + let observation_hash = compute_hash(&read_json(&fixtures_dir().join("observation.json"))); if regenerating() { if attestation.signed_record_hash != observation_hash { @@ -253,3 +360,124 @@ fn the_detached_attestation_round_trips_and_signs_the_observation_record() { ); } } + +/// The published signing key for the attestation example. +/// +/// A test key, and labelled as one everywhere it appears: it is committed to a +/// public repository, so anything it signs is forgeable by anyone. Publishing it +/// is the whole point — a vector nobody can reproduce is a shape example, which +/// is exactly what this fixture used to be. +fn attestation_key() -> (serde_json::Value, [u8; 32]) { + let key = read_json(&fixtures_dir().join(ATTESTATION_KEY_FIXTURE)); + let seed = hex32(&key, "signing_key_seed"); + (key, seed) +} + +/// Decode a 32-byte lowercase-hex member of the key fixture. +fn hex32(key: &serde_json::Value, member: &str) -> [u8; 32] { + let text = key[member] + .as_str() + .unwrap_or_else(|| panic!("{member} is a string")); + let (pairs, rest) = text.as_bytes().as_chunks::<2>(); + assert!( + rest.is_empty() && pairs.len() == 32, + "{member} must be 32 bytes of lowercase hex, found {} characters", + text.len() + ); + let mut out = [0u8; 32]; + for (slot, pair) in out.iter_mut().zip(pairs) { + let hi = (pair[0] as char).to_digit(16).expect("hex"); + let lo = (pair[1] as char).to_digit(16).expect("hex"); + *slot = (hi * 16 + lo) as u8; + } + out +} + +/// The attestation example is a **verifiable vector**, not a shape example. +/// +/// Before this suite could check it, the fixture carried 49 bytes of +/// DER-shaped filler where an Ed25519 signature belongs — a value no +/// implementation could have reproduced or refuted, sitting in the directory the +/// profile calls the canonical home for its vectors. +#[test] +fn the_attestation_example_verifies_under_its_published_key() { + let attestation_path = fixtures_dir().join(ATTESTATION_FIXTURE); + let attestation: RecordAttestation = serde_json::from_value(read_json(&attestation_path)) + .expect("attestation deserializes through RecordAttestation"); + let observation_hash = compute_hash(&read_json(&fixtures_dir().join("observation.json"))); + let (key, seed) = attestation_key(); + + if regenerating() { + let regenerated = sign_record_attestation( + &observation_hash, + &seed, + attestation.key_id.clone(), + attestation.attester_id.clone(), + attestation.issued_at.clone(), + ) + .expect("the observation hash is a well-formed digest"); + let mut text = serde_json::to_string_pretty(®enerated).expect("serializes"); + text.push('\n'); + std::fs::write(&attestation_path, text).expect("rewrite attestation"); + eprintln!("regenerated signature for {}", attestation_path.display()); + return; + } + + let public_key = hex32(&key, "public_key"); + + assert_eq!( + verify_signed_record_hash(&observation_hash, &attestation, &public_key), + AttestationVerdict::Valid, + "the published attestation must verify against the published key \ + (run with REGENERATE_LIFECYCLE_HASHES=1 to re-sign)" + ); + + // And it must fail for the right reason once the record moves under it. + let mut edited = read_json(&fixtures_dir().join("observation.json")); + edited["statement"] = serde_json::json!("an edit made after the record was signed"); + let verdict = verify_signed_record_hash(&compute_hash(&edited), &attestation, &public_key); + assert!( + matches!(verdict, AttestationVerdict::CommitmentMismatch { .. }), + "editing the signed record must be caught as a mismatch, got {verdict:?}" + ); +} + +/// The key fixture must describe the key it publishes, or it is decoration. +#[test] +fn the_published_key_fixture_agrees_with_its_own_seed() { + let (key, seed) = attestation_key(); + let derived = contextgraph_types::attest::public_key_for(&seed); + let derived_hex: String = derived.iter().map(|byte| format!("{byte:02x}")).collect(); + + if regenerating() { + let observation_hash = compute_hash(&read_json(&fixtures_dir().join("observation.json"))); + let message = contextgraph_types::record_attestation_message(&observation_hash) + .expect("the observation hash is a well-formed digest"); + let message_hex: String = message.iter().map(|byte| format!("{byte:02x}")).collect(); + let mut rebuilt = key.clone(); + rebuilt["public_key"] = serde_json::json!(derived_hex); + rebuilt["signed_message_hex"] = serde_json::json!(message_hex); + let mut text = serde_json::to_string_pretty(&rebuilt).expect("serializes"); + text.push('\n'); + std::fs::write(fixtures_dir().join(ATTESTATION_KEY_FIXTURE), text).expect("rewrite key"); + eprintln!("regenerated {ATTESTATION_KEY_FIXTURE}"); + return; + } + + assert_eq!( + key["public_key"].as_str(), + Some(derived_hex.as_str()), + "the published public key is not the one this seed produces" + ); + + let observation_hash = compute_hash(&read_json(&fixtures_dir().join("observation.json"))); + let message = contextgraph_types::record_attestation_message(&observation_hash) + .expect("the observation hash is a well-formed digest"); + let message_hex: String = message.iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!( + key["signed_message_hex"].as_str(), + Some(message_hex.as_str()), + "the published signed message is not the domain tag followed by the \ + observation record's hash" + ); +} diff --git a/contextgraph-types/Cargo.toml b/contextgraph-types/Cargo.toml index 27271b0..b120501 100644 --- a/contextgraph-types/Cargo.toml +++ b/contextgraph-types/Cargo.toml @@ -17,15 +17,35 @@ publish = true [features] # Off by default so the crate's "zero dependencies beyond serde" promise holds -# for the pure wire consumer. `ProvenanceAttestation` itself always compiles — -# only the hashing and signature checking need real cryptography. +# for the pure wire consumer. `ProvenanceAttestation` and `RecordAttestation` +# themselves always compile — only the hashing and signature checking need real +# cryptography. default = [] +# Frame-layer provenance attestation (`SPEC.md` §6.5, ADR 0010). Its preimage is +# a length-prefixed encoding of typed fields, so it needs no JSON canonicalizer. attestation = ["dep:sha2", "dep:ed25519-dalek"] +# Record-layer content addressing (lifecycle profile LH1, ADR 0017): +# `record_hash` over the RFC 8785 (JCS) canonicalization of a record. A record +# is an open-ended JSON document, so this half genuinely needs a conforming +# canonicalizer and a JSON value model — which is why it is a feature of its own +# rather than a widening of `attestation`, and why a frame-only consumer never +# pays for it. +record-hash = ["dep:sha2", "dep:serde_json", "dep:serde_json_canonicalizer"] +# Record-layer attestation (profile LC3, ADR 0017): a detached Ed25519 signature +# over a `record_hash`. Implies `attestation` because the two layers share one +# verdict vocabulary and one hex/digest parser. +record-attestation = ["record-hash", "attestation"] [dependencies] serde.workspace = true sha2 = { workspace = true, optional = true } ed25519-dalek = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +# RFC 8785 (JCS), delegated rather than hand-rolled. JCS number serialization is +# ECMAScript `Number::toString`, whose exponent thresholds and shortest-round-trip +# digit selection are precisely where reimplementations diverge silently; this +# crate routes them through `ryu-js`, the Boa engine's ECMAScript formatter. +serde_json_canonicalizer = { workspace = true, optional = true } [dev-dependencies] serde_json.workspace = true diff --git a/contextgraph-types/README.md b/contextgraph-types/README.md index 50f0f93..f38e205 100644 --- a/contextgraph-types/README.md +++ b/contextgraph-types/README.md @@ -25,6 +25,24 @@ that means for this crate's semver). a provider can do, and what it does with your data (`reads` / `writes` / `egress`). +## Optional features + +All off by default, so the zero-dependency promise above holds for anyone who +opts into nothing. Every wire *type* compiles regardless — a host must be able +to parse, relay, and store an attestation it was not built to check — and these +features add only the hashing and the signature checking. + +| Feature | Adds | For | +|---|---|---| +| `attestation` | `sha2`, `ed25519-dalek` | Frame provenance attestation: the provenance hash chain, frame commitments, RFC 6962 Merkle roots and inclusion proofs (`SPEC.md` §6.5). | +| `record-hash` | `sha2`, `serde_json`, `serde_json_canonicalizer` | The lifecycle profile's `record_hash`: RFC 8785 (JCS) content addressing for a `ContextRecord`. | +| `record-attestation` | `record-hash` + `attestation` | Detached Ed25519 signatures over a `record_hash`. | + +The split is not cosmetic. A frame's attestation preimage is a length-prefixed +encoding of typed fields and needs no JSON canonicalizer; a record is an +open-ended JSON document and genuinely does. Neither layer's consumer pays for +the other's dependencies. + ## Example ```rust diff --git a/contextgraph-types/src/attest.rs b/contextgraph-types/src/attest.rs index 9785fda..9f19736 100644 --- a/contextgraph-types/src/attest.rs +++ b/contextgraph-types/src/attest.rs @@ -326,8 +326,11 @@ fn from_hex(s: &str) -> Option> { return None; } let mut out = Vec::with_capacity(s.len() / 2); - let bytes = s.as_bytes(); - for pair in bytes.chunks_exact(2) { + // `as_chunks::<2>()` over `chunks_exact(2)`: the length check above already + // rules out a remainder, and the fixed-size chunk lets the compiler see both + // indexes are in bounds. + let (pairs, _) = s.as_bytes().as_chunks::<2>(); + for pair in pairs { let hi = (pair[0] as char).to_digit(16)?; let lo = (pair[1] as char).to_digit(16)?; out.push((hi * 16 + lo) as u8); diff --git a/contextgraph-types/src/lib.rs b/contextgraph-types/src/lib.rs index 88f06f0..bc92bb8 100644 --- a/contextgraph-types/src/lib.rs +++ b/contextgraph-types/src/lib.rs @@ -19,6 +19,7 @@ pub mod frame; pub mod identity; pub mod query; pub mod record; +pub mod record_attest; pub mod scope; pub mod token; pub mod usage; @@ -54,6 +55,17 @@ pub use record::{ RecordLink, RecordProvenance, RecordScope, RecordStatus, RequirementResult, SharingScope, ValidationOutcome, }; +pub use record_attest::{ + RECORD_ATTESTATION_DOMAIN, RECORD_HASH_MEMBER, RecordHashError, record_attestation_message, +}; +#[cfg(feature = "record-hash")] +pub use record_attest::{ + record_hash, record_hash_is_current, record_hash_of, record_hash_preimage, +}; +#[cfg(feature = "record-attestation")] +pub use record_attest::{ + sign_record, sign_record_attestation, verify_record_attestation, verify_signed_record_hash, +}; pub use scope::EgressScope; pub use token::{ BYTES_PER_BUDGET_TOKEN, SUGGESTED_HOST_SAFETY_FACTOR, budget_from_model_tokens, budget_tokens, diff --git a/contextgraph-types/src/record.rs b/contextgraph-types/src/record.rs index 59791a1..00c2e15 100644 --- a/contextgraph-types/src/record.rs +++ b/contextgraph-types/src/record.rs @@ -176,26 +176,87 @@ pub struct RecordLink { pub target_record_id: String, } -/// A detached attestation over a record's `record_hash` (reconciliation row C5, -/// shared with issue #12). It is **never** part of the record or its hash -/// preimage — it travels as ledger metadata beside the record, so re-signing or -/// key rotation never perturbs the content-addressed identity. +/// A detached attestation over a record's `record_hash` (profile LC3, +/// reconciliation row C5, shared with issue #12). It is **never** part of the +/// record or its hash preimage — it travels as ledger metadata beside the +/// record, so re-signing or key rotation never perturbs the content-addressed +/// identity. +/// +/// [`record_attest`](crate::record_attest) computes and checks it: the signed +/// message is +/// [`RECORD_ATTESTATION_DOMAIN`](crate::record_attest::RECORD_ATTESTATION_DOMAIN) +/// followed by the 32 raw bytes of `signed_record_hash`. +/// +/// A **distinct type** from [`ProvenanceAttestation`](crate::ProvenanceAttestation) +/// even though five of six fields match, for the reason ADR 0010 gives: the two +/// sign different preimages under different domain tags, and a shared type would +/// invite presenting one as the other. The cryptography already refuses that; +/// the type system makes it unsayable. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordAttestation { /// The `sha256:` `record_hash` this attestation signs. pub signed_record_hash: String, /// The signing key's id; validity windows govern rotation. pub key_id: String, - /// The signature algorithm, e.g. `ed25519`. + /// The signature algorithm, e.g. [`ALGORITHM_ED25519`](crate::ALGORITHM_ED25519). + /// + /// A string rather than an enum so a post-quantum successor is additive: a + /// verifier that does not recognize the value declines with + /// [`AttestationVerdict::UnknownAlgorithm`](crate::AttestationVerdict::UnknownAlgorithm) + /// instead of failing, which is the safe direction. pub algorithm: String, - /// The attesting authority. + /// The attesting authority — who is accountable for the claim, as distinct + /// from which key mechanically produced it. pub attester_id: String, - /// The detached signature (base64/hex per algorithm). + /// The detached signature, lowercase hex for `ed25519` — the encoding every + /// other digest and signature on this wire already uses. pub signature: String, /// When the attestation was issued (protocol timestamp). pub issued_at: String, } +impl RecordAttestation { + /// Build an attestation from its parts. + pub fn new( + signed_record_hash: impl Into, + key_id: impl Into, + algorithm: impl Into, + attester_id: impl Into, + signature: impl Into, + issued_at: impl Into, + ) -> Self { + Self { + signed_record_hash: signed_record_hash.into(), + key_id: key_id.into(), + algorithm: algorithm.into(), + attester_id: attester_id.into(), + signature: signature.into(), + issued_at: issued_at.into(), + } + } + + /// Whether this attestation names a scheme this revision defines. + /// + /// Advisory: a verifier reports `UnknownAlgorithm` rather than treating an + /// unrecognized scheme as a failure to validate. "I cannot check this" and + /// "this is forged" are different findings to an auditor. + pub fn uses_known_algorithm(&self) -> bool { + self.algorithm == crate::attest::ALGORITHM_ED25519 + } + + /// Whether `signed_record_hash` satisfies the protocol digest grammar + /// (`SPEC.md` §6.2) — the same grammar `ContextRecord::record_hash` is held + /// to, checked here because an attestation is validated on its own. + pub fn has_well_formed_signed_record_hash(&self) -> bool { + is_well_formed_digest(&self.signed_record_hash) + } + + /// Whether `issued_at` is a well-formed protocol timestamp (`SPEC.md` §F4). + pub fn has_well_formed_issued_at(&self) -> bool { + is_protocol_timestamp(&self.issued_at) + } +} + /// A knowledge record's sub-kind (reconciliation rows B2/D1). `memory` and /// `fact` are **not** directive kinds; `fact` is a knowledge kind here. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/contextgraph-types/src/record_attest.rs b/contextgraph-types/src/record_attest.rs new file mode 100644 index 0000000..6645ea0 --- /dev/null +++ b/contextgraph-types/src/record_attest.rs @@ -0,0 +1,812 @@ +//! Record content addressing and record attestation — the lifecycle profile's +//! `record_hash` and [`RecordAttestation`], implemented +//! ([`docs/profiles/context-exchange-provider.md`][profile] §3 and §7, +//! [ADR 0017](../../docs/adr/0017-record-hash-and-record-attestation.md)). +//! +//! [profile]: https://github.com/macanderson/context-graph-protocol/blob/main/docs/profiles/context-exchange-provider.md +//! +//! Where [`attest`](crate::attest) covers the *frame* layer a `context/query` +//! returns, this covers the *record* layer a Context Exchange Provider appends, +//! gets, and resolves. Two constructions: +//! +//! 1. **`record_hash`** ([`record_hash`]) — `sha256:` over the RFC 8785 +//! (JCS) canonicalization of a record **with its own `record_hash` member +//! removed from the preimage** (profile LH1). This is the record's identity: +//! what idempotent replay keys on, what a lineage cites, and what an +//! attestation signs. +//! 2. **[`RecordAttestation`]** ([`verify_record_attestation`]) — a detached +//! Ed25519 signature over that hash under a record-layer domain tag. +//! +//! # Why JCS here and not at the frame layer +//! +//! [ADR 0010](../../docs/adr/0010-provenance-attestation.md) §3 rejects JCS for +//! a provenance link and it is right to: a link is six optional strings, and +//! requiring every implementer to obtain a conforming JSON canonicalizer to hash +//! six strings is a tax with no return. A record is the opposite shape — an +//! open-ended JSON document with an extensible body, a `BTreeMap` of extensions, +//! and floating-point confidences. There is no typed encoding to write down that +//! stays correct as the profile grows a member, so the canonicalization has to be +//! generic, and RFC 8785 is the one generic rule with cross-language +//! implementations to reconcile against. +//! +//! The cost is real and this module does not hide it: JCS number serialization +//! is ECMAScript `Number::toString`, whose exponent thresholds and +//! shortest-round-trip digits are where independent implementations quietly +//! disagree. So this crate delegates rather than hand-rolls, and +//! `contextgraph-conformance` pins the RFC's own published vectors +//! (`tests/fixtures/record-hash-vectors.json`) as bytes a third party can diff +//! against when their hash comes out different. +//! +//! # Why the omitted member is *removed*, not blanked +//! +//! Profile LH1 says the member is removed from the preimage, and the observable +//! consequence is worth stating: a record hashes identically whether it carries +//! no `record_hash` at all, the right one, or a wrong one. A producer therefore +//! computes the hash of the record it is about to publish without first having +//! to invent a placeholder, and a verifier never has to know which placeholder +//! the producer chose. A blanking rule would have made the placeholder itself +//! part of the interop contract — one more thing to get wrong in another +//! language for no gain. +//! +//! Only the **top-level** member is removed. A `record_hash` nested inside +//! `extensions` or a body member is ordinary content and stays in the preimage. +//! +//! # Why the signature is domain-separated +//! +//! A frame commitment is already domain-bound by construction: it is +//! `SHA256(domain::FRAME ‖ …)`, so nothing else in this protocol produces those +//! 32 bytes. A `record_hash` is a plain SHA-256 over a JSON document, which any +//! number of unrelated systems also compute. Signing it raw would make one +//! Ed25519 signature mean whatever the presenter says it means, so the signed +//! message is [`RECORD_ATTESTATION_DOMAIN`] followed by the hash's 32 raw bytes. +//! Both halves are fixed length, so the encoding is injective without a length +//! prefix, and any language can build it from the digest string alone. + +// Only the signing and verifying code names the type; the hashing half and the +// ungated constants below do not, so an import at file scope would be unused in +// a default build. +#[cfg(feature = "record-attestation")] +use crate::record::RecordAttestation; + +/// The envelope member a record's own hash lives in, and the one member removed +/// from its preimage (profile LH1). +pub const RECORD_HASH_MEMBER: &str = "record_hash"; + +/// The domain-separation tag a [record attestation](crate::record::RecordAttestation) +/// signs under. +/// +/// Normative: the signed message is these bytes followed by the 32 raw bytes of +/// `signed_record_hash`. A reimplementation in another language that signs +/// anything else produces signatures this protocol will not accept, which is the +/// point — a record attestation must not be interchangeable with a frame +/// attestation or with a signature some unrelated system produced over the same +/// SHA-256. +pub const RECORD_ATTESTATION_DOMAIN: &[u8] = b"contextgraph/attest/1/record"; + +/// Why a `record_hash` could not be computed or used. +/// +/// Named rather than a bare string because the three cases call for different +/// responses: a non-object is a caller bug, a canonicalization failure is a +/// record carrying something JCS refuses (a NaN, a lone surrogate), and a +/// malformed digest is a wire value that failed its grammar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordHashError { + /// The value handed in was not a JSON object, so it has no members to + /// remove and is not a record. + NotAnObject, + /// The record could not be canonicalized under RFC 8785. JCS **must** + /// refuse `NaN`, `Infinity`, and lone surrogates (RFC 8785 §3.2.2.2, + /// §3.2.2.3), so this is a real finding about the record, not a library + /// hiccup. + NotCanonicalizable(String), + /// A typed record would not serialize to JSON. + NotSerializable(String), + /// A digest string was not the `sha256:<64 lowercase hex>` the protocol + /// grammar requires (`SPEC.md` §6.2). + MalformedDigest(String), +} + +impl core::fmt::Display for RecordHashError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::NotAnObject => write!(f, "a record must be a JSON object"), + Self::NotCanonicalizable(why) => { + write!(f, "record is not canonicalizable under RFC 8785: {why}") + } + Self::NotSerializable(why) => write!(f, "record does not serialize to JSON: {why}"), + Self::MalformedDigest(found) => { + write!( + f, + "expected a sha256:<64 lowercase hex> digest, found {found}" + ) + } + } + } +} + +impl std::error::Error for RecordHashError {} + +/// The message an Ed25519 record attestation signs: the domain tag followed by +/// the digest's 32 raw bytes. +/// +/// Public and ungated because it is the normative rule, not an implementation +/// detail — a provider signing in an HSM builds these bytes, signs them with its +/// own backend, and never hands this crate a secret. +pub fn record_attestation_message(record_hash: &str) -> Result, RecordHashError> { + let raw = raw_digest(record_hash)?; + let mut message = Vec::with_capacity(RECORD_ATTESTATION_DOMAIN.len() + raw.len()); + message.extend_from_slice(RECORD_ATTESTATION_DOMAIN); + message.extend_from_slice(&raw); + Ok(message) +} + +/// Parse a `sha256:<64 lowercase hex>` digest into its 32 raw bytes. +fn raw_digest(digest: &str) -> Result<[u8; 32], RecordHashError> { + if !crate::validate::is_well_formed_digest(digest) { + return Err(RecordHashError::MalformedDigest(digest.to_string())); + } + let hex = digest + .split_once(':') + .map(|(_, hex)| hex) + .ok_or_else(|| RecordHashError::MalformedDigest(digest.to_string()))?; + let mut out = [0u8; 32]; + let (pairs, _) = hex.as_bytes().as_chunks::<2>(); + for (slot, pair) in out.iter_mut().zip(pairs) { + let hi = (pair[0] as char) + .to_digit(16) + .ok_or_else(|| RecordHashError::MalformedDigest(digest.to_string()))?; + let lo = (pair[1] as char) + .to_digit(16) + .ok_or_else(|| RecordHashError::MalformedDigest(digest.to_string()))?; + *slot = (hi * 16 + lo) as u8; + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Hashing — gated, because RFC 8785 needs a conforming canonicalizer and SHA-256. +// --------------------------------------------------------------------------- + +#[cfg(feature = "record-hash")] +mod hashing { + use super::*; + use crate::record::ContextRecord; + use serde_json::Value; + use sha2::{Digest, Sha256}; + + /// The exact bytes a record's `record_hash` is taken over: the RFC 8785 + /// (JCS) canonicalization of the record with its top-level `record_hash` + /// member removed (profile LH1). + /// + /// Exposed alongside [`record_hash`] because a hash mismatch between two + /// implementations is unreadable and a byte diff of the preimage is not — + /// this is the function an implementer reaches for at 2am, and the one the + /// golden vectors pin. + pub fn record_hash_preimage(record: &Value) -> Result, RecordHashError> { + let mut preimage = record.clone(); + preimage + .as_object_mut() + .ok_or(RecordHashError::NotAnObject)? + .remove(RECORD_HASH_MEMBER); + serde_json_canonicalizer::to_vec(&preimage) + .map_err(|error| RecordHashError::NotCanonicalizable(error.to_string())) + } + + /// A record's content-addressed identity (profile LH1): + /// `"sha256:" + hex(sha256(JCS(record without its record_hash member)))`. + pub fn record_hash(record: &Value) -> Result { + let preimage = record_hash_preimage(record)?; + let digest: [u8; 32] = Sha256::digest(&preimage).into(); + Ok(crate::attest::digest_string(&digest)) + } + + /// [`record_hash`] for a record already parsed into the reference type. + /// + /// Hashes the record **as this crate models it**. That is the same thing as + /// the wire bytes for any record the reference types round-trip, which the + /// conformance suite proves for every fixture — but a record carrying + /// members outside these types would lose them here, so a host relaying + /// unknown members hashes the wire JSON with [`record_hash`] instead. + pub fn record_hash_of(record: &ContextRecord) -> Result { + let value = serde_json::to_value(record) + .map_err(|error| RecordHashError::NotSerializable(error.to_string()))?; + record_hash(&value) + } + + /// Whether a record's stored `record_hash` is the one its content produces. + /// + /// `Ok(false)` is the interesting answer: the record was edited after it was + /// hashed, or was hashed by an implementation that canonicalizes + /// differently. A record with no `record_hash` member at all is `Ok(false)` + /// rather than an error — it is unhashed, not malformed. + pub fn record_hash_is_current(record: &Value) -> Result { + let stored = record.get(RECORD_HASH_MEMBER).and_then(Value::as_str); + Ok(stored == Some(record_hash(record)?.as_str())) + } +} + +#[cfg(feature = "record-hash")] +pub use hashing::{record_hash, record_hash_is_current, record_hash_of, record_hash_preimage}; + +// --------------------------------------------------------------------------- +// Attestation — gated further, because signatures need Ed25519. +// --------------------------------------------------------------------------- + +#[cfg(feature = "record-attestation")] +mod crypto { + use super::*; + use crate::attest::{ALGORITHM_ED25519, AttestationVerdict}; + use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; + use serde_json::Value; + + /// Verify a detached attestation against the record it claims to sign. + /// + /// Recomputes the record's hash rather than trusting the stored member, so a + /// record whose `record_hash` was rewritten to match a stolen signature is + /// caught here and not merely at the hash check. + /// + /// `Err` means the record could not be hashed at all — a distinct outcome + /// from any verdict, because "this document is not a record" is not a + /// statement about the signature. + pub fn verify_record_attestation( + record: &Value, + attestation: &RecordAttestation, + public_key: &[u8], + ) -> Result { + let expected = super::hashing::record_hash(record)?; + Ok(verify_signed_record_hash( + &expected, + attestation, + public_key, + )) + } + + /// Verify a detached attestation against an already-computed `record_hash`. + /// + /// The primitive an auditor uses when they hold the hash and the signature + /// but not the record — which is the whole point of a detached attestation + /// over a content address. + pub fn verify_signed_record_hash( + expected_record_hash: &str, + attestation: &RecordAttestation, + public_key: &[u8], + ) -> AttestationVerdict { + if attestation.algorithm != ALGORITHM_ED25519 { + return AttestationVerdict::UnknownAlgorithm(attestation.algorithm.clone()); + } + let Ok(message) = record_attestation_message(&attestation.signed_record_hash) else { + return AttestationVerdict::MalformedCommitment; + }; + // Compare hashes *before* touching the signature: a mismatch means the + // record changed after signing, and telling an operator that is far more + // useful than the "bad signature" a naive order would report. + if attestation.signed_record_hash != expected_record_hash { + return AttestationVerdict::CommitmentMismatch { + expected: expected_record_hash.to_string(), + signed: attestation.signed_record_hash.clone(), + }; + } + let Ok(key_bytes) = <[u8; 32]>::try_from(public_key) else { + return AttestationVerdict::MalformedKey; + }; + let Ok(verifying_key) = VerifyingKey::from_bytes(&key_bytes) else { + return AttestationVerdict::MalformedKey; + }; + let Some(sig_bytes) = hex_bytes(&attestation.signature) else { + return AttestationVerdict::MalformedSignature; + }; + let Ok(sig_bytes) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else { + return AttestationVerdict::MalformedSignature; + }; + let signature = Signature::from_bytes(&sig_bytes); + // `verify_strict` rejects small-order keys and the malleable signature + // forms `verify` tolerates. A signature two verifiers can disagree about + // is not evidence. + match verifying_key.verify_strict(&message, &signature) { + Ok(()) => AttestationVerdict::Valid, + Err(_) => AttestationVerdict::BadSignature, + } + } + + /// Sign a `record_hash` in-process, for providers content to hold key + /// material in memory. + /// + /// A provider using an HSM or KMS calls [`record_attestation_message`] + /// instead, signs those bytes with its own backend, and assembles the + /// [`RecordAttestation`] by hand — the protocol specifies the preimage, never + /// the custody of the key. + pub fn sign_record_attestation( + record_hash: &str, + signing_key_seed: &[u8; 32], + key_id: impl Into, + attester_id: impl Into, + issued_at: impl Into, + ) -> Result { + let message = record_attestation_message(record_hash)?; + let signing_key = SigningKey::from_bytes(signing_key_seed); + let signature = signing_key.sign(&message); + Ok(RecordAttestation::new( + record_hash, + key_id, + ALGORITHM_ED25519, + attester_id, + hex_string(&signature.to_bytes()), + issued_at, + )) + } + + /// Sign the record's own recomputed hash — the convenience a provider + /// appending a record wants, so the signed hash cannot drift from the + /// content by a copy-paste. + pub fn sign_record( + record: &Value, + signing_key_seed: &[u8; 32], + key_id: impl Into, + attester_id: impl Into, + issued_at: impl Into, + ) -> Result { + let hash = super::hashing::record_hash(record)?; + sign_record_attestation(&hash, signing_key_seed, key_id, attester_id, issued_at) + } + + /// Lowercase hex, the encoding every signature and digest on this wire uses. + fn hex_string(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from_digit((byte >> 4) as u32, 16).expect("nibble is < 16")); + out.push(char::from_digit((byte & 0x0f) as u32, 16).expect("nibble is < 16")); + } + out + } + + /// Parse lowercase hex into bytes. `None` on any non-hex byte or odd length. + fn hex_bytes(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + let (pairs, _) = s.as_bytes().as_chunks::<2>(); + for pair in pairs { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + out.push((hi * 16 + lo) as u8); + } + Some(out) + } +} + +#[cfg(feature = "record-attestation")] +pub use crypto::{ + sign_record, sign_record_attestation, verify_record_attestation, verify_signed_record_hash, +}; + +#[cfg(all(test, feature = "record-attestation"))] +mod tests { + use super::*; + use crate::attest::{AttestationVerdict, public_key_for, sign_commitment}; + use serde_json::{Value, json}; + + /// A deterministic seed. Tests need reproducible signatures, and this key + /// signs nothing outside this file and the published golden vector. + const SEED: [u8; 32] = [11u8; 32]; + + fn record() -> Value { + json!({ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_obs_0001", + "lineage_id": "lin_obs_0001", + "record_status": "active", + "scope": { "repository_id": "repo_stella" }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "observed", + "record_hash": format!("sha256:{}", "a".repeat(64)), + "provenance": { "origin_provider_id": "provider_example", "producer_kind": "agent" }, + "confidence": 0.82, + "record_kind": "observation", + "statement": "the api handler retries three times before surfacing a 502" + }) + } + + // -- the omit-self rule (profile LH1) ----------------------------------- + + #[test] + fn a_records_own_hash_is_removed_from_its_preimage() { + let mut absent = record(); + absent.as_object_mut().unwrap().remove(RECORD_HASH_MEMBER); + + let mut wrong = record(); + wrong[RECORD_HASH_MEMBER] = json!(format!("sha256:{}", "f".repeat(64))); + + // Removal, not blanking: all three preimages are byte-identical, so a + // producer never has to invent a placeholder and a verifier never has to + // know which one was chosen. + assert_eq!( + record_hash_preimage(&record()).unwrap(), + record_hash_preimage(&absent).unwrap() + ); + assert_eq!( + record_hash_preimage(&record()).unwrap(), + record_hash_preimage(&wrong).unwrap() + ); + assert_eq!(record_hash(&record()), record_hash(&absent)); + } + + #[test] + fn the_preimage_never_contains_the_hash_member() { + let preimage = String::from_utf8(record_hash_preimage(&record()).unwrap()).unwrap(); + assert!( + !preimage.contains(RECORD_HASH_MEMBER), + "a record must never hash over its own hash: {preimage}" + ); + } + + #[test] + fn only_the_top_level_hash_member_is_removed() { + // A `record_hash` nested inside an extension is ordinary content, and + // dropping it would let a producer hide a value from the signature. + let mut nested = record(); + nested["extensions"] = json!({ "record_hash": "sha256:nested" }); + let preimage = String::from_utf8(record_hash_preimage(&nested).unwrap()).unwrap(); + assert!(preimage.contains("sha256:nested"), "{preimage}"); + assert_ne!(record_hash(&nested), record_hash(&record())); + } + + #[test] + fn editing_any_content_changes_the_hash() { + let mut edited = record(); + edited["statement"] = json!("the api handler retries four times"); + assert_ne!(record_hash(&edited), record_hash(&record())); + } + + #[test] + fn member_order_does_not_change_the_hash() { + // JCS sorts members, so two serializations of the same record agree — + // the property the whole scheme rests on. + let forward: Value = serde_json::from_str(r#"{"a":1,"b":2,"record_hash":"x"}"#).unwrap(); + let reverse: Value = serde_json::from_str(r#"{"record_hash":"x","b":2,"a":1}"#).unwrap(); + assert_eq!( + record_hash(&forward).unwrap(), + record_hash(&reverse).unwrap() + ); + } + + #[test] + fn a_non_object_is_not_a_record() { + assert_eq!( + record_hash(&json!([1, 2, 3])), + Err(RecordHashError::NotAnObject) + ); + } + + #[test] + fn a_stored_hash_is_checkable_against_the_content() { + let mut correct = record(); + let computed = record_hash(&correct).unwrap(); + correct[RECORD_HASH_MEMBER] = json!(computed); + assert!(record_hash_is_current(&correct).unwrap()); + + correct["statement"] = json!("edited after hashing"); + assert!(!record_hash_is_current(&correct).unwrap()); + + let mut unhashed = record(); + unhashed.as_object_mut().unwrap().remove(RECORD_HASH_MEMBER); + assert!( + !record_hash_is_current(&unhashed).unwrap(), + "an unhashed record is not current; it is unhashed" + ); + } + + #[test] + fn the_typed_record_hashes_like_its_wire_form() { + let typed: crate::ContextRecord = serde_json::from_value(record()).unwrap(); + let wire = serde_json::to_value(&typed).unwrap(); + assert_eq!(record_hash_of(&typed).unwrap(), record_hash(&wire).unwrap()); + } + + // -- RFC 8785 conformance ------------------------------------------------ + + /// RFC 8785 §3.2.2–§3.2.4: the specification's own worked example. The + /// canonical bytes below are Section 3.2.4's hexadecimal listing, entered + /// verbatim from . + #[test] + fn rfc_8785_section_3_2_worked_example_canonicalizes_byte_for_byte() { + let input: Value = serde_json::from_str( + r#"{ + "numbers": [333333333.33333329, 1E30, 4.50, + 2e-3, 0.000000000000000000000000001], + "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", + "literals": [null, true, false] + }"#, + ) + .expect("the RFC's input parses as JSON"); + + #[rustfmt::skip] + let expected: &[u8] = &[ + 0x7b, 0x22, 0x6c, 0x69, 0x74, 0x65, 0x72, 0x61, 0x6c, 0x73, 0x22, 0x3a, 0x5b, 0x6e, + 0x75, 0x6c, 0x6c, 0x2c, 0x74, 0x72, 0x75, 0x65, 0x2c, 0x66, 0x61, 0x6c, 0x73, 0x65, + 0x5d, 0x2c, 0x22, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x22, 0x3a, 0x5b, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x2e, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x2c, 0x31, 0x65, 0x2b, 0x33, 0x30, 0x2c, 0x34, 0x2e, 0x35, 0x2c, 0x30, + 0x2e, 0x30, 0x30, 0x32, 0x2c, 0x31, 0x65, 0x2d, 0x32, 0x37, 0x5d, 0x2c, 0x22, 0x73, + 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x22, 0xe2, 0x82, 0xac, 0x24, 0x5c, 0x75, + 0x30, 0x30, 0x30, 0x66, 0x5c, 0x6e, 0x41, 0x27, 0x42, 0x5c, 0x22, 0x5c, 0x5c, 0x5c, + 0x5c, 0x5c, 0x22, 0x2f, 0x22, 0x7d, + ]; + + // The canonicalizer takes a whole document; wrapping it in a record whose + // only member is removed is not what is under test, so canonicalize + // directly. + let canonical = serde_json_canonicalizer::to_vec(&input).expect("canonicalizes"); + assert_eq!( + canonical, + expected, + "RFC 8785 §3.2.4 pins these exact bytes; got {}", + String::from_utf8_lossy(&canonical) + ); + } + + /// RFC 8785 §3.2.3's property-sorting test data, with the expected order the + /// RFC states. Sorting is by UTF-16 code unit, which is why the emoji (a + /// surrogate pair, so a leading code unit of 0xD83D) sorts *before* U+FB33 + /// even though its code point is far higher. + #[test] + fn rfc_8785_section_3_2_3_sorts_property_names_by_utf16_code_unit() { + let input: Value = serde_json::from_str( + r#"{ + "\u20ac": "Euro Sign", + "\r": "Carriage Return", + "\ufb33": "Hebrew Letter Dalet With Dagesh", + "1": "One", + "\ud83d\ude00": "Emoji: Grinning Face", + "\u0080": "Control", + "\u00f6": "Latin Small Letter O With Diaeresis" + }"#, + ) + .expect("the RFC's input parses as JSON"); + + let canonical = serde_json_canonicalizer::to_string(&input).expect("canonicalizes"); + let order: Vec<&str> = [ + "Carriage Return", + "One", + "Control", + "Latin Small Letter O With Diaeresis", + "Euro Sign", + "Emoji: Grinning Face", + "Hebrew Letter Dalet With Dagesh", + ] + .into_iter() + .collect(); + + let mut cursor = 0usize; + for value in &order { + let at = canonical[cursor..] + .find(value) + .unwrap_or_else(|| panic!("{value} missing or out of order in {canonical}")); + cursor += at + value.len(); + } + } + + /// RFC 8785 Appendix B, Table 1: IEEE 754 bit patterns and the ECMAScript + /// number text JCS requires for each. `NaN` and the infinities are omitted + /// because JSON cannot carry them (the RFC requires a canonicalizer to + /// refuse them, which `serde_json` enforces one layer earlier by refusing to + /// build the `Value`). + #[test] + fn rfc_8785_appendix_b_number_serialization_samples() { + const SAMPLES: &[(u64, &str)] = &[ + (0x0000000000000000, "0"), + (0x8000000000000000, "0"), + (0x0000000000000001, "5e-324"), + (0x8000000000000001, "-5e-324"), + (0x7fefffffffffffff, "1.7976931348623157e+308"), + (0xffefffffffffffff, "-1.7976931348623157e+308"), + (0x4340000000000000, "9007199254740992"), + (0xc340000000000000, "-9007199254740992"), + (0x4430000000000000, "295147905179352830000"), + (0x44b52d02c7e14af5, "9.999999999999997e+22"), + (0x44b52d02c7e14af6, "1e+23"), + (0x44b52d02c7e14af7, "1.0000000000000001e+23"), + (0x444b1ae4d6e2ef4e, "999999999999999700000"), + (0x444b1ae4d6e2ef4f, "999999999999999900000"), + (0x444b1ae4d6e2ef50, "1e+21"), + (0x3eb0c6f7a0b5ed8c, "9.999999999999997e-7"), + (0x3eb0c6f7a0b5ed8d, "0.000001"), + (0x41b3de4355555553, "333333333.3333332"), + (0x41b3de4355555554, "333333333.33333325"), + (0x41b3de4355555555, "333333333.3333333"), + (0x41b3de4355555556, "333333333.3333334"), + (0x41b3de4355555557, "333333333.33333343"), + (0xbecbf647612f3696, "-0.0000033333333333333333"), + (0x43143ff3c1cb0959, "1424953923781206.2"), + ]; + + for (bits, expected) in SAMPLES { + let value = Value::from(f64::from_bits(*bits)); + let canonical = serde_json_canonicalizer::to_string(&json!({ "n": value })) + .unwrap_or_else(|error| panic!("{bits:#018x} could not canonicalize: {error}")); + assert_eq!( + canonical, + format!("{{\"n\":{expected}}}"), + "RFC 8785 Appendix B pins {bits:#018x} as {expected}" + ); + } + } + + // -- attestation --------------------------------------------------------- + + #[test] + fn a_signed_record_verifies_against_its_own_key() { + let attestation = sign_record( + &record(), + &SEED, + "cep-signing-key-2026-07", + "provider_example", + "2026-07-29T14:00:05Z", + ) + .unwrap(); + let key = public_key_for(&SEED); + assert_eq!( + verify_record_attestation(&record(), &attestation, &key).unwrap(), + AttestationVerdict::Valid + ); + assert!(attestation.uses_known_algorithm()); + assert!(attestation.has_well_formed_issued_at()); + assert_eq!( + attestation.signed_record_hash, + record_hash(&record()).unwrap() + ); + } + + #[test] + fn editing_a_record_after_signing_is_caught_as_a_mismatch() { + let attestation = + sign_record(&record(), &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z").unwrap(); + let mut tampered = record(); + tampered["statement"] = json!("the api handler never retries"); + let key = public_key_for(&SEED); + let verdict = verify_record_attestation(&tampered, &attestation, &key).unwrap(); + assert!( + matches!(verdict, AttestationVerdict::CommitmentMismatch { .. }), + "expected a mismatch, got {verdict:?}" + ); + assert!(!verdict.is_valid()); + } + + #[test] + fn rewriting_the_stored_hash_does_not_launder_a_tampered_record() { + // The attack the recompute exists for: edit the content, then rewrite + // `record_hash` so the record is internally consistent again. Verifying + // against the *stored* member would pass; verifying against the + // recomputed one cannot. + let attestation = + sign_record(&record(), &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z").unwrap(); + let mut laundered = record(); + laundered["statement"] = json!("the api handler never retries"); + let restated = record_hash(&laundered).unwrap(); + laundered[RECORD_HASH_MEMBER] = json!(restated); + assert!(record_hash_is_current(&laundered).unwrap()); + + let key = public_key_for(&SEED); + assert!(matches!( + verify_record_attestation(&laundered, &attestation, &key).unwrap(), + AttestationVerdict::CommitmentMismatch { .. } + )); + } + + #[test] + fn a_wrong_key_is_a_bad_signature_not_a_mismatch() { + let attestation = + sign_record(&record(), &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z").unwrap(); + let other = public_key_for(&[3u8; 32]); + assert_eq!( + verify_record_attestation(&record(), &attestation, &other).unwrap(), + AttestationVerdict::BadSignature, + "the hash is intact; only the key is wrong" + ); + } + + #[test] + fn a_frame_signature_over_the_same_digest_is_not_a_record_attestation() { + // What the domain tag buys. Hand the frame layer the record's own hash + // bytes as a commitment and sign them: the resulting signature is over + // 32 bytes that `signed_record_hash` names exactly, and it must still + // not verify as a record attestation. + let hash = record_hash(&record()).unwrap(); + let raw = raw_digest(&hash).unwrap(); + let frame_signed = sign_commitment(&raw, &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z"); + let lifted = RecordAttestation::new( + hash, + frame_signed.key_id, + frame_signed.algorithm, + frame_signed.attester_id, + frame_signed.signature, + frame_signed.issued_at, + ); + let key = public_key_for(&SEED); + assert_eq!( + verify_record_attestation(&record(), &lifted, &key).unwrap(), + AttestationVerdict::BadSignature, + "a signature from another layer must not be presentable as a record attestation" + ); + } + + #[test] + fn an_unknown_algorithm_is_declined_rather_than_failed() { + let mut attestation = + sign_record(&record(), &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z").unwrap(); + attestation.algorithm = "dilithium3".into(); + let key = public_key_for(&SEED); + let verdict = verify_record_attestation(&record(), &attestation, &key).unwrap(); + assert_eq!( + verdict, + AttestationVerdict::UnknownAlgorithm("dilithium3".into()) + ); + assert!(!verdict.is_valid(), "declining is still not accepting"); + assert!(!attestation.uses_known_algorithm()); + } + + #[test] + fn malformed_keys_signatures_and_hashes_are_named_distinctly() { + let attestation = + sign_record(&record(), &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z").unwrap(); + assert_eq!( + verify_record_attestation(&record(), &attestation, &[0u8; 5]).unwrap(), + AttestationVerdict::MalformedKey + ); + + let mut truncated = attestation.clone(); + truncated.signature = "abcd".into(); + assert_eq!( + verify_record_attestation(&record(), &truncated, &public_key_for(&SEED)).unwrap(), + AttestationVerdict::MalformedSignature + ); + + let mut bad_hash = attestation; + bad_hash.signed_record_hash = "not-a-digest".into(); + assert_eq!( + verify_record_attestation(&record(), &bad_hash, &public_key_for(&SEED)).unwrap(), + AttestationVerdict::MalformedCommitment + ); + + assert_eq!( + record_attestation_message("sha256:short"), + Err(RecordHashError::MalformedDigest("sha256:short".into())) + ); + } + + #[test] + fn the_signed_message_is_the_domain_tag_then_the_raw_digest() { + let hash = record_hash(&record()).unwrap(); + let message = record_attestation_message(&hash).unwrap(); + assert_eq!(message.len(), RECORD_ATTESTATION_DOMAIN.len() + 32); + assert!(message.starts_with(RECORD_ATTESTATION_DOMAIN)); + assert_eq!( + &message[RECORD_ATTESTATION_DOMAIN.len()..], + &raw_digest(&hash).unwrap() + ); + assert_eq!(crate::digest_string(&raw_digest(&hash).unwrap()), hash); + } + + #[test] + fn an_attestation_round_trips_through_json() { + let attestation = + sign_record(&record(), &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z").unwrap(); + let json = serde_json::to_string(&attestation).unwrap(); + let back: RecordAttestation = serde_json::from_str(&json).unwrap(); + assert_eq!(back, attestation); + } + + #[test] + fn a_detached_attestation_verifies_from_the_hash_alone() { + // An auditor holding the hash and the signature, but not the record. + let hash = record_hash(&record()).unwrap(); + let attestation = + sign_record_attestation(&hash, &SEED, "key-1", "oxagen", "2026-07-29T14:00:05Z") + .unwrap(); + assert_eq!( + verify_signed_record_hash(&hash, &attestation, &public_key_for(&SEED)), + AttestationVerdict::Valid + ); + } +} diff --git a/contextgraph-types/tests/record_vectors.rs b/contextgraph-types/tests/record_vectors.rs new file mode 100644 index 0000000..79e83a2 --- /dev/null +++ b/contextgraph-types/tests/record_vectors.rs @@ -0,0 +1,174 @@ +//! Cross-language reference vectors for record content addressing and record +//! attestation (lifecycle profile §3 and §7, ADR 0017). +//! +//! The sibling of [`attestation_vectors`](./attestation_vectors.rs) one layer +//! down. `record_hash` and the record attestation preimage are **normative** +//! rules, and a normative rule with no published vectors is something two +//! implementations can both believe they follow while computing different +//! hashes. These are the values an implementation in any language reconciles +//! against; if your digest matches these, your canonicalization matches the +//! profile. +//! +//! Every value here is a *fixture*, not an assertion about the current code: +//! changing the rule changes these digests, and a diff in this file is a +//! **wire-breaking change**. That is exactly why they are written out rather +//! than recomputed. What proves the rule is right rather than merely stable +//! lives elsewhere — `record_attest`'s unit tests check the canonicalizer +//! against RFC 8785's own published vectors, and +//! `contextgraph-conformance`'s `lifecycle_profile_examples` recomputes the +//! twelve profile fixtures. + +#![cfg(feature = "record-attestation")] + +use contextgraph_types::record_attest::{ + RECORD_ATTESTATION_DOMAIN, record_attestation_message, record_hash, record_hash_preimage, + sign_record, verify_record_attestation, verify_signed_record_hash, +}; +use contextgraph_types::{AttestationVerdict, RecordAttestation, attest::public_key_for}; +use serde_json::{Value, json}; + +/// The published test seed — the ASCII bytes of +/// `contextgraph-lifecycle-test-key!`, the same one `tests/fixtures/` +/// publishes. It signs nothing real and is forgeable by anyone reading this. +const SEED: [u8; 32] = *b"contextgraph-lifecycle-test-key!"; + +/// The reference record: the profile's `observation.json` fixture, inline so +/// this vector travels inside the published crate rather than depending on a +/// file at the repository root. +fn reference_record() -> Value { + json!({ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_obs_0001", + "lineage_id": "lin_obs_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "workspace_id": "ws_main", + "session_id": "sess_412" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "observed", + "record_hash": "sha256:b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "agent", + "origin_authority_id": "authority_acme", + "producer_ref": "agent://trace-miner" + }, + "sensitivity": "internal", + "confidence": 0.82, + "record_kind": "observation", + "statement": "the api handler retries three times before surfacing a 502", + "subject_ref": "trace_run_991" + }) +} + +/// The RFC 8785 canonicalization of the reference record with its own +/// `record_hash` member removed — the exact bytes the digest is taken over. +const REFERENCE_PREIMAGE: &str = concat!( + r#"{"confidence":0.82,"lineage_id":"lin_obs_0001","observed_at":"2026-07-29T14:00:00Z","#, + r#""origin":"observed","provenance":{"origin_authority_id":"authority_acme","#, + r#""origin_provider_id":"provider_example","producer_kind":"agent","#, + r#""producer_ref":"agent://trace-miner"},"record_id":"rec_obs_0001","#, + r#""record_kind":"observation","record_status":"active","#, + r#""schema_version":"contextgraph/lifecycle/1.0-draft","#, + r#""scope":{"repository_id":"repo_stella","session_id":"sess_412","workspace_id":"ws_main"},"#, + r#""sensitivity":"internal","sharing_scope":"repository","#, + r#""statement":"the api handler retries three times before surfacing a 502","#, + r#""subject_ref":"trace_run_991"}"#, +); + +/// The reference record's content-addressed identity. +const REFERENCE_RECORD_HASH: &str = + "sha256:b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc"; + +/// The Ed25519 public key [`SEED`] produces, lowercase hex. +const REFERENCE_PUBLIC_KEY: &str = + "495b4a0a4a16c5444d8626a7ae0bc6eca613676b51fb947238cb8238baa9fde5"; + +/// The detached signature over [`REFERENCE_RECORD_HASH`] under [`SEED`]. +/// Ed25519 is deterministic (RFC 8032), so this is reproducible everywhere. +const REFERENCE_SIGNATURE: &str = concat!( + "8cce3f453510c50d88821eb57dd1767827ba7ab5e29d072b1fadf29583313a04", + "635521228a62b3015399ca8676394087a2bb861a4f893ff4912dea43fdccb905", +); + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[test] +fn the_canonical_preimage_is_these_exact_bytes() { + let preimage = record_hash_preimage(&reference_record()).expect("canonicalizes"); + assert_eq!( + String::from_utf8(preimage).expect("JCS output is UTF-8"), + REFERENCE_PREIMAGE + ); +} + +#[test] +fn the_record_hash_is_this_exact_digest() { + assert_eq!( + record_hash(&reference_record()).expect("canonicalizes"), + REFERENCE_RECORD_HASH + ); +} + +#[test] +fn the_signed_message_is_the_domain_tag_then_the_digest() { + let message = record_attestation_message(REFERENCE_RECORD_HASH).expect("well-formed digest"); + assert_eq!( + hex(&message), + format!( + "{}{}", + hex(RECORD_ATTESTATION_DOMAIN), + REFERENCE_RECORD_HASH + .strip_prefix("sha256:") + .expect("the digest names its algorithm") + ) + ); +} + +#[test] +fn the_published_key_and_signature_are_these_exact_values() { + assert_eq!(hex(&public_key_for(&SEED)), REFERENCE_PUBLIC_KEY); + let attestation = sign_record( + &reference_record(), + &SEED, + "cep-signing-key-2026-07", + "provider_example", + "2026-07-29T14:00:05Z", + ) + .expect("the reference record hashes"); + assert_eq!(attestation.signature, REFERENCE_SIGNATURE); + assert_eq!(attestation.signed_record_hash, REFERENCE_RECORD_HASH); +} + +#[test] +fn the_published_signature_verifies_and_only_over_this_record() { + let attestation = RecordAttestation::new( + REFERENCE_RECORD_HASH, + "cep-signing-key-2026-07", + "ed25519", + "provider_example", + REFERENCE_SIGNATURE, + "2026-07-29T14:00:05Z", + ); + let key = public_key_for(&SEED); + assert_eq!( + verify_record_attestation(&reference_record(), &attestation, &key).expect("hashes"), + AttestationVerdict::Valid + ); + assert_eq!( + verify_signed_record_hash(REFERENCE_RECORD_HASH, &attestation, &key), + AttestationVerdict::Valid + ); + + let mut edited = reference_record(); + edited["statement"] = json!("the api handler never retries"); + assert!(matches!( + verify_record_attestation(&edited, &attestation, &key).expect("hashes"), + AttestationVerdict::CommitmentMismatch { .. } + )); +} diff --git a/docs/GUIDE.md b/docs/GUIDE.md index e3a2582..1f4ee41 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -206,14 +206,14 @@ Read the full ADR before changing anything it covers. | [0006](./adr/0006-prompt-ingestion-as-a-local-provider.md) | Prompt ingestion as a local provider | Text a user pastes into chat is now treated like any other provider's output: split, classified, budgeted, and hashed — no more free pass around the rules. | | [0007](./adr/0007-protocol-product-boundary.md) | The protocol/product boundary | Drew a hard line between the protocol's small atomic frame and one downstream app's much bigger task-specific bundle — they were both sloppily called "ContextFrame" before this. | | [0008](./adr/0008-deploy-topology-and-advertised-urls.md) | Deploy topology and advertised URLs | This repo does not run a website (a separate repo, `cgp-website`, does) — nailed down which host serves what, so we stop advertising broken links. | +| [0009](./adr/0009-adopt-standing-decisions-scr-corpus.md) | Standing decisions as a Steering Context Record corpus | The maintainer's recurring directives to coding agents live in `docs/scr/` as one versioned corpus, identical across the org's repos, instead of being retyped every session. | +| [0010](./adr/0010-provenance-attestation.md) | Provenance attestation | A digest only proves nothing changed since someone wrote it down. A frame's provenance now folds into a signed hash chain bound to the frame's identity, so a third party can check a citation offline. | +| [0011](./adr/0011-open-frame-kind-vocabulary.md) | `FrameKind` is an open vocabulary | A provider can name a frame kind this crate has never heard of and the frame still parses, so a new kind is an additive change rather than a wire break. | +| [0012](./adr/0012-sdk-version-pins-share-a-major.md) | A version pin names its manifest's major | The scaffolder's default SDK pins had drifted a whole major behind the packages they name, invisibly, because every CI job overrides them with a local path. A guard now compares them. | | [0013](./adr/0013-schema-identity-on-a-branded-versioned-url.md) | Schema identity on a branded, versioned URL | The JSON Schemas are now known by a URL on the protocol's own domain, numbered by wire family (`/schema/v1/`) rather than tracking a git branch. The old URLs keep working and implementers need do nothing. | +| [0017](./adr/0017-record-hash-and-record-attestation.md) | `record_hash` and `RecordAttestation` | The record layer's identity, implemented: RFC 8785 canonicalization with the record's own hash removed from the preimage, and a domain-separated Ed25519 signature over it. Says why JCS is right here and wrong at the frame layer. | - - - - + --- diff --git a/docs/adr/0017-record-hash-and-record-attestation.md b/docs/adr/0017-record-hash-and-record-attestation.md new file mode 100644 index 0000000..ffbe0de --- /dev/null +++ b/docs/adr/0017-record-hash-and-record-attestation.md @@ -0,0 +1,167 @@ +# 0017 — `record_hash` and `RecordAttestation`: implementing the record layer's identity + +**Status:** Accepted (`contextgraph/lifecycle/1.0-draft`, additive) + +## Context + +The Context Exchange Provider profile has said since its first draft that a +record is content-addressed: `record_hash` is `sha256:` over the RFC 8785 +(JCS) canonicalization of the record with its own `record_hash` member removed +(`LH1`), and `RecordAttestation` is a detached Ed25519 signature over that hash +(`LC3`). + +Both were prose and a struct. There was no canonicalization code in the +workspace, no record hashing anywhere a provider could call, and no signature +verification at all. What existed instead: + +- A **test helper.** `contextgraph-conformance`'s `lifecycle_profile_examples` + suite carried its own private copy of the hashing rule and checked the + fixtures against it. It was correct, and it was the only implementation — so + the suite proved the fixtures agreed with the suite. +- A **placeholder signature.** `tests/fixtures/record-attestation.json` carried + 49 bytes of DER-shaped filler where a 64-byte Ed25519 signature belongs, with + no key published. No implementation could reproduce or refute it. It sat in + the directory `LF1` calls the canonical home for the profile's golden vectors. +- An **unstated preimage.** "A detached signature over `record_hash`" does not + say what bytes are signed. Nothing had signed anything, so nothing had had to + decide. + +That is the same gap ADR 0010 closed at the frame layer, one layer down +(issue #96). + +## Decision + +Implement both in `contextgraph_types::record_attest`, behind two new +off-by-default features, and publish vectors that make the `LF1` claim true. + +### 1. RFC 8785 (JCS) is right here, and it is delegated + +ADR 0010 §3 rejects JCS for a provenance link, and that decision stands +unchanged: a link is six optional strings, and making every implementer obtain a +conforming JSON canonicalizer to hash six strings is a tax with no return. + +A record is the opposite shape. It is an open-ended JSON document — an +extensible body, a map of namespaced extensions, floating-point confidences — +and there is no typed encoding to write down that stays correct as the profile +grows a member. The canonicalization has to be generic, and RFC 8785 is the one +generic rule with implementations in several languages to reconcile against. + +The cost is real: JCS number serialization is ECMAScript `Number::toString`, +whose exponent thresholds and shortest-round-trip digit selection are exactly +where independent implementations diverge in silence. So the rule is +**delegated, not hand-rolled** — `serde_json_canonicalizer` (MIT), which routes +number formatting through `ryu-js`, the Boa engine's ECMAScript formatter +(Apache-2.0 OR BSL-1.0). The crate was already a dev-dependency of the +conformance suite; this promotes it to a pinned workspace dependency so the +library and the suite that checks it cannot canonicalize with two different +versions. + +Delegation is not a substitute for evidence. `record_attest`'s tests check the +canonicalizer against **RFC 8785's own published vectors**: §3.2.4's +hexadecimal byte listing for the specification's worked example, §3.2.3's +property-sorting test data, and Appendix B's Table 1 of IEEE 754 bit patterns +and their required ECMAScript text — the `-0` case, the `1e+21` and `0.000001` +exponent thresholds, the round-to-even sample, and the rest. + +### 2. The omitted member is removed, not blanked + +`LH1` says removed, and this ADR records what that buys rather than treating it +as arbitrary: a record hashes identically whether it carries no `record_hash` at +all, the correct one, or a wrong one. A producer therefore computes the hash of +the record it is about to publish without first inventing a placeholder, and a +verifier never has to know which placeholder the producer chose. A blanking rule +would have made the placeholder itself part of the interop contract — one more +value to get wrong in another language, for nothing. + +Only the **top-level** member is removed. A `record_hash` nested inside +`extensions` or a body member is ordinary content and stays in the preimage; +dropping it would let a producer hide a value from the signature. + +### 3. The signature is domain-separated + +A frame commitment is domain-bound by construction — it is +`SHA256(domain::FRAME ‖ …)`, so nothing else in this protocol produces those +32 bytes. A `record_hash` is a plain SHA-256 over a JSON document, which any +number of unrelated systems also compute, and signing it raw would make one +Ed25519 signature mean whatever the presenter says it means. + +So the signed message is: + +```text +"contextgraph/attest/1/record" ‖ <32 raw bytes of record_hash> +``` + +Both halves are fixed length, so the encoding is injective without a length +prefix, and any language can build it from the digest string alone. This is +additive to `LC3` rather than a change to it: the signature is still over the +record's hash, and nothing had implemented the ambiguous reading. + +Verification recomputes the record's hash rather than reading the stored member, +so the obvious laundering move — edit the content, then rewrite `record_hash` so +the record is internally consistent again — is caught as a +`CommitmentMismatch` rather than passing. + +### 4. Two features, not one + +`attestation` stays exactly what ADR 0010 §5 made it: `sha2` and +`ed25519-dalek` for the frame layer's length-prefixed encoding. The record layer +adds: + +- **`record-hash`** — `sha2`, `serde_json`, `serde_json_canonicalizer`. Content + addressing with no signatures, which is all a provider keying idempotent + replay needs. +- **`record-attestation`** — `record-hash` plus `attestation`, for the + signatures. + +A frame-only consumer never pays for a JSON canonicalizer, and a provider that +only content-addresses never pays for Ed25519. The crate's "zero dependencies +beyond serde" promise holds for everyone who opts into nothing, which is the +default. + +`AttestationVerdict` is **shared** with the frame layer rather than duplicated. +The verdict is a result vocabulary, not a signed preimage: an auditor needs the +same distinctions at both layers — "this is forged" against "I cannot check +this" against "the content moved under the signature" — and two enums with the +same variants would drift. The types that must stay distinct, and do, are +`RecordAttestation` and `ProvenanceAttestation`. + +### 5. The vectors are published, and checked from two languages + +`LF1` promised `tests/fixtures/` holds golden JCS/`record_hash` vectors. It held +records with hashes, which is half of it: a digest cannot say *where* two +implementations diverged, only that they did. + +- `tests/fixtures/record-hash-vectors.json` publishes the exact JCS preimage + **text** of every record fixture beside its hash, so an implementer whose + digest disagrees gets a byte diff instead of a mystery. +- `tests/fixtures/record-attestation.json` now carries a real Ed25519 signature, + and `tests/fixtures/record-attestation-key.json` publishes the seed, the + public key, and the signed message. The key is a test key, committed to a + public repository, and labelled as one everywhere it appears — publishing it + is the point, because a vector nobody can reproduce is a shape example. +- `contextgraph-types/tests/record_vectors.rs` carries the same values inline, + so they travel inside the published crate rather than depending on files at + the repository root. + +The conformance suite recomputes all of it through the **library**, not through +a copy of the rule, which is what makes `LF3` a statement about shipped code. +`schema/validate-examples.py` then checks the two properties that make a vector +usable to someone who has neither Rust nor a JCS library: that the published +canonical bytes hash to the published digest, and that those bytes parse back to +the fixture with its `record_hash` removed. Neither check claims Python +canonicalizes JSON the way RFC 8785 does — it does not, and a check that +pretended otherwise would be an agreeable coincidence rather than evidence. + +## Consequences + +- A provider can content-address a record and attest to it with the reference + crate instead of reimplementing a rule from prose. +- The record fixtures' hashes are **unchanged** by this work. The library + reproduces the rule the suite's private helper already implemented, which is + the evidence that this is an implementation rather than a redefinition. +- `LC3` gains a normative preimage. A provider that had already shipped a + signature over the bare digest — none exists, since nothing implemented + signing — would have to re-sign. +- The frame layer is untouched. ADR 0010's argument against JCS for a provenance + link is unaffected by this ADR adopting JCS for a record, and the two modules + each say so where a reader will meet the apparent contradiction. diff --git a/docs/profiles/context-exchange-provider.md b/docs/profiles/context-exchange-provider.md index fd4d23c..0671b03 100644 --- a/docs/profiles/context-exchange-provider.md +++ b/docs/profiles/context-exchange-provider.md @@ -73,16 +73,25 @@ identity, of idempotency replay, and of attestation. | # | Requirement | |---|---| -| **LH1** | `record_hash` **MUST** be `sha256:<64 lowercase hex>` (SPEC.md §6.2 grammar) over the **RFC 8785 (JCS)** canonicalization of the record **with its own `record_hash` member removed from the preimage**. A record never hashes over its own hash. | -| **LH2** | Canonicalization is **RFC 8785** exactly: object members sorted by code point, minimal separators, no insignificant whitespace, and the RFC 8785 **number policy** (the ECMAScript `Number.prototype.toString` shortest round-trip form — e.g. `0.9`, not `0.90`; integers with no decimal point). Two implementations that agree on the bytes agree on the hash. | +| **LH1** | `record_hash` **MUST** be `sha256:<64 lowercase hex>` (SPEC.md §6.2 grammar) over the **RFC 8785 (JCS)** canonicalization of the record **with its own top-level `record_hash` member removed from the preimage**. A record never hashes over its own hash. **Removed, not blanked**: a record therefore hashes identically whether it carries no `record_hash`, the right one, or a wrong one, so a producer computes the hash without first inventing a placeholder and a verifier never has to know which placeholder was chosen. A `record_hash` nested inside `extensions` or a body member is ordinary content and **MUST** stay in the preimage. | +| **LH2** | Canonicalization is **RFC 8785** exactly: object members sorted **by UTF-16 code unit** (RFC 8785 §3.2.3 — *not* by code point; the two orders differ for a supplementary character, whose lead surrogate sorts below U+E000), minimal separators, no insignificant whitespace, and the RFC 8785 **number policy** (the ECMAScript `Number.prototype.toString` shortest round-trip form — e.g. `0.9`, not `0.90`; integers with no decimal point; `-0` serialized as `0`). Two implementations that agree on the bytes agree on the hash. | | **LH3** | The **detached** attestation (`RecordAttestation`, §7) is **never** part of the record or its `record_hash` preimage. Re-signing or key rotation therefore never perturbs a record's content-addressed identity. | | **LH4** | The reference implementation **MAY** additionally compute a `command_hash` over `(record_hash + requested_retention + behavior-changing options)` for idempotency keying (§5); that hash is a provider-ledger concern, not part of the record wire shape. | +| **LH5** | The reference implementation is [`contextgraph_types::record_attest`](../../contextgraph-types/src/record_attest.rs), behind the off-by-default `record-hash` feature ([ADR 0017](../adr/0017-record-hash-and-record-attestation.md)). `record_hash_preimage` returns the exact canonical bytes, because a digest alone cannot tell an implementer *where* their canonicalization diverged. Its RFC 8785 conformance is checked against the RFC's own published vectors — §3.2.4's byte listing, §3.2.3's sorting data, and Appendix B's IEEE 754 number table. | The canonical JCS/`record_hash` **golden vectors** are the interop spine and live -in this repo (§9). The reference Rust `serde_json_canonicalizer` and a -`json.dumps(sort_keys=True, separators=(",",":"))` Python canonicalizer both -reproduce the vectors' hashes byte-for-byte; a fully worked example is in -[`tests/fixtures/README.md`](../../tests/fixtures/README.md). +in this repo (§9): `tests/fixtures/record-hash-vectors.json` publishes the +canonical **text** of every fixture's preimage beside its hash. A fully worked +example is in [`tests/fixtures/README.md`](../../tests/fixtures/README.md). + +Reproducing those bytes without a JCS library is possible for *these* fixtures, +and is not the same thing as implementing RFC 8785. A Python +`json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False)` matches +here because every fixture's member names are ASCII and every number round-trips +identically under CPython's `repr` and ECMAScript's `Number::toString`. The +profile guarantees neither property, and a record that broke either would still +be valid — so a provider computes `record_hash` with a conforming canonicalizer +and uses the shortcut only to sanity-check a vector. ## 4. The `ContextRecord` (resolves D1) @@ -198,6 +207,8 @@ envelope, keeping the freeze boundary intact. | **LC1** | Every record carries structured `provenance`: `origin_provider_id`, `origin_authority_id?`, `producer_kind`, `producer_ref?`, `derivation_kind?`, `source_refs?`. `producer_kind` and `derivation_kind` are open vocabularies (recommended `human`/`agent`/`tool`/`system` and `summarization`/`inference`/`transformation`/`import`). | | **LC2** | The envelope `origin` is a coarse class — `observed` \| `derived` \| `declared` \| `imported` — governed by the **origin→derivation validity matrix**: `observed`/`declared` **MUST NOT** carry a `provenance.derivation_kind`; `derived` **MUST** carry one; `imported` **MAY**. | | **LC3** | A `RecordAttestation` is a **detached** signature over a record's `record_hash`: `{signed_record_hash, key_id, algorithm, attester_id, signature, issued_at}`. It travels as **ledger metadata beside** the record, **never inside** the record or its hash preimage (LH3), so key rotation never perturbs identity. Key rotation is by **key-id validity windows**. The attestation type is shared with issue #12. | +| **LC4** | The **signed message** is the domain tag `contextgraph/attest/1/record` (its ASCII bytes, no terminator) followed by the **32 raw bytes** of `signed_record_hash`. Both halves are fixed length, so the encoding is injective without a length prefix and any language can build it from the digest string alone. The tag is what stops a signature produced at another layer — or by an unrelated system that hashed the same JSON — from being presented as a record attestation: a frame commitment is domain-bound by construction, while a `record_hash` is a plain SHA-256 that anyone can also compute. `algorithm` is an open vocabulary; this revision defines `ed25519`, with the signature as **lowercase hex**. | +| **LC5** | A verifier **MUST** recompute the record's `record_hash` from the record in hand rather than trusting its stored member, then compare that against `signed_record_hash` **before** checking the signature. Trusting the stored member would let an attacker edit a record and rewrite its hash to match a stolen signature; checking the hash first means an operator is told the content moved, rather than being told the signature is bad. A verifier **MUST NOT** treat any outcome other than a successful verification as provisionally acceptable — "I cannot check this" and "this is good" are never the same answer. | ## 8. Typed error vocabulary (resolves D5) @@ -234,10 +245,9 @@ secret leakage (SPEC.md §11 C8). | # | Requirement | |---|---| -| **LF1** | **`tests/fixtures/` is the canonical home** for lifecycle-profile example records and golden JCS/`record_hash` vectors — one fixture per `record_kind`, plus a detached `RecordAttestation` example. Downstream implementations reconcile to these byte vectors (coordinating with the fixture-regeneration work, issue #52). | +| **LF1** | **`tests/fixtures/` is the canonical home** for lifecycle-profile example records and golden JCS/`record_hash` vectors — one fixture per `record_kind`; `record-hash-vectors.json`, holding the canonical JCS **text** and hash of every one of them; a detached `RecordAttestation` example carrying a real Ed25519 signature; and `record-attestation-key.json`, the published test key that signs it. The key is committed to a public repository and forgeable by anyone, which is the point: a vector nobody can reproduce is a shape example. Downstream implementations reconcile to these byte vectors (coordinating with the fixture-regeneration work, issue #52). | | **LF2** | The record schema's `$id` **MUST** be `https://contextgraphprotocol.org/schema/v1/contextgraph-lifecycle-record.schema.json` — the protocol's own domain, versioned by the `contextgraph/1` major family this profile is layered on ([ADR 0013](../adr/0013-schema-identity-on-a-branded-versioned-url.md)). It named this repository's GitHub-raw URL until #79, and that URL keeps resolving, so a consumer holding it is not broken. `schema/validate-examples.py` validates every fixture against the schema and pins the exact `$id` string offline; `publish-spec.yml` dereferences it after every publish and asserts the served body reports that same `$id`; `.github/scripts/check-deploy-hygiene.py` enforces the host rule repo-wide (the same discipline as the envelope schema). | -| **LF3** | `contextgraph-conformance`'s [`lifecycle_profile_examples`](../../contextgraph-conformance/tests/lifecycle_profile_examples.rs) suite round-trips every fixture through the reference Rust types, checks the profile envelope invariants (LR/LD/LC), and **recomputes `record_hash` as the JCS-sha256 of the hashless record** — so a fixture cannot merely assert a hash it does not satisfy. | -| **LF4** | **Core conformance** is unchanged: a CEP is green on `contextgraph-conformance` for its declared read capabilities (SPEC.md §12). The **live HTTP-endpoint** profile suite (driving append/get/resolve over a real transport) is future work that rides the operation transport bindings (#5/#50/#13); until then this repo ships the record **schema**, the **JCS golden vectors**, and the round-trip/hash conformance above — the checkable, transport-independent core of the profile. | +| **LF3** | `contextgraph-conformance`'s [`lifecycle_profile_examples`](../../contextgraph-conformance/tests/lifecycle_profile_examples.rs) suite round-trips every fixture through the reference Rust types, checks the profile envelope invariants (LR/LD/LC), **recomputes `record_hash` as the JCS-sha256 of the hashless record**, pins each fixture's canonical preimage text, and **verifies the attestation example under its published key** — so a fixture cannot merely assert a hash it does not satisfy, or carry a signature nothing can check. Every recomputation calls the library (LH5), not a copy of the rule kept in the suite: a second implementation living in the test is how a fixture set ends up agreeing with nothing that ships. || **LF4** | **Core conformance** is unchanged: a CEP is green on `contextgraph-conformance` for its declared read capabilities (SPEC.md §12). The **live HTTP-endpoint** profile suite (driving append/get/resolve over a real transport) is future work that rides the operation transport bindings (#5/#50/#13); until then this repo ships the record **schema**, the **JCS golden vectors**, and the round-trip/hash conformance above — the checkable, transport-independent core of the profile. | ## 10. Transport and security diff --git a/schema/validate-examples.py b/schema/validate-examples.py index cf9420c..7b8dac8 100755 --- a/schema/validate-examples.py +++ b/schema/validate-examples.py @@ -35,6 +35,7 @@ * each schema's `$id` is its public identity — pinned because a schema that resolves to the wrong document is worse than one that 404s. """ +import hashlib import json import re import sys @@ -279,10 +280,20 @@ def _skip_ws(text: str, index: int) -> int: RECORD_SCHEMA_SOURCE = ROOT / "schema" / "contextgraph-lifecycle-record.schema.json" RECORD_SCHEMA = json.loads(RECORD_SCHEMA_SOURCE.read_text()) ATTESTATION_FIXTURE = "record-attestation.json" +ATTESTATION_KEY_FIXTURE = "record-attestation-key.json" +HASH_VECTORS_FIXTURE = "record-hash-vectors.json" +# Everything else in tests/fixtures/ is a record named for its record_kind. An +# explicit list rather than a naming convention, so a new non-record fixture is +# a deliberate entry here instead of something a glob quietly swallows. +NON_RECORD_FIXTURES = { + ATTESTATION_FIXTURE, + ATTESTATION_KEY_FIXTURE, + HASH_VECTORS_FIXTURE, +} fixtures_dir = ROOT / "tests" / "fixtures" record_fixtures = sorted( - p for p in fixtures_dir.glob("*.json") if p.name != ATTESTATION_FIXTURE + p for p in fixtures_dir.glob("*.json") if p.name not in NON_RECORD_FIXTURES ) if not record_fixtures: check("tests/fixtures holds lifecycle record examples", False) @@ -316,5 +327,60 @@ def _skip_ws(text: str, index: int) -> int: record_expected_id = f"{PUBLISHED_SCHEMA_BASE}{RECORD_SCHEMA_SOURCE.name}" check(f"$id is {record_expected_id}", RECORD_SCHEMA.get("$id") == record_expected_id) +# 7. The record_hash golden vectors (profile LF1), checked from a second +# language. +# +# The Rust suite recomputes these with the same canonicalizer the library +# ships, which proves the library and the fixtures agree and nothing more. A +# vector exists for the implementer who has *neither*, so the two properties +# that make it usable are checked here, in Python, with no JCS library and no +# Rust: +# +# * the published canonical text hashes to the published record_hash, so an +# implementer who reproduces the bytes knows the digest follows; and +# * that text parses back to the fixture with its record_hash member +# removed, so the bytes really describe the record beside them and the +# omit-self rule is visible in the artifact rather than only in prose. +# +# Neither check claims Python canonicalizes JSON the way RFC 8785 does. It +# does not, and a check that pretended otherwise would be the kind of +# agreeable coincidence this repository treats as worse than no evidence. +print("\nValidating the record_hash golden vectors (profile LF1)\n") + +vectors_path = fixtures_dir / HASH_VECTORS_FIXTURE +vectors = json.loads(vectors_path.read_text())["vectors"] +check( + f"tests/fixtures/{HASH_VECTORS_FIXTURE} covers every record fixture", + {v["record_file"] for v in vectors} == {p.name for p in record_fixtures}, +) + +for vector in vectors: + name = vector["record_file"] + digest = "sha256:" + hashlib.sha256(vector["jcs_utf8"].encode("utf-8")).hexdigest() + check(f"{name}: the canonical bytes hash to the published record_hash", + digest == vector["record_hash"]) + + record = json.loads((fixtures_dir / name).read_text()) + check(f"{name}: the published record_hash is the one the fixture stores", + record.get("record_hash") == vector["record_hash"]) + + hashless = {k: v for k, v in record.items() if k != "record_hash"} + check(f"{name}: the canonical bytes denote the record minus its own hash", + json.loads(vector["jcs_utf8"]) == hashless) + +# The attestation's signed message: the domain tag, then the digest's raw bytes. +# Pure string arithmetic, so it is checkable without a crypto library. +key_fixture = json.loads((fixtures_dir / ATTESTATION_KEY_FIXTURE).read_text()) +attestation = json.loads((fixtures_dir / ATTESTATION_FIXTURE).read_text()) +expected_message = ( + b"contextgraph/attest/1/record".hex() + + attestation["signed_record_hash"].removeprefix("sha256:") +) +check(f"{ATTESTATION_KEY_FIXTURE}: signed_message_hex is the domain tag then the digest", + key_fixture["signed_message_hex"] == expected_message) +check(f"{ATTESTATION_KEY_FIXTURE}: the key signs the record it names", + attestation["signed_record_hash"] + == json.loads((fixtures_dir / key_fixture["signs"]).read_text())["record_hash"]) + print(f"\n{'OK — all examples validate' if failures == 0 else f'{failures} failure(s)'}") sys.exit(1 if failures else 0) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index bad371a..b726ba1 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -15,46 +15,67 @@ the reference conformance suite validate against the **same bytes**. `contract_validation.json`, `outcome_assessment.json`, `promotion_event.json`, `context_use.json`, `context_use_feedback.json`. The filename stem **is** the `record_kind`. +- `record-hash-vectors.json` — the canonical RFC 8785 (JCS) preimage **text** of + every record fixture, beside the hash it produces. The text is the part that + makes a vector usable: when a third-party implementation computes a different + digest, the digest cannot say where the two canonicalizations diverged and a + byte diff of the preimage can. - `record-attestation.json` — a **detached** `RecordAttestation` (it is not a record kind; it is ledger metadata beside a record, so it is validated against `#/$defs/RecordAttestation`, never the root record schema). Its - `signed_record_hash` signs `observation.json`'s `record_hash`. + `signed_record_hash` signs `observation.json`'s `record_hash`, and its + `signature` is a real Ed25519 signature that the conformance suite verifies. +- `record-attestation-key.json` — the **published test key** that signs it: the + seed, the public key it derives, and the exact message the signature covers. + It is committed to a public repository and forgeable by anyone, which is the + point — a vector nobody can reproduce is a shape example. Never use it for + anything real. ## What validates these -- **Structure:** `python3 schema/validate-examples.py` validates every record - fixture against +- **Structure and cross-language vector checks:** + `python3 schema/validate-examples.py` validates every record fixture against [`schema/contextgraph-lifecycle-record.schema.json`](../../schema/contextgraph-lifecycle-record.schema.json) - and the attestation against `#/$defs/RecordAttestation`. -- **Round-trip + envelope invariants + hash:** + and the attestation against `#/$defs/RecordAttestation`. It then checks, in + Python and with no JCS library, that each published canonical text hashes to + its published `record_hash` and parses back to the fixture with `record_hash` + removed — the two properties an implementer who has neither Rust nor a + canonicalizer actually relies on. +- **Round-trip, envelope invariants, hash, and signature:** [`contextgraph-conformance/tests/lifecycle_profile_examples.rs`](../../contextgraph-conformance/tests/lifecycle_profile_examples.rs) deserializes each fixture through `contextgraph_types::ContextRecord`, checks - the profile invariants, and **recomputes** `record_hash`. + the profile invariants, **recomputes** `record_hash` and the canonical + preimage, and **verifies** the attestation under the published key. Every + recomputation calls `contextgraph_types::record_attest` rather than a copy of + the rule kept in the test. -## Regenerating the hashes +## Regenerating `record_hash` is content-addressed (profile `LH1`). If you edit a fixture's -content, refresh its hash: +content, refresh everything derived from it: ```sh REGENERATE_LIFECYCLE_HASHES=1 cargo test -p contextgraph-conformance \ --test lifecycle_profile_examples ``` -This rewrites each fixture's `record_hash` (and the attestation's -`signed_record_hash`) in place, preserving field order, then re-run without the +This rewrites each fixture's `record_hash` in place preserving field order, +re-signs `record-attestation.json`, and rebuilds `record-hash-vectors.json` and +the derived members of `record-attestation-key.json`. Then re-run without the env var to verify. ## Worked example — how `record_hash` is computed (RFC 8785 JCS) `record_hash = "sha256:" + hex(sha256(JCS(record without its record_hash member)))`. -Take `observation.json`. **Step 1** — remove its own `record_hash` member from -the preimage. **Step 2** — canonicalize the remaining object with **RFC 8785 -(JCS)**: sort object members by code point, minimal separators (`,` and `:`), no -insignificant whitespace, and the RFC 8785 number form (ECMAScript shortest -round-trip — `0.82`, not `0.820`). For `observation.json` that yields exactly -these 637 bytes (one line, shown wrapped here): +Take `observation.json`. **Step 1** — remove its own top-level `record_hash` +member from the preimage. Removed, not blanked: the record hashes the same +whether the member is absent, correct, or wrong, so a producer never has to +invent a placeholder. **Step 2** — canonicalize the remaining object with **RFC +8785 (JCS)**: sort object members by UTF-16 code unit, minimal separators (`,` +and `:`), no insignificant whitespace, and the RFC 8785 number form (ECMAScript +shortest round-trip — `0.82`, not `0.820`). For `observation.json` that yields +exactly these bytes (one line, shown wrapped here): ``` {"confidence":0.82,"lineage_id":"lin_obs_0001","observed_at":"2026-07-29T14:00:00Z","origin":"observed","provenance":{"origin_authority_id":"authority_acme","origin_provider_id":"provider_example","producer_kind":"agent","producer_ref":"agent://trace-miner"},"record_id":"rec_obs_0001","record_kind":"observation","record_status":"active","schema_version":"contextgraph/lifecycle/1.0-draft","scope":{"repository_id":"repo_stella","session_id":"sess_412","workspace_id":"ws_main"},"sensitivity":"internal","sharing_scope":"repository","statement":"the api handler retries three times before surfacing a 502","subject_ref":"trace_run_991"} @@ -66,12 +87,29 @@ these 637 bytes (one line, shown wrapped here): sha256:b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc ``` -which is exactly the `record_hash` stored in `observation.json`. The reference -Rust `serde_json_canonicalizer` and a -`json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False)` Python -canonicalizer both reproduce these bytes and this hash — that byte-agreement is -the interop guarantee the vectors exist to pin (profile `LH2`). +which is exactly the `record_hash` stored in `observation.json`, and the entry +`record-hash-vectors.json` publishes for it. + +Reproducing those bytes without a JCS library is possible for *these* fixtures +and is not the same thing as implementing RFC 8785. A Python +`json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False)` matches +here because every fixture's member names are ASCII and every number round-trips +identically under CPython's `repr` and ECMAScript's `Number::toString`. Neither +is guaranteed by the profile — a member name outside the BMP would sort +differently, and a number near a precision boundary would print differently — so +compute `record_hash` with a conforming canonicalizer and use the shortcut only +to sanity-check a vector. > The **detached attestation is never part of the preimage** (profile `LH3`): > `record_hash` is computed over the record alone, so signing or rotating a key > never changes a record's identity. + +## Worked example — what the attestation signs + +The signature does **not** cover the bare digest. It covers the domain tag +`contextgraph/attest/1/record` followed by the digest's 32 raw bytes (profile +`LC4`), which is what stops a signature produced at the frame layer — or by an +unrelated system that hashed the same JSON — from being presented as a record +attestation. `record-attestation-key.json` publishes those bytes as +`signed_message_hex`, so an implementation in any language can build them and +check the signature with any Ed25519 library. diff --git a/tests/fixtures/record-attestation-key.json b/tests/fixtures/record-attestation-key.json new file mode 100644 index 0000000..8c57a73 --- /dev/null +++ b/tests/fixtures/record-attestation-key.json @@ -0,0 +1,11 @@ +{ + "algorithm": "ed25519", + "key_id": "cep-signing-key-2026-07", + "note": "PUBLISHED TEST KEY — it is committed to a public repository, so anything it signs is forgeable by anyone. It exists so record-attestation.json is a vector an implementation in any language can reproduce and refute, not a shape example. Never use it for anything real.", + "public_key": "495b4a0a4a16c5444d8626a7ae0bc6eca613676b51fb947238cb8238baa9fde5", + "signed_message_hex": "636f6e7465787467726170682f6174746573742f312f7265636f7264b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc", + "signed_message_note": "RECORD_ATTESTATION_DOMAIN (\"contextgraph/attest/1/record\") followed by the 32 raw bytes of the signed record_hash. Ed25519 signs these bytes; it never signs the bare digest, so a signature from another layer cannot be presented as a record attestation.", + "signing_key_seed": "636f6e7465787467726170682d6c6966656379636c652d746573742d6b657921", + "signing_key_seed_note": "The 32 bytes of the ASCII string \"contextgraph-lifecycle-test-key!\", so the seed is legible as a test key rather than as entropy.", + "signs": "observation.json" +} diff --git a/tests/fixtures/record-attestation.json b/tests/fixtures/record-attestation.json index 2fd3932..42697de 100644 --- a/tests/fixtures/record-attestation.json +++ b/tests/fixtures/record-attestation.json @@ -3,6 +3,6 @@ "key_id": "cep-signing-key-2026-07", "algorithm": "ed25519", "attester_id": "provider_example", - "signature": "3045022100c0ffee02207a1753754b6e334dc7e782562dfe52fb54eb67a66d01ecffe499", + "signature": "8cce3f453510c50d88821eb57dd1767827ba7ab5e29d072b1fadf29583313a04635521228a62b3015399ca8676394087a2bb861a4f893ff4912dea43fdccb905", "issued_at": "2026-07-29T14:00:05Z" } diff --git a/tests/fixtures/record-hash-vectors.json b/tests/fixtures/record-hash-vectors.json new file mode 100644 index 0000000..e591966 --- /dev/null +++ b/tests/fixtures/record-hash-vectors.json @@ -0,0 +1,66 @@ +{ + "note": "Golden RFC 8785 (JCS) preimages and record_hash values for the lifecycle-profile record fixtures beside this file. Recomputed by contextgraph-conformance's lifecycle_profile_examples suite; regenerate with REGENERATE_LIFECYCLE_HASHES=1.", + "rule": "record_hash = \"sha256:\" + hex(sha256(JCS(record with its top-level record_hash member removed)))", + "vectors": [ + { + "jcs_utf8": "{\"contract_name\":\"api-handler-acceptance\",\"lineage_id\":\"lin_contract_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"declared\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"human\",\"producer_ref\":\"user_mac\"},\"record_id\":\"rec_contract_0001\",\"record_kind\":\"artifact_contract\",\"record_status\":\"active\",\"requirements\":[{\"description\":\"src/api/handler.rs is present\",\"requirement_kind\":\"file_exists\"},{\"description\":\"cargo test -p api passes\",\"execution_approval_ref\":\"approval_ci_2026_07\",\"requirement_kind\":\"command\"}],\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"environment_id\":\"env_ci\",\"repository_id\":\"repo_stella\"},\"sharing_scope\":\"repository\"}", + "record_file": "artifact_contract.json", + "record_hash": "sha256:4d6690fdd5347dfa14b8b5f3875b2487d09e18d70d4aa9401a4a1f5f3b527d40" + }, + { + "jcs_utf8": "{\"cited\":false,\"lineage_id\":\"lin_use_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"observed\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"system\",\"producer_ref\":\"host://composer\"},\"record_id\":\"rec_use_0001\",\"record_kind\":\"context_use\",\"record_status\":\"active\",\"rendered\":true,\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"repository_id\":\"repo_stella\",\"session_id\":\"sess_412\",\"task_id\":\"task_deploy_fix\"},\"selected\":true,\"sharing_scope\":\"repository\",\"task_ref\":\"task_deploy_fix\",\"used_record_ref\":\"rec_know_0001\"}", + "record_file": "context_use.json", + "record_hash": "sha256:183ea4a10a56ca024166f81403cd5a84a0ba9a356d7c81f21108bc4e735c5784" + }, + { + "jcs_utf8": "{\"confidence\":0.4,\"context_use_ref\":\"rec_use_0001\",\"extensions\":{\"acme:review_channel\":\"slack:#context-review\"},\"feedback\":\"the fact was selected and rendered but never cited in the final answer\",\"lineage_id\":\"lin_fb_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"declared\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"human\",\"producer_ref\":\"user_mac\"},\"rating\":0.2,\"record_id\":\"rec_fb_0001\",\"record_kind\":\"context_use_feedback\",\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"repository_id\":\"repo_stella\",\"task_id\":\"task_deploy_fix\"},\"sharing_scope\":\"repository\"}", + "record_file": "context_use_feedback.json", + "record_hash": "sha256:96681a4b5b2712ed37c4c72362b008ca6b16535602a72d20fb9045ffde94e915" + }, + { + "jcs_utf8": "{\"contract_ref\":\"rec_contract_0001\",\"evidence_links\":[\"rec_evid_0001\"],\"lineage_id\":\"lin_cval_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"observed\",\"outcome\":\"pass\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"system\",\"producer_ref\":\"ci://runner-7\"},\"record_id\":\"rec_cval_0001\",\"record_kind\":\"contract_validation\",\"record_links\":[{\"rel\":\"validates\",\"target_record_id\":\"rec_contract_0001\"}],\"record_status\":\"active\",\"requirement_results\":[{\"outcome\":\"pass\",\"requirement_kind\":\"file_exists\"},{\"detail\":\"42 tests, 0 failures\",\"outcome\":\"pass\",\"requirement_kind\":\"command\"}],\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"environment_id\":\"env_ci\",\"repository_id\":\"repo_stella\"},\"sharing_scope\":\"repository\"}", + "record_file": "contract_validation.json", + "record_hash": "sha256:862f66a847ba716c410b11792a98ec483929776cab3c98d071b4a89f8be45312" + }, + { + "jcs_utf8": "{\"constraint_effect\":\"forbid\",\"directive_kind\":\"constraint\",\"enforcement\":\"blocking\",\"lineage_id\":\"lin_dir_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"declared\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"human\",\"producer_ref\":\"user_security_lead\"},\"record_id\":\"rec_dir_0001\",\"record_kind\":\"directive\",\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"organization_id\":\"org_acme\"},\"sensitivity\":\"confidential\",\"sharing_scope\":\"organization\",\"statement\":\"never write credentials or tokens to logs or traces\"}", + "record_file": "directive.json", + "record_hash": "sha256:0267fa312456074983b526da12682a4b5685f65750081296b42d89399120f4b3" + }, + { + "jcs_utf8": "{\"evidence_kind\":\"log\",\"lineage_id\":\"lin_evid_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"observed\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"tool\",\"producer_ref\":\"tool://log-reader\"},\"record_id\":\"rec_evid_0001\",\"record_kind\":\"evidence\",\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"repository_id\":\"repo_stella\",\"session_id\":\"sess_412\"},\"sharing_scope\":\"repository\",\"statement\":\"log line 2026-07-29T12:04:11Z shows upstream timeout after 3 retries\"}", + "record_file": "evidence.json", + "record_hash": "sha256:57fcddcd66f85b42a6154d8a3e3e43bdf2e0b12605f3fdb81f7b8b9acd295bdf" + }, + { + "jcs_utf8": "{\"confidence\":0.95,\"knowledge_kind\":\"fact\",\"lineage_id\":\"lin_know_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"declared\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"human\",\"producer_ref\":\"user_mac\"},\"record_id\":\"rec_know_0001\",\"record_kind\":\"knowledge\",\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"organization_id\":\"org_acme\",\"repository_id\":\"repo_stella\"},\"sharing_scope\":\"organization\",\"statement\":\"the retry ceiling for the deploy pipeline is five attempts\"}", + "record_file": "knowledge.json", + "record_hash": "sha256:2d3a4530de7392338a66f321e798e65398c1b51955f622ceceaac24c709212c2" + }, + { + "jcs_utf8": "{\"confidence\":0.7,\"lineage_id\":\"lin_mem_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"observed\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"agent\",\"producer_ref\":\"agent://coder\"},\"record_id\":\"rec_mem_0001\",\"record_kind\":\"memory\",\"record_status\":\"active\",\"salience\":0.7,\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"user_id\":\"user_mac\",\"workspace_id\":\"ws_main\"},\"sharing_scope\":\"user\",\"statement\":\"the user prefers terse, review-ready diffs over verbose explanations\"}", + "record_file": "memory.json", + "record_hash": "sha256:5870e397da8c76a8273b76b136a67b6b8ddff5c9aaaa13dc5b85800a98bbd161" + }, + { + "jcs_utf8": "{\"confidence\":0.82,\"lineage_id\":\"lin_obs_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"observed\",\"provenance\":{\"origin_authority_id\":\"authority_acme\",\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"agent\",\"producer_ref\":\"agent://trace-miner\"},\"record_id\":\"rec_obs_0001\",\"record_kind\":\"observation\",\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"repository_id\":\"repo_stella\",\"session_id\":\"sess_412\",\"workspace_id\":\"ws_main\"},\"sensitivity\":\"internal\",\"sharing_scope\":\"repository\",\"statement\":\"the api handler retries three times before surfacing a 502\",\"subject_ref\":\"trace_run_991\"}", + "record_file": "observation.json", + "record_hash": "sha256:b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc" + }, + { + "jcs_utf8": "{\"assessment\":\"the retry ceiling change resolved the intermittent 502s\",\"confidence\":0.75,\"lineage_id\":\"lin_out_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"derived\",\"provenance\":{\"derivation_kind\":\"inference\",\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"agent\",\"producer_ref\":\"agent://judge\",\"source_refs\":[\"rec_cval_0001\"]},\"rating\":0.8,\"record_id\":\"rec_out_0001\",\"record_kind\":\"outcome_assessment\",\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"repository_id\":\"repo_stella\",\"task_id\":\"task_deploy_fix\"},\"sharing_scope\":\"workspace\",\"subject_ref\":\"task_deploy_fix\"}", + "record_file": "outcome_assessment.json", + "record_hash": "sha256:4f66d632c3b820e6d4f467650eae08ebe9ec56b7eec67d128c82b2fda51eb02e" + }, + { + "jcs_utf8": "{\"from_status\":\"proposed\",\"lineage_id\":\"lin_promo_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"declared\",\"provenance\":{\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"human\",\"producer_ref\":\"user_security_lead\"},\"record_id\":\"rec_promo_0001\",\"record_kind\":\"promotion_event\",\"record_links\":[{\"rel\":\"promotes\",\"target_record_id\":\"rec_dir_0001\"}],\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"organization_id\":\"org_acme\",\"repository_id\":\"repo_stella\"},\"sharing_scope\":\"organization\",\"subject_ref\":\"rec_dir_0001\",\"to_status\":\"active\"}", + "record_file": "promotion_event.json", + "record_hash": "sha256:81e644153f47d2c7d4b268118bef27f119e47265e2fd0a1cdba849450d84b90c" + }, + { + "jcs_utf8": "{\"confidence\":0.6,\"evidence_links\":[\"rec_obs_0001\"],\"lineage_id\":\"lin_prop_0001\",\"observed_at\":\"2026-07-29T14:00:00Z\",\"origin\":\"derived\",\"proposed_kind\":\"directive\",\"provenance\":{\"derivation_kind\":\"inference\",\"origin_provider_id\":\"provider_example\",\"producer_kind\":\"agent\",\"producer_ref\":\"agent://promoter\",\"source_refs\":[\"rec_obs_0001\"]},\"rationale\":\"the same retry-then-502 observation recurred across three sessions\",\"record_id\":\"rec_prop_0001\",\"record_kind\":\"record_proposal\",\"record_links\":[{\"rel\":\"refines\",\"target_record_id\":\"rec_dir_0001\"}],\"record_status\":\"active\",\"schema_version\":\"contextgraph/lifecycle/1.0-draft\",\"scope\":{\"repository_id\":\"repo_stella\",\"workspace_id\":\"ws_main\"},\"sharing_scope\":\"repository\"}", + "record_file": "record_proposal.json", + "record_hash": "sha256:d14da312af7734f26b51f7e0ea44310c6bca92a0997b9106198ffe597e4351d2" + } + ] +}