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
12 changes: 7 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.

---

## [Unreleased]

### Fixed

- **`verify_record()` now enforces the canonical v0.2 JSON Schema.** A cryptographically valid signature no longer causes an object with unknown fields, missing required claims, or invalid nested values to be accepted as a verified TRACE record. Schema failures are surfaced as `ValueError` with the failing field path.
## [Unreleased]

### Fixed

- **The confirmation key must now match the trusted signing key.** `verify_record()` compares the RFC 7638 thumbprints of `cnf.jwk` and the caller-supplied trusted key before accepting the signature. A trusted signer can no longer produce a record that verifies under one key while naming another key for downstream proof-of-possession checks.

- **`verify_record()` now enforces the canonical v0.2 JSON Schema.** A cryptographically valid signature no longer causes an object with unknown fields, missing required claims, or invalid nested values to be accepted as a verified TRACE record. Schema failures are surfaced as `ValueError` with the failing field path.

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions docs/tutorials/verifying-a-trust-record.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ except ValueError as e:

## Verify Against a Pinned Public Key

The embedded `cnf.jwk` proves the record was not tampered with after signing. It does not prove the key is one you should trust. For stricter verification, supply your own trusted public key as the second argument.
The embedded `cnf.jwk` names the key the record claims made its mandatory signature binding. It cannot establish trust by itself. Supply a trusted public key out of band; `verify_record()` requires the embedded and trusted keys to have the same RFC 7638 thumbprint, then verifies the signature with the trusted key.

Pass either an `Ed25519PublicKey` object or a JWK dict you obtained out-of-band (for example, from the issuer's published key manifest):

Expand Down Expand Up @@ -85,7 +85,7 @@ pub_key = Ed25519PublicKey.from_public_bytes(x_bytes)
verify_record(record, pub_key)
```

When you supply a key, the library uses it instead of `cnf.jwk`. If the record was signed with a different key, `InvalidSignature` is raised.
When you supply a key, the library first requires it to identify the same public key as `cnf.jwk`, ignoring optional JWK metadata such as `kid`, and then uses the trusted key for signature verification. A key mismatch raises `ValueError`; a matching key with an invalid signature raises `InvalidSignature`.

---

Expand Down
6 changes: 6 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ The pre-image is the RFC 8785 (JCS) canonical form of the record with only `sign

The `cnf.jwk` field embeds the public key. For TEE-issued records, this key is TEE-bound — its private half never leaves the measured enclave.

Resolve trust out of band and require the trusted key and `cnf.jwk` to have the
same RFC 7638 thumbprint before verification. Checking the signature with a
trusted key while allowing the signed record to name a different confirmation
key breaks the binding required by §3.2.2 and can mislead downstream
proof-of-possession checks.

```python
from cryptography.hazmat.primitives.serialization import load_der_public_key

Expand Down
21 changes: 16 additions & 5 deletions src/agentrust_trace/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,10 @@ def verify_record(

if isinstance(public_key_or_jwk, dict):
pub = _pubkey_from_jwk(public_key_or_jwk)
trusted_jwk = public_key_or_jwk
else:
pub = public_key_or_jwk
trusted_jwk = _jwk_from_public_key(pub)

# Revocation: signature validity is permanent, trust is not. A key compromised
# after issuance still produces records that verify, so the only place the
Expand All @@ -409,13 +411,22 @@ def verify_record(
# allow_embedded_key=True the two are the same object, and that path is already
# documented as proving internal consistency only.
if revocation is not None:
trusted_jwk = (
public_key_or_jwk
if isinstance(public_key_or_jwk, dict)
else _jwk_from_public_key(pub)
)
_check_not_revoked(trusted_jwk, revocation)

# The signature binding is defined as a signature made by the key in cnf.
# Verifying with a caller-pinned key is necessary for authenticity, but it
# must not permit a trusted signer to authenticate a record that names a
# different confirmation key for downstream proof-of-possession checks.
cnf = record.get("cnf")
embedded_jwk = cnf.get("jwk") if isinstance(cnf, dict) else None
if not isinstance(embedded_jwk, dict):
raise ValueError("record has no valid cnf.jwk confirmation key")
_pubkey_from_jwk(embedded_jwk)
if not compare_digest(jwk_thumbprint(embedded_jwk), jwk_thumbprint(trusted_jwk)):
raise ValueError(
"record cnf.jwk does not identify the trusted key that verifies its signature"
)

# Freshness: bound the age of the record against its issued-at timestamp.
if max_future_skew_seconds < 0:
raise ValueError("max_future_skew_seconds must be non-negative")
Expand Down
49 changes: 47 additions & 2 deletions tests/test_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,55 @@ def test_verify_record_raises_for_tampered_cnf_jwk():
record = sign_record(_fresh_record(), key)
trusted = key_to_jwk(key)
record["cnf"]["jwk"] = key_to_jwk(other_key)
with pytest.raises(InvalidSignature):
with pytest.raises(ValueError, match=r"cnf\.jwk.*trusted key"):
verify_record(record, trusted)


def test_verify_record_rejects_valid_signature_that_names_another_cnf_key():
"""A trusted signer must not authenticate another key as the confirmation key."""
signer = generate_key()
other = generate_key()
record = _fresh_record()
record["cnf"] = {"jwk": key_to_jwk(other)}
body = _canonical_bytes(record)
record["signature"] = base64.urlsafe_b64encode(signer.sign(body)).rstrip(b"=").decode()

with pytest.raises(ValueError, match=r"cnf\.jwk.*trusted key"):
verify_record(record, key_to_jwk(signer))


def test_verify_record_compares_key_identity_not_optional_jwk_metadata():
key = generate_key()
record = sign_record(_fresh_record(), key)
trusted = {**key_to_jwk(key), "kid": "issuer-key-7", "use": "sig"}

verify_record(record, trusted)


def test_verify_record_rejects_signed_record_without_confirmation_key():
"""A record with no confirmation key does not verify, whoever signed it.

Since schema enforcement landed (#156) the rejection comes from the schema,
which makes `cnf` required, rather than from the cnf-to-trusted-key binding
below it: absent `cnf` is caught before the binding is reached. Both are
correct refusals, and the earlier one is the more fundamental. Asserted on
`cnf` rather than on the exact sentence so this does not re-break the next
time the order of two correct checks changes.

The binding itself is covered by test_verify_record_raises_for_tampered_cnf_jwk
and test_verify_record_rejects_valid_signature_that_names_another_cnf_key,
where `cnf` is present and names the wrong key.
"""
key = generate_key()
record = _fresh_record()
record.pop("cnf", None)
body = _canonical_bytes(record)
record["signature"] = base64.urlsafe_b64encode(key.sign(body)).rstrip(b"=").decode()

with pytest.raises(ValueError, match=r"cnf"):
verify_record(record, key_to_jwk(key))


def test_verify_record_raises_for_missing_signature():
record = dict(_fresh_record())
key = generate_key()
Expand All @@ -289,7 +334,7 @@ def test_verify_record_rejects_wrong_trusted_key():
key_b = generate_key()
record = sign_record(_fresh_record(), key_a)
# Signed by A, verified against B's public key — must not verify.
with pytest.raises(InvalidSignature):
with pytest.raises(ValueError, match=r"cnf\.jwk.*trusted key"):
verify_record(record, key_to_jwk(key_b))


Expand Down