COSE envelope (manifest version 0.2)¶
The signature envelope for manifest version 0.2. Version 0.1 manifests keep verifying through the signing API exactly as before: the envelope follows the manifest version field, never a flag.
Normative reference: spec/agent-manifest-cose-envelope-v0.2.md. Decisions: ADR-0011 (why COSE), ADR-0013 (why no COSE library), ADR-0014 (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¶
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.
sign_manifest_cose ¶
sign_manifest_cose(manifest_dict: dict[str, Any], keypair: Union[Ed25519KeyPair, MlDsa65KeyPair, HybridKeyPair]) -> bytes
Sign manifest_dict with whichever envelope keypair calls for.
sign_cose_sign1 ¶
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).
sign_cose_sign_hybrid ¶
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.
cose_payload ¶
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_hash ¶
Return sha256:<hex> 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.
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.
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)
attach_receipt ¶
Append a SCITT receipt (RFC 9942) to the receipts array (label 394).
attach_attestation ¶
Attach the hardware attestation block produced after signing.
attach_approvals ¶
Attach HITL approval records, each authenticated by its own signature.
attach_unprotected ¶
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.
Verification¶
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:
verify_cose_manifest ¶
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:
| Type | Description |
|---|---|
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 |
|
CoseKeyError | A |
InvalidSignature | A signature did not verify. |
AlgorithmUnavailableError | This build cannot perform the algorithm. |
CoseVerification dataclass ¶
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).
CoseSignature dataclass ¶
One signature entry: its algorithm, its key, and whether it verified.
decode_cose_manifest ¶
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.
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.
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¶
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 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.