From c48e98b56f09512c6e7d986f048b3e600f25256e Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 21:25:36 -0700 Subject: [PATCH 1/3] feat(conformance): check provenance attestation adversarially (F6-F9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC.md §6.5's F6-F9 shipped with their "Verified by" column pointing at `contextgraph_types::attest` — the implementation's own unit tests. Every other guarantee in this protocol earns its credibility from a suite with an adversarial mode behind it, and a guarantee whose only witness is the implementation asserting about itself is the self-attestation §11.1 exists to rule out. The new `attestation` check reads the wire like the §R1, §E1 and §H4 probes do: it takes the attester keys the handshake published, recomputes each served frame's commitment from the frame in hand, and verifies the signature over it in the order §6.5.4 fixes — commitment first, so "the frame moved after signing" is never reported as "the key is wrong". `contextgraph-example-docs` signs what it serves, and grows five `--misbehave` modes, one per forgery the constructions exist to stop: forge-signature wrong key -> BadSignature lift-signature A's signature stapled to B -> CommitmentMismatch truncate-chain a hidden `derivation` link -> CommitmentMismatch swap-content other bytes, signed frame id -> CommitmentMismatch malformed-attestation garbage -> MalformedCommitment `lift-signature` is the one a plausible implementation really does get wrong — sign the bare chain head and every frame citing the same source shares a valid signature. It serves two frames from one backing file so their chain heads and `content_digest`s are equal and only the frame id separates their commitments; `an_attestation_lift_differs_only_in_the_frame_id` asserts that precondition instead of trusting it, because a mode that fails for an unrelated reason proves nothing. Deleting the identity binding from `frame_commitment` makes the mode pass and turns `conformance-red.sh` red, which is the evidence that the check is worth having. `malformed-attestation` also holds F9: the frame stays served, degraded to unattested. The probe asks the reference host that question directly rather than trusting its own bookkeeping, because a host that dropped such frames would hand any peer a denial-of-service primitive. Attestations reach the verifier through two optional envelope members (§6.5.5): `handshake_ack.attester_keys` and `frames.attestations`, detached per F6. Both are additive within contextgraph/1 — a peer that knows nothing about them drops them. §6.5.2 now also pins `provider_id` to the handshake-declared `provider.name`, the only identifier both ends observe. Issue #90 owns the fuller wire treatment (result-set Merkle roots, inclusion proofs); this is the minimum the check cannot run without. A provider that publishes no key and serves no attestation passes: §6.5 makes the construction mandatory and the signing optional, and `conformance-external.sh` treats a skip as a failure. Closes #89 --- CHANGELOG.md | 28 ++ SPEC.md | 45 ++- contextgraph-conformance/Cargo.toml | 8 +- .../src/bin/contextgraph-example-docs.rs | 334 ++++++++++++++++-- .../src/host_conformance.rs | 2 + contextgraph-conformance/src/lib.rs | 332 ++++++++++++++++- .../tests/attestation_conformance.rs | 222 ++++++++++++ .../tests/conformance_suite.rs | 131 ++++++- .../tests/ingest_conformance.rs | 6 +- contextgraph-host/src/http.rs | 8 +- contextgraph-host/src/lib.rs | 5 +- contextgraph-host/src/stdio.rs | 30 +- contextgraph-host/src/wire.rs | 60 +++- contextgraph-mcp-bridge/src/lib.rs | 10 +- contextgraph-refprov/src/lib.rs | 2 + docs/adr/0010-provenance-attestation.md | 7 + docs/composition-walkthrough.md | 4 +- docs/reference-providers.md | 4 +- docs/registry.md | 2 +- .../contextgraph-example-docs.report.json | 7 +- schema/contextgraph-envelope.schema.json | 94 +++++ 21 files changed, 1285 insertions(+), 56 deletions(-) create mode 100644 contextgraph-conformance/tests/attestation_conformance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 430eb5d..411686e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,34 @@ text lands without a human merge. signature nobody checks teaches an implementer to produce forgeries. As a side effect the §6.5 constructions now compile and run under `cargo test`, which nothing in CI did before. +- **An adversarial conformance check for provenance attestation + (`attestation`, `SPEC.md` §6.5 F6–F9).** F6–F9 shipped with their "Verified + by" column pointing at `contextgraph_types::attest` — the implementation's own + unit tests. Every other guarantee in this protocol earns its credibility from + a suite with an adversarial mode behind it, and a guarantee whose only witness + is the implementation asserting about itself is the self-attestation §11.1 + exists to rule out. The suite now recomputes each served frame's commitment + from the frame in hand and verifies the signature over it, against a key the + handshake published. `contextgraph-example-docs` grows five `--misbehave` + modes, one per forgery the constructions exist to stop: `forge-signature` + (wrong key ⇒ `BadSignature`), `lift-signature` (a genuine attestation stapled + to a second frame with identical provenance and an identical `content_digest` + ⇒ `CommitmentMismatch`, and the one a plausible implementation really does get + wrong), `truncate-chain` (a hidden `derivation` link ⇒ `CommitmentMismatch`), + `swap-content` (different bytes under a signed frame id ⇒ + `CommitmentMismatch`), and `malformed-attestation` (garbage ⇒ + `MalformedCommitment`, with the frame still served as *unattested* per F9). + Removing the frame-identity binding from `frame_commitment` makes + `lift-signature` pass and turns `conformance-red.sh` red, which is the whole + point of having the mode. +- **Attestations travel on the wire** through two optional envelope members + (`SPEC.md` §6.5.5): `handshake_ack.attester_keys` publishes the public keys a + provider signs with, and `frames.attestations` carries one detached + attestation per attested frame — beside the frames, never inside one (F6). + Both are additive within `contextgraph/1`: a peer that knows nothing about + them drops them and behaves as before. §6.5.2 now also pins `provider_id` to + the handshake-declared `provider.name`, the only identifier both ends of the + wire observe. - **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/SPEC.md b/SPEC.md index 1948efb..f4d870a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -277,10 +277,10 @@ loud. | **F3** | `citation_label` **MUST** be non-empty — a host must be able to cite a frame by a human label, never a bare id. | `frame-validity` | | **F4** | `valid_from`, `valid_to`, `recorded_at`, and `as_of` **MUST** match `YYYY-MM-DDTHH:MM:SS(.f+)?Z`. | `frame-validity` | | **F5** | Provenance of kind `file` **MUST** carry a digest matching `sha256:<64 lowercase hex>`. | `frame-validity` | -| **F6** | A `ProvenanceAttestation` **MUST** be detached — it **MUST NOT** appear inside the frame it signs, nor inside any hash preimage this spec defines. | `contextgraph_types::attest` | -| **F7** | An attestation's `signed_commitment` **MUST** be the `sha256:<64 lowercase hex>` rendering of a commitment computed exactly as §6.5.2 or §6.5.3 specifies. | `contextgraph_types::attest` | -| **F8** | A verifier that does not recognise an attestation's `algorithm` **MUST** report it as uncheckable and **MUST NOT** treat the frame as attested. "I cannot check this" is never "this is good". | `contextgraph_types::attest` | -| **F9** | A host **MUST NOT** reject or drop a frame solely because it carries an attestation the host cannot verify; an unverifiable attestation degrades the frame to *unattested*, exactly as if it carried none. | `contextgraph_types::attest` | +| **F6** | A `ProvenanceAttestation` **MUST** be detached — it **MUST NOT** appear inside the frame it signs, nor inside any hash preimage this spec defines. | `attestation` | +| **F7** | An attestation's `signed_commitment` **MUST** be the `sha256:<64 lowercase hex>` rendering of a commitment computed exactly as §6.5.2 or §6.5.3 specifies. | `attestation` | +| **F8** | A verifier that does not recognise an attestation's `algorithm` **MUST** report it as uncheckable and **MUST NOT** treat the frame as attested. "I cannot check this" is never "this is good". | `attestation` | +| **F9** | A host **MUST NOT** reject or drop a frame solely because it carries an attestation the host cannot verify; an unverifiable attestation degrades the frame to *unattested*, exactly as if it carried none. | `attestation` | | **F10** | `score` is **provider-local and ordinal** — this spec defines no shared scale. A host **MUST NOT** apply a cross-provider `score` threshold, and **MUST NOT** present a raw `score` as a cross-provider measure of relevance. A host that *orders* frames from different providers by raw `score` **MUST** document it as its own policy choice, never as a protocol guarantee. | host composition | | **F11** | An attestation **MUST** travel beside the frames it covers, in the result's `frame_attestations` / `result_attestation` members, and **MUST NOT** appear as a member of a `ContextFrame` (F6 on the wire). A `frame_attestations` entry **MUST** name the full *(provider id, frame id, `content_digest`)* identity it attests rather than implying it by array position, and **MUST** name a frame the same result carries. | `attestation_wire` suite; envelope schema | | **F12** | A `result_attestation`'s `signed_commitment` **MUST** be the §6.5.3 Merkle root over the commitments of **exactly** the frames carried in `result.frames`, in canonical order — never over a larger candidate set the provider truncated away. | `attestation_wire` suite; `contextgraph_types::attest::result_set_root` | @@ -481,6 +481,43 @@ to one frame from one provider carrying one set of bytes, or it binds to nothing none (D3); the encoding records that absence honestly rather than substituting a placeholder. +`provider_id` is the provider's handshake-declared `provider.name` (§3). A host +also keeps a local id for each provider it has configured, and that one is not a +string the provider ever sees — so it is not one a provider could sign against. +The declared name is the only identifier both ends of the wire observe. + +#### 6.5.5 Carrying an attestation + +An attestation travels **beside** the thing it signs, never inside it (F6). +Two optional members carry it: + +* `handshake_ack.attester_keys` — the public keys the provider signs with. A + provider that publishes none offers no attestation, which is conformant. +* `frames.attestations` — one entry per attested frame, naming its frame by id. + +```jsonc +{ + "type": "handshake_ack", + "protocol_version": "contextgraph/1.0", + "provider": { "name": "example-docs", "version": "1.0.0", + "data_flow": { "reads": true, "writes": false, "egress": false } }, + "capabilities": { "query": { "kinds": ["doc"] } }, + "attester_keys": [ + { "key_id": "example-docs-ed25519-1", "algorithm": "ed25519", + "public_key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" } + ] +} +``` + +A published key settles whether an attestation is **built** the way this section +requires. It settles nothing about *who* signed: it comes from the party under +audit. A deployment that needs the second answer resolves `key_id` against its +own trust store and ignores what the handshake said. + +Both members are optional additions within `contextgraph/1`, so a peer that +knows nothing about them drops them and behaves exactly as it did before (§13 +U1). + #### 6.5.3 Result-set Merkle root A provider signing a whole answer commits to a Merkle root over its frames' diff --git a/contextgraph-conformance/Cargo.toml b/contextgraph-conformance/Cargo.toml index 3b6354f..3256730 100644 --- a/contextgraph-conformance/Cargo.toml +++ b/contextgraph-conformance/Cargo.toml @@ -24,7 +24,13 @@ dist = false [dependencies] # Floor requirements — see the note in contextgraph-host/Cargo.toml: caret reqs break # the release version stamp at the first minor bump. -contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0" } +# `attestation` is ON here, not off-by-default as it is for the pure wire +# consumer: the `attestation` check verifies real Ed25519 signatures over real +# SHA-256 commitments, and a conformance suite that could not run the verifier +# would be asserting §6.5 rather than checking it. +contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0", features = [ + "attestation", +] } contextgraph-host = { path = "../contextgraph-host", version = ">=2.0.0" } serde.workspace = true serde_json.workspace = true diff --git a/contextgraph-conformance/src/bin/contextgraph-example-docs.rs b/contextgraph-conformance/src/bin/contextgraph-example-docs.rs index 70fb57f..5b0426b 100644 --- a/contextgraph-conformance/src/bin/contextgraph-example-docs.rs +++ b/contextgraph-conformance/src/bin/contextgraph-example-docs.rs @@ -16,13 +16,14 @@ use std::io::{BufRead, Write}; use clap::{Parser, ValueEnum}; use sha2::{Digest, Sha256}; -use contextgraph_host::wire::Envelope; +use contextgraph_host::wire::{AttesterKey, Envelope, FrameAttestation}; use contextgraph_types::capability::{QueryCapability, fingerprint_dimensions}; use contextgraph_types::frame::rel; use contextgraph_types::{ - Capabilities, ContextFrame, ContextQuery, ContextQueryResult, DataFlow, EgressScope, ErrorCode, - FrameKind, FrameVerdict, PROTOCOL_VERSION, Provenance, ProviderInfo, Relation, Representation, - Verdict, VerifyRequest, VerifyResponse, budget_tokens, + ALGORITHM_ED25519, Capabilities, ContextFrame, ContextQuery, ContextQueryResult, DataFlow, + EgressScope, ErrorCode, FrameKind, FrameVerdict, PROTOCOL_VERSION, Provenance, + ProvenanceAttestation, ProviderInfo, Relation, Representation, Verdict, VerifyRequest, + VerifyResponse, budget_tokens, public_key_for, sign_frame_attestation, }; /// The embedding space this fixture declares it indexes (`SPEC.md` §E1). Its @@ -114,6 +115,51 @@ enum Misbehave { /// Declare `capabilities.graph` but ignore `query.anchors`, dropping the /// anchored frame instead of boosting it (trips `anchor-relevance`). IgnoreAnchors, + /// Sign an honestly-computed frame commitment with a key that is not the + /// one published at the handshake (trips `attestation`). + /// + /// The commitment matches the frame byte for byte, so §6.5.4's + /// compare-then-verify order reports `BadSignature` rather than a + /// mismatch — "the evidence is intact, the signer is not who they say". + ForgeSignature, + /// Staple frame A's genuine attestation onto frame B (trips + /// `attestation`). + /// + /// This is the forgery the `FrameId` binding of §6.5.2 exists to stop, and + /// the one mode here that a plausible implementation really does get + /// wrong: signing the bare provenance chain head instead of the head bound + /// to the frame's identity. Both frames are served from the *same* backing + /// file with the *same* `content_digest`, so their chain heads are equal + /// and their commitments differ in the frame id alone. Get that wrong and + /// the mode fails for an unrelated reason while proving nothing, which is + /// why `an_attestation_lift_differs_only_in_the_frame_id` asserts the + /// precondition instead of trusting it. + LiftSignature, + /// Sign a chain that records the frame was summarised, then serve the + /// chain with that `derivation` link removed (trips `attestation`). + /// + /// Nothing is re-signed, so the served frame's recomputed commitment no + /// longer matches: `CommitmentMismatch`. A per-link digest set never caught + /// this — dropping a whole link left every surviving digest correct — and + /// it is why §6.5.2 folds the links into a chain. + TruncateChain, + /// Serve a frame under its signed id with a different `content_digest` + /// (trips `attestation`). + /// + /// The digest is well-formed and the provider vouches for it under + /// `context/verify`, so every other check is satisfied; only the signature + /// covering the *bytes* rather than merely the *name* catches the swap + /// (`CommitmentMismatch`). + SwapContent, + /// Attach an unparseable attestation to an otherwise valid frame (trips + /// `attestation`). + /// + /// The verdict is `MalformedCommitment`, and the frame **must still be + /// served**: F9 degrades an unverifiable attestation to *unattested* rather + /// than dropping the frame, because a host that dropped it would hand any + /// peer a denial-of-service primitive — attach garbage, watch the evidence + /// disappear. + MalformedAttestation, } #[derive(Parser)] @@ -185,6 +231,7 @@ fn main() { protocol_version, provider: provider_info(args.misbehave), capabilities: capabilities(), + attester_keys: attester_keys(), }, ); } @@ -249,6 +296,10 @@ fn main() { { frames.retain(|f| !f.valid_from.as_deref().is_some_and(|vf| vf > as_of)); } + // Detached, per F6: the attestations are computed over the + // frames as finally filtered, and ride beside them rather than + // inside one. + let attestations = attestations_for(&frames, args.misbehave); write_envelope( &mut stdout, &Envelope::Frames { @@ -259,6 +310,7 @@ fn main() { dropped_estimate: None, ..Default::default() }, + attestations, }, ); } @@ -364,6 +416,192 @@ fn embedding_dimension_error(query: &ContextQuery, id: Option) -> Option }) } +// --------------------------------------------------------------------------- +// Provenance attestation (`SPEC.md` §6.5, F6–F9) +// --------------------------------------------------------------------------- + +/// The Ed25519 seed this fixture signs with. +/// +/// A hardcoded constant, in a file that is otherwise a test fixture: it signs +/// nothing outside this binary, and the conformance suite needs the signatures +/// to be reproducible across runs and machines. A real provider holds its seed +/// in an HSM or a KMS and calls `frame_commitment` itself — the protocol +/// specifies the preimage, never the custody of the key. +const ATTESTER_SEED: [u8; 32] = [42u8; 32]; + +/// A second seed, used by [`Misbehave::ForgeSignature`] and by nothing else. +/// Distinct from [`ATTESTER_SEED`], so a signature it produces cannot verify +/// under the key the handshake published. +const FORGERY_SEED: [u8; 32] = [43u8; 32]; + +/// The id under which [`ATTESTER_SEED`]'s public key is published. Rotation +/// would be a new id, never a reuse of this one. +const ATTESTER_KEY_ID: &str = "example-docs-ed25519-1"; + +/// Who is accountable for the claim, as distinct from which key produced it. +const ATTESTER_ID: &str = "contextgraph-example-docs"; + +/// A fixed issuance instant, so two runs of this fixture emit byte-identical +/// attestations. +const ATTESTATION_ISSUED_AT: &str = "2026-08-29T00:00:00Z"; + +/// The `provider_id` this fixture binds into every frame commitment +/// (`SPEC.md` §6.5.2). +/// +/// It is the provider's handshake-declared `provider.name`, which is the one +/// identifier both sides of the wire observe — a host-chosen local id (the +/// suite's `provider-under-test`) is not visible to the provider, so signing +/// against it is not something a provider could do. +fn attestation_provider_id() -> String { + provider_info(None).name +} + +/// Render raw bytes as the lowercase hex [`AttesterKey::public_key`] and +/// [`ProvenanceAttestation::signature`] both use. +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// The attester keys this fixture publishes at the handshake. +/// +/// Published *before* any frame moves, and always the honest key — including +/// under [`Misbehave::ForgeSignature`], which signs with [`FORGERY_SEED`] +/// instead. A provider that could republish its key with every answer could +/// make a forged signature verify simply by publishing the forger's key, which +/// is why the declaration lives on the handshake. +fn attester_keys() -> Vec { + vec![AttesterKey { + key_id: ATTESTER_KEY_ID.into(), + algorithm: ALGORITHM_ED25519.into(), + public_key: hex_encode(&public_key_for(&ATTESTER_SEED)), + }] +} + +/// Sign `frame`'s commitment with `seed`. +fn attest(frame: &ContextFrame, seed: &[u8; 32]) -> ProvenanceAttestation { + sign_frame_attestation( + &attestation_provider_id(), + frame, + seed, + ATTESTER_KEY_ID, + ATTESTER_ID, + ATTESTATION_ISSUED_AT, + ) +} + +/// The `derivation` link [`Misbehave::TruncateChain`] signs and then hides: the +/// admission that a frame was *summarised* rather than quoted verbatim. +/// +/// Truncation is the interesting attack because the surviving links stay +/// individually correct — every per-link digest still matches its bytes — so +/// only a chain that folds each link into the next records that a link was ever +/// there (§6.5.2). +fn summarisation_link() -> Provenance { + Provenance { + kind: "derivation".into(), + uri: None, + range: None, + digest: None, + method: Some("summarize".into()), + by: Some(ATTESTER_ID.into()), + } +} + +/// The detached attestations this fixture serves beside `frames` (§6.5). +/// +/// Honest modes sign each frame exactly as served, so every misbehaviour that +/// is *not* about attestation leaves the `attestation` check green and stays +/// attributable to the check that owns it. The five attestation modes each sign +/// one thing and serve another. +fn attestations_for( + frames: &[ContextFrame], + misbehave: Option, +) -> Vec { + let staple = |frame: &ContextFrame, attestation: ProvenanceAttestation| FrameAttestation { + frame_id: frame.id.clone(), + attestation, + }; + match misbehave { + // A commitment computed honestly over the served frame, signed by a key + // the handshake never published. + Some(Misbehave::ForgeSignature) => frames + .iter() + .map(|frame| staple(frame, attest(frame, &FORGERY_SEED))) + .collect(), + // Frame A's genuine attestation, stapled to frame B. `canned_frames` + // has already given B A's provenance and A's `content_digest`, so the + // two commitments differ in the frame id and in nothing else. + Some(Misbehave::LiftSignature) => { + let Some((first, rest)) = frames.split_first() else { + return Vec::new(); + }; + let genuine = attest(first, &ATTESTER_SEED); + std::iter::once(staple(first, genuine.clone())) + .chain(rest.iter().map(|frame| staple(frame, genuine.clone()))) + .collect() + } + // Sign the truth (the chain including the summarisation link), serve + // the lie (the chain without it). Nothing is re-signed. + Some(Misbehave::TruncateChain) => frames + .iter() + .enumerate() + .map(|(index, frame)| { + if index == 0 { + let mut full = frame.clone(); + full.provenance.push(summarisation_link()); + staple(frame, attest(&full, &ATTESTER_SEED)) + } else { + staple(frame, attest(frame, &ATTESTER_SEED)) + } + }) + .collect(), + // Sign the honest bytes, serve a different `content_digest` under the + // same frame id. `canned_frames` served the swapped digest; restoring + // the honest one here is what the provider signed. + Some(Misbehave::SwapContent) => frames + .iter() + .enumerate() + .map(|(index, frame)| { + if index == 0 { + let mut honest = frame.clone(); + honest.content_digest = Some(fixture_digest("getting-started.md")); + staple(frame, attest(&honest, &ATTESTER_SEED)) + } else { + staple(frame, attest(frame, &ATTESTER_SEED)) + } + }) + .collect(), + // Garbage on the first frame, an honest attestation on the rest — so + // the check can report one frame still attested beside the one that + // degraded to unattested (F9). + Some(Misbehave::MalformedAttestation) => frames + .iter() + .enumerate() + .map(|(index, frame)| { + if index == 0 { + staple( + frame, + ProvenanceAttestation::new( + "not-a-commitment", + ATTESTER_KEY_ID, + ALGORITHM_ED25519, + ATTESTER_ID, + "zzzz", + "whenever", + ), + ) + } else { + staple(frame, attest(frame, &ATTESTER_SEED)) + } + }) + .collect(), + _ => frames + .iter() + .map(|frame| staple(frame, attest(frame, &ATTESTER_SEED))) + .collect(), + } +} + /// The directory holding this reference provider's on-disk backing files, /// resolved at compile time so a digest is computed over the same bytes no /// matter where the fixture is spawned from (`SPEC.md` §6.2). @@ -386,11 +624,7 @@ fn fixture_uri(file: &str) -> String { /// grammar, since the flood mode's violation is its frame count, not its digest. fn fixture_digest(file: &str) -> String { let bytes = std::fs::read(format!("{FIXTURE_DIR}/{file}")).unwrap_or_default(); - let hex: String = Sha256::digest(&bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - format!("sha256:{hex}") + format!("sha256:{}", hex_encode(&Sha256::digest(&bytes))) } /// Flip the last hex digit of a well-formed digest, yielding one that still @@ -422,20 +656,67 @@ fn declared_digest(file: &str, misbehave: Option) -> String { } } -/// The digest this fixture serves for a frame id *right now*, or `None` if it -/// does not serve that frame at all. Threaded through `misbehave` so -/// `stale-digest` stays internally *consistent* over the wire: the provider -/// vouches for the very (forged) digest it served, so `verify-honesty` still -/// passes and the forgery is left for `provenance-fixture-consistency` alone to -/// catch by re-reading the file. -fn current_digest(frame_id: &str, misbehave: Option) -> Option { +/// The `content_digest` a frame declares, which is [`declared_digest`] except +/// under [`Misbehave::SwapContent`]. +/// +/// That mode re-serves *different bytes* under a signed frame id: the +/// `content_digest` moves while the `file` provenance digest stays the real one +/// over the real bytes. Splitting the two is what keeps the swap attributable — +/// `provenance-fixture-consistency` re-reads the file and is satisfied, §D1's +/// grammar is satisfied, and only the signature covering the frame's bytes +/// rather than merely its name catches it. +fn declared_content_digest(file: &str, misbehave: Option) -> String { + let declared = declared_digest(file, misbehave); + if misbehave == Some(Misbehave::SwapContent) && file == PRIMARY_FILE { + stale_digest(&declared) + } else { + declared + } +} + +/// The backing file of the frame the attestation modes tamper with. Naming it +/// once keeps [`declared_content_digest`] and [`attestations_for`] pointed at +/// the same frame — if they disagreed, a mode would fail for a reason it does +/// not claim. +const PRIMARY_FILE: &str = "getting-started.md"; + +/// The file each canned frame is served from. +/// +/// Ordinarily one file per frame. Under [`Misbehave::LiftSignature`] the second +/// frame is served from the *first* frame's file, so the two frames carry +/// identical provenance and an identical `content_digest` and their commitments +/// differ in the frame id alone — the one difference the §6.5.2 identity +/// binding exists to catch. +fn backing_file( + frame_id: &str, + misbehave: Option, +) -> Option<(&'static str, &'static str)> { match frame_id { - "frm_getting_started" => Some(declared_digest("getting-started.md", misbehave)), - "frm_configuration" => Some(declared_digest("configuration.md", misbehave)), + "frm_getting_started" => Some((PRIMARY_FILE, "L1-40")), + "frm_configuration" if misbehave == Some(Misbehave::LiftSignature) => { + Some((PRIMARY_FILE, "L1-40")) + } + "frm_configuration" => Some(("configuration.md", "L1-25")), + // The synthetic frame the flood mode clones. `flood.md` is a name this + // fixture does not ship, which is deliberate: the flood mode's + // violation is its frame count, and a link to a file no host can read + // is skipped by the byte-consistency check rather than failed by it. + "frm_flood" => Some(("flood.md", "L1")), _ => None, } } +/// The digest this fixture serves for a frame id *right now*, or `None` if it +/// does not serve that frame at all. Threaded through `misbehave` so every +/// digest-moving mode stays internally *consistent* over the wire: the provider +/// vouches for the very (forged) digest it served, so `verify-honesty` still +/// passes and the forgery is left for the check that owns it — the file bytes +/// for `stale-digest`, the signature for `swap-content`. +fn current_digest(frame_id: &str, misbehave: Option) -> Option { + let (file, _) = backing_file(frame_id, misbehave)?; + Some(declared_content_digest(file, misbehave)) +} + /// Answer a `context/verify` request honestly, by comparing each presented /// digest against the one this provider currently serves (`docs/context-reuse.md` §4). /// @@ -509,8 +790,6 @@ fn canned_frames(misbehave: Option) -> Vec { "Getting Started", "Install the reference binding with `cargo add contextgraph-types`, then implement \ the four required methods.", - "getting-started.md", - "L1-40", "2026-01-01T00:00:00Z", 0.82, misbehave, @@ -530,8 +809,6 @@ fn canned_frames(misbehave: Option) -> Vec { "frm_configuration", "Configuration example", "let host = Host::new().with_provider(\"docs\", provider);", - "configuration.md", - "L1-25", "2026-09-01T00:00:00Z", 0.61, misbehave, @@ -568,20 +845,23 @@ fn canned_frames(misbehave: Option) -> Vec { /// A frame with the defect selected by `misbehave` applied, if any. /// +/// The backing file and range come from [`backing_file`] rather than from the +/// caller, because [`Misbehave::LiftSignature`] re-points the second frame at +/// the first frame's file and both this frame and `context/verify` have to +/// agree about that. +/// /// `valid_from` is the instant the frame's content became true in the world /// (§6.1); callers give the two canned frames *disjoint* windows so an `as_of` /// pin between them is observable — the `as-of-temporal` probe depends on it. -#[allow(clippy::too_many_arguments)] fn doc_frame( id: &str, title: &str, content: &str, - file: &str, - range: &str, valid_from: &str, score: f32, misbehave: Option, ) -> ContextFrame { + let (file, range) = backing_file(id, misbehave).unwrap_or((PRIMARY_FILE, "L1-40")); let honest_cost = budget_tokens(content); ContextFrame { id: id.into(), @@ -592,7 +872,7 @@ fn doc_frame( // frame's file-provenance digest, so a host re-reading the file confirms // both (§6.2, §F5). `stale-digest` flips one hex digit (well-formed but // wrong bytes); `malformed-digest` replaces it with an ungrammatical stub. - content_digest: Some(declared_digest(file, misbehave)), + content_digest: Some(declared_content_digest(file, misbehave)), uri: Some(fixture_uri(file)), // This fixture serves inline `full` frames only. representation: Representation::Full, @@ -655,8 +935,6 @@ fn base_frame( "frm_flood", "Flood", "x", - "flood.md", - "L1", "2026-01-01T00:00:00Z", 0.5, misbehave.filter(|m| !matches!(m, Misbehave::FloodFrames)), diff --git a/contextgraph-conformance/src/host_conformance.rs b/contextgraph-conformance/src/host_conformance.rs index 0834e82..4b9f89e 100644 --- a/contextgraph-conformance/src/host_conformance.rs +++ b/contextgraph-conformance/src/host_conformance.rs @@ -744,6 +744,7 @@ fn handshake_ack_line(version: &str) -> String { }, ..Capabilities::default() }, + attester_keys: vec![], }; serde_json::to_string(&ack).expect("a fixed handshake_ack always serializes") } @@ -759,6 +760,7 @@ fn frames_line() -> String { dropped_estimate: None, ..Default::default() }, + attestations: vec![], }; serde_json::to_string(&env).expect("a fixed frames envelope always serializes") } diff --git a/contextgraph-conformance/src/lib.rs b/contextgraph-conformance/src/lib.rs index 160127d..eb20ba6 100644 --- a/contextgraph-conformance/src/lib.rs +++ b/contextgraph-conformance/src/lib.rs @@ -46,6 +46,16 @@ //! grammatically valid digest that hashes *wrong* — a stale or forged claim — //! is caught here, where §F5's grammar check cannot see it. Host-local: a link //! to files this host cannot read is skipped, not failed. +//! - **attestation** — every detached [provenance +//! attestation](contextgraph_types::ProvenanceAttestation) a provider serves +//! is recomputed from the frame in hand and verified against a key the +//! handshake published (`SPEC.md` §6.5, F6–F9). Where +//! `provenance-fixture-consistency` proves a digest matches its bytes, this +//! proves somebody *signed* for those bytes — the digest and the frame come +//! from the same unauthenticated party, so §6.2 is satisfied in full by a +//! provider that fabricated both. A provider that publishes no attester key +//! and serves no attestation passes: §6.5 makes the construction mandatory +//! and the signing optional. //! //! The suite is deliberately adversarial: pointed at a provider that lies //! about costs, emits an out-of-range score, omits a citation label, or dies @@ -71,13 +81,13 @@ //! [`ReferenceComposingHost`] is the worked example that passes it. use contextgraph_host::{ - ConsentRecord, ContextProvider, DigestVerification, DropReason, Host, HostError, + AttesterKey, ConsentRecord, ContextProvider, DigestVerification, DropReason, Host, HostError, RawStdioConnection, frame_kind_name, verify_file_provenance, }; use contextgraph_types::capability::fingerprint_dimensions; use contextgraph_types::{ - Capabilities, ConsentReceipt, ContextQuery, ContextQueryResult, ErrorCode, FrameId, FrameKind, - Grantor, ProviderInfo, + AttestationVerdict, Capabilities, ConsentReceipt, ContextQuery, ContextQueryResult, ErrorCode, + FrameId, FrameKind, Grantor, ProviderInfo, verify_frame_attestation, }; pub mod composition_conformance; @@ -109,6 +119,7 @@ pub const CHECK_CORRELATION: &str = "correlation"; pub const CHECK_KINDS_FILTER: &str = "kinds-filter"; pub const CHECK_ANCHOR_RELEVANCE: &str = "anchor-relevance"; pub const CHECK_PROVENANCE_FIXTURE_CONSISTENCY: &str = "provenance-fixture-consistency"; +pub const CHECK_ATTESTATION: &str = "attestation"; /// How to reach the provider under test. `contextgraph-inspect` builds one of these /// from its CLI arguments; tests build them directly. @@ -198,6 +209,7 @@ pub async fn run_conformance(target: ProviderTarget) -> ConformanceReport { checks.push(malformed_stdio_probe(&program, &args).await); checks.push(embedding_fingerprint_stdio_probe(&program, &args).await); checks.push(correlation_stdio_probe(&program, &args).await); + checks.push(attestation_stdio_probe(&program, &args).await); } None => { checks.push(CheckResult::skip( @@ -212,6 +224,10 @@ pub async fn run_conformance(target: ProviderTarget) -> ConformanceReport { CHECK_CORRELATION, "wire-level §H4 id-echo probe applies to stdio providers only", )); + checks.push(CheckResult::skip( + CHECK_ATTESTATION, + "wire-level §6.5 attestation probe applies to stdio providers only", + )); } } @@ -768,6 +784,316 @@ async fn correlation_stdio_probe(program: &str, args: &[String]) -> CheckResult } } +/// **§6.5 (F6–F9)** — a provenance attestation is checked, never taken on +/// trust. +/// +/// A digest proves the bytes have not moved since somebody wrote that number +/// down. It says nothing about *who* wrote it, and the digest and the frame come +/// from the same unauthenticated party — so §6.2 is satisfied in full by a +/// provider that fabricated both. A signature is the only thing that closes +/// that, and until this check existed the four guarantees over it were verified +/// by `contextgraph_types::attest`'s own unit tests and by nothing on the wire. +/// Every other guarantee in this protocol earns its credibility from a suite +/// with an adversarial mode behind it; a guarantee whose only witness is the +/// implementation asserting about itself is the self-attestation §11.1 rules +/// out. +/// +/// The probe: +/// +/// 1. reads the [attester keys](contextgraph_host::AttesterKey) the handshake +/// published — a *construction* anchor, not a trust one (see that type); +/// 2. queries, and takes the detached attestations from the `frames` envelope; +/// 3. recomputes each named frame's commitment from the frame in hand and +/// verifies the signature over it, exactly as §6.5.4 orders the two steps; +/// 4. when anything fails to verify, re-asks the same provider **through the +/// reference [`Host`]** and asserts the affected frame still arrives — +/// F9's rule that an unverifiable attestation degrades a frame to +/// *unattested* rather than removing it. +/// +/// **`provider_id` in the commitment is the handshake-declared `provider.name`.** +/// §6.5.2 binds a commitment to a provider id without saying which one, and the +/// host-chosen local id (`provider-under-test` here) is not a string the +/// provider ever sees — so it is not one a provider could sign against. The +/// declared name is the only identifier both ends observe. Pinning that in +/// SPEC.md is tracked separately; until it is, an implementation reading only +/// the spec could pick differently and fail this check for the wrong reason. +/// +/// Raw-stdio like the §R1, §E1 and §H4 probes, and for the same reason: the +/// attestations ride the envelope, and [`Host::query_provider`] hands back a +/// [`ContextQueryResult`] with the envelope already discarded. +/// +/// Passing outcomes, in order of how a provider reaches them: +/// +/// - publishes no key and serves no attestation ⇒ **pass**. §6.5 makes the +/// construction mandatory and the signing optional, so a provider that signs +/// nothing is conformant and this check has nothing to say about it. +/// - every attestation it serves verifies ⇒ **pass**. +/// - every attestation names a scheme this build cannot check ⇒ **skip**, per +/// F8: "I cannot check this" is never "this is good", and it is equally never +/// "this is forged". +async fn attestation_stdio_probe(program: &str, args: &[String]) -> CheckResult { + let mut conn = match RawStdioConnection::spawn(program, args).await { + Ok(conn) => conn, + Err(error) => { + return CheckResult::fail( + CHECK_ATTESTATION, + format!("could not spawn provider: {error}"), + ); + } + }; + let info = match conn.handshake().await { + Ok((info, _)) => info, + Err(error) => { + return CheckResult::skip( + CHECK_ATTESTATION, + format!("handshake failed before the §6.5 probe could run: {error}"), + ); + } + }; + let keys: Vec = conn.attester_keys().to_vec(); + + if let Err(error) = conn + .send(&contextgraph_host::Envelope::Query { + id: None, + query: sample_query(), + }) + .await + { + return CheckResult::fail( + CHECK_ATTESTATION, + format!("provider closed its input before the §6.5 probe query: {error}"), + ); + } + let (frames, attestations) = match conn.recv().await { + Ok(contextgraph_host::Envelope::Frames { + result, + attestations, + .. + }) => (result.frames, attestations), + Ok(other) => { + return CheckResult::fail( + CHECK_ATTESTATION, + format!( + "provider answered the §6.5 probe with an unexpected `{}` envelope", + contextgraph_host::envelope_kind(&other) + ), + ); + } + Err(error) => { + return CheckResult::fail( + CHECK_ATTESTATION, + format!("provider mishandled the §6.5 probe: {error}"), + ); + } + }; + + if keys.is_empty() && attestations.is_empty() { + return CheckResult::pass( + CHECK_ATTESTATION, + "provider publishes no attester key and serves no attestation; §6.5 makes the construction mandatory and the signing optional, so there is nothing here to forge", + ); + } + if keys.is_empty() { + return CheckResult::fail( + CHECK_ATTESTATION, + format!( + "provider served {} attestation(s) but published no attester key at the handshake — nothing reading this wire can check them, and an unverifiable signature is decoration (§6.5.4)", + attestations.len() + ), + ); + } + if frames.is_empty() { + return CheckResult::pass( + CHECK_ATTESTATION, + "provider returned 0 frames, so there is nothing to attest (permitted — nothing relevant to the probe)", + ); + } + if attestations.is_empty() { + return CheckResult::fail( + CHECK_ATTESTATION, + format!( + "provider published {} attester key(s) at the handshake but attested none of the {} frame(s) it served — a signing capability nothing can exercise (§6.5)", + keys.len(), + frames.len() + ), + ); + } + + let mut verified: Vec = Vec::new(); + let mut uncheckable: Vec = Vec::new(); + let mut problems: Vec = Vec::new(); + // Frames whose attestation did not verify: F9 says these must still be + // served, degraded to unattested rather than dropped. + let mut degraded: Vec = Vec::new(); + + for entry in &attestations { + let Some(frame) = frames.iter().find(|frame| frame.id == entry.frame_id) else { + problems.push(format!( + "attestation names frame `{}`, which is not in the answer it rides with (§6.5.2 binds a signature to one frame of one answer)", + entry.frame_id + )); + continue; + }; + let Some(key) = keys + .iter() + .find(|key| key.key_id == entry.attestation.key_id) + else { + problems.push(format!( + "frame `{}` is signed under key_id `{}`, which the handshake never published", + entry.frame_id, entry.attestation.key_id + )); + continue; + }; + let Some(key_bytes) = decode_hex(&key.public_key) else { + problems.push(format!( + "published key `{}` is not lowercase hex, so no verifier can load it", + key.key_id + )); + continue; + }; + + match verify_frame_attestation(&info.name, frame, &entry.attestation, &key_bytes) { + AttestationVerdict::Valid => verified.push(entry.frame_id.clone()), + AttestationVerdict::UnknownAlgorithm(algorithm) => { + uncheckable.push(format!("{} (algorithm `{algorithm}`)", entry.frame_id)); + degraded.push(entry.frame_id.clone()); + } + verdict => { + problems.push(describe_attestation_failure(&entry.frame_id, &verdict)); + degraded.push(entry.frame_id.clone()); + } + } + } + + // F9. An unverifiable attestation degrades its frame to *unattested*; it + // never removes the frame from the answer, because a host that dropped such + // frames would hand any peer a denial-of-service primitive — attach garbage, + // watch the evidence disappear. Asked of the reference host rather than of + // this probe's own bookkeeping, so it is a claim about a host's behaviour + // and not about the suite's. + if !degraded.is_empty() + && let Some(dropped) = frames_the_host_dropped(program, args, °raded).await + { + problems.push(format!( + "the host stopped serving {} after their attestation failed to verify — F9 requires an unverifiable attestation to degrade a frame to unattested, never to remove it", + dropped.join(", ") + )); + } + + if !problems.is_empty() { + return CheckResult::fail( + CHECK_ATTESTATION, + format!( + "{} of {} attestation(s) did not verify (§6.5.4): {}", + problems.len(), + attestations.len(), + problems.join("; ") + ), + ); + } + if verified.is_empty() { + return CheckResult::skip( + CHECK_ATTESTATION, + format!( + "every attestation names a scheme this build cannot check, so F8 declines rather than guessing: {}", + uncheckable.join(", ") + ), + ); + } + + let note = if uncheckable.is_empty() { + String::new() + } else { + format!( + "; {} left unattested by F8 as uncheckable here: {}", + uncheckable.len(), + uncheckable.join(", ") + ) + }; + CheckResult::pass( + CHECK_ATTESTATION, + format!( + "recomputed and verified {} detached attestation(s) over {} served frame(s) against the handshake-published key(s) (§6.5){note}", + verified.len(), + frames.len() + ), + ) +} + +/// Name an [`AttestationVerdict`] failure in the terms §6.5.4 separates them +/// into, because the two loudest ones call for opposite responses: a mismatch +/// says the frame moved after signing (tampering), a bad signature says the key +/// is wrong or the signature forged (key management). +fn describe_attestation_failure(frame_id: &str, verdict: &AttestationVerdict) -> String { + match verdict { + AttestationVerdict::CommitmentMismatch { expected, signed } => format!( + "frame `{frame_id}` recomputes to {expected} but its attestation signs {signed} — the frame, its `content_digest`, or its provenance chain changed after signing (CommitmentMismatch)" + ), + AttestationVerdict::BadSignature => format!( + "frame `{frame_id}` commits correctly but its signature does not verify under the published key — forged, or signed by a key the provider did not declare (BadSignature)" + ), + AttestationVerdict::MalformedKey => { + format!("frame `{frame_id}`: the published key is not a well-formed key (MalformedKey)") + } + AttestationVerdict::MalformedSignature => format!( + "frame `{frame_id}`: the signature field is not well-formed for its algorithm (MalformedSignature)" + ), + AttestationVerdict::MalformedCommitment => format!( + "frame `{frame_id}`: `signed_commitment` is not the `sha256:<64 lowercase hex>` §F7 requires (MalformedCommitment)" + ), + // Handled by the caller, which reports it as uncheckable rather than + // as a failure — F8's whole point. + other => format!("frame `{frame_id}`: {other:?}"), + } +} + +/// Of `expected` frame ids, those the reference [`Host`] does **not** deliver — +/// F9's question, asked of a host rather than of this probe. +/// +/// `None` when the question could not be put (the host could not be stood up, +/// or the query failed): an unanswerable question is not evidence of a +/// violation, and reporting it as one would fail a provider for the suite's own +/// trouble. +async fn frames_the_host_dropped( + program: &str, + args: &[String], + expected: &[String], +) -> Option> { + let mut host = Host::new(); + let id = "f9-probe".to_string(); + host.add_stdio(id.clone(), program, args).await.ok()?; + let result = host.query_provider(&id, &sample_query()).await.ok()?; + let served: Vec<&str> = result + .frames + .iter() + .map(|frame| frame.id.as_str()) + .collect(); + let _ = host.shutdown().await; + let missing: Vec = expected + .iter() + .filter(|id| !served.contains(&id.as_str())) + .cloned() + .collect(); + (!missing.is_empty()).then_some(missing) +} + +/// Decode lowercase hex into bytes. `None` on an odd length or a non-hex digit +/// — a published key that cannot be decoded is a provider defect, not a +/// verification failure, and the two are reported differently. +fn decode_hex(hex: &str) -> Option> { + if !hex.len().is_multiple_of(2) { + return None; + } + hex.as_bytes() + .chunks_exact(2) + .map(|pair| { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + Some((hi * 16 + lo) as u8) + }) + .collect() +} + /// The instant the `as_of` probe pins retrieval to (`SPEC.md` §6.1). Chosen to /// fall *between* the reference fixture's two frame validity windows, so an /// honest provider's pinned answer is observably narrower than its unpinned one. diff --git a/contextgraph-conformance/tests/attestation_conformance.rs b/contextgraph-conformance/tests/attestation_conformance.rs new file mode 100644 index 0000000..2455978 --- /dev/null +++ b/contextgraph-conformance/tests/attestation_conformance.rs @@ -0,0 +1,222 @@ +//! Wire-level witnesses for the provenance-attestation check (`SPEC.md` §6.5, +//! F6–F9). +//! +//! `conformance_suite.rs` asserts that each `--misbehave` mode trips +//! `attestation` and names the right verdict. That is the outcome. These tests +//! assert the **preconditions** the outcomes depend on, by reading the fixture's +//! own wire rather than the suite's summary of it — because a mode that fails +//! for a reason other than the one it claims is a test that will sit quietly +//! through a real regression. +//! +//! The one that matters is `lift-signature`. Its whole claim is that the §6.5.2 +//! identity binding, and nothing else, distinguishes the two frames. If the +//! fixture built them carelessly — a different backing file, a different +//! `content_digest`, a different provenance range — the mode would still go red +//! and would still say `CommitmentMismatch`, while proving nothing about the +//! binding at all. + +use contextgraph_host::{Envelope, FrameAttestation, RawStdioConnection}; +use contextgraph_types::{ + AttestationVerdict, ContextFrame, frame_commitment, provenance_chain_head, + verify_frame_attestation, +}; + +use contextgraph_conformance::sample_query; + +fn fixture() -> String { + env!("CARGO_BIN_EXE_contextgraph-example-docs").to_string() +} + +/// Drive the fixture over the raw wire and return everything the attestation +/// probe reads: the provider's declared name (the `provider_id` §6.5.2 binds +/// into a commitment), its published keys, the frames, and the detached +/// attestations. +async fn wire_exchange( + misbehave: Option<&str>, +) -> ( + String, + Vec, + Vec, + Vec, +) { + let args: Vec = match misbehave { + Some(mode) => vec!["--misbehave".into(), mode.into()], + None => vec![], + }; + let mut conn = RawStdioConnection::spawn(&fixture(), &args) + .await + .expect("fixture spawns"); + let (info, _) = conn.handshake().await.expect("fixture handshakes"); + let keys = conn.attester_keys().to_vec(); + conn.send(&Envelope::Query { + id: None, + query: sample_query(), + }) + .await + .expect("query is accepted"); + let (frames, attestations) = match conn.recv().await.expect("fixture answers") { + Envelope::Frames { + result, + attestations, + .. + } => (result.frames, attestations), + other => panic!( + "expected frames, got {}", + contextgraph_host::envelope_kind(&other) + ), + }; + let _ = conn.shutdown().await; + (info.name, keys, frames, attestations) +} + +fn public_key(keys: &[contextgraph_host::AttesterKey], key_id: &str) -> Vec { + let key = keys + .iter() + .find(|key| key.key_id == key_id) + .unwrap_or_else(|| panic!("handshake published no key `{key_id}`")); + (0..key.public_key.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&key.public_key[i..i + 2], 16).expect("key is lowercase hex")) + .collect() +} + +#[tokio::test] +async fn the_honest_fixture_publishes_a_key_and_signs_every_frame_it_serves() { + let (provider_id, keys, frames, attestations) = wire_exchange(None).await; + assert_eq!(keys.len(), 1, "one published attester key"); + assert_eq!( + attestations.len(), + frames.len(), + "every served frame carries a detached attestation" + ); + for entry in &attestations { + let frame = frames + .iter() + .find(|frame| frame.id == entry.frame_id) + .expect("an attestation names a frame in the same answer"); + let key = public_key(&keys, &entry.attestation.key_id); + assert_eq!( + verify_frame_attestation(&provider_id, frame, &entry.attestation, &key), + AttestationVerdict::Valid, + "frame `{}` must verify against the published key", + frame.id + ); + } +} + +#[tokio::test] +async fn an_attestation_is_detached_and_never_rides_inside_the_frame_it_signs() { + // F6. Serialize the served frame and look for the signature in it: an + // attestation folded into the frame would perturb the frame's + // content-addressed identity every time the key rotated. + let (_, _, frames, attestations) = wire_exchange(None).await; + let signature = &attestations + .first() + .expect("the fixture attests its frames") + .attestation + .signature; + for frame in &frames { + let json = serde_json::to_string(frame).expect("a frame serializes"); + assert!( + !json.contains(signature.as_str()), + "frame `{}` carries its own attestation inline, which F6 forbids", + frame.id + ); + } +} + +#[tokio::test] +async fn an_attestation_lift_differs_only_in_the_frame_id() { + // The precondition the `lift-signature` mode's whole meaning rests on. + // + // A frame commitment is SHA256 over (provider id, frame id, + // content_digest, provenance chain head). Three of those four are asserted + // equal here, so the mismatch the mode produces is attributable to the + // frame id — the identity binding — and to nothing incidental. + let (provider_id, keys, frames, attestations) = wire_exchange(Some("lift-signature")).await; + assert_eq!(frames.len(), 2, "the lift needs two frames to work with"); + let (honest, forged) = (&frames[0], &frames[1]); + + assert_ne!(honest.id, forged.id, "the frames must be distinct frames"); + assert_eq!( + provenance_chain_head(&honest.provenance), + provenance_chain_head(&forged.provenance), + "identical provenance is the point: without it the chain head alone would separate them" + ); + assert_eq!( + honest.content_digest, forged.content_digest, + "a differing content_digest would produce the same mismatch for a reason the mode does not claim" + ); + assert_ne!( + frame_commitment(&provider_id, honest), + frame_commitment(&provider_id, forged), + "the identity binding is what must separate two otherwise-identical commitments" + ); + + // Both entries carry the SAME signature — one genuine attestation, served + // twice — and it is genuinely valid for the frame it was issued over. + let lifted = &attestations + .iter() + .find(|entry| entry.frame_id == forged.id) + .expect("the forged frame carries an attestation") + .attestation; + let key = public_key(&keys, &lifted.key_id); + assert_eq!( + verify_frame_attestation(&provider_id, honest, lifted, &key), + AttestationVerdict::Valid, + "the stapled attestation must be a genuine one, or the mode proves nothing" + ); + assert!( + matches!( + verify_frame_attestation(&provider_id, forged, lifted, &key), + AttestationVerdict::CommitmentMismatch { .. } + ), + "a genuine signature must not validate the frame it was lifted onto" + ); +} + +#[tokio::test] +async fn a_forged_signature_leaves_the_commitment_intact() { + // §6.5.4 orders the two comparisons so an operator is sent to the right + // problem. This asserts the ordering is observable on the wire: under + // `forge-signature` the recomputed commitment still equals the signed one, + // so the finding is about the key and not about tampering. + let (provider_id, keys, frames, attestations) = wire_exchange(Some("forge-signature")).await; + for entry in &attestations { + let frame = frames + .iter() + .find(|frame| frame.id == entry.frame_id) + .expect("an attestation names a frame in the same answer"); + assert_eq!( + contextgraph_types::digest_string(&frame_commitment(&provider_id, frame)), + entry.attestation.signed_commitment, + "the commitment must be honest, or the mode would report a mismatch instead" + ); + let key = public_key(&keys, &entry.attestation.key_id); + assert_eq!( + verify_frame_attestation(&provider_id, frame, &entry.attestation, &key), + AttestationVerdict::BadSignature + ); + } +} + +#[tokio::test] +async fn truncation_hides_a_derivation_link_the_signature_still_covers() { + // The served chain must be the *shorter* one: if the fixture served the + // full chain the mode would be a no-op that happened to go red for some + // other reason. + let (_, _, honest, _) = wire_exchange(None).await; + let (_, _, truncated, _) = wire_exchange(Some("truncate-chain")).await; + assert_eq!( + truncated[0].provenance.len(), + honest[0].provenance.len(), + "the served frame is byte-identical to the honest one — the hidden link was never served" + ); + assert!( + truncated[0] + .provenance + .iter() + .all(|link| link.kind != "derivation"), + "the served chain must not admit the summarisation the signature covers" + ); +} diff --git a/contextgraph-conformance/tests/conformance_suite.rs b/contextgraph-conformance/tests/conformance_suite.rs index b89ccc2..3a2cf85 100644 --- a/contextgraph-conformance/tests/conformance_suite.rs +++ b/contextgraph-conformance/tests/conformance_suite.rs @@ -4,10 +4,10 @@ //! proving the suite catches a broken provider (task deliverable). use contextgraph_conformance::{ - CHECK_ANCHOR_RELEVANCE, CHECK_AS_OF, CHECK_BUDGET_HONESTY, CHECK_CONSENT_SCOPE, - CHECK_CORRELATION, CHECK_EMBEDDING_FINGERPRINT, CHECK_FRAME_VALIDITY, CHECK_HANDSHAKE, - CHECK_KINDS_FILTER, CHECK_MALFORMED, CHECK_PROVENANCE_FIXTURE_CONSISTENCY, CHECK_SHUTDOWN, - CHECK_VERIFY_HONESTY, CheckStatus, ProviderTarget, run_conformance, + CHECK_ANCHOR_RELEVANCE, CHECK_AS_OF, CHECK_ATTESTATION, CHECK_BUDGET_HONESTY, + CHECK_CONSENT_SCOPE, CHECK_CORRELATION, CHECK_EMBEDDING_FINGERPRINT, CHECK_FRAME_VALIDITY, + CHECK_HANDSHAKE, CHECK_KINDS_FILTER, CHECK_MALFORMED, CHECK_PROVENANCE_FIXTURE_CONSISTENCY, + CHECK_SHUTDOWN, CHECK_VERIFY_HONESTY, CheckStatus, ProviderTarget, run_conformance, }; /// Path to the fixture binary, built automatically for integration tests. @@ -40,7 +40,7 @@ async fn a_well_behaved_provider_is_fully_conformant() { report.failures().collect::>() ); // Every check ran and passed (none skipped for a stdio provider). - assert_eq!(report.checks.len(), 13); + assert_eq!(report.checks.len(), 14); for name in [ CHECK_HANDSHAKE, CHECK_CONSENT_SCOPE, @@ -55,6 +55,7 @@ async fn a_well_behaved_provider_is_fully_conformant() { CHECK_MALFORMED, CHECK_EMBEDDING_FINGERPRINT, CHECK_CORRELATION, + CHECK_ATTESTATION, ] { assert_eq!(status_of(&report, name), CheckStatus::Pass, "{name}"); } @@ -260,3 +261,123 @@ async fn ignoring_anchors_fails_the_anchor_relevance_check() { CheckStatus::Fail ); } + +/// The `attestation` check's evidence string, so a mode's test can assert the +/// **named verdict** rather than merely "something went red". A mode that fails +/// for the wrong reason is a test that will pass over a real regression. +fn evidence_of(report: &contextgraph_conformance::ConformanceReport, name: &str) -> String { + report + .checks + .iter() + .find(|check| check.name == name) + .unwrap_or_else(|| panic!("report is missing the `{name}` check")) + .evidence + .clone() +} + +/// Run one attestation misbehaviour and assert it trips `attestation`, that +/// `attestation` is the **only** check it trips, and that the verdict named in +/// the evidence is `expected_verdict`. +async fn attestation_mode_is_caught(mode: &str, expected_verdict: &str) -> String { + let report = run_conformance(target(&["--misbehave", mode])).await; + assert_eq!( + status_of(&report, CHECK_ATTESTATION), + CheckStatus::Fail, + "`{mode}` must trip `attestation`" + ); + // Attributability. A forgery caught by an unrelated check leaves the check + // that owns §6.5 free to stop working unnoticed — the hole + // `conformance-red.sh` grew its expected-check matching to close. + let others: Vec<&str> = report + .failures() + .map(|check| check.name.as_str()) + .filter(|name| *name != CHECK_ATTESTATION) + .collect(); + assert!( + others.is_empty(), + "`{mode}` should trip `attestation` alone, also tripped: {others:?}" + ); + let evidence = evidence_of(&report, CHECK_ATTESTATION); + assert!( + evidence.contains(expected_verdict), + "`{mode}` should report `{expected_verdict}`, got: {evidence}" + ); + evidence +} + +#[tokio::test] +async fn signing_with_an_undeclared_key_is_a_bad_signature() { + // §6.5.4 compares commitments BEFORE examining the signature, so an intact + // frame signed by the wrong key must report the key problem and not a + // tampering one — the two send an operator to opposite places. + attestation_mode_is_caught("forge-signature", "BadSignature").await; +} + +#[tokio::test] +async fn a_lifted_signature_does_not_validate_the_frame_it_was_stapled_to() { + // The forgery the §6.5.2 identity binding exists to stop, and the only one + // here a plausible implementation really does ship: sign the bare chain + // head and every frame citing the same source shares a valid signature. + // `an_attestation_lift_differs_only_in_the_frame_id` proves the two frames + // are otherwise identical, so this mismatch is attributable to the frame id + // and to nothing else. + let evidence = attestation_mode_is_caught("lift-signature", "CommitmentMismatch").await; + assert!( + evidence.contains("frm_configuration"), + "the mismatch must name the frame the signature was lifted ONTO: {evidence}" + ); + assert!( + !evidence.contains("frm_getting_started"), + "the frame the signature genuinely covers must still verify: {evidence}" + ); +} + +#[tokio::test] +async fn truncating_the_provenance_chain_is_a_commitment_mismatch() { + // Drop the `derivation` link and the frame reads as quoted rather than + // summarised. Every surviving link's digest is still correct, which is + // exactly why §6.5.2 folds the links into a chain instead of trusting a set + // of independent digests. + attestation_mode_is_caught("truncate-chain", "CommitmentMismatch").await; +} + +#[tokio::test] +async fn re_serving_different_bytes_under_a_signed_frame_id_is_caught() { + // The `content_digest` moves while the frame id stays. `frame-validity`, + // `verify-honesty` and `provenance-fixture-consistency` are all satisfied — + // the provider vouches for the digest it served and the file's own bytes + // still hash correctly — so only the signature covering the frame's bytes + // rather than merely its name catches it. + attestation_mode_is_caught("swap-content", "CommitmentMismatch").await; +} + +#[tokio::test] +async fn a_garbage_attestation_leaves_the_frame_served_but_unattested() { + // F9. The attestation is unverifiable, so the check goes red — and the + // frame must still be there. A host that dropped it would hand any peer a + // denial-of-service primitive: attach garbage, watch the evidence + // disappear. The probe asks the reference host directly and would append an + // F9 violation to this evidence if the frame had gone missing. + let evidence = attestation_mode_is_caught("malformed-attestation", "MalformedCommitment").await; + assert!( + !evidence.contains("F9"), + "the frame must survive as unattested, not be dropped: {evidence}" + ); + + // The frame is served, and served as an ordinary usable frame: everything + // else about this provider is conformant. + let report = run_conformance(target(&["--misbehave", "malformed-attestation"])).await; + for name in [ + CHECK_FRAME_VALIDITY, + CHECK_BUDGET_HONESTY, + CHECK_VERIFY_HONESTY, + CHECK_PROVENANCE_FIXTURE_CONSISTENCY, + ] { + assert_eq!(status_of(&report, name), CheckStatus::Pass, "{name}"); + } + assert!( + evidence_of(&report, CHECK_FRAME_VALIDITY).starts_with("2 frame(s)"), + "both frames must still be served: {}", + evidence_of(&report, CHECK_FRAME_VALIDITY) + ); +} diff --git a/contextgraph-conformance/tests/ingest_conformance.rs b/contextgraph-conformance/tests/ingest_conformance.rs index fe57feb..83039b1 100644 --- a/contextgraph-conformance/tests/ingest_conformance.rs +++ b/contextgraph-conformance/tests/ingest_conformance.rs @@ -181,7 +181,11 @@ async fn ingested_frames_pass_frame_budget_and_schema_conformance_in_every_repre } // The whole result round-trips through the real NDJSON `frames` envelope. - let envelope = Envelope::Frames { id: None, result }; + let envelope = Envelope::Frames { + id: None, + result, + attestations: vec![], + }; let line = encode_line(&envelope).expect("frames envelope encodes"); assert!(matches!( decode_line(&line).expect("frames envelope decodes"), diff --git a/contextgraph-host/src/http.rs b/contextgraph-host/src/http.rs index 0c5eecb..c17833a 100644 --- a/contextgraph-host/src/http.rs +++ b/contextgraph-host/src/http.rs @@ -205,6 +205,7 @@ impl HttpProvider { protocol_version, provider, capabilities, + .. } => { if !versions_compatible(PROTOCOL_VERSION, &protocol_version) { return Err(HostError::VersionMismatch { @@ -314,7 +315,9 @@ impl ContextProvider for HttpProvider { ) .await?; match reply { - Envelope::Frames { id: echoed, result } => { + Envelope::Frames { + id: echoed, result, .. + } => { verify_correlation(&self.id, sent_id.as_deref(), echoed.as_deref())?; Ok(result) } @@ -398,6 +401,7 @@ mod tests { }, ..Capabilities::default() }, + attester_keys: vec![], }) .unwrap() } @@ -436,6 +440,7 @@ mod tests { dropped_estimate: None, ..Default::default() }, + attestations: vec![], }) .unwrap() } @@ -544,6 +549,7 @@ mod tests { }, ..Capabilities::default() }, + attester_keys: vec![], }) .unwrap(); Mock::given(method("POST")) diff --git a/contextgraph-host/src/lib.rs b/contextgraph-host/src/lib.rs index e89c142..0347821 100644 --- a/contextgraph-host/src/lib.rs +++ b/contextgraph-host/src/lib.rs @@ -94,7 +94,10 @@ pub use ingest::{ pub use provider::{ContextProvider, capability_matches, frame_kind_name}; pub use stdio::{RawStdioConnection, StdioProvider}; pub use verify::{DigestVerification, verify_file_provenance, verify_provenance_digest}; -pub use wire::{Envelope, decode_line, encode_line, envelope_kind, versions_compatible}; +pub use wire::{ + AttesterKey, Envelope, FrameAttestation, decode_line, encode_line, envelope_kind, + versions_compatible, +}; /// The Context Graph Protocol version this host speaks, re-exported from `contextgraph-types` /// (`SPEC.md`). diff --git a/contextgraph-host/src/stdio.rs b/contextgraph-host/src/stdio.rs index 2f5dadd..28de57d 100644 --- a/contextgraph-host/src/stdio.rs +++ b/contextgraph-host/src/stdio.rs @@ -46,8 +46,8 @@ use tokio::task::JoinHandle; use crate::error::HostError; use crate::provider::ContextProvider; use crate::wire::{ - Envelope, decode_line, encode_line, envelope_kind, next_correlation_id, verify_correlation, - versions_compatible, + AttesterKey, Envelope, decode_line, encode_line, envelope_kind, next_correlation_id, + verify_correlation, versions_compatible, }; /// How long the handshake waits for a provider's ack before giving up — @@ -162,6 +162,10 @@ pub struct RawStdioConnection { /// A stable label for error messages before the handshake names the /// provider. label: String, + /// The attester public keys the handshake declared (`SPEC.md` §6.5). + /// Empty until [`handshake`](Self::handshake) runs, and empty afterwards + /// for the many providers that sign nothing. + attester_keys: Vec, } impl RawStdioConnection { @@ -227,6 +231,7 @@ impl RawStdioConnection { child, pgid, label: program.to_string(), + attester_keys: Vec::new(), }) } @@ -304,6 +309,7 @@ impl RawStdioConnection { protocol_version, provider, capabilities, + attester_keys, } => { if !versions_compatible(PROTOCOL_VERSION, &protocol_version) { return Err(HostError::VersionMismatch { @@ -312,6 +318,7 @@ impl RawStdioConnection { provider_version: protocol_version, }); } + self.attester_keys = attester_keys; Ok((provider, capabilities)) } other => Err(HostError::UnexpectedEnvelope { @@ -322,6 +329,17 @@ impl RawStdioConnection { } } + /// The attester public keys this provider declared at the handshake + /// (`SPEC.md` §6.5), empty before the handshake and for a provider that + /// signs nothing. + /// + /// Kept on the connection rather than returned from + /// [`handshake`](Self::handshake) so the attestation conformance probe can + /// reach them without every other caller having to widen a tuple. + pub fn attester_keys(&self) -> &[AttesterKey] { + &self.attester_keys + } + /// Send `shutdown` and wait a bounded grace for the child to exit, /// killing the process group if it overstays (task deliverable 2). A /// provider that already died is not treated as a shutdown error. @@ -777,7 +795,9 @@ impl ContextProvider for StdioProvider { } }; match reply { - Envelope::Frames { id: echoed, result } => { + Envelope::Frames { + id: echoed, result, .. + } => { // The reader matched this reply to us by id, so the echo already // agrees; verifying keeps the §H4 guarantee explicit and local. verify_correlation(&self.id, Some(sent_id.as_str()), echoed.as_deref())?; @@ -876,6 +896,7 @@ mod tests { }, ..Capabilities::default() }, + attester_keys: vec![], }; serde_json::to_string(&ack).unwrap() } @@ -915,6 +936,7 @@ mod tests { dropped_estimate: None, ..Default::default() }, + attestations: vec![], }; serde_json::to_string(&env).unwrap() } @@ -941,6 +963,7 @@ mod tests { correlation: true, ..Capabilities::default() }, + attester_keys: vec![], }; serde_json::to_string(&ack).unwrap() } @@ -983,6 +1006,7 @@ mod tests { dropped_estimate: None, ..Default::default() }, + attestations: vec![], }; serde_json::to_string(&env).unwrap() } diff --git a/contextgraph-host/src/wire.rs b/contextgraph-host/src/wire.rs index 0fa8697..10f21c3 100644 --- a/contextgraph-host/src/wire.rs +++ b/contextgraph-host/src/wire.rs @@ -53,13 +53,55 @@ //! matching rather than by envelope id (`SPEC.md` §9). use contextgraph_types::{ - Capabilities, ContextQuery, ContextQueryResult, ErrorCode, ProviderInfo, VerifyRequest, - VerifyResponse, + Capabilities, ContextQuery, ContextQueryResult, ErrorCode, ProvenanceAttestation, ProviderInfo, + VerifyRequest, VerifyResponse, }; use serde::{Deserialize, Serialize}; use crate::error::HostError; +/// A public key a provider publishes at the handshake, so a verifier can check +/// the [attestations](FrameAttestation) it goes on to serve (`SPEC.md` §6.5.4). +/// +/// **A construction anchor, not a trust anchor.** A key handed over by the party +/// being audited says nothing about *who* signed; it is enough to decide whether +/// an attestation is built the way §6.5 requires, which is the half F6–F9 make +/// mandatory. A deployment that cares who signed resolves +/// [`key_id`](Self::key_id) in its own trust store and ignores this field. +/// +/// It rides the **handshake** rather than the answer for a reason: a key +/// republished with every response could be swapped by the same forgery that +/// swapped the signature, and a wrong-key signature would then verify. Declared +/// once, before any frame moves, it cannot be. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AttesterKey { + /// The key's id, matching [`ProvenanceAttestation::key_id`]. Rotation is a + /// new id, never a reused one. + pub key_id: String, + /// The scheme this key is for, e.g. + /// [`ALGORITHM_ED25519`](contextgraph_types::ALGORITHM_ED25519). + pub algorithm: String, + /// The raw public key, lowercase hex — the encoding + /// [`ProvenanceAttestation::signature`] already uses. + pub public_key: String, +} + +/// One detached [`ProvenanceAttestation`] bound to one frame of an answer +/// (`SPEC.md` §6.5.2). +/// +/// **Detached, per F6.** It names the frame it signs by id and travels beside +/// the result rather than inside it, so re-signing after a key rotation never +/// perturbs the frame's content-addressed identity, and no attestation is ever +/// part of a preimage it covers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrameAttestation { + /// The [`ContextFrame::id`](contextgraph_types::ContextFrame::id) this + /// attestation covers, within the same answer. + pub frame_id: String, + /// The detached signature over that frame's frame commitment (§6.5.2). + pub attestation: ProvenanceAttestation, +} + /// One Context Graph Protocol message. Every variant is a small, versioned, `type`-tagged JSON /// object; the host writes exactly one per line (NDJSON) over stdio and one /// per HTTP body (`SPEC.md` §2). @@ -76,6 +118,12 @@ pub enum Envelope { protocol_version: String, provider: ProviderInfo, capabilities: Capabilities, + /// The public keys this provider signs its attestations with + /// (`SPEC.md` §6.5). Empty ⇒ the provider offers no attestation, which + /// is conformant: §6.5 makes the *construction* mandatory, never the + /// signing. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attester_keys: Vec, }, /// Host → provider retrieval request (`context/query`). Query { @@ -90,6 +138,12 @@ pub enum Envelope { #[serde(default, skip_serializing_if = "Option::is_none")] id: Option, result: ContextQueryResult, + /// Detached provenance attestations over frames in `result` + /// (`SPEC.md` §6.5). Beside the frames, never inside one (F6). A frame + /// named by no entry here is simply unattested, and a frame whose entry + /// does not verify degrades to unattested too (F9) — never dropped. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attestations: Vec, }, /// Host → provider revalidation request: are these held frames still /// valid (`docs/context-reuse.md` §4 `context/verify`)? Carries frame @@ -258,6 +312,7 @@ mod tests { }, ..Capabilities::default() }, + attester_keys: vec![], } } @@ -294,6 +349,7 @@ mod tests { dropped_estimate: None, ..Default::default() }, + attestations: vec![], }, Envelope::Shutdown, Envelope::Error { diff --git a/contextgraph-mcp-bridge/src/lib.rs b/contextgraph-mcp-bridge/src/lib.rs index f84245c..96b1b39 100644 --- a/contextgraph-mcp-bridge/src/lib.rs +++ b/contextgraph-mcp-bridge/src/lib.rs @@ -681,6 +681,7 @@ pub fn run_stdio(config: &BridgeConfig) -> Result<(), String> { protocol_version: PROTOCOL_VERSION.to_string(), provider: info.clone(), capabilities: caps.clone(), + attester_keys: vec![], }, ); } @@ -693,7 +694,14 @@ pub fn run_stdio(config: &BridgeConfig) -> Result<(), String> { } let result = answer_query(&base_frames, &query); // Echo the correlation id so the host can demultiplex (§H4). - write_envelope(&mut stdout, &Envelope::Frames { id, result }); + write_envelope( + &mut stdout, + &Envelope::Frames { + id, + result, + attestations: vec![], + }, + ); } Envelope::Verify { request } => { write_envelope( diff --git a/contextgraph-refprov/src/lib.rs b/contextgraph-refprov/src/lib.rs index 92d07f9..713b1a6 100644 --- a/contextgraph-refprov/src/lib.rs +++ b/contextgraph-refprov/src/lib.rs @@ -119,6 +119,7 @@ pub fn serve(mut source: impl FrameSource) { protocol_version: PROTOCOL_VERSION.to_string(), provider: provider_info(&config), capabilities: capabilities(&config), + attester_keys: vec![], }, ); } @@ -192,6 +193,7 @@ fn handle_query( dropped_estimate, ..Default::default() }, + attestations: vec![], } } diff --git a/docs/adr/0010-provenance-attestation.md b/docs/adr/0010-provenance-attestation.md index 38fd889..755f2a3 100644 --- a/docs/adr/0010-provenance-attestation.md +++ b/docs/adr/0010-provenance-attestation.md @@ -184,6 +184,13 @@ same evidence is genuine. - `contextgraph-types/tests/attestation_vectors.rs` publishes the byte vectors a reimplementation in another language reconciles against. A diff in that file is a wire-breaking change requiring a new major family. +- The `attestation` conformance check now probes F6–F9 on the wire, against a + reference fixture with five adversarial `--misbehave` modes — one per forgery + the constructions above exist to stop. F6–F9's "Verified by" column in + `SPEC.md` points at that check rather than at this crate's own unit tests, + which is the difference between a guarantee and a self-attestation (§11.1). +- Attestations reach a verifier through two optional envelope members + (`handshake_ack.attester_keys`, `frames.attestations`, `SPEC.md` §6.5.5). - Not yet done, and tracked as follow-up work: host-side verification wired into composition, an `attestation` conformance check with an adversarial `--misbehave` mode, attestations carried in the `frames` envelope and the JSON diff --git a/docs/composition-walkthrough.md b/docs/composition-walkthrough.md index 0f73091..726017c 100644 --- a/docs/composition-walkthrough.md +++ b/docs/composition-walkthrough.md @@ -55,7 +55,7 @@ contextgraph-inspect stdio --query "how do we roll out and roll back a deploy" \ -- ./target/debug/contextgraph-mcp-fixture ``` -The bridge passes the full conformance suite — all thirteen checks, no skips — +The bridge passes the full conformance suite — all fourteen checks, no skips — exactly as the reference provider does, because it negotiates and honors the whole surface (`verify`, `graph`, `correlation`, an embedding fingerprint, and byte-verifiable `file` provenance): @@ -63,7 +63,7 @@ byte-verifiable `file` provenance): ```sh ./.github/scripts/conformance-external.sh \ -- ./target/debug/contextgraph-mcp-bridge -- ./target/debug/contextgraph-mcp-fixture -# All 13 checks passed — external provider is conformant. +# All 14 checks passed — external provider is conformant. ``` ### Query it through a host, with a budget audit diff --git a/docs/reference-providers.md b/docs/reference-providers.md index 3d7d001..48e93fb 100644 --- a/docs/reference-providers.md +++ b/docs/reference-providers.md @@ -12,7 +12,7 @@ second and third conformant provider to point at. | `contextgraph-treesitter` | `Symbol` + `Graph` frames | a symbol-graph extraction over Rust source | Both speak the same newline-delimited [`Envelope`](./protocol-surface.md) stdio -protocol as `contextgraph-example-docs`, and both are green on all thirteen +protocol as `contextgraph-example-docs`, and both are green on all fourteen provider-side conformance checks — including the ones that only bite a provider touching real files: `provenance-fixture-consistency` (every `file` provenance digest is re-read and re-hashed off disk, `SPEC.md` §6.2) and `anchor-relevance` @@ -42,7 +42,7 @@ $ ./.github/scripts/conformance-external.sh -- ./target/debug/contextgraph-ripgr OK budget-honesty: 4 frame(s), 69 tokens within the 4096 budget; every declared cost matches its canonical count OK provenance-fixture-consistency: re-read and re-hashed 4 file-provenance digest(s) against the bytes on disk — all match (§6.2) ... -All 13 checks passed — external provider is conformant. +All 14 checks passed — external provider is conformant. ``` Or drive one directly with `contextgraph-inspect stdio -- `. diff --git a/docs/registry.md b/docs/registry.md index 290c71b..d02a825 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -18,7 +18,7 @@ where that count becomes checkable. | Provider | Author | Transport | Declared capabilities | Data flow | Protocol version | Last verified | Report | |---|---|---|---|---|---|---|---| -| [`contextgraph-example-docs`](../contextgraph-conformance/src/bin/contextgraph-example-docs.rs) | CGP maintainers (bundled reference fixture) | stdio | `kinds=[doc, snippet]`, `graph`, `verify`, `correlation`, `embeddings_fingerprint=bge-small-en-v1.5/384/l2` | reads-only, `egress=false` (`local-only`) | `contextgraph/1.0` | 2026-07-29 | 13/13 checks passed — [report](../registry/contextgraph-example-docs.report.json) | +| [`contextgraph-example-docs`](../contextgraph-conformance/src/bin/contextgraph-example-docs.rs) | CGP maintainers (bundled reference fixture) | stdio | `kinds=[doc, snippet]`, `graph`, `verify`, `correlation`, `embeddings_fingerprint=bge-small-en-v1.5/384/l2` | reads-only, `egress=false` (`local-only`) | `contextgraph/1.0` | 2026-08-30 | 14/14 checks passed — [report](../registry/contextgraph-example-docs.report.json) | This founding entry is the reference fixture bundled with `contextgraph-conformance` itself (`SPEC.md` §11 seed providers) — it exists to diff --git a/registry/contextgraph-example-docs.report.json b/registry/contextgraph-example-docs.report.json index 155fa45..b57ea9b 100644 --- a/registry/contextgraph-example-docs.report.json +++ b/registry/contextgraph-example-docs.report.json @@ -4,7 +4,7 @@ { "name": "handshake", "status": "pass", - "evidence": "provider 'contextgraph-example-docs' v0.1.0 — data-flow reads=true writes=false egress=false; query kinds=[\"doc\", \"snippet\"], graph=true" + "evidence": "provider 'contextgraph-example-docs' v2.0.0 — data-flow reads=true writes=false egress=false; query kinds=[\"doc\", \"snippet\"], graph=true" }, { "name": "consent-scope", @@ -65,6 +65,11 @@ "name": "correlation", "status": "pass", "evidence": "provider declares correlation and echoed the request id verbatim on its `frames` reply (§H4)" + }, + { + "name": "attestation", + "status": "pass", + "evidence": "recomputed and verified 2 detached attestation(s) over 2 served frame(s) against the handshake-published key(s) (§6.5)" } ] } diff --git a/schema/contextgraph-envelope.schema.json b/schema/contextgraph-envelope.schema.json index df18a65..9e3b217 100644 --- a/schema/contextgraph-envelope.schema.json +++ b/schema/contextgraph-envelope.schema.json @@ -68,6 +68,11 @@ }, "capabilities": { "$ref": "#/$defs/Capabilities" + }, + "attester_keys": { + "type": "array", + "items": { "$ref": "#/$defs/AttesterKey" }, + "description": "Public keys this provider signs its provenance attestations with (SPEC.md §6.5). Absent or empty means the provider offers no attestation, which is conformant: §6.5 makes the construction mandatory and the signing optional." } }, "required": [ @@ -114,6 +119,11 @@ "type": "string", "minLength": 1, "description": "Correlation id; a provider MUST echo it on the reply (SPEC.md H4)." + }, + "attestations": { + "type": "array", + "items": { "$ref": "#/$defs/FrameAttestation" }, + "description": "Detached provenance attestations over frames in `result` (SPEC.md §6.5). Beside the frames, never inside one (F6). A frame named by no entry here is unattested; a frame whose entry does not verify degrades to unattested too (F9), and is never dropped." } }, "required": [ @@ -123,6 +133,90 @@ "additionalProperties": false }, + "AttesterKey": { + "type": "object", + "description": "A public key a provider publishes at the handshake so a verifier can check the attestations it serves (SPEC.md §6.5.4). A construction anchor, not a trust anchor: a key handed over by the party being audited settles whether an attestation is BUILT correctly, never who signed it.", + "properties": { + "key_id": { + "type": "string", + "minLength": 1, + "description": "Matches ProvenanceAttestation.key_id. Rotation is a new id, never a reused one." + }, + "algorithm": { + "type": "string", + "minLength": 1, + "description": "The scheme this key is for, e.g. `ed25519`. A string rather than an enum so a post-quantum successor is additive." + }, + "public_key": { + "type": "string", + "pattern": "^[0-9a-f]+$", + "description": "The raw public key, lowercase hex — the encoding ProvenanceAttestation.signature already uses." + } + }, + "required": ["key_id", "algorithm", "public_key"], + "additionalProperties": false + }, + + "ProvenanceAttestation": { + "type": "object", + "description": "A detached Ed25519 signature over a frame commitment (SPEC.md §6.5.2). Detached per F6: it never appears inside the frame it signs, nor inside any hash preimage this spec defines.", + "properties": { + "signed_commitment": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "The commitment this attestation signs, computed exactly as §6.5.2 or §6.5.3 specifies (F7)." + }, + "key_id": { + "type": "string", + "minLength": 1 + }, + "algorithm": { + "type": "string", + "minLength": 1, + "description": "A verifier that does not recognise this value MUST report the attestation as uncheckable and MUST NOT treat the frame as attested (F8)." + }, + "attester_id": { + "type": "string", + "minLength": 1, + "description": "The attesting authority — who is accountable for the claim, as distinct from which key produced it." + }, + "signature": { + "type": "string", + "pattern": "^[0-9a-f]+$", + "description": "The detached signature, lowercase hex." + }, + "issued_at": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$", + "description": "When the attestation was issued (SPEC.md §F4 protocol timestamp)." + } + }, + "required": [ + "signed_commitment", + "key_id", + "algorithm", + "attester_id", + "signature", + "issued_at" + ], + "additionalProperties": false + }, + + "FrameAttestation": { + "type": "object", + "description": "One detached attestation bound to one frame of the answer it rides with (SPEC.md §6.5.2). Names its frame by id rather than nesting inside it, which is what keeps re-signing and key rotation from perturbing the frame's content-addressed identity.", + "properties": { + "frame_id": { + "type": "string", + "minLength": 1, + "description": "The ContextFrame.id this attestation covers, within the same answer." + }, + "attestation": { "$ref": "#/$defs/ProvenanceAttestation" } + }, + "required": ["frame_id", "attestation"], + "additionalProperties": false + }, + "Verify": { "type": "object", "description": "host -> provider. Revalidate frames the host already holds (context/verify, docs/context-reuse.md §4). Carries frame identities only — NEVER frame bodies, so verification costs bytes rather than tokens. Capability-gated: a host sends this only to a provider whose handshake advertised capabilities.verify.", From 6820cbbb1d2256a278318aa16d9a7a305e42da9c Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 22:11:24 -0700 Subject: [PATCH 2/3] fix(contextgraph-conformance): parse hex by const-sized chunk clippy 1.98's `chunks_exact_to_as_chunks` fires on `decode_hex`. The even-length check above the call already rules out a remainder, so `.0` discards nothing, and `&[u8; 2]` indexes without the bounds check a `&[u8]` carries. The sibling fix in `contextgraph-types::attest` landed with #114, so only this site remained. Refs #160 --- contextgraph-conformance/src/lib.rs | 6 +++++- contextgraph-conformance/tests/reference_vectors.rs | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/contextgraph-conformance/src/lib.rs b/contextgraph-conformance/src/lib.rs index eb20ba6..efdd7fb 100644 --- a/contextgraph-conformance/src/lib.rs +++ b/contextgraph-conformance/src/lib.rs @@ -1084,8 +1084,12 @@ fn decode_hex(hex: &str) -> Option> { if !hex.len().is_multiple_of(2) { return None; } + // The even-length check above leaves no remainder, so `.0` drops nothing. + // `as_chunks` yields `&[u8; 2]`, which indexes without a bounds check. hex.as_bytes() - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| { let hi = (pair[0] as char).to_digit(16)?; let lo = (pair[1] as char).to_digit(16)?; diff --git a/contextgraph-conformance/tests/reference_vectors.rs b/contextgraph-conformance/tests/reference_vectors.rs index f46e4b3..c4a814a 100644 --- a/contextgraph-conformance/tests/reference_vectors.rs +++ b/contextgraph-conformance/tests/reference_vectors.rs @@ -279,6 +279,7 @@ fn vectors() -> Vec<(&'static str, Envelope)> { ( "handshake_ack/minimal", Envelope::HandshakeAck { + attester_keys: vec![], protocol_version: PROTOCOL_VERSION.into(), provider: ProviderInfo { name: "minimal-provider".into(), @@ -291,6 +292,7 @@ fn vectors() -> Vec<(&'static str, Envelope)> { ( "handshake_ack/maximal", Envelope::HandshakeAck { + attester_keys: vec![], protocol_version: PROTOCOL_VERSION.into(), provider: ProviderInfo { name: "example-docs".into(), @@ -341,6 +343,7 @@ fn vectors() -> Vec<(&'static str, Envelope)> { ( "frames/empty", Envelope::Frames { + attestations: vec![], id: None, result: ContextQueryResult { frames: vec![], @@ -353,6 +356,7 @@ fn vectors() -> Vec<(&'static str, Envelope)> { ( "frames/all-representations", Envelope::Frames { + attestations: vec![], id: Some("req-1".into()), result: ContextQueryResult { frames: vec![ @@ -376,6 +380,7 @@ fn vectors() -> Vec<(&'static str, Envelope)> { ( "frames/attested", Envelope::Frames { + attestations: vec![], id: Some("req-2".into()), result: ContextQueryResult { frames: vec![maximal_frame(), minimal_frame()], From ea3a3df80663b98e1b6d6cec0f4edd9c8d4479c4 Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 22:44:34 -0700 Subject: [PATCH 3/3] fix(contextgraph-host): the wire FrameAttestation lives under wire::, the trust one keeps the root export --- contextgraph-conformance/tests/attestation_conformance.rs | 3 ++- contextgraph-host/src/lib.rs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contextgraph-conformance/tests/attestation_conformance.rs b/contextgraph-conformance/tests/attestation_conformance.rs index 2455978..db17b9d 100644 --- a/contextgraph-conformance/tests/attestation_conformance.rs +++ b/contextgraph-conformance/tests/attestation_conformance.rs @@ -15,7 +15,8 @@ //! and would still say `CommitmentMismatch`, while proving nothing about the //! binding at all. -use contextgraph_host::{Envelope, FrameAttestation, RawStdioConnection}; +use contextgraph_host::wire::FrameAttestation; +use contextgraph_host::{Envelope, RawStdioConnection}; use contextgraph_types::{ AttestationVerdict, ContextFrame, frame_commitment, provenance_chain_head, verify_frame_attestation, diff --git a/contextgraph-host/src/lib.rs b/contextgraph-host/src/lib.rs index 99b5496..c5d5252 100644 --- a/contextgraph-host/src/lib.rs +++ b/contextgraph-host/src/lib.rs @@ -106,8 +106,7 @@ pub use trust::{ }; pub use verify::{DigestVerification, verify_file_provenance, verify_provenance_digest}; pub use wire::{ - AttesterKey, Envelope, FrameAttestation, decode_line, encode_line, envelope_kind, - versions_compatible, + AttesterKey, Envelope, decode_line, encode_line, envelope_kind, versions_compatible, }; /// The Context Graph Protocol version this host speaks, re-exported from `contextgraph-types`