feat(cose): implement the v0.2 COSE envelope behind the version gate (#243 phase 2) - #274
Conversation
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>
|
🟡 Contributor Check: MEDIUM
Automated check by AgenTrust Contributor Check. |
|
Resolved the conflict in Verified on a fresh clone of the updated branch:
Also reproduced the audit job in a clean venv against this branch, since the resolved version is what Resolved through the web editor, so there is a merge commit. |
|
Maintainer note, not an approval. The policy gate needs a named maintainer's review of the current head and this comment does not clear it. Recording what was checked so that review rests on something visible. CI has not run on thisBoth the CI and CodeQL runs for So the test evidence below is a local run, which is evidence but not a substitute for the pipeline. Local test runChecked out All six skips are pre-existing and unrelated to this change (AGT core absent, SNP/TDX/TPM hardware, a real TDX quote). Narrowing to the code this PR adds: Environment: Verification pathRead
Two details that suggest care rather than luck: The dependency change is a net improvementThe Supply-chain review of the diff
That is why letting the workflows run is reasonable, and it is a judgement about this diff rather than a general clearance. Still outstanding
One nit, non-blocking: in the |
|
Addendum, because this is a stronger point for the PR than anything in my note above and I missed it first time. On
With this PR's change to
So the dependency change is not only removing a name-confusion hazard from our extras. It converts twelve permanently-dead post-quantum tests into tests that actually exercise the ML-DSA-65 path. Nothing here changes what is still outstanding: the workflow runs need approving so |
…time Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
|
Good catch; fixed in 2a2e7ed. The signature element is now type-checked at unpack time alongside the protected and unprotected headers, rather than later inside the verification helper. Behaviour is unchanged, since the check existed further down and is covered by Suite still 805 passed, 6 skipped. mypy strict clean. One correction while I'm here: the CHANGELOG entry in this PR says "thirteen" post-quantum tests, which counts one from separate TRACE work that is not in this branch. Twelve is right, as you measured. Happy to fix it if I am pushing again for another reason; otherwise it did not seem worth re-arming the gate for a word. CI and CodeQL are sitting at |
CI has never executed on this PR: it is a fork PR, so the CI and CodeQL runs sat at action_required from the day it opened. Running the gates locally, exactly as ci.yml invokes them, finds four ruff errors in this branch's own new files. F401 _cose.py AlgorithmUnavailableError unused F541 tests/interop/verify_with_pycose.py f-string with no placeholders F401 tests/test_cose.py HDR_RECEIPTS unused F401 tests/test_cose_endpoint.py OverallResult unused All four are ruff --fix output, nothing hand-edited. AlgorithmUnavailableError was checked for re-export before removal: nothing imports it from _cose, and the module that raises it is _signing, which is where verify_cose_manifest's docstring already points. Applied by a maintainer on the contributor's branch so main does not take a red Lint. The rest of the branch is untouched; mypy, bandit and the suite (805 passed, 6 skipped) were already clean and still are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
…290) main has been red since #274 merged. Two deeply-nested-payload tests fail on ubuntu 3.12 and 3.13 while passing on 3.11 and on Windows, and the cause is not a flaky test: the DOS-006 guard did not exist on Linux. _parse_payload relied on json.loads raising RecursionError to refuse a deeply nested payload. CPython on Linux parses thousands of levels without raising, so the 5000-level payload simply parsed, _check_version then raised CoseVersionError("unsupported manifest version None"), and the test expecting CoseStructureError failed. Windows tripped its own recursion limit and therefore looked protected. A control whose behaviour depends on which platform it runs on is not a control. Replaced with an explicit bound: _MAX_PAYLOAD_NESTING = 64, checked by a string-aware scan of the decoded text before json.loads rather than after. Before, because refusing the work after paying for it defeats the purpose; string-aware, because a brace inside a member value must not inflate the count and make a legitimate manifest look like an attack. The RecursionError catch stays as a second line. The new test proves the bound rather than the accident: it refuses a 65-level payload, which is far below any interpreter's recursion limit and therefore cannot be failing for the old reason. 64 is generous - the deepest path this spec defines is about six levels. Found while cutting python-v0.11.0: the release PR's CI failed, and the failures predated it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cuts a release so cMCP can consume intent_hash from a published wheel rather than duplicating the derivation. cmcp installs agent-manifest from PyPI in CI, and 0.10.1 does not carry it, so the cMCP half of the AARM work is blocked until this ships. Contents since 0.10.x: the v0.2 COSE envelope behind the version gate (#274), the Agent Plugins boundary and bundle adapter (#286, #287), the informative OCSF crosswalk (#285), and declared intent (#288). Two versioning problems found while doing it. The repo said 0.10.0 while PyPI already had 0.10.1, and the CHANGELOG had no 0.10.1 entry either, so that release went out without a bump. The publish workflow does not validate the tag against [project] version, so tagging python-v0.11.0 with pyproject left at 0.10.0 would have built and tried to upload 0.10.0 again. Bumping is therefore mandatory, not cosmetic. Removed the [tool.hatch.version] block. It pointed at a __version__ in __init__.py that does not exist and never has; hatchling ignores it because version is static in [project], so it is inert today and a trap for whoever adds dynamic = ["version"] later. Verified by building the wheel and installing it into a clean venv: reports 0.11.0, exports intent_hash and DeclaredIntent, and carries intent in SIGNED_FIELDS. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
versionfield, 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
signature.algorithm, outside the signaturealgin the protected header, covered by itsha256of the payload bytesCOSE_Sign, shared payload by structureRFC 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_fieldscoverage table, thehitl_record.approvalsnormalisation rule, and thetransparency_log_entryordering rule.Phase 2 open items
AM-VEC-COSE-001,unprotected_hex: "a0"The library review is recorded with evidence rather than impressions. Neither
pycosenorcwtships the RFC 9964 code points, and both widen the dependency closure to do less. Two findings came out of it.cbor26.x is a Rust extension with no pure Python fallback, and under the>=5.6,<7pinmanylinux_2_17resolves 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.2manifests 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-8indefinitely, 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.
issuerorexpires_atout of identical signed bytes and both consider the signature valid.NaNandInfinitywere the same class. Both rejected at parse time, which is a parse level check rather than a re canonicalisation.RecursionErrorout ofverify_manifestinstead 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 verifiedVALID, which would have made the gate advisory and left the phase 5 deprecation with nothing to enforce. 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
pqextra previously requiredpyoqs, 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 anAttributeErrorand 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.pycannot sign or verify the post quantum profile without it. Happy to split it out if reviewers prefer.HTTP and CLI
POST /verify/cosetakes the COSE object as raw CBOR underapplication/agent-manifest+cose.application/cbor, and an absent type are refused with 415, and the body is never sniffed.UNVERIFIABLE, neverVALID.Content-Lengthchecked when present and the stream capped regardless.manifest signselects the envelope from the manifest version.manifest verifydetects it from the CBOR tag rather than the file extension.Verification
Run against a fresh clone of this branch, not a working tree.
cbor2floor (5.6.0)_cose.pycoverageAM-VEC-COSE-001regenerated from sourcepycose)VALIDThe single uncovered line in
_cose.pyis a defensiveRecursionErrorbranch that the decoder does not reach.The
COSE_Sign1andCOSE_Signstructures were verified bypycose, 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-8fixtures 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_bytesmethods onEd25519Verifier,MlDsa65VerifierandHybridVerifierare 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 ofMlDsa65Verifier.verify_bytes, so the two could not be separated cleanly. They are additive and nothing else in this PR uses them.Not included
Manifest.versionstill 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