diff --git a/CHANGELOG.md b/CHANGELOG.md index 045351e..1af5227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,49 @@ All notable changes to Agent Manifest are documented here. Format follows [Keep ## [Unreleased] +### Added + +**[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. + +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][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 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 83cd005..fcda488 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -30,13 +30,24 @@ classifiers = [ dependencies = [ "pydantic>=2.7,<3", "cryptography>=42,<50", + # 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 bba8530..823f9ed 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, @@ -87,6 +100,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..26c4d9e --- /dev/null +++ b/python/src/agent_manifest/_cose.py @@ -0,0 +1,906 @@ +"""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 ( + AlgorithmUnavailableError, + 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" + ) + 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 b3d470f..e1afb59 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..5e08b9f --- /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(f"[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..6277ffc --- /dev/null +++ b/python/tests/test_cose.py @@ -0,0 +1,1321 @@ +"""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_RECEIPTS, + 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..0d2038b --- /dev/null +++ b/python/tests/test_cose_endpoint.py @@ -0,0 +1,323 @@ +"""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, + OverallResult, + 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 ac2cc4e..b9eef18 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`. |