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_idor 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
VALIDresult 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.
Related¶
- Key rotation: add a new key to
trusted_key_issuerstogether withtrusted_keys, or manifests signed with the new key fail authorization. - Server-side verification: where the verifier's trusted inputs come from.