Skip to content

feat(cose): implement the v0.2 COSE envelope behind the version gate (#243 phase 2) - #274

Open
zohebk8s wants to merge 1 commit into
agentrust-io:mainfrom
zohebk8s:feat/cose-envelope-v0.2-phase2
Open

feat(cose): implement the v0.2 COSE envelope behind the version gate (#243 phase 2)#274
zohebk8s wants to merge 1 commit into
agentrust-io:mainfrom
zohebk8s:feat/cose-envelope-v0.2-phase2

Conversation

@zohebk8s

@zohebk8s zohebk8s commented Aug 5, 2026

Copy link
Copy Markdown

Summary

Implements phase 2 of the ADR-0011 migration, tracked in #243. COSE signing and verification land alongside the existing path and are selected by the manifest version field, never by a flag. A version 0.1 manifest verifies exactly as it does today and no existing record is reinterpreted.

What the envelope buys

Property v0.1 v0.2
Signed bytes Reconstructed by the verifier Travel with the signature
Algorithm binding signature.algorithm, outside the signature alg in the protected header, covered by it
Post signing data Top level fields with ordering rules Unprotected header
Hardware binds Hash over a field subset sha256 of the payload bytes
Hybrid Two signatures, shared payload by convention One COSE_Sign, shared payload by structure

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.

Three v0.1 mechanisms are deleted rather than ported, because each existed only to work around JSON having no home for data that attaches after signing: the signed_fields coverage table, the hitl_record.approvals normalisation rule, and the transparency_log_entry ordering rule.

Phase 2 open items

Item Status Where
Which CBOR/COSE library, reviewed as a first crypto dependency ✅ Closed ADR-0013
Empty unprotected map, pinned in a vector ✅ Closed AM-VEC-COSE-001, unprotected_hex: "a0"
Negative vectors only this envelope can express ⏭ Phase 3 Present as tests, awaiting promotion
String versus integer IANA labels ⏭ v1.0 Open, as the item says

The library review is recorded with evidence rather than impressions. Neither pycose nor cwt ships the RFC 9964 code points, and both widen the dependency closure to do less. Two findings came out of it. cbor2 6.x is a Rust extension with no pure Python fallback, and under the >=5.6,<7 pin manylinux_2_17 resolves to 5.9.0 while other platforms get 6.1.4. The two majors also decode tag contents differently, so the suite runs against both ends of the range.

Two specification corrections

The specification contradicted itself. Section 2.4 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".

RFC 9864 deprecated EdDSA -8. Standards Track, October 2025, registering fully specified identifiers in its place. Issue #243 recorded the code points as settled before that was accounted for. ML-DSA-65 is confirmed correct against IANA at -49. Ed25519 moves to -19, recorded in ADR-0014. Verifiers keep accepting -8 indefinitely, because manifests are audit records with regulated retention and a signature cannot be reissued under a new identifier without resigning.

Security fixes found while implementing

Three defects were introduced by this work and fixed before it left the branch.

  1. Key to issuer authorisation was skipped on the COSE path. Reusing the shared pipeline dropped the check along with the v0.1 signature block, so any trusted key could sign for any issuer. The v0.1 engine rejects that, so this was a regression. Now checked for every signer, so a hybrid manifest cannot carry an unauthorised component key alongside an authorised one.
  2. Payloads with duplicate member names were accepted. RFC 8785 forbids them and parsers resolve them differently, so two verifiers could read a different issuer or expires_at out of identical signed bytes and both consider the signature valid. NaN and Infinity were the same class. Both rejected at parse time, which is a parse level check rather than a re canonicalisation.
  3. A deeply nested payload raised RecursionError out of verify_manifest instead of returning a verdict.

The version gate also bound in only one direction. A manifest declaring version: "0.2" while carrying a v0.1 detached signature block verified VALID, which would have made the gate advisory and left the phase 5 deprecation with nothing to enforce. Now rejected.

Findings 1 and 2 are also gaps in section 6 of the envelope specification, which never asks whether the signing key is authorised for the payload's issuer and never rejects a payload that parses ambiguously. Any other language SDK following that document reproduces both. Phase 3 should carry a negative vector for each.

Prerequisite included

ML-DSA-65 now comes from cryptography, which implements it as of 47.0.0 and is already a required dependency.

The pq extra previously required pyoqs, which is not published on PyPI, so the post quantum profile could not be installed by following the documented instruction. The module name the SDK imports also belongs to an unrelated project on PyPI, and a successful import was treated as proof of liboqs, which turned every ML-DSA call into an AttributeError and made the SDK claim a capability it did not have. The capability check now identifies liboqs by its API. Deployments already carrying the bindings keep working, because the backend is chosen by the key material.

Thirteen post quantum tests that skipped on every machine without liboqs now execute against real FIPS 204 signatures. This is a prerequisite rather than phase 2 scope, included because _cose.py cannot sign or verify the post quantum profile without it. Happy to split it out if reviewers prefer.

HTTP and CLI

POST /verify/cose takes the COSE object as raw CBOR under application/agent-manifest+cose.

  • Only the exact registered media type is accepted. A vendor tree alias, application/cbor, and an absent type are refused with 415, and the body is never sniffed.
  • No key material crosses the wire. Trust is configured server side, and an unconfigured endpoint returns UNVERIFIABLE, never VALID.
  • The body is bounded before it is parsed, with Content-Length checked when present and the stream capped regardless.
  • A malformed envelope is a verdict rather than a transport error, and parser detail is never reflected back.

manifest sign selects the envelope from the manifest version. manifest verify detects it from the CBOR tag rather than the file extension.

Verification

Run against a fresh clone of this branch, not a working tree.

805 passed, 6 skipped
Check Result
Full suite on a clean clone ✅ 805 passed, 6 skipped (all six are hardware gates)
Same suite on the cbor2 floor (5.6.0) ✅ passed
mypy strict ✅ clean
bandit ✅ clean
_cose.py coverage ✅ 99 percent
AM-VEC-COSE-001 regenerated from source ✅ byte identical
Independent COSE library (pycose) ✅ parses, verifies, rejects both tampers
CLI round trip, sign then verify VALID

The single uncovered line in _cose.py is a defensive RecursionError branch that the decoder does not reach.

The COSE_Sign1 and COSE_Sign structures were verified by pycose, an independent implementation with no relationship to this project, which parses them, verifies the signatures and rejects both a flipped signature byte and a modified payload. That check runs against -8 fixtures because no COSE library implements RFC 9864 or RFC 9964 yet, so neither of this profile's algorithm identifiers has a third party opinion available today. The cost is recorded in ADR-0014 rather than glossed.

One carried change

The verify_bytes methods on Ed25519Verifier, MlDsa65Verifier and HybridVerifier are carried here rather than left out. They were already in the working tree for separate TRACE work, and the ML-DSA backend change rewrites the body of MlDsa65Verifier.verify_bytes, so the two could not be separated cleanly. They are additive and nothing else in this PR uses them.

Not included

Manifest.version still defaults to "0.1" in the SDK model. With the version gate now binding in both directions, flipping it would push every default constructed manifest onto the COSE envelope, so it belongs with the phase 5 deprecation rather than here. A v0.2 example file belongs with the phase 3 vectors.

Refs #243

Phase 2 of the ADR-0011 migration, tracked in agentrust-io#243. COSE signing and
verification land alongside the existing path, selected by the manifest
version field rather than by a flag. A version 0.1 manifest verifies exactly
as it does today and no existing record is reinterpreted.

WHAT THE ENVELOPE BUYS

The signed bytes now travel with the signature, so a verifier never
re-serialises a manifest to check one. RFC 8785 stays the producer side
determinism rule and the basis of the hash bound into hardware, but it is no
longer an input to verification. The algorithm identifier moved into the
protected header where the signature covers it, so the downgrade fixed in
0.6.0 by an explicit cross check cannot be expressed at all. Receipts, the
TEE attestation report and HITL approvals attach in the unprotected header,
which retires the signed_fields coverage table, the hitl_record.approvals
normalisation 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.

PHASE 2 OPEN ITEMS, CLOSED

The library question is settled in ADR-0013: cbor2 for serialisation, with
the COSE structures built in this repository. Neither pycose nor cwt ships
the RFC 9964 code points, and both widen the dependency closure to do less.
The review ADR-0011 asked for is recorded there with evidence rather than
impressions, including the finding that cbor2 6.x is a Rust extension with no
pure Python fallback, and that the pin resolves to 5.9.0 on manylinux_2_17.
The suite runs against both ends of the range.

The unprotected map question had only one answer: a three element array is
not a COSE_Sign1, so the map is always emitted and is now pinned byte for
byte in AM-VEC-COSE-001.

TWO SPECIFICATION CORRECTIONS

The specification contradicted itself. Section 2.4 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".

RFC 9864 (Standards Track, October 2025) deprecated the polymorphic EdDSA
identifier -8 and registered fully specified ones. Issue agentrust-io#243 recorded the
code points as settled before that was accounted for. ML-DSA-65 is confirmed
correct against IANA at -49; Ed25519 moves to -19, recorded in ADR-0014.
Verifiers keep accepting -8 indefinitely, because manifests are audit records
with regulated retention and a signature cannot be reissued under a new
identifier without resigning.

SECURITY FIXES FOUND WHILE IMPLEMENTING

Three defects were introduced by this work and fixed before it left the
branch. Reusing the shared pipeline skipped the key to issuer authorisation
check, so any trusted key could sign for any issuer, a regression against
what the v0.1 engine enforces. A payload carrying duplicate member names was
accepted, which matters because parsers resolve them differently and two
verifiers could read a different issuer or expires_at out of identical signed
bytes. A deeply nested payload raised RecursionError out of verify_manifest
instead of returning a verdict.

The version gate also bound in only one direction. A manifest declaring
version 0.2 while carrying a v0.1 detached signature block verified VALID,
which would have made the gate advisory and left the phase 5 deprecation with
nothing to enforce. It is now rejected.

PREREQUISITE INCLUDED

ML-DSA-65 now comes from cryptography, which implements it as of 47.0.0 and
is already a required dependency. The pq extra previously 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 belongs to an unrelated project on PyPI, and a successful import
was treated as proof of liboqs, which turned every ML-DSA call into an
AttributeError and made the SDK claim a capability it did not have. The
capability check now identifies liboqs by its API. Deployments already
carrying the bindings keep working, since the backend is chosen by the key
material. Thirteen post quantum tests that skipped on every machine without
liboqs now execute against real FIPS 204 signatures.

This is strictly a prerequisite rather than phase 2 scope, and is included
because _cose.py cannot sign or verify the post quantum profile without it.

CARRIED CHANGE

The verify_bytes methods on Ed25519Verifier, MlDsa65Verifier and
HybridVerifier are carried in this commit rather than left out. They were
already in the working tree for separate TRACE work, and the ML-DSA backend
change rewrites the body of MlDsa65Verifier.verify_bytes, so the two cannot
be separated cleanly. They are additive and used by nothing else here.

VERIFICATION

868 tests pass with 6 skipped, all six being hardware gates. The same suite
passes on both ends of the cbor2 range. mypy strict and bandit are clean and
_cose.py is at 99 percent, the single uncovered line being a defensive
RecursionError branch that cbor2's decoder does not reach.

The COSE_Sign1 and COSE_Sign structures were verified by pycose, an
independent implementation with no relationship to this project, which parses
them, verifies the signatures and rejects both a flipped signature byte and a
modified payload. That check runs against -8 fixtures because no COSE library
implements RFC 9864 or RFC 9964 yet, so the identifiers themselves have no
third party opinion available today. The cost is recorded in ADR-0014 rather
than glossed.

Refs agentrust-io#243

Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential LOW
Overall MEDIUM

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:MEDIUM Contributor check flagged MEDIUM risk label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:MEDIUM Contributor check flagged MEDIUM risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants