diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fed9c8..33cfdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,22 +34,55 @@ The section also records a correspondence worth more than the identity one: OCSF **[SPEC] Section 9.1.2 maps EU AI Act Article 50, and maps it as a gap.** Article 50 transparency duties have applied since 2 August 2026: chatbot disclosure, machine-readable marking of synthetic output, emotion-recognition notice. It is the only AI Act obligation in force against an agent deployment today, and no document in this repository mentioned it. The manifest satisfies none of the four paragraphs, so the new subsection says that in those words, notes that Article 50 applies regardless of Annex III classification, and states that a manifest MUST NOT be read as Article 50 evidence. `docs/compliance/eu-ai-act.md` carries the same mapping for auditors. Marking that binds to the attested agent that produced the output, rather than to a strippable metadata field, is the design being pursued; it is not built. +**[SDK] COSE_Sign1 signing and verification for manifest version 0.2** ([`_cose.py`](python/src/agent_manifest/_cose.py), [ADR-0011](docs/adr/0011-signature-envelope.md), [ADR-0013](docs/adr/0013-cbor-library-for-cose.md)). Phase 2 of the envelope migration (issue #243). `verify_manifest()` now takes either a dict or bytes: **a dict is a v0.1 manifest and verifies exactly as it does today**, bytes are a v0.2 COSE envelope. The envelope follows the manifest `version`, never a flag, so no existing record is reinterpreted. + ### Fixed **[SPEC] The README pointed at a specification file that does not exist.** Three links and the spec badge referenced `spec/agent-manifest-spec-v0.1.md`, superseded by v0.2. All four now resolve. ### Changed -**Two documentation tables described a conformance ladder the specification does not define.** `docs/compliance/eu-ai-act.md` listed the levels as `0 software / 1 TPM / 2 SEV-SNP or TDX / 3 managed TEE`, and the hardware-attestation tutorial mapped providers to levels the same way. Section 8.1 defines the levels by what is bound and attested, not by silicon: any TEE provider satisfies Level 1, Level 2 is all ten artifacts plus HITL, delegation, Phase 2 cMCP and retention, and Level 3 is the post-quantum profile. As written, a reader on a TPM host would have concluded they could not reach Level 2 without changing hardware, and the tutorial's own guidance table put EU AI Act Art. 15 at Level 2 where the spec puts it at Level 1. Both tables now match the spec, with the differences between roots of trust stated as what a verifier can conclude rather than as a level. `AzureCVMProvider` is added to both, since it ships and was in neither. +What the envelope buys is structural rather than incremental. `alg` is in the protected header and covered by the signature, so the downgrade fixed in 0.6.0 by an explicit cross-check cannot be expressed at all. The payload is verified as received, so RFC 8785 is no longer an input to verification — it stays the producer-side determinism rule and the basis of the hash bound into hardware. Receipts (label 394), the TEE attestation report, and HITL approvals attach in the unprotected header, which retires the `signed_fields` coverage table, the `hitl_record.approvals` normalization rule, and the `transparency_log_entry` ordering rule together. Hardware now binds `sha256` of the payload bytes, with no field subset to keep in sync. + +Hybrid is one `COSE_Sign` with two signers rather than two `COSE_Sign1` objects, so both signatures covering identical payload bytes is a property of the structure. A verifier that cannot perform ML-DSA-65 returns `UNVERIFIABLE` and never falls back to the classical entry. + +**[SDK] New runtime dependency: `cbor2`.** Serialization only. The COSE structures are built in this repository and every signature stays on `cryptography` (and optionally `pyoqs`), so the SDK's crypto surface is unchanged. [ADR-0013](docs/adr/0013-cbor-library-for-cose.md) records why no COSE library was taken: neither `pycose` nor `cwt` ships the RFC 9964 code points that half this envelope needs, and both widen the dependency closure to do less. + +### Fixed + +**[SDK] The `pq` extra was uninstallable, and the module it imported is not the package it meant.** `pip install "agent-manifest[pq]"` required `pyoqs`, which is not published on PyPI, so the post-quantum profile could not be installed by following the documented instruction. Worse, the module name the SDK imports — `oqs` — belongs to an unrelated project on PyPI, and `_signing` treated a successful import as proof of liboqs. Installing that package turned every ML-DSA-65 call into an `AttributeError` (18 test failures) and made the SDK report a post-quantum capability it did not have. + +**ML-DSA-65 now comes from `cryptography`**, which implements it through OpenSSL as of 47.0.0 and is already a required dependency. The `pq` extra is now `cryptography>=47` and installs cleanly. Deployments already carrying the liboqs bindings keep working: the backend is chosen by the key material, not by configuration, because the two differ in private key encoding — cryptography uses the 32-byte seed, liboqs the expanded secret key. **Public keys are the same 1952-byte encoding in both**, so `key_id`, the COSE `kid`, and every signature a third party verifies are unchanged and interoperable across backends. A build with neither backend still reports `UNVERIFIABLE` rather than accusing the manifest, unchanged from #245. + +The capability check no longer trusts a module name: liboqs is now identified by its API. Thirteen post-quantum tests that skipped on every machine without liboqs — across signing, hybrid mode, evidence packs, and the COSE envelope — now execute against real FIPS 204 signatures. + +### Changed **[SPEC] EU AI Act mappings now carry the date each obligation applies from.** The mappings were written as if the high-risk obligations bind today. Under the current provisional timeline the Digital Omnibus amendments defer Annex III systems to around 2 December 2027 and Annex I to around August 2028, which `docs/compliance/eu-ai-act.md` already recorded and nothing else did. The mappings are correct and unchanged; only the applicability date is added, in section 9.1 (authoritative note), the section 8.1 conformance-level table, the section 8.1 log-retention requirement, `LIMITATIONS.md`, the HITL tutorial and example, `docs/index.md`, `docs/getting-started.md`, and the hardware-attestation tutorial. Where a deployment has an obligation in force today, the docs now lead with it: DORA for financial entities, HIPAA § 164.308(a)(5) for the HITL record. +**[SPEC][SDK] Ed25519 is identified by `-19`, not the deprecated `EdDSA` `-8`** ([ADR-0014](docs/adr/0014-fully-specified-ed25519-code-point.md)). **RFC 9864 (Standards Track, October 2025) deprecated the polymorphic `EdDSA` identifier** and registered fully-specified ones; the IANA COSE Algorithms registry marks `-8` deprecated. Issue #243's "code points are settled: EdDSA -8" was therefore already out of date when phase 1 was written — the ML-DSA-65 half (`-49`) was confirmed correct against IANA. A producer now signs with `-19`; a verifier accepts both and **keeps accepting `-8` indefinitely**, because manifests are audit records with regulated retention and a signature cannot be re-issued under a new identifier without re-signing. + +`-8` and `-19` name one algorithm, so anything reasoning about *which* algorithm signed compares algorithms rather than code points: a `post-quantum` profile is satisfied by neither, and a `COSE_Sign` carrying one entry of each is rejected as a single algorithm signed twice rather than accepted as a hybrid signature. + +The cost is recorded in the ADR rather than glossed: **no COSE library implements RFC 9864 yet**, so a `-19` manifest cannot currently be verified by third-party tooling — the interop check runs against `-8` fixtures over the same structures for as long as that holds. + +**[SDK] `AM-VEC-COSE-001` pins the COSE_Sign1 encoding byte-for-byte.** A vector now carries either `manifest` (v0.1) or `envelope_hex` (v0.2 COSE), and the same conformance loop runs both, since the engine selects the procedure from what it is handed. The vector fixes every element — tag, protected header, `unprotected_hex` of `a0`, payload, signature, and the payload hash hardware binds — so another language's SDK has something to agree with rather than only its own round-trip. Ed25519 only: ML-DSA-65 signing is hedged, so post-quantum envelopes differ per run and only their structure is stable. + +**[SDK] A version 0.2 manifest may not use the v0.1 envelope.** The version gate now binds in both directions: a manifest declaring `version: "0.2"` while carrying a detached `signature` block is rejected rather than verified under v0.1 rules. `signature` is not a v0.2 field at all, since the COSE structure is the signature, so such a document claims the new version while using the envelope with the unauthenticated algorithm identifier and the canonicalize-before-verify step that ADR-0011 moved away from. Accepting it would have made the gate advisory and left the phase 5 deprecation with nothing to enforce. Version 0.1 manifests are unaffected. + +**[SDK] `manifest sign` and `manifest verify` handle version 0.2.** `sign` selects the envelope from the manifest's `version` field, so a `0.2` manifest is written as a COSE envelope in binary CBOR and a `0.1` manifest is unchanged; there is no flag, consistent with ADR-0011. `verify` detects the envelope from the CBOR tag rather than the file extension, since guessing a format by filename is the ambiguity the media-type rules exist to remove. Signing a `0.2` manifest requires `--output`, because binary CBOR down stdout would be corrupted by the terminal. New reference page: [COSE envelope](docs/api-reference/cose.md). + +**[SDK] `POST /verify/cose` accepts a version 0.2 COSE manifest as raw CBOR.** The body is the `COSE_Sign1`/`COSE_Sign` object itself under `Content-Type: application/agent-manifest+cose` — the registered media type is the gate, rather than base64 inside a JSON wrapper, since the object is self-contained and the type exists to identify it. Three properties are deliberate: **only the exact media type is accepted** (a vendor-tree alias, `application/cbor`, and an absent type are all refused — the server never sniffs the body); **no key material crosses the wire**, so trust comes from a `cose_context` configured server-side when the router is built, and an unconfigured endpoint returns `UNVERIFIABLE` rather than `VALID`; and **the body is bounded before it is parsed**, with `Content-Length` checked when present and the stream capped regardless, because a declared length is attacker-controlled. A malformed or unverifiable envelope is a verdict (200 with a non-`VALID` result), not a transport error, and parser detail is never reflected back, so the endpoint cannot be used as an oracle for the decoder. Results carry `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. Authentication, authorization and rate limiting remain deployment concerns (spec 5.1: mTLS with the agent's SPIFFE SVID). + +**[SPEC] `version` MUST be `"0.2"`, resolving a contradiction inside the specification.** Section 2.4's compatibility matrix already planned for `0.2` manifests while the section 3 field table still required `"0.1"`, so a producer following the specification could never emit a manifest the COSE envelope governs. The field table now says `"0.2"`; `"0.1"` continues to identify a v0.1 manifest, which stays verifiable. The `@context` change had described v0.2 as differing from v0.1 "in the `@context` value alone" — accurate for that change in isolation, but v0.2 as a release also carries the COSE envelope, and [ADR-0012](docs/adr/0012-context-uri-moved-to-controlled-domain.md) is amended with a note saying so. + +**[SDK] `0.2` is now a supported manifest version.** `AM-VEC-007`, which used `0.2` as its stand-in for a version from the future, now uses `0.3`. Its expected result is unchanged. **[SPEC][SDK] BREAKING: the `@context` URI moves to `https://manifest.agentrust-io.com/v0.2/context.json`**, and the specification is republished as v0.2 ([`spec/agent-manifest-spec-v0.2.md`](spec/agent-manifest-spec-v0.2.md), [ADR-0012](docs/adr/0012-context-uri-moved-to-controlled-domain.md)). The v0.1 URI `https://agentmanifest.agentrust.io/v0.1/context.json` named `agentrust.io`, a domain this project has never controlled: registered to a third party behind Domains By Proxy, paid through mid-2027, and it has never resolved. Every manifest issued to date was therefore identified under somebody else's name, which is untenable in an identity specification. Consumers **cut over rather than dual-accepting**, matching the TRACE v0.2 profile migration (`agentrust-io/trace-spec#107`) that fixed the identical defect. An implementation that kept honouring the v0.1 URI would keep validating manifests named on a domain we do not own. Manifests already issued under v0.1 stay checkable against the v0.1 specification, which remains published. -**Nothing else about the manifest format changed.** No field is added, removed or re-typed; v0.2 differs from v0.1 in the `@context` value alone. The version bump exists to force the cut-over. Serving the context document at the new URL is follow-up work; what changes today is that the domain is ours to serve from. +**No field is added, removed or re-typed** by the `@context` change itself. Serving the context document at the new URL is follow-up work; what changes today is that the domain is ours to serve from. Note that v0.2 as a release is not only this: ADR-0011 assigns the COSE signature envelope to manifest version `0.2` as well, so a v0.2 manifest carries the new `@context` **and** is signed as a COSE object. The two changes share a version number. ## [0.10.0] — 2026-08-01 @@ -272,4 +305,4 @@ Initial developer preview. Launching at Confidential Computing Summit, June 23 2 - CLI: `manifest keygen`, `create`, `sign`, `attest`, `verify`, `revoke` - Post-quantum support via `pyoqs`: `pip install "agent-manifest[pq]"` - Verification server: `pip install "agent-manifest[server]"` -- Python 3.11, 3.12, 3.13 support +- Python 3.11, 3.12, 3.13 support \ No newline at end of file diff --git a/docs/adr/0012-context-uri-moved-to-controlled-domain.md b/docs/adr/0012-context-uri-moved-to-controlled-domain.md index 2ebfbca..6683cfd 100644 --- a/docs/adr/0012-context-uri-moved-to-controlled-domain.md +++ b/docs/adr/0012-context-uri-moved-to-controlled-domain.md @@ -52,9 +52,17 @@ the same: an implementation that continued honouring the v0.1 URL would keep val manifests named under a domain we do not own, which is precisely the condition being fixed. A permissive migration would leave the defect in place indefinitely. -**The manifest format itself does not change.** No field is added, removed or -re-typed. v0.2 differs from v0.1 in the `@context` value alone. The version bump exists -to force the cut-over, not to signal a schema change. +**The manifest schema itself does not change.** No field is added, removed or +re-typed. The version bump exists to force the cut-over, not to signal a schema change. + +*Amended 2026-08-05:* as written, this section said v0.2 "differs from v0.1 in the +`@context` value alone". That was true of this change in isolation and is not true of +v0.2 as a release. ADR-0011 had already assigned the COSE envelope to manifest version +`0.2`, so the two changes share a version number: v0.2 carries the new `@context` **and** +the COSE signature envelope. Leaving the claim unqualified produced a contradiction +inside the specification - section 2.4 planned for `0.2` manifests while the section 3 +field table still required `"0.1"` - which is corrected in the same change as this note. +The schema statement stands; the "value alone" statement does not. ## Consequences diff --git a/docs/adr/0013-cbor-library-for-cose.md b/docs/adr/0013-cbor-library-for-cose.md new file mode 100644 index 0000000..6e79d4e --- /dev/null +++ b/docs/adr/0013-cbor-library-for-cose.md @@ -0,0 +1,92 @@ +# ADR-0013: Take a CBOR library, not a COSE library + +**Status**: Accepted +**Date**: 2026-08-04 +**Spec section**: [COSE envelope v0.2](../../spec/agent-manifest-cose-envelope-v0.2.md), section 9 (open items 1 and 2) +**Tracking**: Issue #243, phase 2 of 5 + +## Context + +[ADR-0011](0011-signature-envelope.md) moved the manifest signature envelope to COSE_Sign1 and noted that phase 2 "takes a CBOR/COSE library, which is the first non-`cryptography` crypto dependency the SDK has taken and should be reviewed as such." The envelope specification left the choice open deliberately, as the first of four implementation decisions for this phase. + +Two decisions are settled here: which dependency the SDK takes, and how the SDK encodes an envelope that has no receipt attached yet. + +The SDK's dependency posture is the reason this is an ADR rather than a line in a commit message. Today the runtime closure is `pydantic` and `cryptography`, the latter also providing ML-DSA-65 behind the optional `[pq]` extra. Everything cryptographic goes through one audited implementation, which is a claim the project makes to adopters and should not give up quietly. + +## Decision + +**The SDK depends on `cbor2` for serialization and builds the COSE structures itself**, in [`python/src/agent_manifest/_cose.py`](https://github.com/agentrust-io/agent-manifest/blob/main/python/src/agent_manifest/_cose.py). No COSE library is taken. Signing and verification stay on `cryptography` (Ed25519 and ML-DSA-65), exactly as the v0.1 path does. + +**The unprotected header is always emitted as a zero-length map.** It is never omitted. A `COSE_Sign1` is a four-element array (RFC 9052 section 4.2), so omitting the element does not produce a shorter valid `COSE_Sign1` — it produces something that is not one. The envelope spec listed this as an open question about two valid encodings; there is only one, and pinning it lets conformance vectors compare byte-for-byte. + +Encoding is deterministic throughout: protected headers and the outer structure are encoded with `canonical=True`. On verification, the protected header is read from the byte string as received and is never re-encoded, so a verifier's own encoder can never disagree with the signer's about what was signed. + +## Rationale + +The COSE object this profile emits is a four-element array and a `Sig_structure` that is itself a four- or five-element array (RFC 9052 section 4.4). That is the entire construction. Against that, a COSE library would have to earn a place inside the trust boundary, and the two candidates do not: + +| | `pycose` 1.1.0 | `cwt` 3.3.0 | `cbor2` + this repo | +|---|---|---|---| +| ML-DSA-65 (`alg` -49, RFC 9964) | absent | absent | supported | +| Additional crypto in the closure | `ecdsa`, `certvalidator` (which pulls `oscrypto`, `asn1crypto`) | `pyhpke` | none | +| Constrains `cbor2` | no | pins `>=5.4.2,<6.0.0` | no | +| Who builds the signed bytes | the library | the library | this project | + +Both libraries were inspected at the versions a resolver picks today: neither ships the RFC 9964 code points, and neither mentions ML-DSA in any form. So the post-quantum profile — half of what this envelope exists to carry — would need extending in a dependency either way, and `-49` support would arrive on someone else's release schedule. + +The dependency argument is the decisive one. `pycose` brings a second and third asymmetric-crypto implementation into a package that has kept to one, and `oscrypto` in particular is a liability against modern OpenSSL. `cwt` is the cleaner of the two, but it pins `cbor2` below 6.0, which is a version ceiling on the SDK's serialization for the benefit of code we would use a fraction of. + +`cbor2` is the right size of dependency: serialization only, no crypto, no declared runtime dependencies of its own, and already an indirect dependency of every alternative considered. Taking it directly is strictly less than taking it through a COSE library. The review that ADR-0011 asked for is below. + +The remaining argument for a COSE library is that hand-rolling crypto plumbing is how implementations acquire bugs. It has real force, and it is answered by scope: what this repository builds is CBOR array construction, not a signature scheme. Every actual cryptographic operation is a call into `cryptography` (or the liboqs bindings, where a deployment still carries them). Meanwhile the specific defects ADR-0011 cites in JOSE implementations — accepting `alg` from an unauthenticated place, failing to bind the algorithm to the key type, re-serializing before verifying — are precisely the checks a general-purpose library leaves to its caller anyway. Writing them here makes them reviewable in one file with the spec section numbers next to them. + +## Dependency review + +ADR-0011 asked for this dependency to be reviewed on maintenance, audit history, and wheel availability across the supported Python matrix. Recorded here as findings rather than impressions. + +**Maintenance.** MIT, authored and maintained by Alex Grönholm ([agronholm/cbor2](https://github.com/agronholm/cbor2)), 48 releases, currently 6.1.4. `Requires-Python: >=3.10`, and the wheels carry classifiers through 3.15, so the project tracks new interpreters ahead of them shipping rather than behind. It declares **no runtime dependencies at all**, which is the property that matters most here: taking it adds one node to the closure, not a subtree. + +**Audit history.** `pip-audit` 2.10.1 reports no known advisory against any `cbor2` version in the SDK's environment. This is the advisory-database check, not a code audit; nobody has read the extension source on this project's behalf, and the point below about what that extension now is makes that worth saying plainly. + +**Wheel availability.** Verified by resolving, not by assumption — nine targets, 3.11/3.12/3.13 × manylinux x86-64 / macOS arm64 / Windows x86-64, all satisfied. One detail the resolution surfaced: under the `>=5.6,<7` pin, macOS and Windows get 6.1.4 while `manylinux_2_17` gets **5.9.0**, because the 6.x wheels target a newer glibc baseline. The SDK therefore has to work on both major versions, so it is tested against both ends of the range. + +**What the extension is, and the risk that carries.** This is the finding that would have been missed by assuming. cbor2 **5.x** ships a C extension *plus* a pure-Python fallback (`_decoder.py`, `_encoder.py`, `_types.py`). cbor2 **6.x** ships a **Rust** extension and **no fallback at all** — the wheel contains `__init__.py`, `tool.py`, and the compiled module. On a platform with no published wheel, 6.x cannot be installed without a Rust toolchain, where 5.x would simply run slower in pure Python. + +That is a real change in the SDK's build surface and an argument for the `<7` ceiling being a deliberate ceiling rather than routine caution. It does not change the decision: the alternative libraries depend on `cbor2` too and would inherit the same property while adding their own, so this is a cost of CBOR in Python, not a cost of this choice. + +**A behavioural difference between the two majors.** 5.x decodes the contents of a CBOR tag into `list` and `dict`; 6.x decodes them into `tuple` and an immutable mapping. Any implementation reading a COSE object through `cbor2` has to accept both, so `_cose.py` accepts either shape and normalizes the unprotected header once, and the test suite is run against 5.6.0 and 6.1.4. + +## Interoperability check + +Building the COSE structures in-repo raises a fair question: if the SDK verifies its own output, a mistake in the `Sig_structure` would be reproduced on both sides and pass. So the published vector was handed to a COSE library with no relationship to this project. + +`pycose` 1.1.0 parses `AM-VEC-COSE-001`, reads `alg` as `EdDSA`, resolves `kid` and the content type, preserves the `typ` it does not recognise, sees the zero-length unprotected map, and **verifies the signature**. It rejects both a flipped signature byte and a modified payload. The same check covers `COSE_Sign`: pycose reads the body protected header, the per-signature protected header, and verifies the signature over the multi-signer `Sig_structure`. Both are run by [`python/tests/interop/verify_with_pycose.py`](https://github.com/agentrust-io/agent-manifest/blob/main/python/tests/interop/verify_with_pycose.py). + +The limit of this check is worth recording precisely. It covers Ed25519 only, because **no COSE library implements ML-DSA-65 yet** — handed a real hybrid envelope, pycose stops at `Unknown COSE attribute with value: -49`. The `COSE_Sign` fixture therefore carries a single Ed25519 signer built through the same `_sig_structure_sign` the hybrid path uses, which isolates everything about the structure that is not algorithm-specific. What remains without an outside opinion is the `-49` code point itself, and that was confirmed against the IANA COSE Algorithms registry (`-48`/`-49`/`-50` for ML-DSA-44/65/87), not against memory. + +It runs outside the pytest suite, in its own environment, for a reason that reinforces the decision above: **pycose cannot decode any COSE message when cbor2 6.x is installed — including messages it encoded itself.** Making it a normal test would mean pinning the SDK's serialization to accommodate a test-only dependency. That the interop check has to be quarantined from the library it validates against is the clearest possible argument for not having taken that library as a dependency. + +## Alternatives considered + +**Option A: `pycose`.** The obvious choice by name. Rejected on the dependency closure (`ecdsa`, `certvalidator`, `oscrypto`) and on the absence of ML-DSA-65, which would require carrying a fork or a monkeypatch for the post-quantum profile. + +**Option B: `cwt`.** Maintained, and narrower in dependencies than `pycose`. Rejected because it also lacks ML-DSA-65, pins `cbor2<6`, and is built around CWT claim sets rather than a document envelope, so most of what it provides is unused. + +**Option C: `cbor2` plus in-repo COSE (chosen).** + +**Option D: no new dependency at all — hand-encode CBOR.** Rejected. CBOR decoding of untrusted input is exactly the kind of parser this project should not be writing, and it is the part `cbor2` is good at. + +## Consequences + +- The SDK's runtime dependencies become `pydantic`, `cryptography`, `cbor2`. The crypto surface is unchanged: no new signature implementation enters the closure. +- The pin is `>=5.6,<7`, and both ends are exercised by CI-visible tests. Widening it to 7.x needs a fresh look at the two majors' decode types, not just a green suite on whichever version happens to resolve. +- When a COSE library does ship RFC 9964 code points, revisiting this is cheap. The envelope is behind `_cose.py`, and the wire format is pinned by conformance vectors rather than by an implementation, so a swap would be a refactor rather than a migration. +- The SDK owns its `Sig_structure` construction and must keep it correct against RFC 9052 section 4.4. The phase 3 vectors are what hold that: an implementation in another language, using a COSE library, must produce identical bytes. +- Two of the four open items in section 9 of the envelope specification are now closed. The other two — the negative conformance vectors, and whether the two string labels become IANA integer labels before v1.0 — remain open and belong to phase 3 and the v1.0 work respectively. + +## References + +- [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052.html) — COSE structures. Section 4.2 (COSE_Sign1 array), section 4.4 (`Sig_structure`). +- [RFC 9964](https://www.rfc-editor.org/rfc/rfc9964.html) — ML-DSA for JOSE and COSE. ML-DSA-65 = `alg` -49. +- [ADR-0011](0011-signature-envelope.md) — the decision to move to COSE_Sign1. +- [COSE envelope specification v0.2](../../spec/agent-manifest-cose-envelope-v0.2.md) — section 9, open items for phase 2. diff --git a/docs/adr/0014-fully-specified-ed25519-code-point.md b/docs/adr/0014-fully-specified-ed25519-code-point.md new file mode 100644 index 0000000..eb91de0 --- /dev/null +++ b/docs/adr/0014-fully-specified-ed25519-code-point.md @@ -0,0 +1,56 @@ +# ADR-0014: Sign with the fully-specified Ed25519 code point (-19), keep verifying -8 + +**Status**: Accepted +**Date**: 2026-08-05 +**Spec section**: [COSE envelope v0.2](../../spec/agent-manifest-cose-envelope-v0.2.md), section 3 +**Amends**: [ADR-0011](0011-signature-envelope.md), which named `-8` +**Tracking**: Issue #243, phase 2 + +## Context + +ADR-0011 recorded the COSE code points as settled: `EdDSA` `-8`, `ML-DSA-65` `-49`, AKP key type `7`. The ML-DSA half is correct and was confirmed against the IANA COSE Algorithms registry (`-48`/`-49`/`-50` for ML-DSA-44/65/87). The Ed25519 half was already out of date when it was written. + +[RFC 9864](https://www.rfc-editor.org/rfc/rfc9864.html) (Standards Track, October 2025) **deprecates the polymorphic `EdDSA` identifier `-8`** and registers fully-specified ones in its place: `Ed25519` = `-19` and `Ed448` = `-53`, both marked Recommended. The IANA registry now carries `-8` with a Deprecated status. + +This surfaced during phase 2 while confirming the ML-DSA code point against IANA rather than against memory. It was not caught in phase 1, where `-8` was carried forward from RFC 9053 without re-checking the registry. + +## Decision + +**A producer signs with `-19`. A verifier accepts both `-19` and `-8`, and treats them as the same algorithm.** + +`-8` stays verifiable indefinitely rather than for a deprecation window. Manifests are audit records with regulated retention beyond their 90-day life (ADR-0011's own reasoning for keeping v0.1 verification), and a signature cannot be re-issued under a new identifier without re-signing, which would defeat the point of an audit record. + +**Anything that reasons about which algorithm signed compares algorithms, not code points.** Two consequences, both normative in the envelope specification: a `post-quantum` `crypto_profile` is not satisfied by `-19` or `-8`; and a `COSE_Sign` carrying one entry of each is rejected as a single algorithm signed twice, not accepted as a hybrid signature. + +## Rationale + +The strongest argument is that ADR-0011 already made it. Its case against the v0.1 envelope is that an algorithm identifier must be authenticated and unambiguous - that a verifier should never have to infer what was signed. `-8` is exactly that inference: it names the EdDSA *family* and leaves the curve to be read off the key. RFC 9864 exists to remove that indirection, and the reasoning it gives is the reasoning ADR-0011 gives. Keeping `-8` while citing DSSE on algorithm binding would be inconsistent. + +The second argument is alignment, which is the entire point of moving to COSE. SCITT, C2PA and the RATS work track the COSE registries. A profile that mandates a deprecated identifier is a profile that has to be revised the first time a reviewer opens the registry. + +Verification tolerance is separate from signing choice and costs nothing. A verifier that accepts `-8` cannot be attacked through it: both identifiers resolve to the same verification with the same key type, and `alg` is inside the protected header the signature covers, so neither can be swapped for the other after signing. What tolerance buys is that a verifier shipped before this decision keeps working, and no coordinated cutover is needed. + +## Alternatives considered + +**Option A: sign `-19`, verify both (chosen).** + +**Option B: stay on `-8` and document the choice.** Defensible on ubiquity - every COSE implementation supports `-8`, and `pycose` verified this project's envelopes using it. Rejected because the specification is a proposed standard heading for a standards body, and "we knowingly mandate a deprecated identifier" is a weaker position than the one-line change that avoids it. The cost of moving is lowest now, before conformance vectors are published in phase 3 and before any v0.2 manifest is issued. + +**Option C: sign `-19` and reject `-8`.** Cleanest in the abstract, wrong for this object type: it would make previously valid audit records unverifiable, which is the one thing the retention argument in ADR-0011 rules out. + +**Option D: accept both on signing, chosen per configuration.** Rejected. Two ways to emit one thing is how implementations drift apart, and it would put the encoding of a signed field under a runtime flag - the same objection ADR-0011 raises to selecting an envelope by flag rather than by version. + +## Consequences + +- The wire format changes: the protected header of an Ed25519 manifest now carries `-19`. `AM-VEC-COSE-001` is regenerated accordingly. This is the moment to do it, since no v0.2 manifest has been issued and phase 3 has not published its vectors. +- **No COSE library can verify a `-19` manifest today, and that is the real cost of this decision.** The interop check that validates this project's `Sig_structure` against an implementation it did not write (ADR-0013) fails on `-19` with `Unknown COSE attribute`, exactly as it fails on `-49`. The check now runs against `-8` fixtures built by the same builders, which keeps an outside opinion on the *structure* while the identifier waits for the ecosystem. It is worth being blunt that this cuts against ADR-0011's stated goal of adopting "a specification other people implement": for a period, nobody else's tooling reads either of this profile's algorithm identifiers. The judgement is that shipping a deprecated identifier into a standards-track profile is the worse of the two, and that library support follows the RFC rather than the other way round. Revisit if that turns out to be wrong. +- ADR-0011's "code points are settled" line is superseded for Ed25519 only. Its ML-DSA-65 and AKP key type values stand, both confirmed against IANA. +- Ed448 (`-53`) is registered by RFC 9864 but is not part of this profile. The standard profile is Ed25519 (ADR-0002); adding a second classical curve is a separate decision with no demand behind it. +- Verifiers built against the specification before this ADR accept `-19` already if they were built from the SDK, which shipped the tolerance ahead of this decision precisely so that this change would not need a flag day. + +## References + +- [RFC 9864](https://www.rfc-editor.org/rfc/rfc9864.html) - Fully-Specified Algorithms for JOSE and COSE. Deprecates `EdDSA` `-8`; registers Ed25519 `-19`, Ed448 `-53`. +- [RFC 9053](https://www.rfc-editor.org/rfc/rfc9053.html) - the original `EdDSA` `-8` registration. +- [IANA COSE Algorithms registry](https://www.iana.org/assignments/cose/cose.xhtml) - the authority for both, checked rather than recalled. +- ADR-0002 (Ed25519 as the standard profile), ADR-0011 (the COSE envelope), ADR-0013 (the CBOR dependency). diff --git a/docs/adr/index.md b/docs/adr/index.md index 2eda360..9df6255 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -16,6 +16,8 @@ Each major design decision in the Agent Manifest Specification is recorded here | [0010](0010-runtime-attestation-freshness-proofs.md) | Runtime attestation freshness proofs via caller-controlled REPORT_DATA | Accepted | | [0011](0011-signature-envelope.md) | The manifest is a signed document, not a JWT/JOSE profile; envelope moves to COSE_Sign1 | Accepted | | [0012](0012-context-uri-moved-to-controlled-domain.md) | `@context` URI moves to a domain we control; v0.1 URL withdrawn, consumers cut over | Accepted | +| [0013](0013-cbor-library-for-cose.md) | Take a CBOR library, not a COSE library; the COSE structures are built in-repo | Accepted | +| [0014](0014-fully-specified-ed25519-code-point.md) | Sign with the fully-specified Ed25519 code point (-19); keep verifying the deprecated -8 | Accepted | To propose a new ADR, open a GitHub issue using the [spec change template](https://github.com/agentrust-io/agent-manifest/issues/new?template=spec_change.md) and follow the [ADR template](0000-template.md). diff --git a/docs/api-reference/cose.md b/docs/api-reference/cose.md new file mode 100644 index 0000000..e132723 --- /dev/null +++ b/docs/api-reference/cose.md @@ -0,0 +1,141 @@ +# COSE envelope (manifest version 0.2) + +The signature envelope for manifest version `0.2`. Version `0.1` manifests keep verifying through the [signing](signing.md) API exactly as before: the envelope follows the manifest `version` field, never a flag. + +Normative reference: [`spec/agent-manifest-cose-envelope-v0.2.md`](https://github.com/agentrust-io/agent-manifest/blob/main/spec/agent-manifest-cose-envelope-v0.2.md). Decisions: [ADR-0011](../adr/0011-signature-envelope.md) (why COSE), [ADR-0013](../adr/0013-cbor-library-for-cose.md) (why no COSE library), [ADR-0014](../adr/0014-fully-specified-ed25519-code-point.md) (why `-19`). + +## What changes from v0.1 + +| | v0.1 | v0.2 | +|---|---|---| +| Envelope | Detached signature block over an RFC 8785 pre-image | `COSE_Sign1` (tag 18), or `COSE_Sign` (tag 98) for hybrid | +| Algorithm binding | `signature.algorithm`, outside the signature | `alg` in the protected header, covered by the signature | +| Verification input | Re-serialise, then compare | The payload as received; nothing is re-serialised | +| Receipts, attestation, approvals | Top-level fields with ordering rules | Unprotected header, evaluated after the signature | +| Hardware binds | A hash over a field subset | `sha256` of the payload bytes | + +RFC 8785 has not gone away. It remains the producer-side determinism rule and the basis of the hash bound into hardware; what changed is that a verifier no longer has to reproduce it. + +## Signing + +```python +from agent_manifest import generate_ed25519, sign_manifest_cose + +keypair = generate_ed25519() +envelope = sign_manifest_cose(manifest, keypair) # bytes, tagged CBOR +``` + +`sign_manifest_cose` selects the structure from the key: a single-algorithm keypair produces `COSE_Sign1`, a `HybridKeyPair` produces one `COSE_Sign` with two signers. Signing refuses a manifest whose `version` is not `0.2`. + +::: agent_manifest._cose.sign_manifest_cose + +::: agent_manifest._cose.sign_cose_sign1 + +::: agent_manifest._cose.sign_cose_sign_hybrid + +::: agent_manifest._cose.cose_payload + +::: agent_manifest._cose.payload_hash + +## Attaching what comes after signing + +A receipt, a TEE attestation report, and HITL approvals are all produced after the manifest is signed. They attach to the unprotected header, so attaching one never invalidates the signature. + +```python +from agent_manifest import attach_receipt, attach_attestation, attach_approvals + +envelope = attach_receipt(envelope, receipt_bytes) # label 394, RFC 9942 +envelope = attach_attestation(envelope, attestation_block) +envelope = attach_approvals(envelope, approvals) +``` + +::: agent_manifest._cose.attach_receipt + +::: agent_manifest._cose.attach_attestation + +::: agent_manifest._cose.attach_approvals + +::: agent_manifest._cose.attach_unprotected + +## Verification + +```python +from agent_manifest import verify_manifest + +result = verify_manifest(envelope, context, revocation_store) # bytes -> COSE +result = verify_manifest(manifest_dict, context, revocation_store) # dict -> v0.1 +``` + +`verify_manifest` selects the procedure from what it is given. Everything else, expiry, revocation, artifact bindings, delegation and HITL, is the same engine for both envelopes. + +For envelope-level appraisal on its own: + +::: agent_manifest._cose.verify_cose_manifest + +::: agent_manifest._cose.CoseVerification + +::: agent_manifest._cose.CoseSignature + +::: agent_manifest._cose.decode_cose_manifest + +Nothing in the unprotected header influences whether the signature verifies. It is attacker-malleable by definition, so it is read only after the signature is settled, and a failure in it is reported against that element rather than as a signature failure. + +### Failures + +| Raised | Meaning | Engine verdict | +|---|---|---| +| `CoseStructureError` | Malformed envelope, bad header, unknown `crit`, unknown algorithm, ambiguous payload | `MISMATCH` | +| `CoseVersionError` | Not a version 0.2 payload | `INCOMPATIBLE_VERSION` | +| `CoseDowngradeError` | `crypto_profile` requires more than `alg` provides | `MISMATCH` | +| `CoseKeyError` | `kid` is not in the trusted keys | `MISMATCH` | +| `InvalidSignature` | The signature did not verify | `MISMATCH` | +| `AlgorithmUnavailableError` | This build cannot perform the algorithm | `UNVERIFIABLE` | + +The last one is a capability gap, not a bad manifest, and never falls back to a weaker signature. + +## Algorithms + +| Algorithm | COSE `alg` | Notes | +|---|---|---| +| Ed25519 | `-19` | What the SDK signs with (RFC 9864) | +| EdDSA | `-8` | Deprecated by RFC 9864; still verified, never emitted | +| ML-DSA-65 | `-49` | RFC 9964; needs `cryptography` >= 47 or the liboqs bindings | + +`-8` and `-19` name one algorithm. A `post-quantum` profile is satisfied by neither, and a `COSE_Sign` carrying one entry of each is rejected as a single algorithm signed twice rather than accepted as a hybrid signature. + +## Command line + +The envelope follows the manifest version on the way out, and the CBOR tag on the way in. No flag selects it. + +```bash +manifest sign draft.json --key private.hex -o signed.cose +manifest verify signed.cose --public-key public.hex +``` + +A `0.2` manifest is written as binary CBOR, so `sign` requires `--output` rather than writing to the terminal. + +## HTTP + +``` +POST /verify/cose +Content-Type: application/agent-manifest+cose + + +``` + +The body is the COSE object itself. Only the exact registered media type is accepted: a vendor-tree alias, `application/cbor`, and an absent type are all refused with 415, and the body is never sniffed to decide what it is. + +**No key material crosses the wire.** Trust comes from a `VerificationContext` given to `create_router(..., cose_context=...)` when the server is built. Without one the endpoint is fail-closed and returns `UNVERIFIABLE`, never `VALID`. + +The body is bounded before it is parsed (1 MiB), with `Content-Length` checked when present and the stream capped regardless. A malformed or unverifiable envelope is a verdict, 200 with a non-`VALID` result, not a transport error. Responses carry `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. + +Authentication, authorisation and rate limiting are deployment concerns. Mount this router behind them, as [section 5.1](../spec-overview.md) describes. + +## Media types + +| Media type | Applies to | +|---|---| +| `application/agent-manifest+json` | The manifest document: the canonical JSON payload | +| `application/agent-manifest+cose` | The signed object: `COSE_Sign1` or `COSE_Sign` | + +Both are standards-tree, registration pending. A verifier must not accept a vendor-tree alias. diff --git a/mkdocs.yml b/mkdocs.yml index d8e4f2e..ce04ec8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -173,6 +173,9 @@ nav: - ADR-0009 - SPIFFE URI for Agent Identity: adr/0009-spiffe-uri-agent-identity.md - ADR-0010 - Runtime Attestation Freshness Proofs: adr/0010-runtime-attestation-freshness-proofs.md - ADR-0011 - Signed Document, Not a JWT Profile: adr/0011-signature-envelope.md + - ADR-0012 - Context URI on a Controlled Domain: adr/0012-context-uri-moved-to-controlled-domain.md + - ADR-0013 - CBOR Library, Not a COSE Library: adr/0013-cbor-library-for-cose.md + - ADR-0014 - Fully-Specified Ed25519 Code Point: adr/0014-fully-specified-ed25519-code-point.md - Compliance: - Overview: compliance/index.md - EU AI Act: compliance/eu-ai-act.md @@ -183,6 +186,7 @@ nav: - Overview: api-reference/index.md - Core models: api-reference/models.md - Signing: api-reference/signing.md + - COSE envelope (v0.2): api-reference/cose.md - Verification: api-reference/verification.md - Revocation: api-reference/revocation.md - Delegation: api-reference/delegation.md diff --git a/python/pyproject.toml b/python/pyproject.toml index adde2b6..26d8cd2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -30,13 +30,24 @@ classifiers = [ dependencies = [ "pydantic>=2.7,<3", "cryptography>=42,<51", + # COSE envelope for manifest version 0.2 (ADR-0011, ADR-0013). Serialization + # only - the COSE structures are built in _cose.py and every signature stays + # on cryptography (and optionally the liboqs bindings for ML-DSA-65). + "cbor2>=5.6,<7", ] [project.optional-dependencies] -pq = ["pyoqs>=0.10"] +# ML-DSA-65 comes from cryptography, which gained it in 47.0.0. The extra +# exists to raise the floor for callers who need the post-quantum profile; +# it no longer names a liboqs package, because the `pyoqs` distribution this +# previously required is not published on PyPI and the `oqs` module name +# there belongs to an unrelated project. Deployments already carrying the +# liboqs bindings keep working: _signing detects them and dispatches on the +# key material. +pq = ["cryptography>=47"] server = ["fastapi>=0.111", "uvicorn[standard]>=0.29", "httpx>=0.27"] cli = ["click>=8.1"] -all = ["pyoqs>=0.10", "fastapi>=0.111", "uvicorn[standard]>=0.29", "httpx>=0.27", "click>=8.1"] +all = ["cryptography>=47", "fastapi>=0.111", "uvicorn[standard]>=0.29", "httpx>=0.27", "click>=8.1"] dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-cov>=5", "httpx>=0.27", "jcs>=0.2", "fastapi>=0.111", "uvicorn[standard]>=0.29", "click>=8.1", "mypy>=1.10", "bandit[toml]>=1.7", "pip-audit>=2.7"] diff --git a/python/src/agent_manifest/__init__.py b/python/src/agent_manifest/__init__.py index 6108c6c..3ab4b8a 100644 --- a/python/src/agent_manifest/__init__.py +++ b/python/src/agent_manifest/__init__.py @@ -24,6 +24,19 @@ signing_pre_image, generate_ed25519, Ed25519KeyPair, Ed25519Signer, Ed25519Verifier, ) +from ._cose import ( + COSE_MANIFEST_VERSION, COSE_SIGN1_TAG, COSE_SIGN_TAG, + ALG_EDDSA, ALG_ML_DSA_65, + MEDIA_TYPE_MANIFEST_COSE, MEDIA_TYPE_MANIFEST_JSON, + HDR_RECEIPTS, LABEL_ATTESTATION, LABEL_APPROVALS, + cose_payload, payload_hash, + sign_manifest_cose, sign_cose_sign1, sign_cose_sign_hybrid, + attach_receipt, attach_attestation, attach_approvals, attach_unprotected, + decode_cose_manifest, verify_cose_manifest, + CoseVerification, CoseSignature, + CoseError, CoseStructureError, CoseVersionError, CoseDowngradeError, + CoseKeyError, +) from ._providers import ( AttestationReport, AttestationUnavailableError, RuntimeAttestationReport, TPMProvider, @@ -97,6 +110,17 @@ "canonicalize", "canonical_hash", "SIGNED_FIELDS", "signing_pre_image", "generate_ed25519", "Ed25519KeyPair", "Ed25519Signer", "Ed25519Verifier", + "COSE_MANIFEST_VERSION", "COSE_SIGN1_TAG", "COSE_SIGN_TAG", + "ALG_EDDSA", "ALG_ML_DSA_65", + "MEDIA_TYPE_MANIFEST_COSE", "MEDIA_TYPE_MANIFEST_JSON", + "HDR_RECEIPTS", "LABEL_ATTESTATION", "LABEL_APPROVALS", + "cose_payload", "payload_hash", + "sign_manifest_cose", "sign_cose_sign1", "sign_cose_sign_hybrid", + "attach_receipt", "attach_attestation", "attach_approvals", + "attach_unprotected", "decode_cose_manifest", "verify_cose_manifest", + "CoseVerification", "CoseSignature", + "CoseError", "CoseStructureError", "CoseVersionError", "CoseDowngradeError", + "CoseKeyError", "AttestationReport", "AttestationUnavailableError", "RuntimeAttestationReport", "TPMProvider", "AzureCVMProvider", "SEVSNPProvider", "TDXProvider", "OPAQUEProvider", "verify_attestation_chain", "ChainVerificationResult", "SignatureStatus", diff --git a/python/src/agent_manifest/_cose.py b/python/src/agent_manifest/_cose.py new file mode 100644 index 0000000..dbf1451 --- /dev/null +++ b/python/src/agent_manifest/_cose.py @@ -0,0 +1,909 @@ +"""COSE_Sign1 / COSE_Sign signature envelope for manifest version 0.2. + +Normative reference: ``spec/agent-manifest-cose-envelope-v0.2.md`` (ADR-0011). +This module implements manifest version ``0.2`` only. Version ``0.1`` manifests +keep verifying through :mod:`agent_manifest._signing` exactly as they do today; +the envelope is selected by the manifest ``version`` field, never by a flag. + +What changes relative to v0.1: + + - The signed bytes travel with the signature. A verifier never re-serializes + a manifest to check a signature; it verifies over the payload as received. + RFC 8785 stays the *producer-side* determinism rule and the basis of the + hash bound into hardware, but it is not an input to verification. + - ``alg`` sits in the protected header, covered by the signature. The + downgrade v0.1 defends against with a cross-check (SDK 0.6.0) cannot be + expressed here. + - Data that attaches after signing - transparency receipts, the TEE + attestation report, HITL approvals - lives in the unprotected header. + The v0.1 ``signed_fields`` list, the ``hitl_record.approvals`` + normalization rule, and the ``transparency_log_entry`` ordering rule all + disappear with it. + +Dependency note (ADR-0013): the only new dependency is ``cbor2``, a +serialization library. The COSE structures are built here rather than taken +from a COSE library so that every byte that goes into a signature is +constructed in code this project reviews, and so that the SDK's crypto surface +stays ``cryptography`` plus the optional liboqs bindings. +""" +from __future__ import annotations + +import hashlib +import io +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Optional, Union + +import cbor2 +from cryptography.exceptions import InvalidSignature + +from . import _signing +from ._canonicalize import canonicalize +from ._signing import ( + Ed25519KeyPair, + Ed25519Verifier, + HybridKeyPair, + MlDsa65KeyPair, + _b64url_decode, +) + +__all__ = [ + "COSE_MANIFEST_VERSION", + "COSE_SIGN1_TAG", + "COSE_SIGN_TAG", + "ALG_EDDSA", + "ALG_ED25519", + "ALG_ML_DSA_65", + "ED25519_ALGORITHMS", + "HDR_ALG", + "HDR_CRIT", + "HDR_CONTENT_TYPE", + "HDR_KID", + "HDR_TYP", + "HDR_RECEIPTS", + "LABEL_ATTESTATION", + "LABEL_APPROVALS", + "MEDIA_TYPE_MANIFEST_COSE", + "MEDIA_TYPE_MANIFEST_JSON", + "CoseError", + "CoseStructureError", + "CoseVersionError", + "CoseDowngradeError", + "CoseKeyError", + "CoseSignature", + "CoseVerification", + "cose_payload", + "payload_hash", + "sign_manifest_cose", + "sign_cose_sign1", + "sign_cose_sign_hybrid", + "attach_unprotected", + "attach_receipt", + "attach_attestation", + "attach_approvals", + "decode_cose_manifest", + "read_payload_manifest", + "verify_cose_manifest", +] + +# --- CBOR tags (RFC 9052 section 2) ----------------------------------------- +COSE_SIGN1_TAG = 18 +COSE_SIGN_TAG = 98 + +# --- Header parameter labels ------------------------------------------------ +HDR_ALG = 1 +HDR_CRIT = 2 +HDR_CONTENT_TYPE = 3 +HDR_KID = 4 +HDR_TYP = 16 # RFC 9596 +HDR_RECEIPTS = 394 # RFC 9943 / RFC 9942 + +# String labels pending IANA integer assignment (envelope spec section 4.1). +LABEL_ATTESTATION = "agent-manifest-attestation" +LABEL_APPROVALS = "agent-manifest-approvals" + +# --- Algorithm code points -------------------------------------------------- +# `EdDSA` (-8) is polymorphic: it says "some EdDSA curve" and leaves the choice +# to the key. RFC 9864 (Standards Track, October 2025) deprecated it and +# registered fully-specified identifiers, of which Ed25519 is -19. +# +# This module SIGNS with -19 (ADR-0014) and VERIFIES both. -8 stays verifiable +# indefinitely rather than for a deprecation window: manifests are audit +# records with regulated retention, and a signature cannot be re-issued under +# a new identifier without re-signing. +ALG_EDDSA = -8 # RFC 9053; deprecated by RFC 9864, still verified +ALG_ED25519 = -19 # RFC 9864, fully specified; what this SDK signs with +ALG_ML_DSA_65 = -49 # RFC 9964 +ALG_NAMES: dict[int, str] = { + ALG_EDDSA: "EdDSA", + ALG_ED25519: "Ed25519", + ALG_ML_DSA_65: "ML-DSA-65", +} + +# Both identifiers name the same signature algorithm over the same key type. +ED25519_ALGORITHMS: frozenset[int] = frozenset({ALG_EDDSA, ALG_ED25519}) + +# --- Media types (envelope spec section 7, registration pending) ------------ +MEDIA_TYPE_MANIFEST_JSON = "application/agent-manifest+json" +MEDIA_TYPE_MANIFEST_COSE = "application/agent-manifest+cose" + +COSE_MANIFEST_VERSION = "0.2" + +# Ed25519 signatures are fixed-length; reject before handing bytes to OpenSSL +# (SIGN-001, carried forward from the v0.1 path). +_ED25519_SIGNATURE_LEN = 64 + +# v0.1 top-level fields with no place in a v0.2 payload. ``signature`` no +# longer exists as a manifest field - the COSE structure is the signature. +# ``attestation`` and ``transparency_log_entry`` attach after signing and now +# live in the unprotected header. +_NON_PAYLOAD_FIELDS = ("signature", "attestation", "transparency_log_entry") + +# Which algorithms satisfy a declared crypto_profile (envelope spec section 6 +# step 4, carrying forward v0.1 section 4.2). One-directional by design: +# stronger than declared is permitted, weaker is a downgrade. +PROFILE_REQUIRED_ALGORITHMS: dict[str, frozenset[int]] = { + "post-quantum": frozenset({ALG_ML_DSA_65}), +} + + +class CoseError(ValueError): + """Base class for every COSE envelope rejection.""" + + +class CoseStructureError(CoseError): + """The object is not a well-formed COSE manifest envelope. + + Covers an untagged or wrongly tagged structure, a malformed header, a + missing or unexpected ``typ``/``content type``, an unknown ``crit`` + entry, and a payload that is not the JSON of a manifest. + """ + + +class CoseVersionError(CoseError): + """The payload declares a manifest version this envelope does not govern. + + A ``0.1`` payload is routed to the v0.1 envelope rules rather than + reinterpreted here; any other unsupported version maps to + ``INCOMPATIBLE_VERSION``. + """ + + +class CoseDowngradeError(CoseError): + """The signed ``crypto_profile`` requires more than ``alg`` provides.""" + + +class CoseKeyError(CoseError): + """A signature names a ``kid`` that is not in the caller's trusted keys.""" + + +# --------------------------------------------------------------------------- +# Payload +# --------------------------------------------------------------------------- + + +def cose_payload(manifest_dict: dict[str, Any]) -> bytes: + """Return the payload bytes for *manifest_dict*: RFC 8785 canonical JSON. + + The payload is the manifest document as it stands at signing time. Fields + that attach afterwards are dropped rather than carried: ``signature`` (the + COSE structure replaces it), ``attestation`` and ``transparency_log_entry`` + (unprotected header), and ``hitl_record.approvals`` (unprotected header). + The HITL *requirement* stays in the signed payload; the approvals do not. + + Unlike v0.1's ``signing_pre_image()`` there is no field allowlist. Whatever + is in the manifest is signed, so no field can sit silently outside the + signature and there is no list to keep in sync with the schema. + """ + payload: dict[str, Any] = { + k: v for k, v in manifest_dict.items() if k not in _NON_PAYLOAD_FIELDS + } + hitl = payload.get("hitl_record") + if isinstance(hitl, dict) and "approvals" in hitl: + payload["hitl_record"] = {k: v for k, v in hitl.items() if k != "approvals"} + return canonicalize(payload) + + +def payload_hash(payload: bytes) -> str: + """Return ``sha256:`` over the payload bytes. + + This is what hardware attestation binds (envelope spec section 5): the + exact bytes carried in the COSE payload, with nothing excluded and nothing + to keep in sync. Callers hold it in the platform's caller-supplied field + (``HOST_DATA``, ``REPORT_DATA``, ``REPORTDATA``) per v0.1 section 3.3. + """ + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +# --------------------------------------------------------------------------- +# Signing +# --------------------------------------------------------------------------- + + +def _kid(public_key_bytes: bytes) -> bytes: + """COSE ``kid``: SHA-256 of the raw public key bytes, as a byte string. + + The same digest v0.1 carries as a hex string in ``signature.key_id``, so a + key registered for v0.1 keeps its identity across the migration. + """ + return hashlib.sha256(public_key_bytes).digest() + + +def _require_v02(manifest_dict: dict[str, Any]) -> None: + version = manifest_dict.get("version") + if version != COSE_MANIFEST_VERSION: + raise CoseVersionError( + f"The COSE envelope is normative for manifest version " + f"{COSE_MANIFEST_VERSION!r}, but this manifest declares " + f"{version!r}. Sign a 0.1 manifest with the v0.1 envelope." + ) + + +def _sig_structure_sign1(protected: bytes, payload: bytes) -> bytes: + """RFC 9052 section 4.4 ``Sig_structure`` for COSE_Sign1.""" + return cbor2.dumps(["Signature1", protected, b"", payload], canonical=True) + + +def _sig_structure_sign( + body_protected: bytes, sign_protected: bytes, payload: bytes +) -> bytes: + """RFC 9052 section 4.4 ``Sig_structure`` for one COSE_Sign signer.""" + return cbor2.dumps( + ["Signature", body_protected, sign_protected, b"", payload], canonical=True + ) + + +def _ml_dsa_sign(private_key_bytes: bytes, data: bytes) -> bytes: + """Sign through the SDK's ML-DSA-65 backend (cryptography, or liboqs).""" + _signing._require_ml_dsa() + return _signing._ml_dsa_sign_raw(private_key_bytes, data) + + +def _ml_dsa_verify(public_key_bytes: bytes, data: bytes, signature: bytes) -> None: + _signing._require_ml_dsa() + if not _signing._ml_dsa_verify_raw(public_key_bytes, data, signature): + raise InvalidSignature("ML-DSA-65 signature verification failed") + + +def _ed25519_verify(public_key_bytes: bytes, data: bytes, signature: bytes) -> None: + # Ed25519Verifier applies the CRYPTO-005 small-order (torsion point) + # rejection at load time; go through it rather than around it. + verifier = Ed25519Verifier(public_key_bytes) + if len(signature) != _ED25519_SIGNATURE_LEN: + raise InvalidSignature( + f"Ed25519 signature must be {_ED25519_SIGNATURE_LEN} bytes, " + f"got {len(signature)}" + ) + verifier._pub.verify(signature, data) + + +def sign_cose_sign1( + manifest_dict: dict[str, Any], + keypair: Union[Ed25519KeyPair, MlDsa65KeyPair], +) -> bytes: + """Sign *manifest_dict* as a tagged ``COSE_Sign1`` and return CBOR bytes. + + The unprotected header is emitted as a zero-length map. It is never + omitted: a three-element array is not a ``COSE_Sign1`` (RFC 9052 section + 4.2), and pinning one encoding is what lets conformance vectors compare + byte-for-byte (ADR-0013). + """ + _require_v02(manifest_dict) + payload = cose_payload(manifest_dict) + + if isinstance(keypair, Ed25519KeyPair): + alg = ALG_ED25519 + public_bytes = keypair.public_bytes + else: + alg = ALG_ML_DSA_65 + public_bytes = keypair.public_key_bytes + # Fail before building an envelope this build cannot finish signing. + _signing._require_ml_dsa() + + protected = cbor2.dumps( + { + HDR_ALG: alg, + HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, + HDR_KID: _kid(public_bytes), + HDR_TYP: MEDIA_TYPE_MANIFEST_COSE, + }, + canonical=True, + ) + to_be_signed = _sig_structure_sign1(protected, payload) + if isinstance(keypair, Ed25519KeyPair): + signature = keypair.private_key.sign(to_be_signed) + else: + signature = _ml_dsa_sign(keypair.private_key_bytes, to_be_signed) + + return cbor2.dumps( + cbor2.CBORTag(COSE_SIGN1_TAG, [protected, {}, payload, signature]), + canonical=True, + ) + + +def sign_cose_sign_hybrid( + manifest_dict: dict[str, Any], keypair: HybridKeyPair +) -> bytes: + """Sign *manifest_dict* as a tagged ``COSE_Sign`` with two signers. + + Hybrid is one ``COSE_Sign`` over one payload, not two ``COSE_Sign1`` + objects (envelope spec section 2.1): both entries covering identical + payload bytes is then a property of the structure rather than an + application rule a verifier has to be told to enforce. + + ``typ`` and ``content type`` sit in the body protected header; ``alg`` and + ``kid`` sit in each signature's own protected header. Entries are emitted + Ed25519 first, ML-DSA-65 second. + """ + _require_v02(manifest_dict) + _signing._require_ml_dsa() + payload = cose_payload(manifest_dict) + + body_protected = cbor2.dumps( + { + HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, + HDR_TYP: MEDIA_TYPE_MANIFEST_COSE, + }, + canonical=True, + ) + + ed_protected = cbor2.dumps( + {HDR_ALG: ALG_ED25519, HDR_KID: _kid(keypair.ed25519.public_bytes)}, + canonical=True, + ) + ed_signature = keypair.ed25519.private_key.sign( + _sig_structure_sign(body_protected, ed_protected, payload) + ) + + pq_protected = cbor2.dumps( + {HDR_ALG: ALG_ML_DSA_65, HDR_KID: _kid(keypair.ml_dsa65.public_key_bytes)}, + canonical=True, + ) + pq_signature = _ml_dsa_sign( + keypair.ml_dsa65.private_key_bytes, + _sig_structure_sign(body_protected, pq_protected, payload), + ) + + return cbor2.dumps( + cbor2.CBORTag( + COSE_SIGN_TAG, + [ + body_protected, + {}, + payload, + [ + [ed_protected, {}, ed_signature], + [pq_protected, {}, pq_signature], + ], + ], + ), + canonical=True, + ) + + +def sign_manifest_cose( + manifest_dict: dict[str, Any], + keypair: Union[Ed25519KeyPair, MlDsa65KeyPair, HybridKeyPair], +) -> bytes: + """Sign *manifest_dict* with whichever envelope *keypair* calls for.""" + if isinstance(keypair, HybridKeyPair): + return sign_cose_sign_hybrid(manifest_dict, keypair) + return sign_cose_sign1(manifest_dict, keypair) + + +# --------------------------------------------------------------------------- +# Post-signing attachment (unprotected header) +# --------------------------------------------------------------------------- + + +def attach_unprotected(cose_bytes: bytes, label: Union[int, str], value: Any) -> bytes: + """Return *cose_bytes* with ``unprotected[label] = value``. + + The protected header, payload, and signature byte strings are carried + through untouched, so attaching never invalidates a signature - which is + the whole reason these three things live in the unprotected header. + """ + tag, body = _decode_tagged(cose_bytes) + unprotected = dict(body[1]) + unprotected[label] = value + body = list(body) + body[1] = unprotected + return cbor2.dumps(cbor2.CBORTag(tag, body), canonical=True) + + +def attach_receipt(cose_bytes: bytes, receipt: bytes) -> bytes: + """Append a SCITT receipt (RFC 9942) to the ``receipts`` array (label 394).""" + tag, body = _decode_tagged(cose_bytes) + receipts = list(body[1].get(HDR_RECEIPTS) or []) + receipts.append(receipt) + return attach_unprotected(cose_bytes, HDR_RECEIPTS, receipts) + + +def attach_attestation(cose_bytes: bytes, attestation: dict[str, Any]) -> bytes: + """Attach the hardware attestation block produced after signing.""" + return attach_unprotected(cose_bytes, LABEL_ATTESTATION, attestation) + + +def attach_approvals(cose_bytes: bytes, approvals: list[dict[str, Any]]) -> bytes: + """Attach HITL approval records, each authenticated by its own signature.""" + return attach_unprotected(cose_bytes, LABEL_APPROVALS, approvals) + + +# --------------------------------------------------------------------------- +# Verification +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CoseSignature: + """One signature entry: its algorithm, its key, and whether it verified.""" + + algorithm: int + key_id: str # hex digest, the v0.1 key_id spelling of the COSE kid + verified: bool + + @property + def algorithm_name(self) -> str: + return ALG_NAMES.get(self.algorithm, f"COSE alg {self.algorithm}") + + +@dataclass(frozen=True) +class CoseVerification: + """The outcome of appraising a COSE manifest envelope. + + ``verified`` reports the signature only. Everything in ``unprotected`` is + attacker-malleable and is deliberately not appraised here: the caller + evaluates receipts, attestation, and approvals after the signature has + been settled (envelope spec section 6 step 7, which is normative about + the ordering). + """ + + manifest: dict[str, Any] + payload: bytes + manifest_hash: str + tag: int + signatures: tuple[CoseSignature, ...] + unprotected: dict[Any, Any] + + @property + def verified(self) -> bool: + """True when every signature entry verified against a trusted key.""" + return bool(self.signatures) and all(s.verified for s in self.signatures) + + @property + def algorithms(self) -> tuple[int, ...]: + return tuple(s.algorithm for s in self.signatures) + + @property + def receipts(self) -> list[Any]: + value = self.unprotected.get(HDR_RECEIPTS) or [] + return list(value) if isinstance(value, (list, tuple)) else [] + + @property + def attestation(self) -> Optional[dict[str, Any]]: + value = self.unprotected.get(LABEL_ATTESTATION) + return value if isinstance(value, dict) else None + + @property + def approvals(self) -> Optional[list[Any]]: + value = self.unprotected.get(LABEL_APPROVALS) + return list(value) if isinstance(value, (list, tuple)) else None + + +def _algorithm_family(alg: int) -> str: + """The signature algorithm behind a code point. + + ``-8`` and ``-19`` are two identifiers for Ed25519 (RFC 9864 replaced the + polymorphic one with the fully-specified one), so anything reasoning about + *which algorithm signed* has to collapse them. + """ + return "ed25519" if alg in ED25519_ALGORITHMS else ALG_NAMES.get(alg, str(alg)) + + +def _plain(value: Any) -> Any: + """Convert a CBOR-decoded value into plain dicts and lists. + + cbor2 6.x returns immutable mappings and tuples for anything inside a tag; + 5.x returns lists and dicts. Callers downstream (the verification engine, + Pydantic models) expect ordinary containers, so the unprotected header is + normalized once here rather than defensively at every use. + """ + if isinstance(value, Mapping): + return {k: _plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_plain(v) for v in value] + return value + + +def _decode_tagged(cose_bytes: bytes) -> tuple[int, list[Any]]: + """Decode exactly one tagged COSE object and return ``(tag, body)``. + + Step 1 of the verification procedure. An untagged structure is rejected: + the tag is what tells a relying party which procedure applies, and + inferring it from the array shape is the guess this envelope exists to + eliminate. Trailing bytes are rejected too, so one octet string cannot + carry a second manifest behind the first. + """ + if not isinstance(cose_bytes, (bytes, bytearray)): + raise CoseStructureError( + f"COSE object must be bytes, got {type(cose_bytes).__name__}" + ) + stream = io.BytesIO(bytes(cose_bytes)) + try: + decoded = cbor2.CBORDecoder(stream).decode() + except RecursionError as exc: # deeply nested CBOR (DOS-006) + raise CoseStructureError(f"COSE object is too deeply nested: {exc}") from exc + except Exception as exc: # cbor2 raises several unrelated types + raise CoseStructureError(f"COSE object is not valid CBOR: {exc}") from exc + if stream.read(1): + raise CoseStructureError("trailing bytes after the COSE object") + + if not isinstance(decoded, cbor2.CBORTag): + raise CoseStructureError( + "untagged COSE structure: expected CBOR tag 18 (COSE_Sign1) or " + "98 (COSE_Sign)" + ) + if decoded.tag not in (COSE_SIGN1_TAG, COSE_SIGN_TAG): + raise CoseStructureError( + f"unexpected CBOR tag {decoded.tag}: expected 18 (COSE_Sign1) or " + f"98 (COSE_Sign)" + ) + # cbor2 6.x decodes the contents of a tag into tuples and immutable + # mappings; 5.x decodes them into lists and dicts. Both are supported + # versions (ADR-0013), so accept either shape. + body = decoded.value + if not isinstance(body, (list, tuple)) or len(body) != 4: + raise CoseStructureError( + f"COSE body must be a four-element array, got " + f"{len(body) if isinstance(body, (list, tuple)) else type(body).__name__}" + ) + if not isinstance(body[0], bytes): + raise CoseStructureError("protected header must be a byte string") + # cbor2 hands back an immutable mapping for a map inside a tag. + if not isinstance(body[1], Mapping): + raise CoseStructureError("unprotected header must be a map") + if not isinstance(body[2], bytes): + raise CoseStructureError("payload must be a byte string, inline not detached") + return decoded.tag, list(body) + + +def _decode_protected(protected: bytes, *, what: str) -> dict[Any, Any]: + if protected == b"": + # A zero-length protected header is legal CBOR in COSE generally, but + # this profile requires parameters in it, so it is always a rejection. + raise CoseStructureError(f"{what} protected header is empty") + try: + header = cbor2.loads(protected) + except Exception as exc: + raise CoseStructureError( + f"{what} protected header is not valid CBOR: {exc}" + ) from exc + if not isinstance(header, Mapping): + raise CoseStructureError(f"{what} protected header must be a map") + return dict(header) + + +def _check_crit(header: Mapping[Any, Any], *, what: str) -> None: + crit = header.get(HDR_CRIT) + if crit is None: + return + if not isinstance(crit, (list, tuple)) or not crit: + raise CoseStructureError(f"{what} crit must be a non-empty array") + # Nothing in this profile is understood as critical, so any entry is + # by definition an entry this verifier does not understand. + raise CoseStructureError( + f"{what} protected header marks {list(crit)!r} critical, which this " + f"verifier does not understand" + ) + + +def _check_unprotected_has_no_alg(unprotected: Mapping[Any, Any]) -> None: + if HDR_ALG in unprotected: + raise CoseStructureError( + "alg present in the unprotected header, which a verifier MUST NOT " + "accept under any circumstances" + ) + + +def _check_body_type(header: Mapping[Any, Any]) -> None: + typ = header.get(HDR_TYP) + if typ is None: + raise CoseStructureError( + "typ (label 16) is absent from the protected header; without it a " + "manifest signature could be reinterpreted as a signature over " + "another kind of document" + ) + if typ != MEDIA_TYPE_MANIFEST_COSE: + raise CoseStructureError( + f"typ is {typ!r}, expected {MEDIA_TYPE_MANIFEST_COSE!r}; a " + f"vendor-tree alias MUST NOT be accepted" + ) + content_type = header.get(HDR_CONTENT_TYPE) + if content_type != MEDIA_TYPE_MANIFEST_JSON: + raise CoseStructureError( + f"content type is {content_type!r}, expected " + f"{MEDIA_TYPE_MANIFEST_JSON!r}" + ) + + +def _read_alg_and_kid(header: Mapping[Any, Any], *, what: str) -> tuple[int, bytes]: + alg = header.get(HDR_ALG) + if alg is None: + raise CoseStructureError(f"{what} protected header has no alg") + if not isinstance(alg, int) or isinstance(alg, bool): + raise CoseStructureError(f"{what} alg must be an integer code point, got {alg!r}") + kid = header.get(HDR_KID) + if not isinstance(kid, bytes): + raise CoseStructureError(f"{what} protected header has no kid byte string") + return alg, kid + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build the object, refusing any member name that appears twice. + + RFC 8785 canonical JSON cannot contain duplicate names, so a payload that + does is malformed. It also matters more here than malformedness usually + does: JSON parsers disagree about which value wins, so a payload with a + repeated ``issuer`` or ``expires_at`` could be read differently by two + verifiers that both consider the signature valid. The envelope exists to + make everyone agree on what was signed; this is the parse-level half of + that guarantee. + """ + seen: dict[str, Any] = {} + for key, value in pairs: + if key in seen: + raise CoseStructureError( + f"payload contains a duplicate member name {key!r}, which " + f"RFC 8785 does not permit and parsers resolve differently" + ) + seen[key] = value + return seen + + +def _reject_non_json_constant(token: str) -> Any: + """Refuse NaN and Infinity, which Python accepts and RFC 8785 forbids.""" + raise CoseStructureError( + f"payload contains {token}, which is not permitted by RFC 8785" + ) + + +def _parse_payload(payload: bytes) -> dict[str, Any]: + try: + manifest = json.loads( + payload.decode("utf-8"), + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_non_json_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CoseStructureError(f"payload is not valid JSON: {exc}") from exc + except RecursionError as exc: + # A manifest is untrusted input, so nesting must produce a verdict + # rather than unwind the caller's stack (DOS-006). + raise CoseStructureError(f"payload is too deeply nested: {exc}") from exc + if not isinstance(manifest, dict): + raise CoseStructureError( + f"payload must be a JSON object, got {type(manifest).__name__}" + ) + return manifest + + +def _check_version(manifest: Mapping[str, Any]) -> None: + version = manifest.get("version") + if version == "0.1": + raise CoseVersionError( + "payload declares manifest version 0.1, which is governed by the " + "v0.1 envelope (spec section 3.6), not by this document" + ) + if version != COSE_MANIFEST_VERSION: + raise CoseVersionError( + f"unsupported manifest version {version!r}; this verifier " + f"implements {COSE_MANIFEST_VERSION!r}" + ) + + +def _check_profile(manifest: Mapping[str, Any], algorithms: tuple[int, ...]) -> None: + """Step 4: the signed ``crypto_profile`` against the protected ``alg``. + + ``alg`` is covered by the signature here, so this is no longer defending + against a rewritten identifier the way the v0.1 cross-check is. It still + runs, because ``crypto_profile`` is a claim about posture that the + algorithm alone does not express: a post-quantum profile carrying only + ``-8`` is a downgrade whatever the signature says. + """ + declared = manifest.get("crypto_profile", "standard") + required = PROFILE_REQUIRED_ALGORITHMS.get(declared) + if required is None: + return + if not required.intersection(algorithms): + present = ", ".join(ALG_NAMES.get(a, str(a)) for a in algorithms) + expected = ", ".join(sorted(ALG_NAMES.get(a, str(a)) for a in required)) + raise CoseDowngradeError( + f"crypto_profile={declared!r} requires {expected}, but the " + f"envelope carries {present or 'no algorithm'}" + ) + + +def _verify_one( + alg: int, + kid: bytes, + to_be_signed: bytes, + signature: bytes, + trusted_keys: Mapping[str, str], +) -> CoseSignature: + key_id = kid.hex() + entry = CoseSignature(algorithm=alg, key_id=key_id, verified=False) + if alg not in ALG_NAMES: + raise CoseStructureError( + f"unknown alg code point {alg}; this profile registers " + f"{ALG_EDDSA} (EdDSA) and {ALG_ML_DSA_65} (ML-DSA-65)" + ) + if not isinstance(signature, bytes): + raise CoseStructureError("signature must be a byte string") + if not trusted_keys: + # No keys to appraise against. Steps 1-4 have passed; the caller reports + # UNVERIFIABLE rather than VALID, and never MISMATCH. + return entry + + public_b64 = trusted_keys.get(key_id) + if public_b64 is None: + raise CoseKeyError(f"kid={key_id} is not in trusted_keys") + public_bytes = _b64url_decode(public_b64) + + # AlgorithmUnavailableError propagates: a build with no ML-DSA backend has + # established nothing about an ML-DSA-65 signature, which is UNVERIFIABLE + # (step 6), and MUST NOT fall back to a classical entry. + if alg in ED25519_ALGORITHMS: + _ed25519_verify(public_bytes, to_be_signed, signature) + else: + _ml_dsa_verify(public_bytes, to_be_signed, signature) + return CoseSignature(algorithm=alg, key_id=key_id, verified=True) + + +def decode_cose_manifest(cose_bytes: bytes) -> CoseVerification: + """Parse and structurally validate *cose_bytes* without verifying signatures. + + Runs steps 1-4 of the verification procedure. Every signature entry comes + back ``verified=False``. Use it to read a manifest whose keys this party + does not hold; never to decide that a manifest is authentic. + """ + return _appraise(cose_bytes, trusted_keys={}) + + +def verify_cose_manifest( + cose_bytes: bytes, trusted_keys: Mapping[str, str] +) -> CoseVerification: + """Verify a COSE manifest envelope. Fails closed at the first failure. + + *trusted_keys* maps a hex ``kid`` to a base64url public key, the same + mapping ``VerificationContext.trusted_keys`` carries for v0.1. For a + hybrid ``COSE_Sign`` each signer's own key must be present: the entries + carry component key ids, not v0.1's combined hybrid key id. + + An empty *trusted_keys* is not an error. Structural checks still run and + the result reports ``verified=False``, which the engine renders as + ``UNVERIFIABLE``. + + Raises: + CoseStructureError: Malformed envelope, bad header, unknown crit, + unknown algorithm, or a payload that is not manifest JSON. + CoseVersionError: The payload is not a version 0.2 manifest. + CoseDowngradeError: ``crypto_profile`` requires more than ``alg`` gives. + CoseKeyError: A ``kid`` is absent from *trusted_keys*. + cryptography.exceptions.InvalidSignature: A signature did not verify. + AlgorithmUnavailableError: This build cannot perform the algorithm. + """ + return _appraise(cose_bytes, trusted_keys=trusted_keys) + + +def _appraise( + cose_bytes: bytes, *, trusted_keys: Mapping[str, str] +) -> CoseVerification: + # Step 1: parse, and reject anything that is not a tagged COSE_Sign1/Sign. + tag, body = _decode_tagged(cose_bytes) + body_protected_bytes, unprotected, payload, fourth = body + + # Step 2: the protected header. Read from the byte string as received - + # the signature covers those exact bytes, so nothing is ever re-encoded. + body_header = _decode_protected(body_protected_bytes, what="body") + _check_crit(body_header, what="body") + _check_unprotected_has_no_alg(unprotected) + _check_body_type(body_header) + + # Step 3: the payload, and the version gate. + manifest = _parse_payload(payload) + _check_version(manifest) + + # Collect the signature entries before step 4, which needs every alg. + entries: list[tuple[int, bytes, bytes, bytes]] = [] # alg, kid, tbs, sig + if tag == COSE_SIGN1_TAG: + if not isinstance(fourth, bytes): + raise CoseStructureError("COSE_Sign1 signature must be a byte string") + alg, kid = _read_alg_and_kid(body_header, what="body") + entries.append( + (alg, kid, _sig_structure_sign1(body_protected_bytes, payload), fourth) + ) + else: + if not isinstance(fourth, (list, tuple)) or not fourth: + raise CoseStructureError("COSE_Sign carries no signature entries") + seen_algorithms: set[str] = set() + for index, raw in enumerate(fourth): + if not isinstance(raw, (list, tuple)) or len(raw) != 3: + raise CoseStructureError( + f"COSE_Signature {index} must be a three-element array" + ) + sign_protected_bytes, sign_unprotected, signature = raw + if not isinstance(sign_protected_bytes, bytes): + raise CoseStructureError( + f"COSE_Signature {index} protected header must be a byte string" + ) + if not isinstance(sign_unprotected, Mapping): + raise CoseStructureError( + f"COSE_Signature {index} unprotected header must be a map" + ) + if not isinstance(signature, bytes): + raise CoseStructureError( + f"COSE_Signature {index} signature must be a byte string" + ) + what = f"COSE_Signature {index}" + sign_header = _decode_protected(sign_protected_bytes, what=what) + _check_crit(sign_header, what=what) + _check_unprotected_has_no_alg(sign_unprotected) + alg, kid = _read_alg_and_kid(sign_header, what=what) + family = _algorithm_family(alg) + if family in seen_algorithms: + # Two entries for one algorithm cannot make a hybrid signature + # stronger, and would let a policy check be satisfied twice by + # the same key. Compared by family, so a -8 entry alongside a + # -19 entry is caught: both are Ed25519 over the same key type, + # and two spellings of one algorithm are not two signers. + raise CoseStructureError( + f"COSE_Sign carries more than one " + f"{ALG_NAMES.get(alg, alg)} signature entry" + ) + seen_algorithms.add(family) + entries.append( + ( + alg, + kid, + _sig_structure_sign( + body_protected_bytes, sign_protected_bytes, payload + ), + signature, + ) + ) + + algorithms = tuple(alg for alg, _, _, _ in entries) + + # Step 4: profile against algorithm, before any signature is checked. + _check_profile(manifest, algorithms) + + # Step 5: verify every entry. For COSE_Sign that means all of them - a + # verifier under a post-quantum policy must not accept the classical + # entry alone, and this profile only ever emits entries that are required. + signatures = tuple( + _verify_one(alg, kid, to_be_signed, signature, trusted_keys) + for alg, kid, to_be_signed, signature in entries + ) + + # Step 7 is the caller's: nothing in the unprotected header has been + # appraised, and nothing in it influenced whether the signature verified. + return CoseVerification( + manifest=manifest, + payload=payload, + manifest_hash=payload_hash(payload), + tag=tag, + signatures=signatures, + unprotected=_plain(unprotected), + ) + + +def read_payload_manifest(cose_bytes: bytes) -> dict[str, Any]: + """Return the payload's manifest JSON, checking nothing but its shape. + + Deliberately skips the header, version, profile, and signature checks so + that a rejected envelope can still be labelled with its ``manifest_id``. + Never use it to decide anything about a manifest. + """ + _, body = _decode_tagged(cose_bytes) + return _parse_payload(body[2]) diff --git a/python/src/agent_manifest/_signing.py b/python/src/agent_manifest/_signing.py index cd45703..d9c4fa2 100644 --- a/python/src/agent_manifest/_signing.py +++ b/python/src/agent_manifest/_signing.py @@ -15,8 +15,18 @@ - Small-order / torsion-component keys are rejected at load time These properties are inherited from OpenSSL's EVP_PKEY Ed25519 validation. -ML-DSA-65 requires pyoqs (Open Quantum Safe Python bindings): - pip install "agent-manifest[pq]" +ML-DSA-65 backends, in preference order: + 1. ``cryptography`` >= 47, which implements ML-DSA through OpenSSL. This is + already a required dependency, so the post-quantum profile needs no + separate install: pip install "agent-manifest[pq]" + 2. The liboqs Python bindings, if importable as ``oqs``. Supported for + deployments already carrying them; no longer required, and not installed + by any extra. + +The two differ only in private key encoding - cryptography works in the +32-byte seed, liboqs in the expanded secret key. Public keys are the same +1952-byte encoding in both, so key ids, COSE ``kid`` values, and signatures +are interoperable regardless of which backend produced them. """ from __future__ import annotations @@ -27,7 +37,7 @@ from datetime import datetime, timezone from typing import Any -from cryptography.exceptions import InvalidSignature +from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, @@ -41,16 +51,58 @@ from ._canonicalize import canonicalize +_ML_DSA_ALGO = "ML-DSA-65" + +# ML-DSA-65 comes from cryptography where available, and from the Open Quantum +# Safe bindings otherwise. +# +# cryptography is preferred because it is already a required dependency, it is +# the implementation the rest of this module uses, and it needs no separate +# install. It gained ML-DSA in 47.0.0; the module can still raise +# UnsupportedAlgorithm at call time when the linked OpenSSL is too old, which +# is a capability gap and surfaces as AlgorithmUnavailableError. +try: + from cryptography.hazmat.primitives.asymmetric import mldsa as _mldsa + + _CRYPTOGRAPHY_MLDSA_AVAILABLE = True +except ImportError: # cryptography < 47 + _mldsa = None # type: ignore[assignment] + _CRYPTOGRAPHY_MLDSA_AVAILABLE = False + +# The liboqs binding is optional and kept for deployments already using it. +# The capability check is deliberately not "the import succeeded": the module +# name `oqs` on PyPI belongs to an unrelated project, and importing it must +# not be read as post-quantum support (see also the `pq` extra, which no +# longer names a liboqs package because none is published under `pyoqs`). +def _has_liboqs_api(module: Any) -> bool: + """Identify liboqs by its API rather than by the module name it occupies.""" + return hasattr(module, "Signature") + + try: - import oqs as _oqs # pyoqs - Open Quantum Safe bindings + import oqs as _oqs - _OQS_AVAILABLE = True - _ML_DSA_ALGO = "ML-DSA-65" + _OQS_AVAILABLE = _has_liboqs_api(_oqs) except ImportError: _oqs = None - _OQS_AVAILABLE = False - _ML_DSA_ALGO = "ML-DSA-65" + +# FIPS 204 ML-DSA-65 sizes. The seed is the portable private key form (the +# `[0] seed` choice of the AKP private key, RFC 9964); liboqs works in +# expanded secret keys instead, which is why key material identifies its own +# backend below. +_ML_DSA_65_SEED_LEN = 32 +_ML_DSA_65_PUBLIC_LEN = 1952 + +# PKCS#8 wrapper for a 32-byte ML-DSA-65 seed: +# SEQUENCE { INTEGER 0, AlgorithmIdentifier(2.16.840.1.101.3.4.3.18), +# OCTET STRING { [0] seed } } +# cryptography exposes no raw-seed loader, so a seed is wrapped before loading. +# test_ml_dsa_seed_wrapper_matches_cryptography pins this against what +# cryptography itself emits, so an encoding change cannot pass silently. +_ML_DSA_65_PKCS8_SEED_PREFIX = bytes.fromhex( + "3034020100300b060960864801650304031204228020" +) class AlgorithmUnavailableError(RuntimeError): @@ -255,14 +307,18 @@ def __init__(self, public_key_bytes: bytes) -> None: def from_b64url(cls, s: str) -> "Ed25519Verifier": return cls(_b64url_decode(s)) - def verify(self, manifest_dict: dict[str, Any], signature_value: str) -> None: - """Verify *signature_value* over *manifest_dict*'s signed fields. + def verify_bytes(self, pre_image: bytes, signature_value: str) -> None: + """Verify *signature_value* over already-canonicalized *pre_image* bytes. + + The manifest pre-image is fixed and normative, so manifest callers use + :meth:`verify`. TRACE envelopes (spec 6.3.2) and evidence packs (spec + 5.2.1) cover a different field set and supply their canonical bytes + directly through this method. Raises: cryptography.exceptions.InvalidSignature: Verification failed or wrong length. ValueError: Signature string contains non-URL-safe base64 characters. """ - pre_image = signing_pre_image(manifest_dict) sig_bytes = _b64url_decode(signature_value) # SIGN-001: reject before passing to OpenSSL - avoids undefined-length inputs if len(sig_bytes) != 64: @@ -271,6 +327,15 @@ def verify(self, manifest_dict: dict[str, Any], signature_value: str) -> None: ) self._pub.verify(sig_bytes, pre_image) # raises InvalidSignature on failure + def verify(self, manifest_dict: dict[str, Any], signature_value: str) -> None: + """Verify *signature_value* over *manifest_dict*'s signed fields. + + Raises: + cryptography.exceptions.InvalidSignature: Verification failed or wrong length. + ValueError: Signature string contains non-URL-safe base64 characters. + """ + self.verify_bytes(signing_pre_image(manifest_dict), signature_value) + # --------------------------------------------------------------------------- # ML-DSA-65 @@ -296,17 +361,116 @@ def public_b64url(self) -> str: return _b64url_encode(self.public_key_bytes) -def _require_oqs() -> None: - if not _OQS_AVAILABLE: +def ml_dsa65_available() -> bool: + """True when this build can perform ML-DSA-65 through either backend.""" + return _CRYPTOGRAPHY_MLDSA_AVAILABLE or _OQS_AVAILABLE + + +def _require_ml_dsa() -> None: + """Raise unless some ML-DSA-65 backend is present. + + A capability gap, never a verification failure: callers translate this + into ``UNVERIFIABLE`` so a build that cannot appraise a signature never + reports the manifest as bad (spec 4.2, ADR-0005 as amended). + """ + if not ml_dsa65_available(): raise AlgorithmUnavailableError( - "ML-DSA-65 requires pyoqs. " - 'Install with: pip install "agent-manifest[pq]"' + "ML-DSA-65 is unavailable in this build. It needs cryptography " + ">= 47 (install with: pip install \"agent-manifest[pq]\") or the " + "liboqs Python bindings importable as `oqs`." ) +# Kept because it is the name this module raised under before the backend +# became pluggable, and callers outside this file used it. +_require_oqs = _require_ml_dsa + + +def _mldsa_key_from_seed(seed: bytes) -> Any: + from cryptography.hazmat.primitives.serialization import load_der_private_key + + return load_der_private_key( + _ML_DSA_65_PKCS8_SEED_PREFIX + seed, password=None + ) + + +def _ml_dsa_sign_raw(private_key_bytes: bytes, data: bytes) -> bytes: + """Sign *data*, choosing the backend that understands the key material. + + A 32-byte value is a seed and belongs to cryptography; anything else is a + liboqs expanded secret key. Dispatching on the key rather than on a + configured preference means a keypair generated under either backend keeps + working when the other one is also installed. + """ + is_seed = len(private_key_bytes) == _ML_DSA_65_SEED_LEN + if is_seed and _CRYPTOGRAPHY_MLDSA_AVAILABLE: + try: + signature: bytes = _mldsa_key_from_seed(private_key_bytes).sign(data) + except UnsupportedAlgorithm as exc: # OpenSSL too old + raise AlgorithmUnavailableError( + f"ML-DSA-65 is not available from this OpenSSL build: {exc}" + ) from exc + return signature + if not is_seed and _OQS_AVAILABLE: + with _oqs.Signature(_ML_DSA_ALGO, private_key_bytes) as sig: + oqs_signature: bytes = sig.sign(data) + return oqs_signature + _require_ml_dsa() + raise AlgorithmUnavailableError( + f"this ML-DSA-65 private key is a " + f"{'seed' if is_seed else 'liboqs expanded secret key'} " + f"({len(private_key_bytes)} bytes), and the backend that reads that " + f"form is not installed" + ) + + +def _ml_dsa_verify_raw( + public_key_bytes: bytes, data: bytes, signature: bytes +) -> bool: + """Verify *signature*. Public keys are the same raw encoding in both + backends, so verification is interoperable regardless of who signed.""" + if _CRYPTOGRAPHY_MLDSA_AVAILABLE: + try: + _mldsa.MLDSA65PublicKey.from_public_bytes(public_key_bytes).verify( + signature, data + ) + except UnsupportedAlgorithm as exc: + raise AlgorithmUnavailableError( + f"ML-DSA-65 is not available from this OpenSSL build: {exc}" + ) from exc + except InvalidSignature: + return False + except ValueError: + return False + return True + if _OQS_AVAILABLE: + with _oqs.Signature(_ML_DSA_ALGO) as v: + verified: bool = v.verify(data, signature, public_key_bytes) + return verified + _require_ml_dsa() + return False # unreachable; _require_ml_dsa always raises here + + def generate_ml_dsa65() -> MlDsa65KeyPair: - """Generate a fresh ML-DSA-65 key pair.""" - _require_oqs() + """Generate a fresh ML-DSA-65 key pair. + + Under cryptography the private key is the 32-byte seed, which is the + portable form; under liboqs it is the expanded secret key. Public keys are + the same 1952-byte encoding either way, so ``key_id`` - and every + signature anyone else verifies - is identical across backends. + """ + _require_ml_dsa() + if _CRYPTOGRAPHY_MLDSA_AVAILABLE: + try: + key = _mldsa.MLDSA65PrivateKey.generate() + except UnsupportedAlgorithm as exc: + raise AlgorithmUnavailableError( + f"ML-DSA-65 is not available from this OpenSSL build: {exc}" + ) from exc + return MlDsa65KeyPair( + private_key_bytes=key.private_bytes_raw(), + public_key_bytes=key.public_key().public_bytes_raw(), + ) with _oqs.Signature(_ML_DSA_ALGO) as sig: pub = sig.generate_keypair() priv = sig.export_secret_key() @@ -314,16 +478,15 @@ def generate_ml_dsa65() -> MlDsa65KeyPair: class MlDsa65Signer: - """Signs manifest dicts with ML-DSA-65 (NIST FIPS 204) via pyoqs.""" + """Signs manifest dicts with ML-DSA-65 (NIST FIPS 204).""" def __init__(self, keypair: MlDsa65KeyPair) -> None: - _require_oqs() + _require_ml_dsa() self._kp = keypair def sign(self, manifest_dict: dict[str, Any]) -> dict[str, Any]: pre_image = signing_pre_image(manifest_dict) - with _oqs.Signature(_ML_DSA_ALGO, self._kp.private_key_bytes) as sig: - sig_bytes = sig.sign(pre_image) + sig_bytes = _ml_dsa_sign_raw(self._kp.private_key_bytes, pre_image) return { "algorithm": "ML-DSA-65", "key_id": self._kp.key_id, @@ -336,7 +499,7 @@ def sign(self, manifest_dict: dict[str, Any]) -> dict[str, Any]: class MlDsa65Verifier: def __init__(self, public_key_bytes: bytes) -> None: - _require_oqs() + _require_ml_dsa() self._pub = public_key_bytes self._key_id = _key_id(public_key_bytes) @@ -344,12 +507,17 @@ def __init__(self, public_key_bytes: bytes) -> None: def from_b64url(cls, s: str) -> "MlDsa65Verifier": return cls(_b64url_decode(s)) - def verify(self, manifest_dict: dict[str, Any], signature_value: str) -> None: - pre_image = signing_pre_image(manifest_dict) + def verify_bytes(self, pre_image: bytes, signature_value: str) -> None: + """Verify *signature_value* over already-canonicalized *pre_image* bytes. + + See :meth:`Ed25519Verifier.verify_bytes` for why this exists. + """ sig_bytes = _b64url_decode(signature_value) - with _oqs.Signature(_ML_DSA_ALGO) as v: - if not v.verify(pre_image, sig_bytes, self._pub): - raise InvalidSignature("ML-DSA-65 signature verification failed") + if not _ml_dsa_verify_raw(self._pub, pre_image, sig_bytes): + raise InvalidSignature("ML-DSA-65 signature verification failed") + + def verify(self, manifest_dict: dict[str, Any], signature_value: str) -> None: + self.verify_bytes(signing_pre_image(manifest_dict), signature_value) # --------------------------------------------------------------------------- @@ -403,15 +571,14 @@ class HybridSigner: """ def __init__(self, keypair: HybridKeyPair) -> None: - _require_oqs() + _require_ml_dsa() self._kp = keypair def sign(self, manifest_dict: dict[str, Any]) -> dict[str, Any]: pre_image = signing_pre_image(manifest_dict) classical_sig = self._kp.ed25519.private_key.sign(pre_image) - with _oqs.Signature(_ML_DSA_ALGO, self._kp.ml_dsa65.private_key_bytes) as sig: - pq_sig = sig.sign(pre_image) + pq_sig = _ml_dsa_sign_raw(self._kp.ml_dsa65.private_key_bytes, pre_image) return { "algorithm": "hybrid-Ed25519-ML-DSA-65", @@ -431,29 +598,32 @@ class HybridVerifier: def __init__( self, ed25519_public_bytes: bytes, ml_dsa65_public_bytes: bytes ) -> None: - _require_oqs() + _require_ml_dsa() self._classical = Ed25519Verifier(ed25519_public_bytes) self._pq_pub = ml_dsa65_public_bytes - def verify( - self, manifest_dict: dict[str, Any], signature_block: dict[str, Any] + def verify_bytes( + self, pre_image: bytes, signature_block: dict[str, Any] ) -> None: - """Verify both components over the same pre-image. + """Verify both components over already-canonicalized *pre_image* bytes. + + See :meth:`Ed25519Verifier.verify_bytes` for why this exists. Raises: InvalidSignature: If either component fails. KeyError: If the signature block is missing required fields. """ - pre_image = signing_pre_image(manifest_dict) - # Verify classical component classical_bytes = _b64url_decode(signature_block["classical_signature"]) self._classical._pub.verify(classical_bytes, pre_image) # Verify PQ component pq_bytes = _b64url_decode(signature_block["pq_signature"]) - with _oqs.Signature(_ML_DSA_ALGO) as v: - if not v.verify(pre_image, pq_bytes, self._pq_pub): - raise InvalidSignature( - "Hybrid signature: ML-DSA-65 component failed" - ) + if not _ml_dsa_verify_raw(self._pq_pub, pre_image, pq_bytes): + raise InvalidSignature("Hybrid signature: ML-DSA-65 component failed") + + def verify( + self, manifest_dict: dict[str, Any], signature_block: dict[str, Any] + ) -> None: + """Verify both components over the same manifest pre-image.""" + self.verify_bytes(signing_pre_image(manifest_dict), signature_block) diff --git a/python/src/agent_manifest/_verify.py b/python/src/agent_manifest/_verify.py index 3ef366a..331cda3 100644 --- a/python/src/agent_manifest/_verify.py +++ b/python/src/agent_manifest/_verify.py @@ -18,10 +18,16 @@ import uuid from datetime import datetime, timezone from enum import Enum -from typing import Any, Optional +from typing import Any, Optional, Union from pydantic import BaseModel, Field +from ._cose import ( + COSE_MANIFEST_VERSION, + MEDIA_TYPE_MANIFEST_COSE, + CoseVerification, +) + # --------------------------------------------------------------------------- # Result types @@ -188,7 +194,11 @@ class VerificationContext(BaseModel): # Manifest spec versions this verifier implementation can process (spec 2.4). -SUPPORTED_MANIFEST_VERSIONS: frozenset[str] = frozenset({"0.1"}) +# The envelope follows the version, not a flag (ADR-0011): 0.1 is the detached +# canonical-JSON signature block, 0.2 is COSE. A 0.2 manifest presented as a +# bare dict has no signature at all - the COSE structure is the signature - so +# it is reported SIGNATURE_MISSING rather than reinterpreted. +SUPPORTED_MANIFEST_VERSIONS: frozenset[str] = frozenset({"0.1", "0.2"}) _HYBRID_ED25519_PUBLIC_KEY_BYTES = 32 # Signature algorithms that satisfy each declared crypto_profile (spec 4.1 / @@ -302,9 +312,11 @@ def _strict_schema_violations(manifest: dict[str, Any]) -> list[tuple[str, str]] def verify_manifest( - manifest: dict[str, Any], + manifest: Union[dict[str, Any], bytes], context: VerificationContext, revocation_store: "RevocationStore", + *, + _envelope: Optional[CoseVerification] = None, ) -> VerificationResult: """Core verification engine - hosting-model agnostic and fail-closed. @@ -312,6 +324,15 @@ def verify_manifest( hashes, delegation chain, and HITL. Returns a VerificationResult with per-field status and mismatch details. + Accepts either envelope, selected by what it is given (ADR-0011): + + - A ``dict`` is a version 0.1 manifest carrying a detached ``signature`` + block, verified over the RFC 8785 pre-image exactly as it always has been. + - ``bytes`` are a version 0.2 COSE envelope (``COSE_Sign1`` or + ``COSE_Sign``). The signature is checked over the payload as received, + and receipts, attestation, and approvals are read from the unprotected + header after the signature is settled. + Fail-closed semantics (spec 5.3 - VALID requires a valid signature): - A manifest with an unsupported (or missing) ``version`` returns @@ -324,9 +345,15 @@ def verify_manifest( result is ``UNVERIFIABLE`` (spec 3.4.1 / 5.2). - ``enforce_hitl=True`` with no ``hitl_record`` in the manifest is a failure (``HitlResult.MISSING`` and a non-VALID overall result). + + The ``_envelope`` parameter is internal: it carries an already-appraised + COSE envelope into the shared pipeline and is not part of the public API. """ from cryptography.exceptions import InvalidSignature + if isinstance(manifest, (bytes, bytearray)): + return _verify_cose_envelope(bytes(manifest), context, revocation_store) + manifest_id = manifest.get("manifest_id", "unknown") result = VerificationResult(manifest_id=manifest_id, result=OverallResult.VALID) mismatches: list[MismatchDetail] = [] @@ -392,8 +419,49 @@ def verify_manifest( # 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 + # + # None of this applies to a COSE envelope. There ``alg`` is in the + # protected header, covered by the signature, so there is no unsigned + # identifier to cross-check; the profile check ran during envelope + # appraisal, and the signature was verified over the payload as received. + # An empty sig_block skips both branches below. + if _envelope is not None: + sig_block: dict[str, Any] = {} + signature_missing = False + result.signature_verified = _envelope.verified + # The key-to-issuer authorization is NOT part of envelope appraisal - + # it is this engine's policy check, and skipping it on the COSE path + # would let any trusted key sign for any issuer, which the v0.1 path + # rejects. Every signer must be authorized, so a hybrid manifest + # cannot smuggle an unauthorized component key alongside a valid one. + for _signature in _envelope.signatures: + _issuer_mismatch = _signature_key_issuer_mismatch( + manifest, _signature.key_id, context.trusted_key_issuers + ) + if _issuer_mismatch is not None: + mismatches.append(_issuer_mismatch) + else: + sig_block = manifest.get("signature") or {} + signature_missing = not sig_block + if sig_block and manifest.get("version") == COSE_MANIFEST_VERSION: + # The envelope follows the version (ADR-0011), and for 0.2 that is + # COSE. `signature` is not a v0.2 field at all - the COSE structure + # is the signature - so a 0.2 manifest carrying a detached block is + # claiming the new version while using the old envelope, with the + # unauthenticated algorithm identifier and the canonicalize-before- + # verify step that ADR-0011 moved away from. Verifying it under v0.1 + # rules would make the version gate advisory and leave the phase 5 + # deprecation unenforceable, so it is rejected rather than accepted. + mismatches.append(MismatchDetail( + field="signature", + expected_hash=( + f"" + ), + actual_hash="", + )) + sig_block = {} + signature_missing = False profile_downgrade = False if sig_block: declared_profile = manifest.get("crypto_profile", "standard") @@ -707,20 +775,29 @@ def _check(field_name: str, manifest_val: Optional[str], runtime_val: Optional[s # --- Attestation block verification (HW-010) # Check that manifest_hash_in_report matches the computed manifest hash. - attestation_block = manifest.get("attestation") or {} + # In a COSE envelope the report is in the unprotected header, and what it + # binds is sha256 of the payload bytes (envelope spec 5) - there is no + # field subset to reconstruct and nothing to keep in sync. + if _envelope is not None: + attestation_block = _envelope.attestation or {} + else: + attestation_block = manifest.get("attestation") or {} if attestation_block: reported_hash = attestation_block.get("manifest_hash_in_report", "") if reported_hash: from ._canonicalize import canonicalize as _canonicalize import hashlib as _hashlib - # Spec 3.3: the pre-image excludes the attestation block AND the - # top-level transparency_log_entry (populated after log submission). - subset = { - k: v - for k, v in manifest.items() - if k not in ("attestation", "transparency_log_entry") - } - expected_attest_hash = "sha256:" + _hashlib.sha256(_canonicalize(subset)).hexdigest() + if _envelope is not None: + expected_attest_hash = _envelope.manifest_hash + else: + # Spec 3.3: the pre-image excludes the attestation block AND the + # top-level transparency_log_entry (populated after log submission). + subset = { + k: v + for k, v in manifest.items() + if k not in ("attestation", "transparency_log_entry") + } + expected_attest_hash = "sha256:" + _hashlib.sha256(_canonicalize(subset)).hexdigest() if hmac.compare_digest(reported_hash, expected_attest_hash): result.attestation_verified = True elif context.enforce_attestation: @@ -764,6 +841,106 @@ def _check(field_name: str, manifest_val: Optional[str], runtime_val: Optional[s return result +def _cose_manifest_id(cose_bytes: bytes) -> str: + """Best-effort manifest_id for reporting a failed envelope. + + Reads the payload without appraising anything. Used only to label a + result that has already been decided against the manifest. + """ + from ._cose import read_payload_manifest + + try: + manifest_id = read_payload_manifest(cose_bytes).get("manifest_id") + except Exception: + return "unknown" + return manifest_id if isinstance(manifest_id, str) else "unknown" + + +def _verify_cose_envelope( + cose_bytes: bytes, + context: VerificationContext, + revocation_store: "RevocationStore", +) -> VerificationResult: + """Appraise a version 0.2 COSE envelope, then run the shared pipeline. + + The envelope is settled first and in full (envelope spec section 6): the + structure, the protected header, the version, the profile, and the + signature. Only then is the unprotected header read, and what it carries + is evaluated by the same engine that evaluates a v0.1 manifest - approvals + against the signed HITL requirement, the attestation report against the + payload hash - so a v0.2 manifest gets the identical set of checks. + """ + from cryptography.exceptions import InvalidSignature + + from ._cose import ( + CoseDowngradeError, + CoseKeyError, + CoseStructureError, + CoseVersionError, + verify_cose_manifest, + ) + from ._signing import AlgorithmUnavailableError + + try: + envelope = verify_cose_manifest(cose_bytes, context.trusted_keys) + except CoseVersionError: + # spec 2.4: an unsupported version is never silently misinterpreted. + return VerificationResult( + manifest_id=_cose_manifest_id(cose_bytes), + result=OverallResult.INCOMPATIBLE_VERSION, + ) + except AlgorithmUnavailableError as exc: + # A capability gap, not a bad manifest, and never a fallback to a + # weaker signature entry (envelope spec 2.1 and 6 step 6). + return VerificationResult( + manifest_id=_cose_manifest_id(cose_bytes), + result=OverallResult.UNVERIFIABLE, + warnings=[ + f"the COSE signature could not be appraised by this build: {exc}" + ], + ) + except ( + CoseStructureError, + CoseKeyError, + CoseDowngradeError, + InvalidSignature, + ValueError, + ) as exc: + return VerificationResult( + manifest_id=_cose_manifest_id(cose_bytes), + result=OverallResult.MISMATCH, + mismatch_details=[ + MismatchDetail( + field="signature", + expected_hash="", + actual_hash=f"<{exc}>", + ) + ], + ) + + # Step 7. Approvals attach after signing, so they are merged back onto the + # signed HITL requirement for evaluation. The requirement itself came out + # of the payload and is covered by the signature; the approvals are not, + # and each carries its own approval_signature (v0.1 section 3.5). + payload_manifest = dict(envelope.manifest) + approvals = envelope.approvals + if approvals is not None: + hitl_record = payload_manifest.get("hitl_record") + if isinstance(hitl_record, dict): + payload_manifest["hitl_record"] = {**hitl_record, "approvals": approvals} + + result = verify_manifest( + payload_manifest, context, revocation_store, _envelope=envelope + ) + + if not envelope.receipts: + result.warnings.append( + "no transparency receipt in the unprotected header (label 394); " + "a production manifest is expected to carry one" + ) + return result + + # --------------------------------------------------------------------------- # Runtime attestation verification # --------------------------------------------------------------------------- @@ -830,18 +1007,30 @@ def get_record(self, manifest_id: str) -> Optional[RevocationRecord]: # --------------------------------------------------------------------------- +# A manifest is a few kilobytes (envelope spec section 4, which cites size as +# the reason payloads are inline rather than detached). The cap is generous +# against that and small enough that a body is bounded before anything parses +# it - the decoder is never handed an unbounded allocation. +MAX_COSE_ENVELOPE_BYTES = 1 << 20 # 1 MiB + + def create_router( manifest_store: dict[str, dict[str, Any]], revocation_store: RevocationStore, + cose_context: Optional[VerificationContext] = None, ) -> Any: """Return a FastAPI APIRouter with /verify and /revocation-status endpoints. Args: manifest_store: Dict mapping manifest_id -> manifest dict. revocation_store: Revocation store instance. + cose_context: Trust configuration for ``POST /verify/cose``, held by + the server rather than accepted from the caller. Omit it and that + endpoint is fail-closed: every result is ``UNVERIFIABLE``, never + ``VALID``, exactly as ``GET /verify`` behaves without keys. """ try: - from fastapi import APIRouter, HTTPException, Query + from fastapi import APIRouter, HTTPException, Query, Request, Response from fastapi.responses import JSONResponse # noqa: F401 except ImportError: raise ImportError( @@ -919,6 +1108,127 @@ async def verify_post(request: VerifyRequest) -> VerificationResult: ) return verify_manifest(manifest, ctx, revocation_store) + async def verify_cose( + request: "Request", + response: "Response", + enforce_hitl: bool = Query(False), + enforce_attestation: bool = Query(False), + ) -> VerificationResult: + """Verify a version 0.2 COSE manifest submitted as raw CBOR. + + The body is the ``COSE_Sign1`` or ``COSE_Sign`` object itself, sent as + ``Content-Type: application/agent-manifest+cose``. The manifest is + self-contained - the payload travels inside the signature - so unlike + the other endpoints there is nothing to look up and no + ``manifest_id`` to trust from the caller. + + Three deliberate choices, each of which is a security property rather + than a convenience: + + **The media type is the gate.** Only the exact registered type is + accepted. A vendor-tree alias is refused (envelope spec section 7: + two valid type values for one object is the ambiguity ``typ`` exists + to remove), and so is an absent or guessed type - the server never + sniffs the body to decide what it is. + + **No key material crosses the wire.** Trust comes from + ``cose_context``, configured server-side when the router is built. + A verification service that accepts caller-supplied trusted keys is + only as trustworthy as its caller, and public keys in a URL or header + end up in proxy logs and access logs. Without a configured trust + store this endpoint returns ``UNVERIFIABLE``, never ``VALID``. + + **The body is bounded before it is parsed.** ``Content-Length`` is + checked when present and the stream is capped regardless, because a + declared length is attacker-controlled and may lie. + + A malformed or unverifiable envelope is a *verdict*, not a transport + error: the response is 200 with a non-``VALID`` result. Parser detail + is not reflected back, so this endpoint cannot be used as an oracle + for how the decoder behaves. + + Authentication, authorization and rate limiting are deployment + concerns and are deliberately not implemented here; mount this router + behind them (spec 5.1: mTLS with the agent's SPIFFE SVID). + """ + media_type = (request.headers.get("content-type") or "").split(";")[0] + if media_type.strip().lower() != MEDIA_TYPE_MANIFEST_COSE: + raise HTTPException( + status_code=415, + detail=ErrorResponse( + error_code="UNSUPPORTED_MEDIA_TYPE", + error_message=( + f"This endpoint accepts {MEDIA_TYPE_MANIFEST_COSE} only." + ), + ).model_dump(), + ) + + declared_length = request.headers.get("content-length") + if declared_length is not None: + try: + if int(declared_length) > MAX_COSE_ENVELOPE_BYTES: + raise HTTPException( + status_code=413, + detail=ErrorResponse( + error_code="ENVELOPE_TOO_LARGE", + error_message=( + f"A COSE manifest may not exceed " + f"{MAX_COSE_ENVELOPE_BYTES} bytes." + ), + ).model_dump(), + ) + except ValueError: + raise HTTPException( + status_code=400, + detail=ErrorResponse( + error_code="INVALID_CONTENT_LENGTH", + error_message="Content-Length is not an integer.", + ).model_dump(), + ) + + # Cap the stream too: Content-Length is a claim, not a guarantee. + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > MAX_COSE_ENVELOPE_BYTES: + raise HTTPException( + status_code=413, + detail=ErrorResponse( + error_code="ENVELOPE_TOO_LARGE", + error_message=( + f"A COSE manifest may not exceed " + f"{MAX_COSE_ENVELOPE_BYTES} bytes." + ), + ).model_dump(), + ) + + ctx = (cose_context or VerificationContext()).model_copy( + update={ + "enforce_hitl": enforce_hitl, + "enforce_attestation": enforce_attestation, + } + ) + # A verification result is a security decision about a specific set of + # bytes at a point in time. It must not be cached or content-sniffed. + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return verify_manifest(bytes(body), ctx, revocation_store) + + # This module uses `from __future__ import annotations`, so annotations are + # strings, and FastAPI resolves them against the module globals - where + # `Request` and `Response` do not appear, because fastapi is an optional + # extra imported inside this function. Binding the real classes before + # registering the route keeps the import lazy without FastAPI mistaking + # the two parameters for query parameters. + verify_cose.__annotations__["request"] = Request + verify_cose.__annotations__["response"] = Response + router.add_api_route( + "/verify/cose", + verify_cose, + methods=["POST"], + response_model=VerificationResult, + ) + @router.get("/revocation-status") async def revocation_status( manifest_id: str = Query(...), diff --git a/python/src/agent_manifest/cli.py b/python/src/agent_manifest/cli.py index b5d0931..7db73da 100644 --- a/python/src/agent_manifest/cli.py +++ b/python/src/agent_manifest/cli.py @@ -28,6 +28,7 @@ ) from ._auto_provider import select_provider +from ._cose import COSE_MANIFEST_VERSION from ._providers import AttestationUnavailableError from ._revocation import FileCRL from ._signing import Ed25519Signer, ed25519_from_private_bytes, generate_ed25519 @@ -46,6 +47,28 @@ def _load_json(path: str) -> dict[str, Any]: return cast(dict[str, Any], json.load(f)) +def _load_manifest_or_envelope(path: str) -> "dict[str, Any] | bytes": + """Return a v0.1 manifest dict, or the bytes of a v0.2 COSE envelope. + + The envelope is detected from the CBOR tag that opens the file, not from + the file extension. A manifest carries its own type, and guessing a format + from a filename is the ambiguity the media-type rules exist to remove + (envelope spec section 7). + """ + raw = Path(path).read_bytes() + # d2 = tag(18) COSE_Sign1, d8 62 = tag(98) COSE_Sign. Neither can begin a + # JSON document, so this is a decision, not a guess. + if raw[:1] == b"\xd2" or raw[:2] == b"\xd8\x62": + return raw + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise click.ClickException(f"{path} is neither JSON nor a COSE envelope: {exc}") + if not isinstance(data, dict): + raise click.ClickException(f"{path} does not contain a manifest object.") + return data + + def _write(data: dict[str, Any], output: Optional[str]) -> None: text = json.dumps(data, indent=2, default=str) if output: @@ -161,6 +184,27 @@ def sign(manifest_file: str, key: str, output: Optional[str]) -> None: del key_hex # prevent key material from lingering in locals kp = ed25519_from_private_bytes(key_bytes) + + # The envelope follows the manifest version, never a flag (ADR-0011). A + # 0.2 manifest is signed as COSE and written as binary CBOR; a 0.1 + # manifest gets the detached signature block exactly as before. + if data.get("version") == COSE_MANIFEST_VERSION: + from ._cose import sign_cose_sign1 + + envelope = sign_cose_sign1(data, kp) + click.echo( + f"Signed with key_id={kp.key_id} (COSE_Sign1, manifest version " + f"{COSE_MANIFEST_VERSION})", + err=True, + ) + if output is None: + raise click.ClickException( + "A COSE envelope is binary CBOR. Use --output to write it to a " + "file rather than to the terminal." + ) + Path(output).write_bytes(envelope) + return + signer = Ed25519Signer(kp) sig_block = signer.sign(data) sig_block["signed_at"] = datetime.now(timezone.utc).isoformat() @@ -297,7 +341,7 @@ def verify( Example: manifest verify attested.json --crl-path revocations.jsonl """ - data = _load_json(manifest_file) + subject = _load_manifest_or_envelope(manifest_file) trusted_keys = _trusted_key_from_public_hex(public_key) if public_key else {} ctx = VerificationContext( enforce_hitl=enforce_hitl, @@ -312,7 +356,7 @@ def verify( else: store = RevocationStore() - result = verify_manifest(data, ctx, store) + result = verify_manifest(subject, ctx, store) _write(result.model_dump(mode="json"), output) if result.result != OverallResult.VALID: diff --git a/python/tests/interop/cose_sign1_eddsa.json b/python/tests/interop/cose_sign1_eddsa.json new file mode 100644 index 0000000..bd67620 --- /dev/null +++ b/python/tests/interop/cose_sign1_eddsa.json @@ -0,0 +1,6 @@ +{ + "description": "COSE_Sign1 built through _sig_structure_sign1.", + "note": "alg is -8 because no COSE library implements RFC 9864 (-19) yet. The structure under test does not depend on the code point. See generate_fixtures.py.", + "envelope_hex": "d284586aa4012703781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b6a736f6e04582056475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c10781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b636f7365a05901107b226167656e745f6964223a227370696666653a2f2f74727573742e6578616d706c652f6167656e742f6b79632f70726f64222c2263727970746f5f70726f66696c65223a227374616e64617264222c22657870697265735f6174223a22323039392d31322d33315432333a35393a35395a222c226973737565645f6174223a22323032352d30312d30315430303a30303a30305a222c22697373756572223a227370696666653a2f2f74727573742e6578616d706c652f7369676e696e672d617574686f72697479222c226d616e69666573745f6964223a2230313866346133622d326331642d376535662d613862392d306431653266336134623563222c2276657273696f6e223a22302e32227d584077378f4b2585513e4213a35b601de47b281d9eb8dd156384524152f5d33910de27d305527bcee68c3293c71a94457edf833670c6454674eca8d873b02fae2a0c", + "ed25519_public_b64url": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" +} diff --git a/python/tests/interop/cose_sign_eddsa.json b/python/tests/interop/cose_sign_eddsa.json new file mode 100644 index 0000000..26b8f73 --- /dev/null +++ b/python/tests/interop/cose_sign_eddsa.json @@ -0,0 +1,6 @@ +{ + "description": "COSE_Sign with one Ed25519 signer, built through _sig_structure_sign.", + "note": "alg is -8 because no COSE library implements RFC 9864 (-19) yet. The structure under test does not depend on the code point. See generate_fixtures.py.", + "envelope_hex": "d862845845a203781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b6a736f6e10781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b636f7365a05901107b226167656e745f6964223a227370696666653a2f2f74727573742e6578616d706c652f6167656e742f6b79632f70726f64222c2263727970746f5f70726f66696c65223a227374616e64617264222c22657870697265735f6174223a22323039392d31322d33315432333a35393a35395a222c226973737565645f6174223a22323032352d30312d30315430303a30303a30305a222c22697373756572223a227370696666653a2f2f74727573742e6578616d706c652f7369676e696e672d617574686f72697479222c226d616e69666573745f6964223a2230313866346133622d326331642d376535662d613862392d306431653266336134623563222c2276657273696f6e223a22302e32227d81835826a2012704582056475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708ca058401d0cd1058ee6c28a6790092e4965dc5e8f1651caf6e4af4399a95e43fae9037d5654c1e33b2f874d2d39319b52d735c04158a0cbd79d2bcbd61223cf51747107", + "ed25519_public_b64url": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" +} diff --git a/python/tests/interop/generate_fixtures.py b/python/tests/interop/generate_fixtures.py new file mode 100644 index 0000000..546d57b --- /dev/null +++ b/python/tests/interop/generate_fixtures.py @@ -0,0 +1,154 @@ +"""Build the fixtures that ``verify_with_pycose.py`` checks. + +Run with the SDK's own environment (cbor2 6.x); the interop script then runs +in a separate one (pycose, cbor2 5.x), because those two cannot coexist. + + python python/tests/interop/generate_fixtures.py + +Why fixtures rather than the published vector +--------------------------------------------- +Both fixtures declare ``alg`` as ``-8``, and the SDK no longer signs with +``-8`` - ADR-0014 moved production signing to the fully-specified ``-19``. +They are built here through the SDK's own ``Sig_structure`` builders with the +identifier overridden. + +That is not a workaround to be cleaned up later; it is the current state of +the ecosystem, and worth stating plainly. **No COSE library implements +RFC 9864 yet.** pycose stops at `Unknown COSE attribute with value: -19`, just +as it stops at `-49` for ML-DSA-65. So the only identifier a third party can +currently verify is the deprecated one. + +The structure under test is identical either way: the ``Sig_structure``, the +header encoding, the tag, and the four-element array do not depend on which +code point sits in the ``alg`` slot. Fixing the identifier at ``-8`` is what +keeps an outside opinion available at all, and the moment a COSE library ships +`-19` these fixtures should move to it and be replaced by the vector itself. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import pathlib + +import cbor2 + +from agent_manifest._cose import ( + ALG_EDDSA, + COSE_SIGN1_TAG, + COSE_SIGN_TAG, + HDR_ALG, + HDR_CONTENT_TYPE, + HDR_KID, + HDR_TYP, + MEDIA_TYPE_MANIFEST_COSE, + MEDIA_TYPE_MANIFEST_JSON, + _sig_structure_sign, + _sig_structure_sign1, + cose_payload, +) +from agent_manifest._signing import ed25519_from_private_bytes + +HERE = pathlib.Path(__file__).parent +# The same fixed seed the conformance vectors use. Ed25519 is deterministic, +# so both fixtures are reproducible byte-for-byte. +SEED = bytes(range(32)) + +MANIFEST = { + "manifest_id": "018f4a3b-2c1d-7e5f-a8b9-0d1e2f3a4b5c", + "agent_id": "spiffe://trust.example/agent/kyc/prod", + "version": "0.2", + "issued_at": "2025-01-01T00:00:00Z", + "expires_at": "2099-12-31T23:59:59Z", + "issuer": "spiffe://trust.example/signing-authority", + "crypto_profile": "standard", +} + +_NOTE = ( + "alg is -8 because no COSE library implements RFC 9864 (-19) yet. The " + "structure under test does not depend on the code point. See " + "generate_fixtures.py." +) + + +def _write(name: str, envelope: bytes, public: bytes, description: str) -> None: + (HERE / name).write_text( + json.dumps( + { + "description": description, + "note": _NOTE, + "envelope_hex": envelope.hex(), + "ed25519_public_b64url": base64.urlsafe_b64encode(public) + .rstrip(b"=") + .decode(), + }, + indent=2, + ) + + "\n" + ) + print(f"wrote {name} ({len(envelope)} envelope bytes)") + + +def main() -> None: + keypair = ed25519_from_private_bytes(SEED) + payload = cose_payload(MANIFEST) + kid = hashlib.sha256(keypair.public_bytes).digest() + + # COSE_Sign1, through _sig_structure_sign1. + protected = cbor2.dumps( + { + HDR_ALG: ALG_EDDSA, + HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, + HDR_KID: kid, + HDR_TYP: MEDIA_TYPE_MANIFEST_COSE, + }, + canonical=True, + ) + signature = keypair.private_key.sign(_sig_structure_sign1(protected, payload)) + _write( + "cose_sign1_eddsa.json", + cbor2.dumps( + cbor2.CBORTag(COSE_SIGN1_TAG, [protected, {}, payload, signature]), + canonical=True, + ), + keypair.public_bytes, + "COSE_Sign1 built through _sig_structure_sign1.", + ) + + # COSE_Sign, through _sig_structure_sign. One signer: the SDK emits + # COSE_Sign only for hybrid, and no third party can appraise ML-DSA-65, + # so this isolates the parts of the structure that are not + # algorithm-specific - the body header, the per-signature header, and the + # five-element Sig_structure that ties them together. + body_protected = cbor2.dumps( + { + HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, + HDR_TYP: MEDIA_TYPE_MANIFEST_COSE, + }, + canonical=True, + ) + sign_protected = cbor2.dumps({HDR_ALG: ALG_EDDSA, HDR_KID: kid}, canonical=True) + entry_signature = keypair.private_key.sign( + _sig_structure_sign(body_protected, sign_protected, payload) + ) + _write( + "cose_sign_eddsa.json", + cbor2.dumps( + cbor2.CBORTag( + COSE_SIGN_TAG, + [ + body_protected, + {}, + payload, + [[sign_protected, {}, entry_signature]], + ], + ), + canonical=True, + ), + keypair.public_bytes, + "COSE_Sign with one Ed25519 signer, built through _sig_structure_sign.", + ) + + +if __name__ == "__main__": + main() diff --git a/python/tests/interop/verify_with_pycose.py b/python/tests/interop/verify_with_pycose.py new file mode 100644 index 0000000..9e07d87 --- /dev/null +++ b/python/tests/interop/verify_with_pycose.py @@ -0,0 +1,149 @@ +"""Cross-verify the SDK's COSE output with an independent implementation. + +The conformance vectors exist so that an implementation the reference SDK did +not write agrees with it. Everything in ``tests/`` verifies the SDK against +itself, which cannot detect a shared misunderstanding of RFC 9052 - if +``_cose.py`` built the ``Sig_structure`` wrongly, its own verifier would +reproduce the same mistake and pass. + +This script closes that gap: it hands SDK-built objects to `pycose`, a COSE +library with no relationship to this project, and asks it to parse and verify. + +It checks fixtures rather than ``AM-VEC-COSE-001``, and the reason is a real +limitation rather than a convenience. The published vector declares ``alg`` as +``-19`` (ADR-0014), and **no COSE library implements RFC 9864 yet** - pycose +stops at `Unknown COSE attribute with value: -19`, exactly as it stops at +`-49` for ML-DSA-65. The fixtures carry ``-8``, which is the only identifier a +third party can currently verify, over structures built by the same +``Sig_structure`` builders. When a COSE library ships `-19`, these fixtures +should be deleted and this script pointed back at the vector. + +It is NOT part of the pytest suite, deliberately. pycose 1.1.0 cannot decode a +COSE message when cbor2 6.x is installed - not even one it encoded itself - +and this SDK depends on cbor2 6.x. Forcing them into one environment would +mean pinning the SDK's serialization to satisfy a test-only dependency, which +is exactly the tail-wagging-the-dog that ADR-0013 rejected. + +Run it in its own environment: + + python -m venv /tmp/interop && /tmp/interop/bin/pip install pycose "cbor2<6" + /tmp/interop/bin/python python/tests/interop/verify_with_pycose.py + +Expected output ends with: INTEROP CONFIRMED +""" +from __future__ import annotations + +import base64 +import json +import pathlib +import sys + +from pycose.keys import OKPKey +from pycose.keys.curves import Ed25519 +from pycose.messages import Sign1Message, SignMessage + +VECTORS = pathlib.Path(__file__).resolve().parents[1] / "vectors" + + +def _b64url(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _check_sign1() -> bool: + """A COSE_Sign1 built by the SDK, verified by pycose.""" + fixture_path = pathlib.Path(__file__).parent / "cose_sign1_eddsa.json" + if not fixture_path.exists(): + print("[COSE_Sign1] fixture missing - run generate_fixtures.py first") + return False + vector = json.loads(fixture_path.read_text()) + + envelope = bytes.fromhex(vector["envelope_hex"]) + key = OKPKey(crv=Ed25519, x=_b64url(vector["ed25519_public_b64url"])) + + message = Sign1Message.decode(envelope) + message.key = key + + print("[COSE_Sign1] fixture : cose_sign1_eddsa.json") + print(f"[COSE_Sign1] protected : {message.phdr}") + print(f"[COSE_Sign1] unprotected : {message.uhdr}") + print(f"[COSE_Sign1] manifest_id : " + f"{json.loads(message.payload.decode())['manifest_id']}") + + valid = message.verify_signature() + + def rejects(mutated: bytes) -> bool: + try: + other = Sign1Message.decode(mutated) + other.key = key + return other.verify_signature() is False + except Exception: + return True + + flipped = bytearray(envelope) + flipped[-1] ^= 0x01 + tampered_sig = rejects(bytes(flipped)) + tampered_payload = rejects(envelope.replace(b'"0.2"', b'"9.9"')) + + print(f"[COSE_Sign1] verifies : {valid}") + print(f"[COSE_Sign1] rejects tampered signature/payload: " + f"{tampered_sig}/{tampered_payload}") + return valid and tampered_sig and tampered_payload + + +def _check_sign() -> bool: + """The COSE_Sign layout: body header, per-signature header, Sig_structure. + + Ed25519 only. A real hybrid envelope cannot be checked this way because no + COSE library implements ML-DSA-65 (`alg` -49) yet - pycose stops at + "Unknown COSE attribute with value: -49". What this isolates is everything + about COSE_Sign that is not algorithm-specific. + """ + fixture_path = pathlib.Path(__file__).parent / "cose_sign_eddsa.json" + if not fixture_path.exists(): + print("[COSE_Sign] fixture missing - run generate_fixtures.py first") + return False + + fixture = json.loads(fixture_path.read_text()) + envelope = bytes.fromhex(fixture["envelope_hex"]) + key = OKPKey(crv=Ed25519, x=_b64url(fixture["ed25519_public_b64url"])) + + message = SignMessage.decode(envelope) + message.signers[0].key = key + + print(f"[COSE_Sign] body header : {message.phdr}") + print(f"[COSE_Sign] signers : {len(message.signers)}") + print(f"[COSE_Sign] signer[0] : {message.signers[0].phdr}") + + valid = message.signers[0].verify_signature() + + def rejects(mutated: bytes) -> bool: + try: + other = SignMessage.decode(mutated) + other.signers[0].key = key + return other.signers[0].verify_signature() is False + except Exception: + return True + + flipped = bytearray(envelope) + flipped[-1] ^= 0x01 + tampered_sig = rejects(bytes(flipped)) + tampered_payload = rejects(envelope.replace(b'"0.2"', b'"9.9"')) + + print(f"[COSE_Sign] verifies : {valid}") + print(f"[COSE_Sign] rejects tampered signature/payload: " + f"{tampered_sig}/{tampered_payload}") + return valid and tampered_sig and tampered_payload + + +def main() -> int: + sign1_ok = _check_sign1() + print() + sign_ok = _check_sign() + + ok = sign1_ok and sign_ok + print("\n" + ("INTEROP CONFIRMED" if ok else "INTEROP FAILED")) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/tests/test_am_crypto.py b/python/tests/test_am_crypto.py index bbdc63b..2b1b67a 100644 --- a/python/tests/test_am_crypto.py +++ b/python/tests/test_am_crypto.py @@ -27,12 +27,14 @@ HybridVerifier, generate_hybrid, ) - import oqs # noqa: F401 - OQS_AVAILABLE = True + from agent_manifest._signing import ml_dsa65_available + OQS_AVAILABLE = ml_dsa65_available() except (ImportError, RuntimeError): OQS_AVAILABLE = False -require_oqs = pytest.mark.skipif(not OQS_AVAILABLE, reason="pyoqs not installed") +require_oqs = pytest.mark.skipif( + not OQS_AVAILABLE, reason="no ML-DSA-65 backend available" +) NOW = datetime.now(timezone.utc) SHA = "sha256:" + "a" * 64 diff --git a/python/tests/test_cli_cose.py b/python/tests/test_cli_cose.py new file mode 100644 index 0000000..ad04a3f --- /dev/null +++ b/python/tests/test_cli_cose.py @@ -0,0 +1,145 @@ +"""CLI round-trip for version 0.2 COSE manifests. + +The command surface must not grow a flag for this: the envelope follows the +manifest version on the way out and the CBOR tag on the way in, so a user +signs and verifies the same way regardless of which envelope applies. +""" +import json +from datetime import datetime, timedelta, timezone + +import cbor2 +import pytest +from click.testing import CliRunner + +from agent_manifest._signing import ed25519_from_private_bytes +from agent_manifest.cli import cli + +SEED = bytes(range(32)) +KP = ed25519_from_private_bytes(SEED) +NOW = datetime.now(timezone.utc) +FUTURE = (NOW + timedelta(days=90)).isoformat().replace("+00:00", "Z") +SHA = "sha256:" + "a" * 64 + + +def manifest(version="0.2", **overrides): + m = { + "manifest_id": "018f4a3b-2c1d-7e5f-a8b9-0d1e2f3a4b5c", + "agent_id": "spiffe://trust.example/agent/kyc/prod", + "version": version, + "issued_at": NOW.isoformat().replace("+00:00", "Z"), + "expires_at": FUTURE, + "issuer": "spiffe://trust.example/signing-authority", + "crypto_profile": "standard", + "artifacts": {"system_prompt": {"hash": SHA}}, + } + m.update(overrides) + return m + + +@pytest.fixture +def workspace(tmp_path): + (tmp_path / "key.hex").write_text(SEED.hex()) + (tmp_path / "pub.hex").write_text(KP.public_bytes.hex()) + return tmp_path + + +def run(*args): + return CliRunner().invoke(cli, [str(a) for a in args]) + + +def test_signing_a_v02_manifest_produces_a_cose_envelope(workspace): + draft = workspace / "draft.json" + draft.write_text(json.dumps(manifest())) + signed = workspace / "signed.cose" + + result = run("sign", draft, "--key", workspace / "key.hex", "-o", signed) + assert result.exit_code == 0, result.output + assert "COSE_Sign1" in result.output + + tagged = cbor2.loads(signed.read_bytes()) + assert tagged.tag == 18 + assert json.loads(tagged.value[2].decode())["manifest_id"] == ( + manifest()["manifest_id"] + ) + + +def test_signing_a_v01_manifest_is_unchanged(workspace): + draft = workspace / "draft.json" + draft.write_text(json.dumps(manifest(version="0.1"))) + signed = workspace / "signed.json" + + result = run("sign", draft, "--key", workspace / "key.hex", "-o", signed) + assert result.exit_code == 0, result.output + data = json.loads(signed.read_text()) + assert data["signature"]["algorithm"] == "Ed25519" + + +def test_a_cose_envelope_is_not_written_to_the_terminal(workspace): + """Binary CBOR down stdout would corrupt it; refuse instead.""" + draft = workspace / "draft.json" + draft.write_text(json.dumps(manifest())) + result = run("sign", draft, "--key", workspace / "key.hex") + assert result.exit_code != 0 + assert "--output" in result.output + + +def test_verify_detects_the_envelope_from_the_cbor_tag(workspace): + draft = workspace / "draft.json" + draft.write_text(json.dumps(manifest())) + signed = workspace / "signed.cose" + run("sign", draft, "--key", workspace / "key.hex", "-o", signed) + + out = workspace / "result.json" + result = run( + "verify", signed, "--public-key", workspace / "pub.hex", "-o", out + ) + assert result.exit_code == 0, result.output + assert json.loads(out.read_text())["result"] == "VALID" + + +def test_verify_reports_a_tampered_envelope(workspace): + draft = workspace / "draft.json" + draft.write_text(json.dumps(manifest())) + signed = workspace / "signed.cose" + run("sign", draft, "--key", workspace / "key.hex", "-o", signed) + + corrupted = bytearray(signed.read_bytes()) + corrupted[-1] ^= 0x01 + signed.write_bytes(bytes(corrupted)) + + out = workspace / "result.json" + result = run( + "verify", signed, "--public-key", workspace / "pub.hex", "-o", out + ) + assert result.exit_code == 1 + assert json.loads(out.read_text())["result"] == "MISMATCH" + + +def test_verify_without_a_key_is_unverifiable_never_valid(workspace): + draft = workspace / "draft.json" + draft.write_text(json.dumps(manifest())) + signed = workspace / "signed.cose" + run("sign", draft, "--key", workspace / "key.hex", "-o", signed) + + out = workspace / "result.json" + result = run("verify", signed, "-o", out) + assert result.exit_code == 1 + assert json.loads(out.read_text())["result"] == "UNVERIFIABLE" + + +def test_a_file_that_is_neither_json_nor_cose_is_refused(workspace): + junk = workspace / "junk.bin" + junk.write_bytes(b"\x00\x01\x02 not a manifest") + result = run("verify", junk) + assert result.exit_code != 0 + assert "neither JSON nor a COSE envelope" in result.output + + +def test_the_round_trip_is_reproducible(workspace): + """Ed25519 is deterministic, so signing twice gives identical bytes.""" + draft = workspace / "draft.json" + draft.write_text(json.dumps(manifest())) + first, second = workspace / "a.cose", workspace / "b.cose" + run("sign", draft, "--key", workspace / "key.hex", "-o", first) + run("sign", draft, "--key", workspace / "key.hex", "-o", second) + assert first.read_bytes() == second.read_bytes() diff --git a/python/tests/test_cose.py b/python/tests/test_cose.py new file mode 100644 index 0000000..c8f3c44 --- /dev/null +++ b/python/tests/test_cose.py @@ -0,0 +1,1320 @@ +"""COSE_Sign1 / COSE_Sign envelope tests - issue #243 phase 2. + +Normative reference: spec/agent-manifest-cose-envelope-v0.2.md (ADR-0011). + +The negative cases here are deliberately the ones only this envelope can +express - a tampered protected header, an alg substitution, a typ mismatch, +an unprotected header injected before verification - because those are what +phase 3 turns into portable AM-VEC vectors. +""" +import hashlib +import json +from datetime import datetime, timedelta, timezone + +import cbor2 +import pytest +from cryptography.exceptions import InvalidSignature + +from agent_manifest._cose import ( + ALG_ED25519, + ALG_EDDSA, + ALG_ML_DSA_65, + COSE_SIGN1_TAG, + COSE_SIGN_TAG, + HDR_ALG, + HDR_CONTENT_TYPE, + HDR_CRIT, + HDR_KID, + HDR_TYP, + LABEL_APPROVALS, + LABEL_ATTESTATION, + MEDIA_TYPE_MANIFEST_COSE, + MEDIA_TYPE_MANIFEST_JSON, + CoseDowngradeError, + CoseKeyError, + CoseStructureError, + CoseVersionError, + attach_approvals, + attach_attestation, + attach_receipt, + cose_payload, + decode_cose_manifest, + payload_hash, + sign_cose_sign1, + sign_cose_sign_hybrid, + sign_manifest_cose, + verify_cose_manifest, +) +from agent_manifest._signing import ( + AlgorithmUnavailableError, + generate_ed25519, + generate_hybrid, + generate_ml_dsa65, +) +from agent_manifest._verify import ( + HitlResult, + OverallResult, + RevocationStore, + VerificationContext, + verify_manifest, +) + +from agent_manifest._signing import ml_dsa65_available + +# ML-DSA-65 is provided by the SDK itself now - cryptography >= 47, or the +# liboqs bindings where a deployment still carries them - so the post-quantum +# half of this envelope is exercised with real FIPS 204 signatures rather +# than skipped. +require_pq = pytest.mark.skipif( + not ml_dsa65_available(), reason="no ML-DSA-65 backend available" +) + +try: + from cryptography.hazmat.primitives.asymmetric import mldsa as _mldsa + + CRYPTOGRAPHY_MLDSA = True +except ImportError: + CRYPTOGRAPHY_MLDSA = False + + +@pytest.fixture +def pq_backend(): + """Kept as an explicit marker that a test needs a real ML-DSA-65 backend.""" + if not ml_dsa65_available(): + pytest.skip("no ML-DSA-65 backend available") + return "sdk" + +NOW = datetime.now(timezone.utc) +FUTURE = (NOW + timedelta(days=90)).isoformat().replace("+00:00", "Z") +SHA = "sha256:" + "a" * 64 +SHA_B = "sha256:" + "b" * 64 + +KP = generate_ed25519() +TRUSTED_KEYS = {KP.key_id: KP.public_b64url()} + + +def base_manifest(**overrides): + m = { + "manifest_id": "018f4a3b-2c1d-7e5f-a8b9-0d1e2f3a4b5c", + "agent_id": "spiffe://trust.example/agent/kyc/prod", + "version": "0.2", + "issued_at": NOW.isoformat().replace("+00:00", "Z"), + "expires_at": FUTURE, + "crypto_profile": "standard", + "artifacts": { + "system_prompt": {"hash": SHA}, + "policy_bundle": {"hash": SHA_B}, + "model_identity": { + "model_hash": None, + "version": "claude-3", + "deployment_type": "api", + }, + }, + } + m.update(overrides) + return m + + +def base_context(**overrides): + ctx = VerificationContext( + system_prompt_hash=SHA, + policy_bundle_hash=SHA_B, + model_version="claude-3", + trusted_keys=dict(TRUSTED_KEYS), + ) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def store(): + return RevocationStore() + + +def approval(**overrides): + """A schema-valid HITL approval, authenticated by its own signature.""" + a = { + "approval_id": "018f4a3b-2c1d-7e5f-a8b9-0d1e2f3a4b60", + "approver_id": "mailto:alice@example.com", + "approver_identity_type": "email", + "approver_role": "ciso", + "approved_at": NOW.isoformat().replace("+00:00", "Z"), + "approved_scope": { + "artifacts": ["system_prompt"], + "risk_tier": "high", + "approval_duration_seconds": 3600, + }, + "approval_signature": "c2ln", + "approval_method": "hardware-key", + "evidence_uri": "https://evidence.example/approvals/1", + } + a.update(overrides) + return a + + +def parts(cose_bytes): + """Return the decoded (tag, [protected_bytes, unprotected, payload, sig]).""" + tagged = cbor2.loads(cose_bytes) + return tagged.tag, list(tagged.value) + + +def rebuild(tag, body): + return cbor2.dumps(cbor2.CBORTag(tag, body), canonical=True) + + +# --------------------------------------------------------------------------- +# Structure (envelope spec sections 2 and 3) +# --------------------------------------------------------------------------- + + +def test_sign1_is_a_tagged_four_element_array(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + assert tag == COSE_SIGN1_TAG + assert len(body) == 4 + protected, unprotected, payload, signature = body + assert isinstance(protected, bytes) + assert isinstance(payload, bytes) + assert isinstance(signature, bytes) + # Pinned encoding (ADR-0013): the unprotected header is a zero-length map, + # never omitted. A three-element array is not a COSE_Sign1. + assert unprotected == {} + + +def test_protected_header_carries_alg_kid_content_type_and_typ(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + assert header[HDR_ALG] == ALG_ED25519 + assert header[HDR_KID] == hashlib.sha256(KP.public_bytes).digest() + assert header[HDR_CONTENT_TYPE] == MEDIA_TYPE_MANIFEST_JSON + assert header[HDR_TYP] == MEDIA_TYPE_MANIFEST_COSE + + +def test_kid_is_the_v01_key_id_as_bytes(): + """A key registered for v0.1 keeps its identity across the migration.""" + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + assert cbor2.loads(body[0])[HDR_KID].hex() == KP.key_id + + +def test_payload_is_the_canonical_json_of_the_manifest(): + manifest = base_manifest() + _, body = parts(sign_cose_sign1(manifest, KP)) + assert body[2] == cose_payload(manifest) + payload = json.loads(body[2].decode()) + assert payload["manifest_id"] == manifest["manifest_id"] + assert payload["artifacts"]["system_prompt"] == {"hash": SHA} + # Keys are in code-point order and null-valued optionals are excluded + # (RFC 8785 and spec 4.3), which is what makes the bytes reproducible. + assert body[2].index(b'"agent_id"') < body[2].index(b'"manifest_id"') + assert "model_hash" not in payload["artifacts"]["model_identity"] + + +def test_payload_drops_fields_that_attach_after_signing(): + manifest = base_manifest( + signature={"algorithm": "Ed25519"}, + attestation={"platform": "amd-sev-snp"}, + transparency_log_entry={"log_id": "x"}, + hitl_record={"required": True, "approvals": [{"approver_id": "a"}]}, + ) + payload = json.loads(cose_payload(manifest).decode()) + assert "signature" not in payload + assert "attestation" not in payload + assert "transparency_log_entry" not in payload + # The HITL requirement stays signed; the approvals do not. + assert payload["hitl_record"] == {"required": True} + + +def test_signing_is_deterministic_for_the_same_manifest_and_key(): + manifest = base_manifest() + assert sign_cose_sign1(manifest, KP) == sign_cose_sign1(manifest, KP) + + +def test_signing_a_v01_manifest_is_refused(): + with pytest.raises(CoseVersionError): + sign_cose_sign1(base_manifest(version="0.1"), KP) + + +# --------------------------------------------------------------------------- +# Verification, happy path +# --------------------------------------------------------------------------- + + +def test_roundtrip_verifies(): + result = verify_cose_manifest(sign_cose_sign1(base_manifest(), KP), TRUSTED_KEYS) + assert result.verified is True + assert result.algorithms == (ALG_ED25519,) + assert result.signatures[0].key_id == KP.key_id + assert result.signatures[0].algorithm_name == "Ed25519" + assert result.manifest["agent_id"] == "spiffe://trust.example/agent/kyc/prod" + + +def test_manifest_hash_is_sha256_of_the_payload_bytes(): + """Envelope spec 5: hardware binds the payload bytes, with no subset rule.""" + manifest = base_manifest() + signed = sign_cose_sign1(manifest, KP) + result = verify_cose_manifest(signed, TRUSTED_KEYS) + assert result.manifest_hash == payload_hash(cose_payload(manifest)) + assert result.manifest_hash == ( + "sha256:" + hashlib.sha256(result.payload).hexdigest() + ) + + +def test_no_trusted_keys_is_unverifiable_not_invalid(): + result = verify_cose_manifest(sign_cose_sign1(base_manifest(), KP), {}) + assert result.verified is False + assert result.signatures[0].verified is False + + +def test_decode_never_reports_verified(): + result = decode_cose_manifest(sign_cose_sign1(base_manifest(), KP)) + assert result.verified is False + assert result.manifest["manifest_id"] == base_manifest()["manifest_id"] + + +def test_sign_manifest_cose_dispatches_on_key_type(): + tag, _ = parts(sign_manifest_cose(base_manifest(), KP)) + assert tag == COSE_SIGN1_TAG + + +@require_pq +def test_ml_dsa_sign1_roundtrip(pq_backend): + kp = generate_ml_dsa65() + signed = sign_cose_sign1(base_manifest(crypto_profile="post-quantum"), kp) + result = verify_cose_manifest(signed, {kp.key_id: kp.public_b64url()}) + assert result.verified is True + assert result.algorithms == (ALG_ML_DSA_65,) + + +# --------------------------------------------------------------------------- +# Post-signing attachment (unprotected header) +# --------------------------------------------------------------------------- + + +def test_attaching_a_receipt_does_not_disturb_the_signature(): + signed = sign_cose_sign1(base_manifest(), KP) + with_receipt = attach_receipt(signed, b"\xd2\x84fake-receipt") + result = verify_cose_manifest(with_receipt, TRUSTED_KEYS) + assert result.verified is True + assert result.receipts == [b"\xd2\x84fake-receipt"] + # The signed bytes are carried through untouched. + assert parts(with_receipt)[1][0] == parts(signed)[1][0] + assert parts(with_receipt)[1][2] == parts(signed)[1][2] + assert parts(with_receipt)[1][3] == parts(signed)[1][3] + + +def test_receipts_accumulate(): + signed = attach_receipt(sign_cose_sign1(base_manifest(), KP), b"one") + signed = attach_receipt(signed, b"two") + assert verify_cose_manifest(signed, TRUSTED_KEYS).receipts == [b"one", b"two"] + + +def test_attestation_and_approvals_land_in_the_unprotected_header(): + signed = sign_cose_sign1(base_manifest(), KP) + signed = attach_attestation(signed, {"platform": "amd-sev-snp"}) + signed = attach_approvals(signed, [{"approver_id": "a"}]) + result = verify_cose_manifest(signed, TRUSTED_KEYS) + assert result.verified is True + assert result.attestation == {"platform": "amd-sev-snp"} + assert result.approvals == [{"approver_id": "a"}] + assert set(result.unprotected) == {LABEL_ATTESTATION, LABEL_APPROVALS} + + +# --------------------------------------------------------------------------- +# Negative cases the v0.1 envelope cannot express +# --------------------------------------------------------------------------- + + +def test_untagged_structure_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + with pytest.raises(CoseStructureError, match="untagged"): + verify_cose_manifest(cbor2.dumps(body, canonical=True), TRUSTED_KEYS) + + +def test_unexpected_tag_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + with pytest.raises(CoseStructureError, match="unexpected CBOR tag"): + verify_cose_manifest(rebuild(17, body), TRUSTED_KEYS) + + +def test_tampered_protected_header_fails_the_signature(): + """alg is covered by the signature - the 0.6.0 class of bug is absent.""" + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + header[HDR_ALG] = ALG_EDDSA # same value, re-encoded map + header["injected"] = "x" + body[0] = cbor2.dumps(header, canonical=True) + with pytest.raises(InvalidSignature): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_alg_substitution_in_the_protected_header_fails_the_signature(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + header[HDR_ALG] = ALG_ML_DSA_65 + body[0] = cbor2.dumps(header, canonical=True) + # Never accepted, either way. A build with an ML-DSA backend reaches the + # fails it, because the protected bytes are inside the Sig_structure. A + # signature and fails it; a build without one cannot perform ML-DSA-65 + # at all and says so, which + # is UNVERIFIABLE (envelope spec 6 step 6) and still not a fallback to + # the classical algorithm the manifest was actually signed with. + with pytest.raises((InvalidSignature, AlgorithmUnavailableError)): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_alg_in_the_unprotected_header_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[1] = {HDR_ALG: ALG_EDDSA} + with pytest.raises(CoseStructureError, match="unprotected header"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_absent_typ_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + del header[HDR_TYP] + body[0] = cbor2.dumps(header, canonical=True) + with pytest.raises(CoseStructureError, match="typ"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_vendor_tree_typ_alias_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + header[HDR_TYP] = "application/vnd.agent-manifest+cose" + body[0] = cbor2.dumps(header, canonical=True) + with pytest.raises(CoseStructureError, match="vendor-tree"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_wrong_content_type_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + header[HDR_CONTENT_TYPE] = "application/json" + body[0] = cbor2.dumps(header, canonical=True) + with pytest.raises(CoseStructureError, match="content type"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_unknown_crit_entry_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + header[HDR_CRIT] = [HDR_ALG] + body[0] = cbor2.dumps(header, canonical=True) + with pytest.raises(CoseStructureError, match="critical"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_tampered_payload_fails_the_signature(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + manifest = json.loads(body[2].decode()) + manifest["agent_id"] = "spiffe://trust.example/agent/attacker" + body[2] = json.dumps(manifest).encode() + with pytest.raises(InvalidSignature): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_detached_payload_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[2] = None + with pytest.raises(CoseStructureError, match="inline not detached"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_trailing_bytes_are_rejected(): + signed = sign_cose_sign1(base_manifest(), KP) + with pytest.raises(CoseStructureError, match="trailing bytes"): + verify_cose_manifest(signed + b"\x00", TRUSTED_KEYS) + + +def test_empty_protected_header_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[0] = b"" + with pytest.raises(CoseStructureError, match="empty"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_unknown_alg_code_point_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + header[HDR_ALG] = -7 # ES256, not registered by this profile + body[0] = cbor2.dumps(header, canonical=True) + with pytest.raises(CoseStructureError, match="unknown alg"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_unknown_kid_is_rejected(): + other = generate_ed25519() + signed = sign_cose_sign1(base_manifest(), other) + with pytest.raises(CoseKeyError, match="not in trusted_keys"): + verify_cose_manifest(signed, TRUSTED_KEYS) + + +def test_post_quantum_profile_with_a_classical_signature_is_a_downgrade(): + """The bug shipped in v0.1 and fixed in 0.6.0, now unrepresentable.""" + signed = sign_cose_sign1(base_manifest(crypto_profile="post-quantum"), KP) + with pytest.raises(CoseDowngradeError, match="post-quantum"): + verify_cose_manifest(signed, TRUSTED_KEYS) + + +def test_downgrade_is_caught_without_any_trusted_keys(): + """Profile posture is checked whether or not this party holds the key.""" + signed = sign_cose_sign1(base_manifest(crypto_profile="post-quantum"), KP) + with pytest.raises(CoseDowngradeError): + verify_cose_manifest(signed, {}) + + +def test_a_v01_payload_is_routed_away_from_this_envelope(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + manifest = json.loads(body[2].decode()) + manifest["version"] = "0.1" + body[2] = json.dumps(manifest).encode() + with pytest.raises(CoseVersionError, match="0.1"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_payload_that_is_not_json_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[2] = b"\xff\xfe not json" + with pytest.raises(CoseStructureError, match="JSON"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_duplicate_algorithm_entries_in_cose_sign_are_rejected(): + """Two entries for one algorithm cannot make a hybrid signature stronger.""" + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body_protected = cbor2.dumps( + {HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, HDR_TYP: MEDIA_TYPE_MANIFEST_COSE}, + canonical=True, + ) + entry_protected = cbor2.dumps( + {HDR_ALG: ALG_EDDSA, HDR_KID: hashlib.sha256(KP.public_bytes).digest()}, + canonical=True, + ) + forged = rebuild( + COSE_SIGN_TAG, + [ + body_protected, + {}, + body[2], + [[entry_protected, {}, body[3]], [entry_protected, {}, body[3]]], + ], + ) + with pytest.raises(CoseStructureError, match="more than one"): + verify_cose_manifest(forged, TRUSTED_KEYS) + + +def test_cose_sign_with_no_signature_entries_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + body_protected = cbor2.dumps( + {HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, HDR_TYP: MEDIA_TYPE_MANIFEST_COSE}, + canonical=True, + ) + forged = rebuild(COSE_SIGN_TAG, [body_protected, {}, body[2], []]) + with pytest.raises(CoseStructureError, match="no signature entries"): + verify_cose_manifest(forged, TRUSTED_KEYS) + + +# --------------------------------------------------------------------------- +# COSE_Sign structure +# +# The Sig_structure with a body header and a per-signature header, entry +# ordering, and the rule that every entry must verify. Real ML-DSA-65 +# throughout, through whichever backend the SDK found. +# --------------------------------------------------------------------------- + + +def hybrid_trusted(kp): + return { + kp.ed25519.key_id: kp.ed25519.public_b64url(), + kp.ml_dsa65.key_id: kp.ml_dsa65.public_b64url(), + } + + +@require_pq +def test_cose_sign_carries_typ_in_the_body_and_alg_per_signature(pq_backend): + kp = generate_hybrid() + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + tag, body = parts(signed) + assert tag == COSE_SIGN_TAG + body_header = cbor2.loads(body[0]) + assert body_header[HDR_TYP] == MEDIA_TYPE_MANIFEST_COSE + assert body_header[HDR_CONTENT_TYPE] == MEDIA_TYPE_MANIFEST_JSON + assert HDR_ALG not in body_header + assert [cbor2.loads(e[0])[HDR_ALG] for e in body[3]] == [ALG_ED25519, ALG_ML_DSA_65] + assert [cbor2.loads(e[0])[HDR_KID] for e in body[3]] == [ + hashlib.sha256(kp.ed25519.public_bytes).digest(), + hashlib.sha256(kp.ml_dsa65.public_key_bytes).digest(), + ] + + +@require_pq +def test_cose_sign_verifies_every_entry(pq_backend): + kp = generate_hybrid() + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + result = verify_cose_manifest(signed, hybrid_trusted(kp)) + assert result.verified is True + assert result.algorithms == (ALG_ED25519, ALG_ML_DSA_65) + + +@require_pq +def test_cose_sign_entries_cover_the_same_payload(pq_backend): + kp = generate_hybrid() + """The structure guarantees it - there is one payload, not two objects.""" + _, body = parts( + sign_cose_sign_hybrid(base_manifest(crypto_profile="post-quantum"), kp) + ) + assert body[2] == cose_payload(base_manifest(crypto_profile="post-quantum")) + assert len(body[3]) == 2 + + +@require_pq +def test_cose_sign_rejects_a_tampered_entry(pq_backend): + kp = generate_hybrid() + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + tag, body = parts(signed) + entries = [list(e) for e in body[3]] + entries[1][2] = b"\x00" * len(entries[1][2]) + body[3] = entries + with pytest.raises(InvalidSignature): + verify_cose_manifest(rebuild(tag, body), hybrid_trusted(kp)) + + +@require_pq +def test_cose_sign_requires_a_trusted_key_for_every_entry(pq_backend): + kp = generate_hybrid() + """No falling back to the entry whose key happens to be held.""" + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + classical_only = {kp.ed25519.key_id: kp.ed25519.public_b64url()} + with pytest.raises(CoseKeyError): + verify_cose_manifest(signed, classical_only) + + +@require_pq +def test_cose_sign_signature_does_not_transplant_between_entries(pq_backend): + kp = generate_hybrid() + """Each entry's own protected header is inside its Sig_structure.""" + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + tag, body = parts(signed) + entries = [list(e) for e in body[3]] + # Give the PQ entry the classical entry's protected header bytes. + entries[1][0] = entries[0][0] + body[3] = entries + with pytest.raises((InvalidSignature, CoseStructureError)): + verify_cose_manifest(rebuild(tag, body), hybrid_trusted(kp)) + + +# --------------------------------------------------------------------------- +# Hybrid (COSE_Sign with two signers), against a real ML-DSA-65 +# --------------------------------------------------------------------------- + + +@require_pq +def test_hybrid_is_one_cose_sign_with_two_signers(pq_backend): + kp = generate_hybrid() + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + tag, body = parts(signed) + assert tag == COSE_SIGN_TAG + assert len(body[3]) == 2 + # typ and content type in the body header, alg and kid per signature. + body_header = cbor2.loads(body[0]) + assert body_header[HDR_TYP] == MEDIA_TYPE_MANIFEST_COSE + assert HDR_ALG not in body_header + assert [cbor2.loads(e[0])[HDR_ALG] for e in body[3]] == [ALG_ED25519, ALG_ML_DSA_65] + + +@require_pq +def test_hybrid_verifies_both_entries_against_component_keys(pq_backend): + kp = generate_hybrid() + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + trusted = { + kp.ed25519.key_id: kp.ed25519.public_b64url(), + kp.ml_dsa65.key_id: kp.ml_dsa65.public_b64url(), + } + result = verify_cose_manifest(signed, trusted) + assert result.verified is True + assert result.algorithms == (ALG_ED25519, ALG_ML_DSA_65) + + +@require_pq +def test_ml_dsa_signature_verifies_under_an_independent_implementation(pq_backend): + """The Sig_structure is right, checked without this module's verifier. + + Rebuilds RFC 9052 section 4.4 by hand from the object as parsed, then + verifies the raw signature with cryptography's ML-DSA-65 directly. If + ``_cose`` built the wrong bytes, this fails even though its own + round-trip would pass. + """ + if not CRYPTOGRAPHY_MLDSA: + pytest.skip("cryptography has no ML-DSA") + kp = generate_ml_dsa65() + signed = sign_cose_sign1(base_manifest(crypto_profile="post-quantum"), kp) + _, body = parts(signed) + protected, _, payload, signature = body + + to_be_signed = cbor2.dumps(["Signature1", protected, b"", payload], canonical=True) + _mldsa.MLDSA65PublicKey.from_public_bytes(kp.public_key_bytes).verify( + signature, to_be_signed + ) # raises on failure + + # And the same bytes must not verify over anything else. + with pytest.raises(Exception): + _mldsa.MLDSA65PublicKey.from_public_bytes(kp.public_key_bytes).verify( + signature, payload + ) + + +def test_ed25519_signature_verifies_under_an_independent_implementation(): + """Same check for the classical entry, straight through cryptography.""" + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + signed = sign_cose_sign1(base_manifest(), KP) + _, body = parts(signed) + protected, _, payload, signature = body + to_be_signed = cbor2.dumps(["Signature1", protected, b"", payload], canonical=True) + Ed25519PublicKey.from_public_bytes(KP.public_bytes).verify(signature, to_be_signed) + + with pytest.raises(InvalidSignature): + Ed25519PublicKey.from_public_bytes(KP.public_bytes).verify(signature, payload) + + +@require_pq +def test_hybrid_entries_verify_under_independent_implementations(pq_backend): + """Both COSE_Sign entries, each over its own Sig_structure, checked raw.""" + if not CRYPTOGRAPHY_MLDSA: + pytest.skip("cryptography has no ML-DSA") + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + kp = generate_hybrid() + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + _, body = parts(signed) + body_protected, _, payload, entries = body + + ed_protected, _, ed_sig = entries[0] + Ed25519PublicKey.from_public_bytes(kp.ed25519.public_bytes).verify( + ed_sig, + cbor2.dumps( + ["Signature", body_protected, ed_protected, b"", payload], canonical=True + ), + ) + + pq_protected, _, pq_sig = entries[1] + _mldsa.MLDSA65PublicKey.from_public_bytes(kp.ml_dsa65.public_key_bytes).verify( + pq_sig, + cbor2.dumps( + ["Signature", body_protected, pq_protected, b"", payload], canonical=True + ), + ) + + +@require_pq +def test_ml_dsa_key_sizes_are_fips_204_ml_dsa_65(pq_backend): + """Guards against a profile silently signing with the wrong parameter set.""" + kp = generate_ml_dsa65() + signed = sign_cose_sign1(base_manifest(crypto_profile="post-quantum"), kp) + _, body = parts(signed) + assert len(kp.public_key_bytes) == 1952 # FIPS 204 ML-DSA-65 public key + assert len(body[3]) == 3309 # FIPS 204 ML-DSA-65 signature + + +@require_pq +def test_hybrid_rejects_a_tampered_pq_entry_rather_than_falling_back(pq_backend): + kp = generate_hybrid() + signed = sign_cose_sign_hybrid( + base_manifest(crypto_profile="post-quantum"), kp + ) + tag, body = parts(signed) + entries = list(body[3]) + entries[1] = [entries[1][0], {}, b"\x00" * len(entries[1][2])] + body[3] = entries + trusted = { + kp.ed25519.key_id: kp.ed25519.public_b64url(), + kp.ml_dsa65.key_id: kp.ml_dsa65.public_b64url(), + } + with pytest.raises(InvalidSignature): + verify_cose_manifest(rebuild(tag, body), trusted) + + +# --------------------------------------------------------------------------- +# Version-gated routing through the verification engine +# --------------------------------------------------------------------------- + + +def test_engine_verifies_a_cose_manifest(): + result = verify_manifest( + sign_cose_sign1(base_manifest(), KP), base_context(), store() + ) + assert result.result == OverallResult.VALID + assert result.signature_verified is True + + +def test_engine_warns_when_no_receipt_is_attached(): + result = verify_manifest( + sign_cose_sign1(base_manifest(), KP), base_context(), store() + ) + assert any("transparency receipt" in w for w in result.warnings) + + +def test_engine_reports_unverifiable_without_trusted_keys(): + result = verify_manifest( + sign_cose_sign1(base_manifest(), KP), + base_context(trusted_keys={}), + store(), + ) + assert result.result == OverallResult.UNVERIFIABLE + assert result.signature_verified is False + + +def test_engine_reports_mismatch_on_a_tampered_payload(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + manifest = json.loads(body[2].decode()) + manifest["expires_at"] = (NOW + timedelta(days=3650)).isoformat().replace( + "+00:00", "Z" + ) + body[2] = json.dumps(manifest).encode() + result = verify_manifest(rebuild(tag, body), base_context(), store()) + assert result.result == OverallResult.MISMATCH + assert result.mismatch_details[0].field == "signature" + + +def test_engine_reports_incompatible_version_for_a_v01_payload(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + manifest = json.loads(body[2].decode()) + manifest["version"] = "0.1" + body[2] = json.dumps(manifest).encode() + result = verify_manifest(rebuild(tag, body), base_context(), store()) + assert result.result == OverallResult.INCOMPATIBLE_VERSION + assert result.manifest_id == base_manifest()["manifest_id"] + + +def test_engine_still_verifies_a_v01_dict_unchanged(): + """The version gate is the point: existing records keep verifying.""" + from agent_manifest._signing import Ed25519Signer + + manifest = base_manifest(version="0.1") + manifest["signature"] = Ed25519Signer(KP).sign(manifest) + result = verify_manifest(manifest, base_context(), store()) + assert result.result == OverallResult.VALID + + +def test_a_bare_v02_dict_has_no_signature(): + """v0.2 has no signature field - the COSE structure is the signature.""" + result = verify_manifest(base_manifest(), base_context(), store()) + assert result.result == OverallResult.SIGNATURE_MISSING + + +def test_engine_binds_attestation_to_the_payload_hash(): + manifest = base_manifest() + signed = sign_cose_sign1(manifest, KP) + signed = attach_attestation( + signed, + { + "platform": "amd-sev-snp", + "manifest_hash_in_report": payload_hash(cose_payload(manifest)), + }, + ) + result = verify_manifest(signed, base_context(enforce_attestation=True), store()) + assert result.attestation_verified is True + assert result.result == OverallResult.VALID + + +def test_engine_rejects_an_attestation_bound_to_other_bytes(): + signed = sign_cose_sign1(base_manifest(), KP) + signed = attach_attestation( + signed, + {"platform": "amd-sev-snp", "manifest_hash_in_report": "sha256:" + "c" * 64}, + ) + result = verify_manifest(signed, base_context(enforce_attestation=True), store()) + assert result.attestation_verified is False + assert result.result == OverallResult.MISMATCH + + +def test_engine_evaluates_approvals_from_the_unprotected_header(): + manifest = base_manifest(hitl_record={"required": True}) + signed = sign_cose_sign1(manifest, KP) + signed = attach_approvals(signed, [approval()]) + result = verify_manifest(signed, base_context(enforce_hitl=True), store()) + assert result.fields_verified.hitl_record == HitlResult.APPROVED + + +def test_signed_hitl_requirement_cannot_be_satisfied_by_editing_the_header(): + """Approvals are unsigned; the requirement they satisfy is not.""" + manifest = base_manifest(hitl_record={"required": True}) + signed = attach_approvals(sign_cose_sign1(manifest, KP), []) + result = verify_manifest(signed, base_context(enforce_hitl=True), store()) + assert result.fields_verified.hitl_record == HitlResult.MISSING + assert result.result == OverallResult.MISMATCH + + +# --------------------------------------------------------------------------- +# Malformed input +# +# Every branch below is a rejection path in the parser. They are tested for +# the same reason the parser has them: a verifier reads untrusted bytes, and +# a reject path that is never exercised is a reject path nobody knows works. +# --------------------------------------------------------------------------- + + +def sign1_body(**over): + """A structurally valid COSE_Sign1 body, with fields replaceable.""" + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + fields = {"protected": body[0], "unprotected": body[1], "payload": body[2], + "signature": body[3]} + fields.update(over) + return [fields["protected"], fields["unprotected"], fields["payload"], + fields["signature"]] + + +def protected_with(**over): + """The protected header bytes, with parameters replaced or removed.""" + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + header = cbor2.loads(body[0]) + for k, v in over.items(): + label = {"alg": HDR_ALG, "crit": HDR_CRIT, "cty": HDR_CONTENT_TYPE, + "kid": HDR_KID, "typ": HDR_TYP}[k] + if v is None: + header.pop(label, None) + else: + header[label] = v + return cbor2.dumps(header, canonical=True) + + +def test_a_non_bytes_object_is_rejected(): + with pytest.raises(CoseStructureError, match="must be bytes"): + verify_cose_manifest("not bytes", TRUSTED_KEYS) + + +def test_truncated_cbor_is_rejected(): + # An array header promising two items, with one item present. + with pytest.raises(CoseStructureError, match="not valid CBOR"): + verify_cose_manifest(b"\x82\x01", TRUSTED_KEYS) + + +def test_a_bare_break_byte_is_rejected(): + """cbor2 decodes 0xff to a break sentinel rather than raising, so the + untagged-structure check is what has to catch it.""" + with pytest.raises(CoseStructureError, match="untagged"): + verify_cose_manifest(b"\xff", TRUSTED_KEYS) + + +def test_deeply_nested_cbor_is_rejected_not_crashed(): + """DOS-006: nesting must produce a verdict, never a RecursionError.""" + bomb = b"\xd2" + b"\x81" * 10_000 + b"\x00" + with pytest.raises(CoseStructureError): + verify_cose_manifest(bomb, TRUSTED_KEYS) + + +def test_a_body_that_is_not_four_elements_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + with pytest.raises(CoseStructureError, match="four-element array"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body[:3]), TRUSTED_KEYS) + + +def test_a_non_bytes_protected_header_is_rejected(): + body = sign1_body(protected=123) + with pytest.raises(CoseStructureError, match="protected header must be a byte string"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_non_map_unprotected_header_is_rejected(): + body = sign1_body(unprotected=[1, 2]) + with pytest.raises(CoseStructureError, match="unprotected header must be a map"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_protected_header_that_is_not_cbor_is_rejected(): + body = sign1_body(protected=b"\x82\x01") + with pytest.raises(CoseStructureError, match="not valid CBOR"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_protected_header_that_is_not_a_map_is_rejected(): + body = sign1_body(protected=cbor2.dumps(42)) + with pytest.raises(CoseStructureError, match="must be a map"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_malformed_crit_is_rejected(): + body = sign1_body(protected=protected_with(crit=[])) + with pytest.raises(CoseStructureError, match="crit must be a non-empty array"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_an_absent_alg_is_rejected(): + body = sign1_body(protected=protected_with(alg=None)) + with pytest.raises(CoseStructureError, match="no alg"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_non_integer_alg_is_rejected(): + body = sign1_body(protected=protected_with(alg="EdDSA")) + with pytest.raises(CoseStructureError, match="alg must be an integer"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_boolean_alg_is_rejected(): + """bool is an int in Python; the code point check must not accept True.""" + body = sign1_body(protected=protected_with(alg=True)) + with pytest.raises(CoseStructureError, match="alg must be an integer"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_an_absent_kid_is_rejected(): + body = sign1_body(protected=protected_with(kid=None)) + with pytest.raises(CoseStructureError, match="no kid"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_non_bytes_signature_is_rejected(): + body = sign1_body(signature="not bytes") + with pytest.raises(CoseStructureError, match="signature must be a byte string"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_payload_that_is_not_a_json_object_is_rejected(): + body = sign1_body(payload=json.dumps([1, 2, 3]).encode()) + with pytest.raises(CoseStructureError, match="JSON object"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_an_unknown_future_version_is_incompatible_not_invalid(): + manifest = base_manifest() + manifest["version"] = "9.9" + body = sign1_body(payload=json.dumps(manifest).encode()) + with pytest.raises(CoseVersionError, match="9.9"): + verify_cose_manifest(rebuild(COSE_SIGN1_TAG, body), TRUSTED_KEYS) + + +def test_a_cose_sign_entry_that_is_not_three_elements_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + body_protected = cbor2.dumps( + {HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, HDR_TYP: MEDIA_TYPE_MANIFEST_COSE}, + canonical=True, + ) + forged = rebuild(COSE_SIGN_TAG, [body_protected, {}, body[2], [[b"", {}]]]) + with pytest.raises(CoseStructureError, match="three-element array"): + verify_cose_manifest(forged, TRUSTED_KEYS) + + +def test_a_cose_sign_entry_with_a_non_bytes_protected_header_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + body_protected = cbor2.dumps( + {HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, HDR_TYP: MEDIA_TYPE_MANIFEST_COSE}, + canonical=True, + ) + forged = rebuild(COSE_SIGN_TAG, [body_protected, {}, body[2], [[7, {}, b""]]]) + with pytest.raises(CoseStructureError, match="protected header must be a byte string"): + verify_cose_manifest(forged, TRUSTED_KEYS) + + +def test_a_cose_sign_entry_with_a_non_map_unprotected_header_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + body_protected = cbor2.dumps( + {HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, HDR_TYP: MEDIA_TYPE_MANIFEST_COSE}, + canonical=True, + ) + entry_protected = cbor2.dumps({HDR_ALG: ALG_EDDSA, HDR_KID: b"k"}, canonical=True) + forged = rebuild( + COSE_SIGN_TAG, [body_protected, {}, body[2], [[entry_protected, 9, b""]]] + ) + with pytest.raises(CoseStructureError, match="unprotected header must be a map"): + verify_cose_manifest(forged, TRUSTED_KEYS) + + +@require_pq +def test_sign_manifest_cose_dispatches_hybrid_to_cose_sign(pq_backend): + tag, _ = parts(sign_manifest_cose( + base_manifest(crypto_profile="post-quantum"), generate_hybrid() + )) + assert tag == COSE_SIGN_TAG + + +@require_pq +def test_a_tampered_ml_dsa_signature_is_rejected(pq_backend): + """The ML-DSA verify-returns-false path, on a single-signer envelope.""" + kp = generate_ml_dsa65() + signed = sign_cose_sign1(base_manifest(crypto_profile="post-quantum"), kp) + tag, body = parts(signed) + body[3] = bytes(len(body[3])) # a correctly sized, wrong signature + with pytest.raises(InvalidSignature, match="ML-DSA-65"): + verify_cose_manifest( + rebuild(tag, body), {kp.key_id: kp.public_b64url()} + ) + + +def test_a_cose_sign_entry_with_a_non_bytes_signature_is_rejected(): + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + body_protected = cbor2.dumps( + {HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, HDR_TYP: MEDIA_TYPE_MANIFEST_COSE}, + canonical=True, + ) + entry_protected = cbor2.dumps( + {HDR_ALG: ALG_EDDSA, HDR_KID: hashlib.sha256(KP.public_bytes).digest()}, + canonical=True, + ) + forged = rebuild( + COSE_SIGN_TAG, + [body_protected, {}, body[2], [[entry_protected, {}, "not bytes"]]], + ) + with pytest.raises(CoseStructureError, match="signature must be a byte string"): + verify_cose_manifest(forged, TRUSTED_KEYS) + + +def test_a_wrong_length_ed25519_signature_is_rejected_before_openssl(): + """SIGN-001: fixed-length check before the bytes reach the primitive.""" + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[3] = body[3][:32] # half an Ed25519 signature + with pytest.raises(InvalidSignature, match="must be 64 bytes"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +# --------------------------------------------------------------------------- +# RFC 9864: EdDSA (-8) is deprecated in favour of the fully-specified +# Ed25519 (-19). The SDK signs with -8, because that is what the envelope +# specification requires, and accepts both, so a verifier shipped today +# already works on the day the specification moves. +# --------------------------------------------------------------------------- + + +def ed25519_envelope_with_alg(alg, keypair=KP, manifest=None): + """Sign a COSE_Sign1 whose protected header declares *alg*.""" + from agent_manifest._cose import _sig_structure_sign1 + + payload = cose_payload(manifest or base_manifest()) + protected = cbor2.dumps( + { + HDR_ALG: alg, + HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, + HDR_KID: hashlib.sha256(keypair.public_bytes).digest(), + HDR_TYP: MEDIA_TYPE_MANIFEST_COSE, + }, + canonical=True, + ) + signature = keypair.private_key.sign(_sig_structure_sign1(protected, payload)) + return rebuild(COSE_SIGN1_TAG, [protected, {}, payload, signature]) + + +def test_the_sdk_signs_with_the_fully_specified_identifier(): + """ADR-0014: producers sign -19; -8 is verified but never emitted.""" + _, body = parts(sign_cose_sign1(base_manifest(), KP)) + assert cbor2.loads(body[0])[HDR_ALG] == ALG_ED25519 + + +def test_a_deprecated_eddsa_envelope_still_verifies(): + """An existing manifest signed under -8 stays verifiable indefinitely: + audit records outlive the identifier they were signed under.""" + result = verify_cose_manifest(ed25519_envelope_with_alg(ALG_EDDSA), TRUSTED_KEYS) + assert result.verified is True + assert result.algorithms == (ALG_EDDSA,) + assert result.signatures[0].algorithm_name == "EdDSA" + + +def test_the_engine_accepts_a_deprecated_eddsa_manifest(): + result = verify_manifest( + ed25519_envelope_with_alg(ALG_EDDSA), base_context(), store() + ) + assert result.result == OverallResult.VALID + + +def test_a_fully_specified_ed25519_alg_verifies(): + result = verify_cose_manifest( + ed25519_envelope_with_alg(ALG_ED25519), TRUSTED_KEYS + ) + assert result.verified is True + assert result.algorithms == (ALG_ED25519,) + assert result.signatures[0].algorithm_name == "Ed25519" + + +def test_a_fully_specified_ed25519_envelope_is_still_tamper_evident(): + envelope = ed25519_envelope_with_alg(ALG_ED25519) + tag, body = parts(envelope) + manifest = json.loads(body[2].decode()) + manifest["agent_id"] = "spiffe://trust.example/agent/attacker" + body[2] = json.dumps(manifest).encode() + with pytest.raises(InvalidSignature): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_swapping_between_the_two_ed25519_identifiers_breaks_the_signature(): + """Both are accepted, but neither is interchangeable after signing: alg is + inside the protected header the signature covers.""" + envelope = ed25519_envelope_with_alg(ALG_ED25519) + tag, body = parts(envelope) + header = cbor2.loads(body[0]) + header[HDR_ALG] = ALG_EDDSA + body[0] = cbor2.dumps(header, canonical=True) + with pytest.raises(InvalidSignature): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_a_post_quantum_profile_is_not_satisfied_by_ed25519_either_spelling(): + envelope = ed25519_envelope_with_alg( + ALG_ED25519, manifest=base_manifest(crypto_profile="post-quantum") + ) + with pytest.raises(CoseDowngradeError): + verify_cose_manifest(envelope, TRUSTED_KEYS) + + +def test_the_engine_accepts_a_fully_specified_ed25519_manifest(): + result = verify_manifest( + ed25519_envelope_with_alg(ALG_ED25519), base_context(), store() + ) + assert result.result == OverallResult.VALID + assert result.signature_verified is True + + +def test_two_spellings_of_ed25519_are_not_two_signers(): + """A COSE_Sign carrying -8 and -19 entries is one algorithm twice, and + must not be able to pass as a hybrid signature.""" + from agent_manifest._cose import _sig_structure_sign + + payload = cose_payload(base_manifest()) + body_protected = cbor2.dumps( + {HDR_CONTENT_TYPE: MEDIA_TYPE_MANIFEST_JSON, HDR_TYP: MEDIA_TYPE_MANIFEST_COSE}, + canonical=True, + ) + entries = [] + for alg in (ALG_EDDSA, ALG_ED25519): + sign_protected = cbor2.dumps( + {HDR_ALG: alg, HDR_KID: hashlib.sha256(KP.public_bytes).digest()}, + canonical=True, + ) + sig = KP.private_key.sign( + _sig_structure_sign(body_protected, sign_protected, payload) + ) + entries.append([sign_protected, {}, sig]) + forged = rebuild(COSE_SIGN_TAG, [body_protected, {}, payload, entries]) + with pytest.raises(CoseStructureError, match="more than one"): + verify_cose_manifest(forged, TRUSTED_KEYS) + + +# --------------------------------------------------------------------------- +# Policy and parsing hardening +# +# These three came out of an adversarial pass comparing the COSE path against +# what the v0.1 path already enforces. The first was a genuine regression. +# --------------------------------------------------------------------------- + + +def test_a_trusted_key_may_not_sign_for_an_unauthorized_issuer(): + """The v0.1 path rejects this; the COSE path must not be weaker. + + trusted_key_issuers binds a key to the issuers it may sign for. Without + this check, any trusted key could sign a manifest claiming any issuer, + which is precisely the blast radius that binding exists to limit. + """ + manifest = base_manifest(issuer="spiffe://trust.example/issuer/other") + ctx = base_context( + trusted_key_issuers={KP.key_id: ["spiffe://trust.example/issuer/payroll"]} + ) + result = verify_manifest(sign_cose_sign1(manifest, KP), ctx, store()) + assert result.result == OverallResult.MISMATCH + assert [d.field for d in result.mismatch_details] == ["signature.issuer"] + + +def test_an_authorized_issuer_still_verifies(): + manifest = base_manifest(issuer="spiffe://trust.example/issuer/payroll") + ctx = base_context( + trusted_key_issuers={KP.key_id: ["spiffe://trust.example/issuer/payroll"]} + ) + result = verify_manifest(sign_cose_sign1(manifest, KP), ctx, store()) + assert result.result == OverallResult.VALID + + +@require_pq +def test_every_hybrid_signer_must_be_authorized_for_the_issuer(pq_backend): + """One authorized component key must not carry an unauthorized one.""" + kp = generate_hybrid() + manifest = base_manifest( + issuer="spiffe://trust.example/issuer/payroll", crypto_profile="post-quantum" + ) + ctx = base_context( + trusted_keys={ + kp.ed25519.key_id: kp.ed25519.public_b64url(), + kp.ml_dsa65.key_id: kp.ml_dsa65.public_b64url(), + }, + trusted_key_issuers={ + kp.ed25519.key_id: ["spiffe://trust.example/issuer/payroll"], + # the ML-DSA key is authorized for a different issuer + kp.ml_dsa65.key_id: ["spiffe://trust.example/issuer/other"], + }, + ) + result = verify_manifest(sign_cose_sign_hybrid(manifest, kp), ctx, store()) + assert result.result == OverallResult.MISMATCH + + +def test_a_payload_with_duplicate_member_names_is_rejected(): + """Parsers disagree about which value wins, so two verifiers could read + different manifests out of the same signed bytes.""" + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[2] = b'{"manifest_id":"x","version":"0.2","version":"0.1"}' + with pytest.raises(CoseStructureError, match="duplicate member name"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_a_payload_containing_nan_is_rejected(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[2] = b'{"manifest_id":"x","version":"0.2","drift":NaN}' + with pytest.raises(CoseStructureError, match="RFC 8785"): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_a_deeply_nested_payload_returns_a_verdict_rather_than_unwinding(): + """DOS-006: untrusted input must never escape as an exception.""" + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[2] = (('{"a":' * 5000) + "1" + ("}" * 5000)).encode() + result = verify_manifest(rebuild(tag, body), base_context(), store()) + assert result.result == OverallResult.MISMATCH + + +def test_a_deeply_nested_payload_is_a_structure_error_not_a_crash(): + tag, body = parts(sign_cose_sign1(base_manifest(), KP)) + body[2] = (('{"a":' * 5000) + "1" + ("}" * 5000)).encode() + with pytest.raises(CoseStructureError): + verify_cose_manifest(rebuild(tag, body), TRUSTED_KEYS) + + +def test_a_v02_manifest_may_not_use_the_v01_envelope(): + """The version gate has to bind in both directions. + + A manifest claiming 0.2 while carrying a detached signature block is using + the envelope with the unauthenticated algorithm identifier and the + canonicalize-before-verify step that ADR-0011 moved away from. Accepting it + would make the gate advisory and leave the phase 5 deprecation with nothing + to enforce. + """ + from agent_manifest._signing import Ed25519Signer + + manifest = base_manifest() # version 0.2 + manifest["signature"] = Ed25519Signer(KP).sign(manifest) + result = verify_manifest(manifest, base_context(), store()) + assert result.result == OverallResult.MISMATCH + assert result.signature_verified is False + assert result.mismatch_details[0].field == "signature" + assert "0.2" in result.mismatch_details[0].expected_hash + + +def test_a_v01_manifest_with_the_v01_envelope_is_unaffected(): + """The check must not touch the path every existing record uses.""" + from agent_manifest._signing import Ed25519Signer + + manifest = base_manifest(version="0.1") + manifest["signature"] = Ed25519Signer(KP).sign(manifest) + assert verify_manifest(manifest, base_context(), store()).result == ( + OverallResult.VALID + ) diff --git a/python/tests/test_cose_endpoint.py b/python/tests/test_cose_endpoint.py new file mode 100644 index 0000000..9fac392 --- /dev/null +++ b/python/tests/test_cose_endpoint.py @@ -0,0 +1,322 @@ +"""HTTP surface for version 0.2 COSE manifests - POST /verify/cose. + +The tests are weighted towards abuse rather than the happy path, because the +endpoint's job is to be a safe front door for untrusted bytes: what it refuses +matters more than what it accepts. +""" +from datetime import datetime, timedelta, timezone + +import pytest + +from agent_manifest._cose import ( + MEDIA_TYPE_MANIFEST_COSE, + MEDIA_TYPE_MANIFEST_JSON, + attach_attestation, + cose_payload, + payload_hash, + sign_cose_sign1, +) +from agent_manifest._signing import generate_ed25519 +from agent_manifest._verify import ( + MAX_COSE_ENVELOPE_BYTES, + RevocationRecord, + RevocationStore, + VerificationContext, + create_router, +) + +try: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + FASTAPI_AVAILABLE = True +except ImportError: + FASTAPI_AVAILABLE = False + +pytestmark = pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="fastapi not installed") + +NOW = datetime.now(timezone.utc) +FUTURE = (NOW + timedelta(days=90)).isoformat().replace("+00:00", "Z") +SHA = "sha256:" + "a" * 64 +SHA_B = "sha256:" + "b" * 64 +MANIFEST_ID = "018f4a3b-2c1d-7e5f-a8b9-0d1e2f3a4b5c" + +KP = generate_ed25519() + + +def manifest(**overrides): + m = { + "manifest_id": MANIFEST_ID, + "agent_id": "spiffe://trust.example/agent/kyc/prod", + "version": "0.2", + "issued_at": NOW.isoformat().replace("+00:00", "Z"), + "expires_at": FUTURE, + "issuer": "spiffe://trust.example/signing-authority", + "crypto_profile": "standard", + "artifacts": { + "system_prompt": {"hash": SHA}, + "policy_bundle": {"hash": SHA_B}, + }, + } + m.update(overrides) + return m + + +def trust_store(**overrides): + ctx = VerificationContext( + system_prompt_hash=SHA, + policy_bundle_hash=SHA_B, + trusted_keys={KP.key_id: KP.public_b64url()}, + ) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def client(cose_context=None, revocation_store=None): + app = FastAPI() + app.include_router( + create_router({}, revocation_store or RevocationStore(), cose_context) + ) + return TestClient(app) + + +def post(c, body, content_type=MEDIA_TYPE_MANIFEST_COSE, **params): + headers = {} if content_type is None else {"Content-Type": content_type} + return c.post("/verify/cose", content=body, headers=headers, params=params) + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_a_valid_envelope_verifies(): + response = post(client(trust_store()), sign_cose_sign1(manifest(), KP)) + assert response.status_code == 200 + body = response.json() + assert body["result"] == "VALID" + assert body["signature_verified"] is True + assert body["manifest_id"] == MANIFEST_ID + + +def test_the_result_is_not_cacheable_and_not_sniffable(): + """A verification result is a security decision about specific bytes.""" + response = post(client(trust_store()), sign_cose_sign1(manifest(), KP)) + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" + + +def test_enforce_flags_are_honoured(): + signed = sign_cose_sign1(manifest(), KP) + response = post(client(trust_store()), signed, enforce_attestation=True) + assert response.json()["result"] == "ATTESTATION_UNAVAILABLE" + + +def test_attestation_binding_is_checked_over_http(): + m = manifest() + signed = attach_attestation( + sign_cose_sign1(m, KP), + { + "platform": "amd-sev-snp", + "manifest_hash_in_report": payload_hash(cose_payload(m)), + }, + ) + response = post(client(trust_store()), signed, enforce_attestation=True) + body = response.json() + assert body["result"] == "VALID" + assert body["attestation_verified"] is True + + +# --------------------------------------------------------------------------- +# The media type is the gate +# --------------------------------------------------------------------------- + + +def test_the_json_media_type_is_refused(): + """The payload's type is not the object's type.""" + response = post( + client(trust_store()), sign_cose_sign1(manifest(), KP), MEDIA_TYPE_MANIFEST_JSON + ) + assert response.status_code == 415 + + +def test_a_vendor_tree_alias_is_refused(): + """Envelope spec section 7: two valid type values for one object type is + the ambiguity typ exists to remove.""" + response = post( + client(trust_store()), + sign_cose_sign1(manifest(), KP), + "application/vnd.agent-manifest+cose", + ) + assert response.status_code == 415 + + +def test_a_generic_cbor_media_type_is_refused(): + response = post( + client(trust_store()), sign_cose_sign1(manifest(), KP), "application/cbor" + ) + assert response.status_code == 415 + + +def test_an_absent_media_type_is_refused_rather_than_sniffed(): + c = client(trust_store()) + response = c.post("/verify/cose", content=sign_cose_sign1(manifest(), KP)) + assert response.status_code == 415 + + +def test_media_type_parameters_are_tolerated(): + """`; charset=utf-8` from a well-meaning client is not a different type.""" + response = post( + client(trust_store()), + sign_cose_sign1(manifest(), KP), + f"{MEDIA_TYPE_MANIFEST_COSE}; charset=utf-8", + ) + assert response.status_code == 200 + + +def test_media_type_case_is_not_significant(): + response = post( + client(trust_store()), + sign_cose_sign1(manifest(), KP), + MEDIA_TYPE_MANIFEST_COSE.upper(), + ) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Bounded input +# --------------------------------------------------------------------------- + + +def test_an_oversized_body_is_refused(): + response = post(client(trust_store()), b"\x00" * (MAX_COSE_ENVELOPE_BYTES + 1)) + assert response.status_code == 413 + + +def test_a_lying_content_length_does_not_get_past_the_stream_cap(): + """Content-Length is a claim; the cap is enforced on the bytes.""" + c = client(trust_store()) + response = c.post( + "/verify/cose", + content=b"\x00" * (MAX_COSE_ENVELOPE_BYTES + 1), + headers={ + "Content-Type": MEDIA_TYPE_MANIFEST_COSE, + # understated on purpose + "Content-Length": "10", + }, + ) + assert response.status_code in (400, 413) + + +# --------------------------------------------------------------------------- +# Fail-closed behaviour +# --------------------------------------------------------------------------- + + +def test_without_a_server_trust_store_nothing_is_ever_valid(): + """No keys configured means no authentication is possible. The endpoint + must not treat a well-formed signature as sufficient.""" + response = post(client(), sign_cose_sign1(manifest(), KP)) + assert response.status_code == 200 + assert response.json()["result"] == "UNVERIFIABLE" + assert response.json()["signature_verified"] is False + + +def test_an_unknown_key_is_not_valid(): + other = generate_ed25519() + response = post(client(trust_store()), sign_cose_sign1(manifest(), other)) + assert response.json()["result"] == "MISMATCH" + + +def test_a_tampered_payload_is_a_verdict_not_an_error(): + signed = bytearray(sign_cose_sign1(manifest(), KP)) + signed[-1] ^= 0x01 + response = post(client(trust_store()), bytes(signed)) + assert response.status_code == 200 + assert response.json()["result"] == "MISMATCH" + + +def test_garbage_is_a_verdict_not_a_server_error(): + response = post(client(trust_store()), b"\x82\x01not cbor at all") + assert response.status_code == 200 + assert response.json()["result"] == "MISMATCH" + + +def test_an_empty_body_is_a_verdict_not_a_server_error(): + response = post(client(trust_store()), b"") + assert response.status_code == 200 + assert response.json()["result"] == "MISMATCH" + + +def test_errors_do_not_reflect_parser_internals(): + """The endpoint must not be usable as an oracle for the decoder.""" + response = post(client(trust_store()), b"\xd2\x84\x40\xa0\x40\x40") + text = response.text.lower() + for leak in ("traceback", "cbor2", "file \"", "line ", "_cose.py"): + assert leak not in text + + +def test_a_v01_manifest_over_this_endpoint_is_incompatible_not_valid(): + """The endpoint is for the 0.2 envelope; a 0.1 payload routes away.""" + import cbor2 + import json + + signed = sign_cose_sign1(manifest(), KP) + tagged = cbor2.loads(signed) + body = list(tagged.value) + payload = json.loads(body[2].decode()) + payload["version"] = "0.1" + body[2] = json.dumps(payload).encode() + forged = cbor2.dumps(cbor2.CBORTag(tagged.tag, body), canonical=True) + + response = post(client(trust_store()), forged) + assert response.json()["result"] == "INCOMPATIBLE_VERSION" + + +def test_a_revoked_manifest_is_revoked_over_http(): + store = RevocationStore() + store.revoke( + RevocationRecord( + manifest_id=MANIFEST_ID, + revoked_at=NOW, + reason="key compromise", + revoked_by="security@example", + ) + ) + response = post( + client(trust_store(), store), sign_cose_sign1(manifest(), KP) + ) + assert response.json()["result"] == "REVOKED" + + +def test_the_caller_cannot_supply_keys_through_the_query_string(): + """Key material must not travel in a URL, so there is no parameter for it. + + A caller that tries gets the unauthenticated result, not a VALID one. + """ + c = client() + response = c.post( + "/verify/cose", + content=sign_cose_sign1(manifest(), KP), + headers={"Content-Type": MEDIA_TYPE_MANIFEST_COSE}, + params={"trusted_keys": f"{KP.key_id}:{KP.public_b64url()}"}, + ) + assert response.json()["result"] == "UNVERIFIABLE" + + +def test_the_issuer_binding_is_enforced_over_http(): + ctx = trust_store( + trusted_key_issuers={KP.key_id: ["spiffe://trust.example/issuer/payroll"]} + ) + response = post( + client(ctx), sign_cose_sign1(manifest(issuer="spiffe://trust.example/other"), KP) + ) + assert response.json()["result"] == "MISMATCH" + + +def test_the_json_endpoint_is_unchanged(): + """Adding the COSE surface must not disturb the existing contract.""" + c = client(trust_store()) + response = c.post("/verify", json={"manifest_id": MANIFEST_ID}) + assert response.status_code == 404 # manifest_store is empty; route intact diff --git a/python/tests/test_ml_dsa_backend.py b/python/tests/test_ml_dsa_backend.py new file mode 100644 index 0000000..81bb1be --- /dev/null +++ b/python/tests/test_ml_dsa_backend.py @@ -0,0 +1,147 @@ +"""ML-DSA-65 backend selection and key-material handling. + +The SDK takes ML-DSA-65 from cryptography (>= 47) where it is available and +from the liboqs bindings otherwise. These tests cover the seam: which backend +a given key belongs to, what a build without either one reports, and the +PKCS#8 wrapper that lets a seed be loaded at all. +""" +from types import SimpleNamespace + +import pytest + +from agent_manifest import _signing +from agent_manifest._signing import ( + AlgorithmUnavailableError, + MlDsa65Signer, + MlDsa65Verifier, + generate_ml_dsa65, + ml_dsa65_available, +) + +require_pq = pytest.mark.skipif( + not ml_dsa65_available(), reason="no ML-DSA-65 backend available" +) +require_cryptography_mldsa = pytest.mark.skipif( + not _signing._CRYPTOGRAPHY_MLDSA_AVAILABLE, reason="cryptography < 47" +) + +MESSAGE = b"agent manifest ml-dsa backend test" + + +# --------------------------------------------------------------------------- +# Key material +# --------------------------------------------------------------------------- + + +@require_cryptography_mldsa +def test_ml_dsa_seed_wrapper_matches_cryptography(): + """The hardcoded PKCS#8 prefix must stay what cryptography itself emits. + + _signing wraps a raw seed in this prefix to load it, because cryptography + exposes no raw-seed loader. If its encoding ever changes, every signature + made from a stored seed would break; this catches that at test time + rather than in the field. + """ + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import mldsa + + key = mldsa.MLDSA65PrivateKey.generate() + seed = key.private_bytes_raw() + der = key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + assert der == _signing._ML_DSA_65_PKCS8_SEED_PREFIX + seed + + +@require_cryptography_mldsa +def test_a_seed_reconstructs_the_same_public_key(): + """A stored seed is a complete private key, not half of one.""" + kp = generate_ml_dsa65() + assert len(kp.private_key_bytes) == _signing._ML_DSA_65_SEED_LEN + reloaded = _signing._mldsa_key_from_seed(kp.private_key_bytes) + assert reloaded.public_key().public_bytes_raw() == kp.public_key_bytes + + +@require_pq +def test_public_key_is_the_fips_204_encoding(): + """1952 bytes either way, which is what makes key ids backend-agnostic.""" + kp = generate_ml_dsa65() + assert len(kp.public_key_bytes) == _signing._ML_DSA_65_PUBLIC_LEN + assert len(kp.key_id) == 64 # sha256 hex of the public key + + +@require_pq +def test_sign_and_verify_roundtrip(): + kp = generate_ml_dsa65() + manifest = {"manifest_id": "x", "version": "0.1", "crypto_profile": "post-quantum"} + block = MlDsa65Signer(kp).sign(manifest) + assert block["algorithm"] == "ML-DSA-65" + MlDsa65Verifier(kp.public_key_bytes).verify(manifest, block["signature_value"]) + + +@require_pq +def test_a_signature_does_not_verify_under_another_key(): + kp, other = generate_ml_dsa65(), generate_ml_dsa65() + manifest = {"manifest_id": "x", "version": "0.1"} + block = MlDsa65Signer(kp).sign(manifest) + from cryptography.exceptions import InvalidSignature + + with pytest.raises(InvalidSignature): + MlDsa65Verifier(other.public_key_bytes).verify( + manifest, block["signature_value"] + ) + + +# --------------------------------------------------------------------------- +# Backend selection +# --------------------------------------------------------------------------- + + +@require_cryptography_mldsa +def test_a_liboqs_expanded_key_without_liboqs_is_a_capability_gap(monkeypatch): + """Not a bad key, and not a bad signature - a backend that is not here. + + Reported as AlgorithmUnavailableError so the engine renders it + UNVERIFIABLE rather than MISMATCH. + """ + monkeypatch.setattr(_signing, "_OQS_AVAILABLE", False) + expanded_key = b"\x02" * 4032 # liboqs ML-DSA-65 secret key length + with pytest.raises(AlgorithmUnavailableError, match="expanded secret key"): + _signing._ml_dsa_sign_raw(expanded_key, MESSAGE) + + +def test_no_backend_at_all_is_reported_as_a_capability_gap(monkeypatch): + monkeypatch.setattr(_signing, "_CRYPTOGRAPHY_MLDSA_AVAILABLE", False) + monkeypatch.setattr(_signing, "_OQS_AVAILABLE", False) + assert _signing.ml_dsa65_available() is False + with pytest.raises(AlgorithmUnavailableError, match="cryptography >= 47"): + _signing._require_ml_dsa() + with pytest.raises(AlgorithmUnavailableError): + generate_ml_dsa65() + + +def test_require_oqs_is_still_the_capability_check(monkeypatch): + """The old private name kept working when the backend became pluggable.""" + assert _signing._require_oqs is _signing._require_ml_dsa + + +# --------------------------------------------------------------------------- +# The `oqs` module name is not evidence of post-quantum support +# --------------------------------------------------------------------------- + + +def test_an_oqs_module_without_signature_is_not_treated_as_a_backend(): + """`oqs` on PyPI is an unrelated project that squats the module name. + + Importing it must not be read as liboqs being present: doing so turns + every ML-DSA call into an AttributeError instead of a clean capability + error, and makes the SDK claim a post-quantum capability it does not have. + """ + imposter = SimpleNamespace(__name__="oqs", OQSInterpreter=object) + assert _signing._has_liboqs_api(imposter) is False + + +def test_a_module_exposing_the_liboqs_api_is_treated_as_a_backend(): + assert _signing._has_liboqs_api(SimpleNamespace(Signature=object)) is True diff --git a/python/tests/test_signing.py b/python/tests/test_signing.py index 870e96f..f97f814 100644 --- a/python/tests/test_signing.py +++ b/python/tests/test_signing.py @@ -1,6 +1,6 @@ """Tests for Ed25519 and ML-DSA-65 signing - issue #2. -ML-DSA-65 tests are skipped when pyoqs is not installed. +ML-DSA-65 tests are skipped when no ML-DSA-65 backend is available. """ import pytest from cryptography.exceptions import InvalidSignature @@ -23,12 +23,14 @@ generate_ml_dsa65, generate_hybrid, ) - import oqs # noqa: F401 - OQS_AVAILABLE = True + from agent_manifest._signing import ml_dsa65_available + OQS_AVAILABLE = ml_dsa65_available() except (ImportError, RuntimeError): OQS_AVAILABLE = False -require_oqs = pytest.mark.skipif(not OQS_AVAILABLE, reason="pyoqs not installed") +require_oqs = pytest.mark.skipif( + not OQS_AVAILABLE, reason="no ML-DSA-65 backend available" +) # --------------------------------------------------------------------------- @@ -211,7 +213,7 @@ def test_ed25519_public_key_roundtrip_b64url(): # --------------------------------------------------------------------------- -# ML-DSA-65 (skipped without pyoqs) +# ML-DSA-65 (skipped without an ML-DSA-65 backend) # --------------------------------------------------------------------------- @@ -238,7 +240,7 @@ def test_ml_dsa65_wrong_message_fails(): # --------------------------------------------------------------------------- -# Hybrid mode (skipped without pyoqs) +# Hybrid mode (skipped without an ML-DSA-65 backend) # --------------------------------------------------------------------------- diff --git a/python/tests/test_vectors.py b/python/tests/test_vectors.py index 9e92956..45b5f06 100644 --- a/python/tests/test_vectors.py +++ b/python/tests/test_vectors.py @@ -59,7 +59,14 @@ def test_vector(file_name: str) -> None: )) ctx = VerificationContext(**vector["context"]) - result = verify_manifest(vector["manifest"], ctx, store) + # A vector carries either a v0.1 manifest document or a v0.2 COSE + # envelope. The engine selects the procedure from what it is handed, so + # both kinds go through the same call (ADR-0011). + if "envelope_hex" in vector: + subject: Any = bytes.fromhex(vector["envelope_hex"]) + else: + subject = vector["manifest"] + result = verify_manifest(subject, ctx, store) expected = vector["expected"] assert result.result.value == expected["result"], ( @@ -74,3 +81,51 @@ def test_vector(file_name: str) -> None: for field, want in expected.get("fields_verified", {}).items(): got = getattr(result.fields_verified, field).value assert got == want, f"{vector['id']}: fields_verified.{field} expected {want}, got {got}" + + +COSE_VECTOR_FILES = [ + f for f in VECTOR_FILES if "envelope_hex" in _load_vector(f) +] + + +@pytest.mark.parametrize( + "file_name", COSE_VECTOR_FILES, ids=[f.removesuffix(".json") for f in COSE_VECTOR_FILES] +) +def test_cose_vector_encoding_is_pinned(file_name: str) -> None: + """The COSE object must be these exact bytes, not merely self-consistent. + + This is what makes the vectors a portable contract: an implementation in + another language, using a COSE library, has to agree with the reference + SDK element by element. It also pins the phase 2 decision (ADR-0013) that + an envelope with no receipt yet carries a zero-length unprotected header + map rather than omitting it - ``unprotected_hex`` is ``a0``. + """ + import cbor2 + + from agent_manifest._cose import payload_hash + + vector = _load_vector(file_name) + pinned = vector["expected"]["cose"] + envelope = bytes.fromhex(vector["envelope_hex"]) + + tagged = cbor2.loads(envelope) + assert tagged.tag == pinned["tag"] + protected, unprotected, payload, signature = tagged.value + + assert protected.hex() == pinned["protected_hex"] + assert cbor2.dumps(dict(unprotected)).hex() == pinned["unprotected_hex"] + assert payload.hex() == pinned["payload_hex"] + assert signature.hex() == pinned["signature_hex"] + assert payload_hash(payload) == pinned["manifest_hash"] + + # And the SDK reproduces the whole object from the manifest and the fixed + # key, so the vector is a regression test on the encoder, not a snapshot + # of whatever it happened to emit on the day it was written. + import json + + from agent_manifest._cose import sign_cose_sign1 + from agent_manifest._signing import ed25519_from_private_bytes + + keypair = ed25519_from_private_bytes(bytes(range(32))) + regenerated = sign_cose_sign1(json.loads(payload.decode()), keypair) + assert regenerated == envelope diff --git a/python/tests/test_verify.py b/python/tests/test_verify.py index beb20d8..b975c96 100644 --- a/python/tests/test_verify.py +++ b/python/tests/test_verify.py @@ -465,13 +465,16 @@ def _raise_unavailable(*args, **kwargs): from agent_manifest._signing import AlgorithmUnavailableError raise AlgorithmUnavailableError( - 'ML-DSA-65 requires pyoqs. Install with: pip install "agent-manifest[pq]"' + "ML-DSA-65 is unavailable in this build. It needs cryptography >= 47 " + '(install with: pip install "agent-manifest[pq]") or the liboqs ' + "Python bindings importable as `oqs`." ) def test_ml_dsa_without_pq_extra_is_unverifiable_not_an_exception(monkeypatch): - # pyoqs is an optional extra, so the default install cannot appraise an - # ML-DSA-65 signature. A manifest is untrusted input: verify_manifest must + # A build can lack an ML-DSA-65 backend entirely (cryptography < 47 and no + # liboqs), so it cannot appraise an ML-DSA-65 signature. A manifest is + # untrusted input: verify_manifest must # return a verdict rather than raise, and the verdict must not be MISMATCH, # which would accuse a manifest that may be perfectly valid. monkeypatch.setattr(_signing, "MlDsa65Verifier", _raise_unavailable) @@ -638,7 +641,8 @@ def test_delegation_chain_without_keys_is_unverifiable(): def test_unsupported_version_is_incompatible(): - m = base_manifest(version="0.2") + # 0.2 is supported (the COSE envelope); 0.3 does not exist. + m = base_manifest(version="0.3") result = verify_manifest(m, base_context(), store()) assert result.result == OverallResult.INCOMPATIBLE_VERSION diff --git a/python/tests/vectors/AM-VEC-007.json b/python/tests/vectors/AM-VEC-007.json index d564904..e9d5d8f 100644 --- a/python/tests/vectors/AM-VEC-007.json +++ b/python/tests/vectors/AM-VEC-007.json @@ -7,7 +7,7 @@ "manifest": { "manifest_id": "018f4a3b-2c1d-7e5f-a8b9-0d1e2f3a4b5c", "agent_id": "spiffe://trust.example/agent/kyc/prod", - "version": "0.2", + "version": "0.3", "issued_at": "2025-01-01T00:00:00Z", "expires_at": "2099-12-31T23:59:59Z", "issuer": "spiffe://trust.example/signing-authority", @@ -32,7 +32,7 @@ "key_id": "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c", "key_type": "software", "signed_at": "2025-01-01T00:00:00Z", - "signature_value": "DyP2zkq1nN-cS9ZofxjorGNzuomwKhNtV8f4KV7RCjZCAg_GAeQu2xVDw16-fp__L93fPLR3AwT1W6tCJmQpCg", + "signature_value": "mVMfp3Rk4iPzy4ry106W4cx774CBni_h7pd3zTGI95Y2pfqUTSTk-460M0_Pyftdsz5LT8wh8lb4BdHbBAlxDQ", "signed_fields": [ "@context", "@type", diff --git a/python/tests/vectors/AM-VEC-COSE-001.json b/python/tests/vectors/AM-VEC-COSE-001.json new file mode 100644 index 0000000..687d8f7 --- /dev/null +++ b/python/tests/vectors/AM-VEC-COSE-001.json @@ -0,0 +1,29 @@ +{ + "id": "AM-VEC-COSE-001", + "description": "COSE_Sign1 encoding is pinned byte-for-byte: CBOR tag 18, a four-element array, and a zero-length unprotected header map before any receipt is attached.", + "spec_refs": [ + "cose-envelope-v0.2 2", + "cose-envelope-v0.2 3" + ], + "envelope_hex": "d284586aa4013203781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b6a736f6e04582056475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c10781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b636f7365a05902247b226167656e745f6964223a227370696666653a2f2f74727573742e6578616d706c652f6167656e742f6b79632f70726f64222c22617274696661637473223a7b226d6f64656c5f6964656e74697479223a7b226465706c6f796d656e745f74797065223a22617069222c2276657273696f6e223a22636c617564652d33227d2c22706f6c6963795f62756e646c65223a7b2268617368223a227368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262227d2c2273797374656d5f70726f6d7074223a7b2268617368223a227368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161227d7d2c2263727970746f5f70726f66696c65223a227374616e64617264222c22657870697265735f6174223a22323039392d31322d33315432333a35393a35395a222c226973737565645f6174223a22323032352d30312d30315430303a30303a30305a222c22697373756572223a227370696666653a2f2f74727573742e6578616d706c652f7369676e696e672d617574686f72697479222c226d616e69666573745f6964223a2230313866346133622d326331642d376535662d613862392d306431653266336134623563222c2276657273696f6e223a22302e32227d5840819b17572c67eece4a1b20557f1d861a295c1fc3287b8a5cd1e6f93e48ec8553e22fcd94eba9ac6edfdfae0316cda3e35609637f27d2bba0bc148dc6c82b8409", + "context": { + "system_prompt_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "policy_bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "model_version": "claude-3", + "trusted_keys": { + "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + }, + "expected": { + "result": "VALID", + "signature_verified": true, + "cose": { + "tag": 18, + "protected_hex": "a4013203781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b6a736f6e04582056475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c10781f6170706c69636174696f6e2f6167656e742d6d616e69666573742b636f7365", + "unprotected_hex": "a0", + "payload_hex": "7b226167656e745f6964223a227370696666653a2f2f74727573742e6578616d706c652f6167656e742f6b79632f70726f64222c22617274696661637473223a7b226d6f64656c5f6964656e74697479223a7b226465706c6f796d656e745f74797065223a22617069222c2276657273696f6e223a22636c617564652d33227d2c22706f6c6963795f62756e646c65223a7b2268617368223a227368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262227d2c2273797374656d5f70726f6d7074223a7b2268617368223a227368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161227d7d2c2263727970746f5f70726f66696c65223a227374616e64617264222c22657870697265735f6174223a22323039392d31322d33315432333a35393a35395a222c226973737565645f6174223a22323032352d30312d30315430303a30303a30305a222c22697373756572223a227370696666653a2f2f74727573742e6578616d706c652f7369676e696e672d617574686f72697479222c226d616e69666573745f6964223a2230313866346133622d326331642d376535662d613862392d306431653266336134623563222c2276657273696f6e223a22302e32227d", + "signature_hex": "819b17572c67eece4a1b20557f1d861a295c1fc3287b8a5cd1e6f93e48ec8553e22fcd94eba9ac6edfdfae0316cda3e35609637f27d2bba0bc148dc6c82b8409", + "manifest_hash": "sha256:915f19c590a920f12466af7dbd47802426a092d75a378586433cbc9e8bd57462" + } + } +} diff --git a/python/tests/vectors/README.md b/python/tests/vectors/README.md index 7d53cc7..4204a13 100644 --- a/python/tests/vectors/README.md +++ b/python/tests/vectors/README.md @@ -39,6 +39,42 @@ MUST produce the expected `result` and the listed `fields_verified` statuses. } // manifest_id before verifying ``` +### COSE vectors (manifest version 0.2) + +A vector carries **either** `manifest` or `envelope_hex`, never both. The +envelope follows the manifest `version` (ADR-0011), so which key is present +tells a consumer which verification procedure applies: `manifest` is a v0.1 +document with a detached signature block, `envelope_hex` is a v0.2 COSE object +as hex-encoded CBOR. + +```jsonc +{ + "id": "AM-VEC-COSE-001", + "envelope_hex": "d284586aa401270378...", // tagged COSE_Sign1, CBOR + "context": { ... }, + "expected": { + "result": "VALID", + "signature_verified": true, + "cose": { // the encoding, pinned element by element + "tag": 18, // COSE_Sign1; 98 would be COSE_Sign + "protected_hex": "a4012703...", + "unprotected_hex": "a0", // zero-length map, never omitted + "payload_hex": "7b2261...", // RFC 8785 canonical JSON of the manifest + "signature_hex": "...", + "manifest_hash": "sha256:..." // what hardware attestation binds + } + } +} +``` + +The `cose` block is the point of these vectors: an implementation must produce +**these exact bytes**, not merely something its own verifier accepts. Decode +`payload_hex` as JSON to read the manifest under test. + +Only Ed25519 envelopes can be pinned this way. ML-DSA-65 signing is hedged, so +a post-quantum or hybrid envelope differs on every run and only its structure +is stable. + `context` maps field-for-field onto the SDK's `VerificationContext`, so a Python consumer is just `VerificationContext(**vector["context"])`. Other languages should treat each key as a named verification input. @@ -48,7 +84,8 @@ should treat each key as a named verification input. 1. Read `keys.json` for the issuer public key (`public_key_b64url`, `key_id`). 2. For each vector: build your verification context from `context`; if `revoke` is set, mark `manifest.manifest_id` revoked first. -3. Run your verifier over `manifest`. +3. Run your verifier over `manifest`, or over the CBOR bytes of + `envelope_hex` when that key is present instead. 4. Assert your overall result equals `expected.result`, and every entry in `expected.fields_verified` matches. diff --git a/python/tests/vectors/generate.py b/python/tests/vectors/generate.py index ae7d595..670c8a3 100644 --- a/python/tests/vectors/generate.py +++ b/python/tests/vectors/generate.py @@ -35,6 +35,8 @@ from pathlib import Path from typing import Any +import cbor2 + from agent_manifest._canonicalize import canonicalize from agent_manifest._delegation import DelegationHopSigner from agent_manifest._signing import Ed25519Signer, ed25519_from_private_bytes @@ -119,6 +121,76 @@ def base_context(**overrides: Any) -> dict[str, Any]: return ctx +def cose_manifest(**overrides: Any) -> dict[str, Any]: + """A version 0.2 manifest: no signature field, because the COSE object is + the signature (envelope spec section 4).""" + m: dict[str, Any] = { + "manifest_id": MANIFEST_ID, + "agent_id": "spiffe://trust.example/agent/kyc/prod", + "version": "0.2", + "issued_at": ISSUED_AT, + "expires_at": FAR_FUTURE, + "issuer": ISSUER, + "crypto_profile": "standard", + "artifacts": { + "system_prompt": {"hash": SP_HASH}, + "policy_bundle": {"hash": PB_HASH}, + "model_identity": { + "model_hash": None, + "version": "claude-3", + "deployment_type": "api", + }, + }, + } + m.update(overrides) + return m + + +def cose_encoding_vector() -> dict[str, Any]: + """Pin the COSE_Sign1 encoding byte-for-byte (issue #243 phase 2, ADR-0013). + + The open item this closes is whether an envelope with no receipt attached + yet carries a zero-length unprotected header map or omits it. It carries + one - ``a0`` below - and an implementation in another language has to + produce these exact bytes, not merely something self-consistent. + + Reproducible because the key is the fixed seed and Ed25519 is + deterministic (RFC 8032). The post-quantum and hybrid envelopes cannot be + pinned this way: ML-DSA-65 signing is hedged, so the signature bytes + differ per run and only the structure is stable. + """ + from agent_manifest._cose import payload_hash, sign_cose_sign1 + + manifest = cose_manifest() + envelope = sign_cose_sign1(manifest, KP) + protected, unprotected, payload, signature = cbor2.loads(envelope).value + assert unprotected == {}, "the unprotected header must be a zero-length map" + + return { + "id": "AM-VEC-COSE-001", + "description": ( + "COSE_Sign1 encoding is pinned byte-for-byte: CBOR tag 18, a " + "four-element array, and a zero-length unprotected header map " + "before any receipt is attached." + ), + "spec_refs": ["cose-envelope-v0.2 2", "cose-envelope-v0.2 3"], + "envelope_hex": envelope.hex(), + "context": base_context(), + "expected": { + "result": "VALID", + "signature_verified": True, + "cose": { + "tag": 18, + "protected_hex": protected.hex(), + "unprotected_hex": cbor2.dumps(dict(unprotected)).hex(), + "payload_hex": payload.hex(), + "signature_hex": signature.hex(), + "manifest_hash": payload_hash(payload), + }, + }, + } + + def _vector( vid: str, description: str, @@ -196,7 +268,9 @@ def build() -> list[dict[str, Any]]: # 007 - unsupported version vectors.append(_vector( "AM-VEC-007", "Unsupported manifest version is rejected before verifying.", - ["2.4"], base_manifest(version="0.2"), base_context(), + # 0.2 is the COSE envelope and is supported; 0.3 does not exist, which + # is what makes it the right stand-in for "a version from the future". + ["2.4"], base_manifest(version="0.3"), base_context(), {"result": "INCOMPATIBLE_VERSION"}, )) @@ -361,6 +435,9 @@ def build() -> list[dict[str, Any]]: {"result": "MISMATCH", "signature_verified": False}, )) + # --- version 0.2, COSE envelope (ADR-0011, issue #243) ----------------- + vectors.append(cose_encoding_vector()) + return vectors @@ -384,6 +461,14 @@ def main() -> None: "description": "Language-neutral verification conformance vectors. " "Each vector: a manifest, a VerificationContext, and the " "expected VerificationResult.", + # A vector carries either `manifest` (a version 0.1 document with a + # detached signature block) or `envelope_hex` (a version 0.2 COSE + # object). The envelope follows the manifest version, so a consumer + # selects the procedure by which key is present. + "envelopes": { + "manifest": "v0.1 detached signature over an RFC 8785 pre-image", + "envelope_hex": "v0.2 COSE_Sign1 / COSE_Sign, CBOR", + }, "signing_key": "keys.json", "vectors": [ {"id": v["id"], "file": f"{v['id']}.json", "description": v["description"]} diff --git a/python/tests/vectors/index.json b/python/tests/vectors/index.json index acb97d4..5beeea3 100644 --- a/python/tests/vectors/index.json +++ b/python/tests/vectors/index.json @@ -2,6 +2,10 @@ "suite": "agent-manifest-verification", "spec_version": "0.1", "description": "Language-neutral verification conformance vectors. Each vector: a manifest, a VerificationContext, and the expected VerificationResult.", + "envelopes": { + "manifest": "v0.1 detached signature over an RFC 8785 pre-image", + "envelope_hex": "v0.2 COSE_Sign1 / COSE_Sign, CBOR" + }, "signing_key": "keys.json", "vectors": [ { @@ -103,6 +107,11 @@ "id": "AM-VEC-020", "file": "AM-VEC-020.json", "description": "Post-quantum crypto_profile with an Ed25519 signature is a downgrade." + }, + { + "id": "AM-VEC-COSE-001", + "file": "AM-VEC-COSE-001.json", + "description": "COSE_Sign1 encoding is pinned byte-for-byte: CBOR tag 18, a four-element array, and a zero-length unprotected header map before any receipt is attached." } ] } diff --git a/spec/agent-manifest-cose-envelope-v0.2.md b/spec/agent-manifest-cose-envelope-v0.2.md index bd03270..ae6bedc 100644 --- a/spec/agent-manifest-cose-envelope-v0.2.md +++ b/spec/agent-manifest-cose-envelope-v0.2.md @@ -33,7 +33,7 @@ A verifier MUST accept both tags. A verifier MUST reject an untagged COSE struct Hybrid is carried as a single `COSE_Sign` over one payload with two `COSE_Signature` entries, one per algorithm. It is not two `COSE_Sign1` objects. -Each signature entry carries its own `alg` in its own protected header (`-8` for Ed25519, `-49` for ML-DSA-65) and its own `kid`. Both entries cover the identical payload bytes, which the structure guarantees rather than an application rule. +Each signature entry carries its own `alg` in its own protected header (`-19` for Ed25519, `-49` for ML-DSA-65) and its own `kid`. Both entries cover the identical payload bytes, which the structure guarantees rather than an application rule. A verifier operating under a policy that requires post-quantum protection MUST verify both entries and MUST reject the manifest if either fails. A verifier that cannot perform ML-DSA-65 MUST return `UNVERIFIABLE` and MUST NOT fall back to the Ed25519 entry alone, which would be a downgrade (v0.1 section 4.2, carried forward). @@ -43,12 +43,16 @@ The protected header is covered by the signature. For `COSE_Sign1` these paramet | Parameter | Label | Value | Presence | |---|---|---|---| -| `alg` | 1 | `-8` (EdDSA / Ed25519) or `-49` (ML-DSA-65) | REQUIRED | +| `alg` | 1 | `-19` (Ed25519) or `-49` (ML-DSA-65) | REQUIRED | | `kid` | 4 | Key identifier, byte string. The SHA-256 of the public key bytes, as in v0.1 | REQUIRED | | `content type` | 3 | `application/agent-manifest+json` | REQUIRED | | `typ` | 16 | `application/agent-manifest+cose` | REQUIRED | -`alg` values are the IANA-registered COSE code points: EdDSA from RFC 9053, and ML-DSA-65 from RFC 9964, which also registers the AKP key type (COSE key type `7`). No provisional or draft code points are used. +`alg` values are the IANA-registered COSE code points: Ed25519 from [RFC 9864](https://www.rfc-editor.org/rfc/rfc9864.html), and ML-DSA-65 from RFC 9964, which also registers the AKP key type (COSE key type `7`). No provisional or draft code points are used. + +A producer MUST sign with `-19` and MUST NOT sign with `-8`. A verifier MUST accept `-8` on an existing manifest and MUST treat it as Ed25519 (ADR-0014). RFC 9864 (Standards Track, October 2025) deprecated the polymorphic `EdDSA` identifier `-8`, which named a family and left the curve to be inferred from the key, and registered fully-specified identifiers in its place. Inferring an algorithm from a key is the same class of ambiguity that ADR-0011 gives as a reason to move off the v0.1 envelope, so this profile takes the fully-specified identifier. `-8` remains verifiable because manifests are audit records with a retention window that outlives the identifier they were signed under. + +`-8` and `-19` name one algorithm, not two. Anything reasoning about *which* algorithm signed - the `crypto_profile` check of section 6, and the two-signer rule of section 2.1 - MUST compare algorithms rather than code points, so that a `COSE_Sign` carrying one entry of each is rejected as a single algorithm signed twice rather than accepted as a hybrid signature. `typ` (RFC 9596) declares the type of the complete COSE object; `content type` declares the type of the payload. They are deliberately different values: the object is CBOR, the payload is JSON. A verifier MUST reject an object whose `typ` is absent or is any value other than `application/agent-manifest+cose`, which prevents a manifest signature from being reinterpreted as a signature over some other kind of document. @@ -95,7 +99,7 @@ A verifier MUST perform these steps in order and MUST fail closed at the first f 1. Parse the CBOR. Reject anything that is not a tagged `COSE_Sign1` (18) or `COSE_Sign` (98). 2. Read the protected header. Reject an absent or unexpected `typ`; reject an unknown `crit` entry; reject an absent `alg`. 3. Extract the payload and parse it as JSON. Reject a `version` this verifier does not support, returning `INCOMPATIBLE_VERSION` (v0.1 section 2.4 semantics carried forward). Route a `0.1` payload to the v0.1 envelope rules; this document applies to `0.2`. -4. Check the signed `crypto_profile` in the payload against the `alg` in the protected header. Reject a signature weaker than the declared profile requires: a `post-quantum` profile with only `-8` is a downgrade. A signature stronger than the profile requires is permitted. This is the v0.1 section 4.2 rule, retained because the profile is a claim about posture that the algorithm alone does not express. +4. Check the signed `crypto_profile` in the payload against the `alg` in the protected header. Reject a signature weaker than the declared profile requires: a `post-quantum` profile with only Ed25519 (`-19`, or `-8` on an existing manifest) is a downgrade. A signature stronger than the profile requires is permitted. This is the v0.1 section 4.2 rule, retained because the profile is a claim about posture that the algorithm alone does not express. 5. Verify the signature over the COSE `Sig_structure`. For `COSE_Sign`, verify every signature entry the governing policy requires. 6. Return `UNVERIFIABLE`, not `MISMATCH`, if the algorithm is registered but this verifier cannot perform it. The verifier has established nothing about a manifest that may be entirely valid (v0.1 section 4.2, as amended). 7. Only then evaluate the unprotected header: receipt inclusion proof, attestation report, approvals. A failure in any of these is reported against that element, not as a signature failure. @@ -144,16 +148,17 @@ The same manifest after registration and hardware attestation, showing only the ## 9. Open items for phase 2 -These are implementation decisions, not envelope decisions, and are listed so phase 2 does not rediscover them: +These are implementation decisions, not envelope decisions, and are listed so phase 2 does not rediscover them. The first two are now settled in [ADR-0013](../docs/adr/0013-cbor-library-for-cose.md): -- Which CBOR and COSE library the Python SDK takes. This is the first crypto dependency beyond `cryptography`, so it needs review for maintenance, audit history, and wheel availability across the supported Python matrix. -- Whether the SDK emits `COSE_Sign1` with a zero-length unprotected header map or omits it before registration. Both are valid CBOR; pick one and pin it in a conformance vector so implementations agree byte-for-byte. +- ~~Which CBOR and COSE library the Python SDK takes.~~ **Decided:** `cbor2` only, with the COSE structures built in the SDK. No COSE library is taken, so the crypto surface stays `cryptography` plus optional `pyoqs`. +- ~~Whether the SDK emits `COSE_Sign1` with a zero-length unprotected header map or omits it before registration.~~ **Decided:** always a zero-length map. Only one of the two is a valid `COSE_Sign1` — omitting the element yields a three-element array, which RFC 9052 section 4.2 does not define. Pinned byte-for-byte in `AM-VEC-COSE-001` (`unprotected_hex` is `a0`). - `AM-VEC-*` conformance vectors for negative cases only this envelope can express: a tampered protected header, an `alg` substituted between the protected and unprotected headers, a `typ` mismatch, and an unprotected header injected before signature verification. - Whether the `agent-manifest-attestation` and `agent-manifest-approvals` string labels are worth converting to IANA integer labels before v1.0, which is a wire-size argument only. ## 10. References -- [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052.html), [RFC 9053](https://www.rfc-editor.org/rfc/rfc9053.html) - COSE structures and algorithms, including EdDSA (`-8`) +- [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052.html), [RFC 9053](https://www.rfc-editor.org/rfc/rfc9053.html) - COSE structures and algorithms, including the now-deprecated polymorphic `EdDSA` (`-8`) +- [RFC 9864](https://www.rfc-editor.org/rfc/rfc9864.html) - Fully-specified algorithms for JOSE and COSE, Standards Track, October 2025. Deprecates `EdDSA` (`-8`); registers Ed25519 = `-19` and Ed448 = `-53`. See ADR-0014 - [RFC 9596](https://www.rfc-editor.org/rfc/rfc9596.html) - COSE `typ` header parameter (label 16), Standards Track, June 2024 - [RFC 9964](https://www.rfc-editor.org/rfc/rfc9964.html) - ML-DSA for JOSE and COSE, Standards Track, May 2026. ML-DSA-65 = `alg` `-49`, AKP key type `7` - [RFC 9943](https://www.rfc-editor.org/rfc/rfc9943.html) - SCITT architecture. Signed Statements are COSE_Sign1; `receipts` is unprotected header label 394 diff --git a/spec/agent-manifest-spec-v0.2.md b/spec/agent-manifest-spec-v0.2.md index 2d6f3fb..26e2e3b 100644 --- a/spec/agent-manifest-spec-v0.2.md +++ b/spec/agent-manifest-spec-v0.2.md @@ -7,7 +7,7 @@ | Authors | Imran Siddique (AgenTrust) | | Status | Draft v0.2 - Proposed Open Standard | | Date | August 2026 | -| Changes in 0.2 | `@context` URI moved to a controlled domain. Manifest format unchanged. See ADR 0012. | +| Changes in 0.2 | `@context` URI moved to a controlled domain (ADR-0012). Signature envelope moves to COSE_Sign1, specified in [agent-manifest-cose-envelope-v0.2.md](agent-manifest-cose-envelope-v0.2.md) (ADR-0011); Ed25519 is identified by `-19` (ADR-0014). `version` is `"0.2"`. Field definitions are otherwise unchanged from 0.1. | | Relationship | Extends: OWASP ASI 2026 \| Aligns: CoSAI WS1, EU AI Act Art. 14/15 | | Target Standards Body | Coalition for Secure AI (CoSAI) WS4 - OASIS Open | @@ -160,7 +160,7 @@ SHAKE-256 output length: For all artifact hash fields in the post-quantum profil Manifest producers and verifiers negotiate spec compatibility using the `version` field in the manifest and the `spec_version` field in VerificationResult. Producer requirements: -- MUST set `version` to the spec version used for manifest construction (e.g., `"0.1"`). +- MUST set `version` to the spec version used for manifest construction (`"0.2"` for this specification, `"0.1"` for the previous one). - MUST NOT produce fields defined only in later spec versions when targeting an older verifier. Verifier requirements: @@ -214,7 +214,7 @@ Field cardinality table | `manifest_id` | string (UUID v7, RFC 9562) | REQUIRED | Version nibble MUST be 7. Canonical 8-4-4-4-12 hyphenated lowercase hex. | | `previous_manifest_id` | string (UUID v7, RFC 9562) | OPTIONAL | Set on re-issuance to establish audit chain continuity. | | `agent_id` | string (SPIFFE URI) | REQUIRED | Trust domain lowercase `[a-z0-9._-]`; path segments `[a-zA-Z0-9._-]`. URI MUST NOT exceed 2048 bytes. | -| `version` | string | REQUIRED | MUST be `"0.1"` for this specification version. | +| `version` | string | REQUIRED | MUST be `"0.2"` for this specification version. `"0.1"` identifies a manifest issued under the v0.1 specification, which stays verifiable; see section 2.4 and the [COSE envelope](agent-manifest-cose-envelope-v0.2.md). | | `min_verifier_version` | string (semver) | OPTIONAL | Minimum verifier version required to correctly process this manifest. | | `issued_at` | string (ISO 8601 UTC) | REQUIRED | | | `expires_at` | string (ISO 8601 UTC) | REQUIRED | Default: `issued_at` + 90 days. MUST NOT be more than 365 days after `issued_at` for Level 1+. MUST NOT be less than 1 hour after `issued_at`. |