From 421629ad07a9f24085a7e99f6df7187aef8e1231 Mon Sep 17 00:00:00 2001 From: Carlos Hernandez-Vaquero Date: Mon, 6 Jul 2026 12:11:45 +0200 Subject: [PATCH] docs: clarify cnf signing preimage Signed-off-by: Carlos Hernandez-Vaquero --- docs/schema.md | 2 +- docs/verification.md | 4 ++-- schema/trace-claim.json | 2 +- spec/trace-v0.1.md | 2 +- src/agentrust_trace/models.py | 8 ++++---- src/agentrust_trace/schema/trace-v0.1.json | 2 +- src/agentrust_trace/sign.py | 4 ++-- tests/test_sign.py | 10 ++++++++++ 8 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index f22bf3e..f063ad9 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -19,7 +19,7 @@ JSON Schema for the TRACE v0.1 Trust Record. Source: [`schema/trace-claim.json`] | `appraisal` | object | **yes** | Verifier judgment | | `transparency` | string | **yes** | SCITT transparency log anchor URI (empty string if not anchored) | | `cnf` | object | **yes** | Confirmation method — contains the `jwk` signing key | -| `signature` | string | **yes** | Base64url Ed25519 / ES256 / ES384 signature over the record | +| `signature` | string | **yes** | Base64url Ed25519 / ES256 / ES384 signature over the canonical record with only `signature` absent; `cnf` is included | ## `model` diff --git a/docs/verification.md b/docs/verification.md index 40d5127..c25edef 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -8,7 +8,7 @@ This is the normative protocol from [§3.3 of the spec](../spec/trace-v0.1.md). ### Step 1 — Parse the envelope -A TRACE Trust Record is a signed JSON object. The `signature` field contains a base64url-encoded Ed25519 (or ES256/ES384) signature over the canonical JSON of the record with `signature` and `cnf` removed. +A TRACE Trust Record is a signed JSON object. The `signature` field contains a base64url-encoded Ed25519 (or ES256/ES384) signature over the canonical JSON of the record with only `signature` removed. The `cnf.jwk` public key remains in the signed pre-image, binding that key to the rest of the record. ```python import json, base64 @@ -21,7 +21,7 @@ payload = {k: v for k, v in record.items() if k != "signature"} payload_bytes = rfc8785.dumps(payload) # JCS canonical bytes, NOT json.dumps ``` -The pre-image is the RFC 8785 (JCS) canonical form of the record with `signature` removed. `json.dumps(sort_keys=True)` is **not** JCS-conformant — it diverges for non-ASCII strings and IEEE 754 numbers — so use a JCS library (the spec mandates this in §3.2.2). +The pre-image is the RFC 8785 (JCS) canonical form of the record with only `signature` removed. All other top-level fields, including `cnf`, are included. `json.dumps(sort_keys=True)` is **not** JCS-conformant — it diverges for non-ASCII strings and IEEE 754 numbers — so use a JCS library (the spec mandates this in §3.2.2). ### Step 2 — Resolve the public key diff --git a/schema/trace-claim.json b/schema/trace-claim.json index bf506ba..d6292ad 100644 --- a/schema/trace-claim.json +++ b/schema/trace-claim.json @@ -276,7 +276,7 @@ }, "signature": { "type": "string", - "description": "OPTIONAL embedded signature: base64url (no padding) signature by the cnf key over the canonical JSON form of the record with this field absent. Every Trust Record MUST be signature-bound per spec section 3.2.2, but enveloped profiles (e.g. JWS, cMCP RuntimeClaim) carry the signature outside the record, so this field is not required by the schema.", + "description": "OPTIONAL embedded signature: base64url (no padding) signature by the cnf key over the canonical JSON form of the record with only this field absent. Every Trust Record MUST be signature-bound per spec section 3.2.2, but enveloped profiles (e.g. JWS, cMCP RuntimeClaim) carry the signature outside the record, so this field is not required by the schema.", "pattern": "^[A-Za-z0-9_-]+$" } }, diff --git a/spec/trace-v0.1.md b/spec/trace-v0.1.md index 68faee3..5b3a964 100644 --- a/spec/trace-v0.1.md +++ b/spec/trace-v0.1.md @@ -193,7 +193,7 @@ Each field is independently verifiable. Sub-records (e.g., per-tool-call transcr **Canonical form (RFC 8785 JCS).** The canonical form of a TRACE record for signature purposes is produced by the following algorithm: -1. Construct the record object with all fields EXCEPT the `signature` field (for embedded-signature profiles) or the outer envelope (for enveloping-signature profiles). +1. Construct the record object with all fields EXCEPT the `signature` field (for embedded-signature profiles) or the outer envelope (for enveloping-signature profiles). The `cnf` field, including `cnf.jwk`, is included in the canonical form. 2. Apply RFC 8785 JSON Canonicalization Scheme (JCS) to produce a deterministic byte sequence: - Object keys are sorted in Unicode code-point order (ascending). - No whitespace between tokens. diff --git a/src/agentrust_trace/models.py b/src/agentrust_trace/models.py index 6cc5559..695e919 100644 --- a/src/agentrust_trace/models.py +++ b/src/agentrust_trace/models.py @@ -154,7 +154,7 @@ class TrustRecord(BaseModel): transparency: Annotated[str, Field(min_length=1)] cnf: ConfirmationKey signature: Annotated[str, Field(pattern=r"^[A-Za-z0-9_-]+$")] | None = None - """Optional embedded signature (base64url, no padding) by the cnf key over the - canonical JSON form of the record with this field absent. Every Trust Record must - be signature-bound per spec section 3.2.2; enveloped profiles carry the signature - outside the record instead of in this field.""" + """Optional embedded signature (base64url, no padding) by the cnf key over the + canonical JSON form of the record with only this field absent. Every Trust Record must + be signature-bound per spec section 3.2.2; enveloped profiles carry the signature + outside the record instead of in this field.""" diff --git a/src/agentrust_trace/schema/trace-v0.1.json b/src/agentrust_trace/schema/trace-v0.1.json index b1a2b6b..b0f5ded 100644 --- a/src/agentrust_trace/schema/trace-v0.1.json +++ b/src/agentrust_trace/schema/trace-v0.1.json @@ -276,7 +276,7 @@ }, "signature": { "type": "string", - "description": "OPTIONAL embedded signature: base64url (no padding) signature by the cnf key over the canonical JSON form of the record with this field absent. Every Trust Record MUST be signature-bound per spec section 3.2.2, but enveloped profiles (e.g. JWS, cMCP RuntimeClaim) carry the signature outside the record, so this field is not required by the schema.", + "description": "OPTIONAL embedded signature: base64url (no padding) signature by the cnf key over the canonical JSON form of the record with only this field absent. Every Trust Record MUST be signature-bound per spec section 3.2.2, but enveloped profiles (e.g. JWS, cMCP RuntimeClaim) carry the signature outside the record, so this field is not required by the schema.", "pattern": "^[A-Za-z0-9_-]+$" } }, diff --git a/src/agentrust_trace/sign.py b/src/agentrust_trace/sign.py index 858c7cd..f45616d 100644 --- a/src/agentrust_trace/sign.py +++ b/src/agentrust_trace/sign.py @@ -1,7 +1,7 @@ """Signing utilities for TRACE Trust Records. Produces a signed record dict with an embedded ``signature`` field -- -Ed25519 over the canonical JSON of the record with the signature field absent. +Ed25519 over the canonical JSON of the record with only the signature field absent. This is the same convention used by cMCP RuntimeClaim and verified by trace-tests TR-SIG at all conformance levels. """ @@ -92,7 +92,7 @@ def sign_record(record: dict[str, Any], key: Ed25519PrivateKey) -> dict[str, Any """Return a copy of *record* with ``cnf.jwk`` populated and a ``signature`` field added. The signature is Ed25519 over the canonical JSON (sorted keys, no whitespace) - of the record with the ``signature`` field absent. ``cnf.jwk`` is set to the + of the record with only the ``signature`` field absent. ``cnf.jwk`` is set to the public key derived from *key*. The returned dict is a plain JSON-serialisable object. Pass it to diff --git a/tests/test_sign.py b/tests/test_sign.py index d8366ab..81ac9da 100644 --- a/tests/test_sign.py +++ b/tests/test_sign.py @@ -150,6 +150,16 @@ def test_verify_record_raises_for_tampered_record(): verify_record(record, trusted) +def test_verify_record_raises_for_tampered_cnf_jwk(): + key = generate_key() + other_key = generate_key() + record = sign_record(_fresh_record(), key) + trusted = key_to_jwk(key) + record["cnf"]["jwk"] = key_to_jwk(other_key) + with pytest.raises(InvalidSignature): + verify_record(record, trusted) + + def test_verify_record_raises_for_missing_signature(): record = dict(_fresh_record()) key = generate_key()