Skip to content

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

Merged
imran-siddique merged 6 commits into
agentrust-io:mainfrom
zohebk8s:feat/cose-envelope-v0.2-phase2
Aug 11, 2026
Merged

feat(cose): implement the v0.2 COSE envelope behind the version gate (#243 phase 2)#274
imran-siddique merged 6 commits into
agentrust-io:mainfrom
zohebk8s:feat/cose-envelope-v0.2-phase2

Conversation

@zohebk8s

@zohebk8s zohebk8s commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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
@zohebk8s

zohebk8s commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Resolved the conflict in python/pyproject.toml. It was between #278 lifting the cryptography cap to <51 and this branch adding cbor2 on the adjacent line. Took <51 and kept the cbor2 entry, so the branch carries #278 rather than reverting it.

Verified on a fresh clone of the updated branch:

Check Result
Full suite 805 passed, 6 skipped (the six are hardware gates)
mypy strict clean
cryptography entries in dependencies exactly one, at <51
Conflict markers remaining none

Also reproduced the audit job in a clean venv against this branch, since the resolved version is what pip-audit actually looks at rather than the declared range. pip install -e ".[dev]" resolves cryptography==50.0.0, and after the pip and setuptools upgrade step, the audit reports No known vulnerabilities found. So this branch picks up #278 rather than reverting it.

Resolved through the web editor, so there is a merge commit.

@imran-siddique

Copy link
Copy Markdown
Member

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 this

Both the CI and CodeQL runs for 5cf8dc5 are sitting at action_required — this is a fork PR, so the workflows need approval before they execute. Nothing has been exercised by our own pipeline yet. The only check reporting is the maintainer-review gate.

So the test evidence below is a local run, which is evidence but not a substitute for the pipeline.

Local test run

Checked out 5cf8dc5, installed cbor2 per the new dependency, ran the suite:

805 passed, 6 skipped

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:

test_cose.py, test_cose_endpoint.py, test_ml_dsa_backend.py, test_cli_cose.py
144 passed

Environment: cryptography 49.0.0, cbor2 6.1.4.

Verification path

Read _appraise / _verify_one specifically, since that is where an envelope implementation goes wrong. Four properties that matter are present and correct:

  1. The protected header is read from the bytes as received and never re-encoded. The signature covers those exact bytes, so re-serializing to inspect them is the classic way to introduce a canonicalization bug. This avoids it.
  2. alg in an unprotected header is rejected, on the body header and on every per-signature header. Algorithm confusion through the malleable half is the obvious attack and it is closed.
  3. The crypto profile is checked before any signature is verified. Downgrade is settled ahead of the expensive work rather than after it.
  4. verified is bool(signatures) and all(...). For a hybrid COSE_Sign every entry must verify, so a post-quantum policy cannot be satisfied by the classical entry alone, and an empty signature list is not "verified". AlgorithmUnavailableError propagating rather than falling back is consistent with that.

Two details that suggest care rather than luck: -8 and -19 are collapsed to one Ed25519 family, so two spellings of one algorithm are not counted as two signers; and an empty trusted_keys returns verified=False rather than raising, which keeps UNVERIFIABLE distinct from MISMATCH.

The dependency change is a net improvement

The pq extra previously required pyoqs>=0.10. That distribution is not published on PyPI, and the oqs module name there belongs to an unrelated project — a name-confusion hazard sitting in our own extras. Replacing it with cryptography>=47, which now carries ML-DSA-65, removes that. cbor2 is used for serialization only; every signature stays on cryptography.

Supply-chain review of the diff

  • No changes under .github/workflows/.
  • No subprocess, os.system, eval, exec, network calls, or pickle in any added line.
  • The pyproject.toml change is the two dependency edits above and nothing else.

That is why letting the workflows run is reasonable, and it is a judgement about this diff rather than a general clearance.

Still outstanding

  • The workflow runs need approving so CI and CodeQL have a record. mypy, bandit and pip-audit are in the pipeline and have not run against this.
  • A named-maintainer approval of 5cf8dc5 per require-maintainer-approval.yml. That gate exists so external contributions get human judgement, and this note is deliberately not a substitute for it.

One nit, non-blocking: in the COSE_Sign branch the third element of a signature entry is not type-checked at unpack, only later in _verify_one. Behaviour is correct either way, since it fails closed with a clear CoseStructureError, but the check would read more consistently alongside the two above it.

@imran-siddique

Copy link
Copy Markdown
Member

Addendum, because this is a stronger point for the PR than anything in my note above and I missed it first time.

On main, twelve tests skip like this:

12 skipped: pyoqs not installed

pyoqs cannot be installed. It is not published on PyPI, which is the same fact that makes the extra worth removing. So those twelve post-quantum tests have never executed — not locally, not in CI, not once since the extra was written. They have been reporting as skips, which reads as "environment not configured" rather than "unrunnable by construction".

With this PR's change to pq = ["cryptography>=47"], they run. That is the difference between the two skip counts:

passed skipped
main 647 18 (12 of them pyoqs)
this PR 805 6 (all pre-existing hardware/optional-dep)

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 mypy, bandit and pip-audit see this, and the policy gate still needs a named maintainer's review of 5cf8dc5.

…time

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

zohebk8s commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

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 test_a_cose_sign_entry_with_a_non_bytes_signature_is_rejected, but the error now names which signature entry is malformed and all three elements are validated in one place.

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 action_required, so they need a maintainer to release the workflow runs when you get a chance.

imran-siddique and others added 2 commits August 10, 2026 21:13
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>
@imran-siddique
imran-siddique merged commit d3ff209 into agentrust-io:main Aug 11, 2026
1 check failed
imran-siddique added a commit that referenced this pull request Aug 11, 2026
…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>
imran-siddique added a commit that referenced this pull request Aug 11, 2026
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>
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.

3 participants