Skip to content

Issuer key authorization

This page is for anyone who verifies manifests and configured trusted keys before spec §5.3.3 existed. It explains how to add the second check that §5.3.3 requires: a trusted key may sign only for the issuers (the organizations that sign manifests) it is authorized for. If you use the SDK verifier, this is a configuration change. If you maintain your own verifier, it is a code change, and the last part of this page separates what §5.3.3 requires from how the SDK implements it.

Applies to SDK 0.16.0. Spec reference: §5.3.3 Issuer Key Authorization.

Breaking change in SDK 0.16.0

Up to SDK 0.15.0, a verifier that set trusted_keys without trusted_key_issuers skipped issuer authorization and could return VALID. From 0.16.0 the same configuration returns UNVERIFIABLE. If you upgrade, add trusted_key_issuers first.

Why a trusted key is not enough

Your verifier already holds trusted_keys, a mapping from a key ID to a public key. That mapping answers one question: which key made this signature? It does not answer a second one: is that key allowed to sign for the issuer this manifest names?

Without the second check, any key you trust can sign a manifest that claims any issuer. If you trust keys from two organizations, either one can produce manifests in the other's name, and every signature still verifies.

§5.3.3 therefore separates the two checks:

Check Configured in Answers
Key identification trusted_keys Which key made the signature?
Issuer authorization trusted_key_issuers May that key sign for the manifest's issuer?

The key ID is the SHA-256 hash of the raw public key bytes, hex encoded. It identifies a key. It does not authorize one.

The authorization must come from configuration you control. It must never be derived from the manifest itself, and in particular not from agent_id or any other identifier the signed subject controls. A manifest cannot vouch for its own signer.

If you use the SDK verifier

Nothing in trusted_keys changes. Add trusted_key_issuers next to it, mapping each trusted key ID to the issuer SPIFFE URIs that key may sign for:

from agent_manifest import VerificationContext, verify_manifest

context = VerificationContext(
    # unchanged: key ID -> base64url-encoded public key
    trusted_keys={issuer_key_id: issuer_public_key_b64url},
    # new: key ID -> issuers this key may sign for
    trusted_key_issuers={issuer_key_id: ["spiffe://trust.example/issuer/prod"]},
    # ... your observed artifact hashes, as before
)

result = verify_manifest(manifest_or_cose_bytes, context, revocation_store)

From the CLI, name the issuer next to the public key:

manifest verify signed.json --public-key pub.hex \
  --authorized-issuer spiffe://trust.example/issuer/prod

trusted_key_issuers is an SDK input. It is not a manifest field, and the normative POST /verify request body in §5.1 does not define it. The SDK's FastAPI router (create_router()) accepts it as an SDK-level extension on its own POST /verify request body. Treat it there like trusted_keys: a value a caller sends you is not your authorization policy.

Choose a setting

trusted_key_issuers has three meaningfully different values:

Value Meaning Effect
None (the default) No authorization policy, so authorization cannot be determined. The signature is not verified and the result is UNVERIFIABLE, with a warning naming §5.3.3. Before 0.16.0 the check was skipped.
{} Configured, and no key is authorized for any issuer. Every signed manifest fails the check. Fail-closed.
{key_id: [issuers]} Configured with explicit authorizations. A manifest passes only if its signing key is listed for its issuer.

None and {} are not the same, and §5.3.3 requires the difference. An empty mapping is a policy that authorizes nothing, so an authorization failure: MISMATCH. None is no policy, so authorization that cannot be determined: UNVERIFIABLE. Neither reaches VALID.

A deployment that set trusted_keys alone before 0.16.0 now gets UNVERIFIABLE until it supplies a policy. §5.3.3 requires the check, not this data structure: the authorization path is deployment policy, and other verifiers may hold it in any form that is external to the manifest.

Gate on the result, not on signature_verified

Let a request through only when result.result is OverallResult.VALID. When the authorization check runs and fails, the result is MISMATCH. Checks that run earlier can end verification first: schema validation, revocation, the validity window, and on v0.2 envelope appraisal. The result is then REVOKED, EXPIRED or another non-VALID value, and the authorization check does not run.

In the result of verify_manifest(), signature_verified is true only when authorization passed and the signature checked out. It still does not mean VALID: other checks can produce MISMATCH, as AM-VEC-002 shows for a changed system prompt hash.

Envelope Unauthorized key result.result signature_verified
v0.1 detached signature the signature is not checked MISMATCH false
v0.2 COSE the signature is not checked MISMATCH false
either, trusted_key_issuers=None the signature is not checked UNVERIFIABLE false

Conformance vector AM-VEC-COSE-009 is the second case: a valid signature from a trusted key that is not authorized for the manifest's issuer, refused before the signature is checked.

For v0.2, pass the COSE bytes to verify_manifest(). Calling verify_cose_manifest() on its own appraises the envelope and checks the signature, but it does not run issuer authorization.

Read the mismatch details

