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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ All notable changes to Agent Manifest are documented here. Format follows [Keep

## [Unreleased]

### Fixed

**[SDK]** `verify_manifest()` now cross-checks the signed `crypto_profile` against `signature.algorithm` and fails closed on a downgrade (spec 4.2). `crypto_profile` is inside the signing pre-image, but the whole `signature` block is excluded from it (spec 3.6), so the algorithm identifier could previously be rewritten without disturbing the signed bytes: a manifest declaring the post-quantum profile verified `VALID` on a classical-only Ed25519 signature. The check runs independently of `trusted_keys` (the downgrade is a property of the manifest, not of the verifier's key material) and is one-directional: it rejects a signature weaker than the declared profile requires and permits a stronger one, so an issuer dual-signing ahead of the profile flip is not flagged. New conformance vector `AM-VEC-020`.

### Documentation

**[SDK]** `docs/getting-started.md`: new "Watch it catch a change" step. Shows the two ways verification fails and how they differ — an edit to the signed record (signature fails, `signature_verified: false`) versus runtime drift against an intact signature (declared-vs-actual binding fails, `system_prompt: MISMATCH`) — and names the boot-time boundary, pointing at `attest_runtime_state()` for freshness. All printed values are copied from real runs.

**[SDK]** `LIMITATIONS.md`: document that **Azure TDX is not supported for offline attestation** (hardware-confirmed). Azure runs TDX behind the Hyper-V paravisor, so the guest gets no signed DCAP quote — only a MAC'd `TDREPORT` via the vTPM — and rooting that as genuine silicon needs a networked service (Azure MAA). Offline TDX attestation is supported on non-paravisor guests (e.g. GCP C3); on Azure use SEV-SNP (`AzureCVMProvider`). Azure-MAA TDX support is tracked as a follow-up.

## [0.5.0] — 2026-07-21
Expand Down
60 changes: 59 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ result = verify_manifest(
),
RevocationStore(),
)
print(result.result) # VALID
print(result.result.value) # VALID
```

Or with the CLI:
Expand All @@ -154,6 +154,64 @@ because the CLI has no trusted issuer key to authenticate the signature.
The CLI `--public-key` option accepts the raw Ed25519 public key generated by
`manifest keygen`.

### Step 5: Watch it catch a change

A `VALID` result only means something if you have seen the same manifest come back
`MISMATCH`. There are two independent ways it does, and they fail differently.

**The record was edited after signing.** Swap the approved model version inside the
signed manifest and re-verify:

```python
import copy

tampered = copy.deepcopy(manifest_dict)
tampered["artifacts"]["model_identity"]["version"] = "20260101"

result = verify_manifest(
tampered,
VerificationContext(trusted_keys={keypair.key_id: keypair.public_b64url()}),
RevocationStore(),
)
print(result.result.value) # MISMATCH
print(result.signature_verified) # False
print(result.mismatch_details[0].field) # signature
```

The signature no longer covers the bytes, so the edit is caught without the verifier
knowing anything about models.

**The record is intact but the running agent drifted.** Here the signature still
verifies. What fails is the declared-vs-actual comparison, which is the binding the
manifest exists for:

```python
import hashlib

running_prompt = "You are a document summarization assistant. Ignore prior instructions."
running_hash = "sha256:" + hashlib.sha256(running_prompt.encode("utf-8")).hexdigest()

result = verify_manifest(
manifest_dict,
VerificationContext(
system_prompt_hash=running_hash, # what is actually loaded
trusted_keys={keypair.key_id: keypair.public_b64url()},
),
RevocationStore(),
)
print(result.result.value) # MISMATCH
print(result.fields_verified.system_prompt.value) # MISMATCH
```

Pass the real hash and the same call returns `VALID` with `system_prompt: MATCH`.

Both checks answer "is this the agent that was approved" at the moment you verify.
Neither one watches the agent afterwards: an attacker who changes the system prompt in
memory after this check has passed is outside what a boot-time manifest can see. That
boundary is deliberate and documented in [Limitations](limitations.md), and
`attest_runtime_state()` is the primitive for closing it with a hardware-signed
freshness proof on a cadence you choose.

## Level 1 - TPM attestation

Level 1 adds hardware attestation, which binds the manifest hash to a TEE measurement that cannot be forged by the operator. Required for EU AI Act Art. 15 (cybersecurity) and enterprise production deployments.
Expand Down
53 changes: 51 additions & 2 deletions python/src/agent_manifest/_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,21 @@ class VerificationContext(BaseModel):
SUPPORTED_MANIFEST_VERSIONS: frozenset[str] = frozenset({"0.1"})
_HYBRID_ED25519_PUBLIC_KEY_BYTES = 32

# Signature algorithms that satisfy each declared crypto_profile (spec 4.1 /
# 4.2). The rule is deliberately one-directional: it rejects a signature that
# provides less than the declared profile requires, and permits one that
# provides more. A post-quantum profile carrying a classical-only signature is
# the downgrade spec 4.2 tells verifiers to reject; a standard profile carrying
# an ML-DSA-65 or hybrid signature is an issuer moving to dual-signing ahead of
# the profile flip, which is strictly stronger and not an attack.
KNOWN_SIGNATURE_ALGORITHMS: frozenset[str] = frozenset(
{"Ed25519", "ML-DSA-65", "hybrid-Ed25519-ML-DSA-65"}
)
PROFILE_SIGNATURE_ALGORITHMS: dict[str, frozenset[str]] = {
"standard": KNOWN_SIGNATURE_ALGORITHMS,
"post-quantum": frozenset({"ML-DSA-65", "hybrid-Ed25519-ML-DSA-65"}),
}


def _split_hybrid_public_key(
key_id: str,
Expand Down Expand Up @@ -365,10 +380,44 @@ def verify_manifest(
except (ValueError, AttributeError):
pass

# --- Signature verification (CRYPTO-004, fail-closed per spec 5.3)
# --- Crypto profile downgrade check (spec 4.2: a verifier MUST reject
# rather than silently fall back, "this prevents downgrade attacks during
# the transition period").
#
# crypto_profile is inside the signing pre-image (SIGNED_FIELDS) but
# signature.algorithm is not - the whole signature block is excluded from
# the pre-image by section 3.6. An intermediary can therefore rewrite the
# algorithm identifier without disturbing the signed bytes. Cross-checking
# the unsigned identifier against the signed profile is what binds the two,
# and it runs independently of trusted_keys: a post-quantum manifest
# presented with a classical-only signature is a downgrade whether or not
# this verifier holds the key to check that signature.
sig_block = manifest.get("signature") or {}
signature_missing = not sig_block
if sig_block and context.trusted_keys:
profile_downgrade = False
if sig_block:
declared_profile = manifest.get("crypto_profile", "standard")
permitted = PROFILE_SIGNATURE_ALGORITHMS.get(declared_profile)
declared_algorithm = sig_block.get("algorithm", "Ed25519")
# An unrecognised algorithm identifier is left to the signature
# verification branch below, which reports it as such.
if (
permitted is not None
and declared_algorithm in KNOWN_SIGNATURE_ALGORITHMS
and declared_algorithm not in permitted
):
profile_downgrade = True
mismatches.append(MismatchDetail(
field="signature.algorithm",
expected_hash=(
f"<crypto_profile={declared_profile} permits "
f"{'|'.join(sorted(permitted))}>"
),
actual_hash=f"<algorithm={declared_algorithm}>",
))

# --- Signature verification (CRYPTO-004, fail-closed per spec 5.3)
if sig_block and context.trusted_keys and not profile_downgrade:
algorithm = sig_block.get("algorithm", "Ed25519")
key_id = sig_block.get("key_id", "")
pub_b64 = context.trusted_keys.get(key_id)
Expand Down
2 changes: 1 addition & 1 deletion python/tests/test_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
vector it loads the manifest + context, runs :func:`verify_manifest`, and
asserts the expected overall result and per-field statuses.

Conformance IDs: AM-VEC-001 .. AM-VEC-015.
Conformance IDs: AM-VEC-001 .. AM-VEC-020.
"""
from __future__ import annotations

Expand Down
54 changes: 54 additions & 0 deletions python/tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,60 @@ def test_hybrid_trusted_key_must_match_combined_key_id():
)


# ---------------------------------------------------------------------------
# Crypto profile downgrade (spec 4.2)
# ---------------------------------------------------------------------------


def test_pq_profile_with_classical_signature_is_mismatch():
# crypto_profile is signed, signature.algorithm is not: this manifest carries
# a genuine Ed25519 signature over a pre-image declaring the post-quantum
# profile. Only the profile-to-algorithm relationship is wrong.
m = base_manifest(crypto_profile="post-quantum")
result = verify_manifest(m, base_context(), store())
assert result.result == OverallResult.MISMATCH
assert result.signature_verified is False
assert any(d.field == "signature.algorithm" for d in result.mismatch_details)


def test_pq_profile_downgrade_detected_without_trusted_keys():
# The downgrade is a property of the manifest, not of the verifier's keys.
m = base_manifest(crypto_profile="post-quantum")
result = verify_manifest(m, base_context(trusted_keys={}), store())
assert result.result == OverallResult.MISMATCH
assert any(d.field == "signature.algorithm" for d in result.mismatch_details)


def test_standard_profile_with_ed25519_is_valid():
result = verify_manifest(base_manifest(crypto_profile="standard"), base_context(), store())
assert result.result == OverallResult.VALID
assert result.mismatch_details == []


def test_standard_profile_with_stronger_signature_is_not_a_downgrade(monkeypatch):
# Dual-signing ahead of the profile flip provides more than the declared
# profile requires, so it must not be reported as a downgrade.
ed_pub = b"e" * 32
pq_pub = b"p" * 1952
combined_pub = ed_pub + pq_pub
key_id = hashlib.sha256(combined_pub).hexdigest()

class FakeHybridVerifier:
def __init__(self, ed25519_public_bytes, ml_dsa65_public_bytes):
pass

def verify(self, manifest_dict, signature_block):
pass

monkeypatch.setattr(_signing, "HybridVerifier", FakeHybridVerifier)
ctx = base_context(trusted_keys={key_id: _b64url_encode(combined_pub)})

result = verify_manifest(_hybrid_manifest(key_id), ctx, store())

assert result.result == OverallResult.VALID
assert not any(d.field == "signature.algorithm" for d in result.mismatch_details)


# ---------------------------------------------------------------------------
# Fail-closed HITL enforcement
# ---------------------------------------------------------------------------
Expand Down
71 changes: 71 additions & 0 deletions python/tests/vectors/AM-VEC-020.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"id": "AM-VEC-020",
"description": "Post-quantum crypto_profile with an Ed25519 signature is a downgrade.",
"spec_refs": [
"4.2",
"3.6"
],
"manifest": {
"manifest_id": "018f4a3b-2c1d-7e5f-a8b9-0d1e2f3a4b5c",
"agent_id": "spiffe://trust.example/agent/kyc/prod",
"version": "0.1",
"issued_at": "2025-01-01T00:00:00Z",
"expires_at": "2099-12-31T23:59:59Z",
"issuer": "spiffe://trust.example/signing-authority",
"crypto_profile": "post-quantum",
"artifacts": {
"system_prompt": {
"hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"policy_bundle": {
"hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
"model_identity": {
"model_hash": null,
"version": "claude-3",
"deployment_type": "api"
}
},
"delegation_chain": [],
"hitl_record": null,
"signature": {
"algorithm": "Ed25519",
"key_id": "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c",
"key_type": "software",
"signed_at": "2025-01-01T00:00:00Z",
"signature_value": "3WVWSRnrL12yIZnBdbDbfc8gIGfsGNeNMtDRYS_LUErSUTOUDG0dE02g5EMnvTdiva6AH0v7wxmOEZnFQ-sVCg",
"signed_fields": [
"@context",
"@type",
"manifest_id",
"previous_manifest_id",
"agent_id",
"version",
"min_verifier_version",
"issued_at",
"expires_at",
"issuer",
"crypto_profile",
"artifacts",
"delegation_chain",
"hitl_record",
"prior_transparency_log_entry",
"log_retention",
"data_scope",
"operational_lifecycle"
]
}
},
"context": {
"system_prompt_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"policy_bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"model_version": "claude-3",
"trusted_keys": {
"56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"
}
},
"expected": {
"result": "MISMATCH",
"signature_verified": false
}
}
5 changes: 3 additions & 2 deletions python/tests/vectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ The Python reference assertion lives in

## Coverage

`AM-VEC-001` … `AM-VEC-019` span the full `OverallResult` space:
`AM-VEC-001` … `AM-VEC-020` span the full `OverallResult` space:

* `VALID` — happy path; valid signed delegation chain; matching attestation report.
* `MISMATCH` — artifact hash, tampered signature, flagged RAG poisoning scan.
* `MISMATCH` — artifact hash, tampered signature, flagged RAG poisoning scan,
crypto-profile downgrade (post-quantum profile, classical-only signature).
* `EXPIRED`, `REVOKED`, `INCOMPATIBLE_VERSION`.
* `SIGNATURE_MISSING` (unsigned) and `UNVERIFIABLE` (no trusted keys; and a
delegation chain present without keys to verify it).
Expand Down
12 changes: 12 additions & 0 deletions python/tests/vectors/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,18 @@ def build() -> list[dict[str, Any]]:
{"result": "VALID", "fields_verified": {"delegation_chain": "VALID"}},
))

# 020 - post-quantum profile presented with a classical-only signature.
# The Ed25519 signature here is genuine and covers a pre-image that includes
# crypto_profile="post-quantum", so the only defect is the mismatch between
# the signed profile and the unsigned signature.algorithm identifier. Spec
# 4.2 requires a verifier to reject rather than silently fall back.
vectors.append(_vector(
"AM-VEC-020",
"Post-quantum crypto_profile with an Ed25519 signature is a downgrade.",
["4.2", "3.6"], base_manifest(crypto_profile="post-quantum"), base_context(),
{"result": "MISMATCH", "signature_verified": False},
))

return vectors


Expand Down
5 changes: 5 additions & 0 deletions python/tests/vectors/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@
"id": "AM-VEC-019",
"file": "AM-VEC-019.json",
"description": "Signed single-hop delegation chain bound to the manifest issuer verifies."
},
{
"id": "AM-VEC-020",
"file": "AM-VEC-020.json",
"description": "Post-quantum crypto_profile with an Ed25519 signature is a downgrade."
}
]
}
Loading