Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Peer-call enforcement decision core (Tier 2): `ca2a_runtime.policy.LocalPolicy` and `ca2a_runtime.peer` (`effective_scope`, `enforce_peer_call`). Effective permission is the delegated leaf scope intersected with the callee's local policy; a granted call emits a linked provenance record. New error `SCOPE_NOT_PERMITTED`. Claim C3 (scope-policy intersection) is now a validated experiment. Cedar-engine binding of the local policy and live A2A transport wiring remain open.
- Sealed peer channel (Tier 2): `ca2a_runtime.channel` (`SealedChannel`, `generate_channel_keypair`, `open_sealed`). HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing a payload to the peer's attested key; only the peer's private key opens it, and a wrong key or tampered ciphertext fails closed. Claim C4 (sealed-payload confidentiality) is now a validated experiment at the cryptographic layer. The enclave-binding of the private key (a hardware property) and live-path wiring remain open.
- Cross-operator attestation (Claim C6) validated in software: a two-operator harness composing the SEV-SNP verifier, measurement pinning, and the sealed channel demonstrates independent keys, mutual attestation, confidential cross-operator delegation, and binary-swap detection. Synthetic report vectors (a genuine report needs SEV-SNP hardware); real hardware end to end remains open. **All six claims (C1-C6) are now validated experiments.**
- RFC 8785 (JSON Canonicalization Scheme) canonicalization: `ca2a_runtime.canonical.canonicalize`. Credential and provenance bodies are now signed over the JCS encoding (UTF-16 key ordering, JCS string escaping, literal non-ASCII, shortest-decimal integers), so cA2A signatures are cross-verifiable with agent-manifest. ASCII credentials are byte-identical to the previous encoding, so existing signatures still verify.
- Repository scaffold: governance, CI/CD, docs framework, and packaging at parity with the agentrust-io house standard

### Not yet implemented
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple
- Attestation-gated SPIFFE mTLS (cmcp)
- Audit chain with external signed evidence references (cmcp)
- Cedar policy engine (cmcp)
- Ed25519 + RFC 8785 canonicalization (all three repos)
- Ed25519 + RFC 8785 canonicalization (all three repos; cA2A now ships a JCS canonicalizer in `ca2a_runtime.canonical`)

## v0.1: Profile and offline verifier

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/delegation-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ A `DelegationCredential` has the following signed body plus a detached signature

## Canonicalization

The signed bytes are a deterministic JSON encoding of the body: sorted keys, compact separators, UTF-8, `scope` as a sorted array. This is the byte string signed and verified. It is a practical subset of RFC 8785 sufficient for the ASCII fields used here; full RFC 8785 alignment with agent-manifest is tracked on the roadmap.
The signed bytes are the RFC 8785 (JSON Canonicalization Scheme) encoding of the body: keys sorted by UTF-16 code units, JCS minimal string escaping, non-ASCII emitted literally as UTF-8, integers in shortest decimal form, `scope` as a sorted array. This is the byte string signed and verified. Using JCS makes cA2A signatures cross-verifiable with agent-manifest and any other conforming implementation. See `ca2a_runtime.canonical`.

## Verification invariants

Expand Down
224 changes: 112 additions & 112 deletions docs/spec/provenance-dag.md

Large diffs are not rendered by default.

406 changes: 203 additions & 203 deletions docs/tutorials/emit-and-verify-provenance.md

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions src/ca2a_runtime/canonical.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""RFC 8785 JSON Canonicalization Scheme (JCS) for the value types cA2A signs.

Credentials and provenance records are signed over the canonical byte encoding
of a JSON object. RFC 8785 fixes that encoding so any conforming implementation
(here and in agent-manifest) produces identical bytes and therefore
cross-verifiable signatures.