Each authorization failure appears in result.mismatch_details with field="signature.issuer". There are no separate result codes such as KEY_NOT_AUTHORIZED; the outcome is a MISMATCH like any other.

Situation expected_hash actual_hash
The manifest has no issuer <manifest issuer authorized for signature key> <missing manifest issuer>
The key has no entry, or an empty list, in trusted_key_issuers <key_id=… authorized for issuer='…'> <key_id has no issuer authorization>
The key is authorized, but not for this issuer <issuer in trusted_key_issuers['…']> <issuer='…'>

A key ID that is not in trusted_keys at all fails earlier, with field="signature" and <key_id not found in trusted_keys>. That is a key resolution failure, not an authorization failure, and issuer authorization does not run.

A missing policy produces no mismatch detail. It appears as UNVERIFIABLE with a warning in result.warnings that names §5.3.3.

Since SDK 0.13.0, a v0.2 manifest without issuer fails schema validation with MISMATCH before this check runs. A v0.1 manifest without issuer still passes schema validation, but it can never reach VALID. With a policy it yields the "no issuer" row above; without one, UNVERIFIABLE.

If you maintain your own verifier

§5.3.3 requires every conformant verifier to:

  • take issuer authorization from a trust path external to the manifest, and never from agent_id or another identifier the subject controls
  • confirm that the signing key is authorized for the manifest's declared issuer
  • complete key resolution and issuer authorization before signature verification, in both envelopes
  • return no VALID result when authorization fails or cannot be determined, and keep those two conditions distinct

How you store the policy and how you report failures is up to your implementation. The blocks below show the current SDK implementation, _signature_key_issuer_mismatch() in python/src/agent_manifest/_verify.py, as a reference. Since 0.16.0 it follows the §5.3.3 order and withholds VALID when no policy is supplied.

SDK reference: the authorization check
def _signature_key_issuer_mismatch(
    manifest: dict[str, Any],
    key_id: str,
    trusted_key_issuers: Optional[dict[str, list[str]]],
) -> Optional[MismatchDetail]:
    if trusted_key_issuers is None:
        return None

    issuer = manifest.get("issuer")
    if not isinstance(issuer, str) or not issuer:
        return MismatchDetail(
            field="signature.issuer",
            expected_hash="<manifest issuer authorized for signature key>",
            actual_hash="<missing manifest issuer>",
        )

    allowed_issuers = trusted_key_issuers.get(key_id)
    if not allowed_issuers:
        return MismatchDetail(
            field="signature.issuer",
            expected_hash=f"<key_id={key_id} authorized for issuer={issuer!r}>",
            actual_hash="<key_id has no issuer authorization>",
        )

    if issuer not in allowed_issuers:
        return MismatchDetail(
            field="signature.issuer",
            expected_hash=f"<issuer in trusted_key_issuers[{key_id!r}]>",
            actual_hash=f"<issuer={issuer!r}>",
        )

    return None

The only manifest inputs are the signing key_id and manifest["issuer"]. The authorization itself comes entirely from trusted_key_issuers. The function returns None for a None policy; its callers treat that case as authorization undetermined and leave the signature unverified. The explicit is None test keeps it apart from an empty mapping, which authorizes nothing; a truthiness test would merge the two.

SDK reference: where the check runs in each envelope

v0.1 detached signature. Authorization gates cryptographic verification. The verifier resolves the key through trusted_keys, runs the authorization check, and verifies the signature only if authorization passes:

resolve key_id in trusted_keys ── not found ──> MISMATCH on "signature"
        │
        v
no trusted_key_issuers ──> UNVERIFIABLE, signature not verified
        │
        v
authorization check ── fails ──> MISMATCH on "signature.issuer",
        │                        signature not verified
        v
verify signature

v0.2 COSE envelope. The same order since 0.16.0. The envelope is decoded without checking the signature, each kid is resolved against trusted_keys and authorized for the manifest's issuer, and only then is the signature checked. In a multi-signature envelope every signer must be authorized.

The outcome is the same in both envelopes: no VALID without authorization, and signature_verified is false whenever authorization fails or cannot be determined.

Test your configuration

The conformance vectors cover each case:

Vector Case Expected
AM-VEC-022 Trusted key authorized for a different issuer MISMATCH
AM-VEC-023 Key authorized for the subject (agent_id), not the issuer MISMATCH
AM-VEC-024 Empty trusted_key_issuers mapping MISMATCH
AM-VEC-025 v0.1: no trusted_key_issuers policy UNVERIFIABLE
AM-VEC-COSE-009 v0.2: trusted key, valid signature, wrong issuer MISMATCH, signature_verified: false
AM-VEC-COSE-016 v0.2: no trusted_key_issuers policy UNVERIFIABLE

Vectors are in python/tests/vectors/; their README describes how to run them against your own verifier.

  • Key rotation: add a new key to trusted_key_issuers together with trusted_keys, or manifests signed with the new key fail authorization.
  • Server-side verification: where the verifier's trusted inputs come from.