From 85fe829aa55abb6a21fd07f3a8d59bcc8bc67c4a Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 21:04:45 -0700 Subject: [PATCH 1/4] feat(contextgraph-types): implement record_hash and RecordAttestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifecycle profile has always defined `record_hash` as the sha256 over the RFC 8785 (JCS) canonicalization of a record with its own `record_hash` removed (LH1), and `RecordAttestation` as a detached Ed25519 signature over it (LC3). 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; and the attestation fixture carried 49 bytes of DER-shaped filler where a signature belongs, with no key published, so no implementation could reproduce or refute it. `contextgraph_types::record_attest` makes the rule callable, behind two new off-by-default features. `record-hash` adds RFC 8785 canonicalization (delegated to serde_json_canonicalizer, whose numbers route through ryu-js — JCS number serialization is ECMAScript Number::toString, and its exponent thresholds are where reimplementations diverge in silence). `record-attestation` adds Ed25519 on top. A frame-only consumer pays for neither, and the crate's zero-dependency default is unchanged. The signed message is domain-separated: "contextgraph/attest/1/record" followed by the digest's 32 raw bytes. 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, so signing it raw would let one signature mean whatever the presenter says it means. Verification recomputes the record's hash rather than reading the stored member, so editing a record and rewriting its hash to match a stolen signature is caught as a mismatch instead of passing. Evidence: the canonicalizer is checked against RFC 8785's own vectors — §3.2.4's byte listing, §3.2.3's sorting data, and Appendix B's IEEE 754 number table. The twelve fixtures' hashes are unchanged, which is what shows this reproduces the existing rule rather than redefining it. tests/fixtures/ now publishes the canonical preimage text of every fixture, a real signature, and the test key that produced it; the conformance suite recomputes all of it through the library. schema/validate-examples.py checks the vectors from Python, without a JCS library, so an implementer who has neither Rust nor a canonicalizer can still rely on them. A CI job builds and runs each feature combination — until now every feature this crate has was off by default and no job turned any of them on, so the attestation code added in #87 compiled nowhere in CI. Also corrects LH2, which said JCS sorts members by code point. RFC 8785 §3.2.3 sorts by UTF-16 code unit, and the two orders differ for a supplementary character. Closes #96 Signed-off-by: macanderson --- .github/workflows/ci.yml | 25 + CHANGELOG.md | 43 + Cargo.lock | 1 + Cargo.toml | 6 +- contextgraph-conformance/Cargo.toml | 9 +- .../tests/lifecycle_profile_examples.rs | 287 ++++++- contextgraph-types/Cargo.toml | 24 +- contextgraph-types/README.md | 18 + contextgraph-types/src/lib.rs | 12 + contextgraph-types/src/record.rs | 75 +- contextgraph-types/src/record_attest.rs | 806 ++++++++++++++++++ contextgraph-types/tests/record_vectors.rs | 174 ++++ ...0012-record-hash-and-record-attestation.md | 167 ++++ docs/profiles/context-exchange-provider.md | 27 +- schema/validate-examples.py | 68 +- tests/fixtures/README.md | 80 +- tests/fixtures/record-attestation-key.json | 11 + tests/fixtures/record-attestation.json | 2 +- tests/fixtures/record-hash-vectors.json | 66 ++ 19 files changed, 1828 insertions(+), 73 deletions(-) create mode 100644 contextgraph-types/src/record_attest.rs create mode 100644 contextgraph-types/tests/record_vectors.rs create mode 100644 docs/adr/0012-record-hash-and-record-attestation.md create mode 100644 tests/fixtures/record-attestation-key.json create mode 100644 tests/fixtures/record-hash-vectors.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 770fbd1..6fdf2ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,31 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace + 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 4e1551b..8994b07 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 0012](./docs/adr/0012-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..07c60fd 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,121 @@ 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 hex = key["signing_key_seed"] + .as_str() + .expect("signing_key_seed is a string"); + let mut seed = [0u8; 32]; + for (slot, pair) in seed.iter_mut().zip(hex.as_bytes().chunks_exact(2)) { + 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; + } + (key, seed) +} + +/// 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_hex = key["public_key"].as_str().expect("public_key is a string"); + let mut public_key = [0u8; 32]; + for (slot, pair) in public_key + .iter_mut() + .zip(public_key_hex.as_bytes().chunks_exact(2)) + { + 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; + } + + 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..7ff8666 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 0012): +# `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 0012): 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/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..143708e --- /dev/null +++ b/contextgraph-types/src/record_attest.rs @@ -0,0 +1,806 @@ +//! 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 0012](../../docs/adr/0012-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. + +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 [`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]; + for (slot, pair) in out.iter_mut().zip(hex.as_bytes().chunks_exact(2)) { + 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); + for pair in s.as_bytes().chunks_exact(2) { + 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..05ea4d2 --- /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 0012). +//! +//! 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/adr/0012-record-hash-and-record-attestation.md b/docs/adr/0012-record-hash-and-record-attestation.md new file mode 100644 index 0000000..7c3e128 --- /dev/null +++ b/docs/adr/0012-record-hash-and-record-attestation.md @@ -0,0 +1,167 @@ +# 0012 — `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 45fe004..babba75 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 0012](../adr/0012-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,9 +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** name this repository's GitHub-raw URL — the only host that serves these bytes, since this repository deploys no website ([ADR 0008](../adr/0008-deploy-topology-and-advertised-urls.md)), so there is no second copy to keep in sync. `schema/validate-examples.py` validates every fixture against the schema and pins the exact `$id` string, and `.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. | +| **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 6ab0848..5e68f48 100755 --- a/schema/validate-examples.py +++ b/schema/validate-examples.py @@ -26,6 +26,7 @@ * the served `$id` copy is the schema's public identity — checked because a stale schema that still resolves is worse than one that 404s. """ +import hashlib import json import re import sys @@ -255,10 +256,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) @@ -292,5 +303,60 @@ def _skip_ws(text: str, index: int) -> int: record_expected_id = f"https://raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/{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" + } + ] +} From df78f02d2321174f37867d0777e77db725180b18 Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 21:15:54 -0700 Subject: [PATCH 2/4] fix(contextgraph-types): gate the RecordAttestation import, renumber the ADR to 0017 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import is used only by the signing and verifying code, so a default build — every CI job except the new feature matrix — failed `-D warnings` on `unused_imports`. Gated to `record-attestation`, with the one ungated doc link that named the type rewritten as an explicit path so it still resolves with the import absent. The local check that reported this clean was `rg -c '^(error|warning)'` over cargo's output. Cargo colourises when it thinks it is talking to a terminal, so the escape sits before the word and `^error` matches nothing — a filter that cannot see the errors, reported as silence. The loop also mis-quoted `--features X` as one argument, so three of the five combinations never ran at all. Re-verified by exit code. ADR 0012 renumbered to 0017: PR #106 adds a differently-named docs/adr/0012-*.md, and two files with different names merge cleanly into a tree holding two ADR 0012s with nothing to catch it. Numbers are now allocated centrally. docs/GUIDE.md's decision log gains the entry, along with 0009, 0010 and 0011, which had been missing since they landed — adding a row to an index while leaving it knowingly incomplete is not a fix. Refs #96 Signed-off-by: macanderson --- CHANGELOG.md | 2 +- contextgraph-types/Cargo.toml | 4 ++-- contextgraph-types/src/record_attest.rs | 8 ++++++-- contextgraph-types/tests/record_vectors.rs | 2 +- docs/GUIDE.md | 6 +++++- ...tion.md => 0017-record-hash-and-record-attestation.md} | 2 +- docs/profiles/context-exchange-provider.md | 2 +- 7 files changed, 17 insertions(+), 9 deletions(-) rename docs/adr/{0012-record-hash-and-record-attestation.md => 0017-record-hash-and-record-attestation.md} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8994b07..e892959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ text lands without a human merge. ### Added - **Record content addressing and record attestation, implemented (lifecycle profile `LH1`/`LC3`; - [ADR 0012](./docs/adr/0012-record-hash-and-record-attestation.md)).** The + [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 diff --git a/contextgraph-types/Cargo.toml b/contextgraph-types/Cargo.toml index 7ff8666..b120501 100644 --- a/contextgraph-types/Cargo.toml +++ b/contextgraph-types/Cargo.toml @@ -24,14 +24,14 @@ 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 0012): +# 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 0012): a detached Ed25519 signature +# 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"] diff --git a/contextgraph-types/src/record_attest.rs b/contextgraph-types/src/record_attest.rs index 143708e..e912b3b 100644 --- a/contextgraph-types/src/record_attest.rs +++ b/contextgraph-types/src/record_attest.rs @@ -1,7 +1,7 @@ //! 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 0012](../../docs/adr/0012-record-hash-and-record-attestation.md)). +//! [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 //! @@ -62,13 +62,17 @@ //! 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 [`RecordAttestation`] +/// 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 diff --git a/contextgraph-types/tests/record_vectors.rs b/contextgraph-types/tests/record_vectors.rs index 05ea4d2..79e83a2 100644 --- a/contextgraph-types/tests/record_vectors.rs +++ b/contextgraph-types/tests/record_vectors.rs @@ -1,5 +1,5 @@ //! Cross-language reference vectors for record content addressing and record -//! attestation (lifecycle profile §3 and §7, ADR 0012). +//! 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** diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 5be94b0..1bed6e8 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -204,8 +204,12 @@ 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. | +| [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/0012-record-hash-and-record-attestation.md b/docs/adr/0017-record-hash-and-record-attestation.md similarity index 99% rename from docs/adr/0012-record-hash-and-record-attestation.md rename to docs/adr/0017-record-hash-and-record-attestation.md index 7c3e128..ffbe0de 100644 --- a/docs/adr/0012-record-hash-and-record-attestation.md +++ b/docs/adr/0017-record-hash-and-record-attestation.md @@ -1,4 +1,4 @@ -# 0012 — `record_hash` and `RecordAttestation`: implementing the record layer's identity +# 0017 — `record_hash` and `RecordAttestation`: implementing the record layer's identity **Status:** Accepted (`contextgraph/lifecycle/1.0-draft`, additive) diff --git a/docs/profiles/context-exchange-provider.md b/docs/profiles/context-exchange-provider.md index babba75..3e94953 100644 --- a/docs/profiles/context-exchange-provider.md +++ b/docs/profiles/context-exchange-provider.md @@ -77,7 +77,7 @@ identity, of idempotency replay, and of attestation. | **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 0012](../adr/0012-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. | +| **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): `tests/fixtures/record-hash-vectors.json` publishes the From 2eea8f66d428e7ef39eb0edf2adf964e62188505 Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 21:20:44 -0700 Subject: [PATCH 3/4] docs(guide): complete the ADR decision log The index stopped at 0008; 0009, 0010, 0011 and 0012 had each landed without a row. Adding only 0017 would have left a table that jumps from 0008 to 0017 and still misses four decisions, so all five are in. Refs #96 Signed-off-by: macanderson --- docs/GUIDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 1bed6e8..f0c0db0 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -207,9 +207,10 @@ Read the full ADR before changing anything it covers. | [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. | | [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. | - + --- From 4a7379a595ab701cf8efc2005ba94b6e0981246f Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 21:35:17 -0700 Subject: [PATCH 4/4] fix(contextgraph-types): use as_chunks for the fixed-width hex pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust 1.98's clippy adds `chunks_exact_to_as_chunks`, warn-by-default, so `-D warnings` turned red on every hex decoder here — including `attest.rs`'s `from_hex`, which predates this branch. CI pins `dtolnay/rust-toolchain@stable`, so the toolchain moved under a tree nobody had changed; the pre-existing site is fixed here because the job cannot go green while it stands. `as_chunks::<2>()` is also the better shape: the length check above each loop already rules out a remainder, and a fixed-size chunk lets the compiler see both indexes are in bounds. The two copies of the hex decode in the conformance suite collapse into one `hex32` helper that also checks the length it assumes. A note on how the earlier local run missed this: cargo replays a cached clippy result for an unchanged crate, so a `clippy` that had passed before the lint existed kept reporting success. /tmp/verify.sh now touches every source first. Refs #96 Signed-off-by: macanderson --- .../tests/lifecycle_profile_examples.rs | 33 ++++++++++--------- contextgraph-types/src/attest.rs | 7 ++-- contextgraph-types/src/record_attest.rs | 6 ++-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/contextgraph-conformance/tests/lifecycle_profile_examples.rs b/contextgraph-conformance/tests/lifecycle_profile_examples.rs index 07c60fd..85fad91 100644 --- a/contextgraph-conformance/tests/lifecycle_profile_examples.rs +++ b/contextgraph-conformance/tests/lifecycle_profile_examples.rs @@ -369,16 +369,28 @@ fn the_detached_attestation_round_trips_and_signs_the_observation_record() { /// 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 hex = key["signing_key_seed"] + 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() - .expect("signing_key_seed is a string"); - let mut seed = [0u8; 32]; - for (slot, pair) in seed.iter_mut().zip(hex.as_bytes().chunks_exact(2)) { + .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; } - (key, seed) + out } /// The attestation example is a **verifiable vector**, not a shape example. @@ -411,16 +423,7 @@ fn the_attestation_example_verifies_under_its_published_key() { return; } - let public_key_hex = key["public_key"].as_str().expect("public_key is a string"); - let mut public_key = [0u8; 32]; - for (slot, pair) in public_key - .iter_mut() - .zip(public_key_hex.as_bytes().chunks_exact(2)) - { - 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; - } + let public_key = hex32(&key, "public_key"); assert_eq!( verify_signed_record_hash(&observation_hash, &attestation, &public_key), 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/record_attest.rs b/contextgraph-types/src/record_attest.rs index e912b3b..6645ea0 100644 --- a/contextgraph-types/src/record_attest.rs +++ b/contextgraph-types/src/record_attest.rs @@ -150,7 +150,8 @@ fn raw_digest(digest: &str) -> Result<[u8; 32], RecordHashError> { .map(|(_, hex)| hex) .ok_or_else(|| RecordHashError::MalformedDigest(digest.to_string()))?; let mut out = [0u8; 32]; - for (slot, pair) in out.iter_mut().zip(hex.as_bytes().chunks_exact(2)) { + 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()))?; @@ -364,7 +365,8 @@ mod crypto { return None; } let mut out = Vec::with_capacity(s.len() / 2); - for pair in s.as_bytes().chunks_exact(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);