Skip to content

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_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.

cose_payload

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_hash

payload_hash(payload: bytes) -> str

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

attach_receipt(cose_bytes: bytes, receipt: bytes) -> bytes

Append a SCITT receipt (RFC 9942) to the receipts array (label 394).

attach_attestation

attach_attestation(cose_bytes: bytes, attestation: dict[str, Any]) -> bytes

Attach the hardware attestation block produced after signing.

attach_approvals

attach_approvals(cose_bytes: bytes, approvals: list[dict[str, Any]]) -> bytes

Attach HITL approval records, each authenticated by its own signature.

attach_unprotected

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.

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_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:

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

crypto_profile requires more than alg gives.

CoseKeyError

A kid is absent from trusted_keys.

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).

verified property

verified: bool

True when every signature entry verified against a trusted key.

CoseSignature dataclass

One signature entry: its algorithm, its key, and whether it verified.

decode_cose_manifest

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.

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

POST /verify/cose
Content-Type: application/agent-manifest+cose

<raw CBOR>

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.