This implements JCS for the JSON value types cA2A uses: objects, arrays,
strings, integers, booleans, and null. Object keys are sorted by their UTF-16
code units, strings use JCS minimal escaping (control characters only; non-ASCII
is emitted literally as UTF-8), and integers serialize as their shortest decimal
form. Floating-point numbers are not part of the cA2A data model and are
rejected rather than serialized approximately.
"""

from __future__ import annotations

from typing import Any

# JCS short escapes for control characters (RFC 8785 section 3.2.2.2).
_SHORT_ESCAPES = {
0x08: "\\b",
0x09: "\\t",
0x0A: "\\n",
0x0C: "\\f",
0x0D: "\\r",
0x22: '\\"',
0x5C: "\\\\",
}


def _escape_string(s: str) -> str:
out: list[str] = ['"']
for ch in s:
code = ord(ch)
if code in _SHORT_ESCAPES:
out.append(_SHORT_ESCAPES[code])
elif code < 0x20:
out.append(f"\\u{code:04x}")
else:
out.append(ch)
out.append('"')
return "".join(out)


def _serialize(value: Any) -> str:
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if isinstance(value, str):
return _escape_string(value)
if isinstance(value, int): # bool already handled above
return str(value)
if isinstance(value, float):
raise TypeError("RFC 8785 canonicalization of floats is not supported in cA2A")
if isinstance(value, list):
return "[" + ",".join(_serialize(v) for v in value) + "]"
if isinstance(value, dict):
items = sorted(value.items(), key=lambda kv: str(kv[0]).encode("utf-16-be"))
return "{" + ",".join(f"{_escape_string(str(k))}:{_serialize(v)}" for k, v in items) + "}"
raise TypeError(f"unsupported type for canonicalization: {type(value).__name__}")


def canonicalize(value: Any) -> bytes:
"""Return the RFC 8785 canonical UTF-8 encoding of ``value``."""
return _serialize(value).encode("utf-8")
20 changes: 11 additions & 9 deletions src/ca2a_runtime/delegation/credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@
4. Anti-replay: parent_id links to the previous credential_id and every
credential_id in the chain is unique.

Canonicalization uses a deterministic JSON encoding (sorted keys, compact
separators, UTF-8). This is the stable byte string signed and verified; it is a
practical subset of RFC 8785 sufficient for the ASCII credential fields used
here. Full RFC 8785 alignment with agent-manifest is tracked on the roadmap.
Canonicalization uses RFC 8785 (JSON Canonicalization Scheme), so the signed
byte string is identical across conforming implementations and cA2A signatures
are cross-verifiable with agent-manifest. See ca2a_runtime.canonical.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Any

Expand All @@ -29,6 +27,7 @@
Ed25519PublicKey,
)

from ca2a_runtime.canonical import canonicalize
from ca2a_runtime.errors import (
BrokenDelegationLink,
CredentialReplay,
Expand All @@ -46,10 +45,13 @@ def new_keypair() -> tuple[Ed25519PrivateKey, str]:


def canonical_bytes(payload: dict[str, Any]) -> bytes:
"""Deterministic byte encoding of a credential body (signature excluded)."""
return json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
"""RFC 8785 (JCS) canonical byte encoding of a credential body.

This is the stable byte string signed and verified; using JCS makes cA2A
signatures cross-verifiable with agent-manifest and any other conforming
implementation. See ca2a_runtime.canonical.
"""
return canonicalize(payload)


@dataclass(frozen=True)
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/test_canonical.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Tests for the RFC 8785 (JCS) canonicalizer."""

from __future__ import annotations

import pytest

from ca2a_runtime.canonical import canonicalize


def test_key_order_is_deterministic() -> None:
assert canonicalize({"b": 1, "a": 2}) == canonicalize({"a": 2, "b": 1})
assert canonicalize({"b": 1, "a": 2}) == b'{"a":2,"b":1}'


def test_primitives() -> None:
assert canonicalize(None) == b"null"
assert canonicalize(True) == b"true"
assert canonicalize(False) == b"false"
assert canonicalize(42) == b"42"
assert canonicalize("x") == b'"x"'


def test_nested_structures() -> None:
assert canonicalize({"scope": ["b", "a"], "n": 0}) == b'{"n":0,"scope":["b","a"]}'


def test_control_character_escaping() -> None:
# Newline and tab use short escapes; other controls use \\u00xx.
assert canonicalize("a\nb\tc") == b'"a\\nb\\tc"'
assert canonicalize("\x00\x1f") == b'"\\u0000\\u001f"'
assert canonicalize('a"b\\c') == b'"a\\"b\\\\c"'


def test_non_ascii_is_literal_utf8() -> None:
# JCS does not escape non-ASCII; it is emitted as UTF-8 bytes.
assert canonicalize({"k": "é"}) == '{"k":"é"}'.encode()


def test_utf16_key_ordering() -> None:
# Keys sort by UTF-16 code units; ASCII keys sort as expected.
out = canonicalize({"z": 1, "a": 1, "m": 1})
assert out == b'{"a":1,"m":1,"z":1}'


def test_float_rejected() -> None:
with pytest.raises(TypeError):
canonicalize({"x": 1.5})
Loading