Skip to main content

Overview

Every verification verdict is signed with an ES256 JWT attestation — a cryptographic proof that the verification took place. This enables:
  • Tamper detection — any modification invalidates the signature
  • Non-repudiation — the signing service is identified by DID
  • Audit compliance — attestations are stored and queryable
  • Cross-service verification — any QWED node can verify the token

How it works


JWT structure

The kid is derived from a SHA-256 fingerprint of the public key, so it stays stable as long as QWED_A2A_SIGNING_KEY_PEM is unchanged — even across process restarts.

Payload


Configure the signing key

QWED A2A requires a persistent ECDSA P-256 signing key so that JWT attestations signed before a restart can still be verified afterwards. Keys are never generated inside the process — the service loads them from the QWED_A2A_SIGNING_KEY_PEM environment variable (or the pem_key constructor argument).
If QWED_A2A_SIGNING_KEY_PEM is not set, A2ACryptoService fails closed: calls to sign_verdict, verify_attestation, get_public_key_jwk, and the interceptor’s intercept() raise RuntimeError. This is deliberate — signing with a per-process ephemeral key would silently break audit continuity.
Generate an unencrypted PKCS#8 P-256 key with openssl:
Store the PEM in your secrets manager (AWS Secrets Manager, Vault, etc.) and inject it as an environment variable at runtime. Every replica in the same logical deployment must load the same PEM so all instances share one key_id and can verify each other’s tokens.

Signing a verdict

Each trace_id becomes the token’s jti and must be unique per instance: signing the same trace_id again within its validity window raises ValueError. See Replay protection.

Verifying an attestation

verify_attestation checks the token against an AttestationContext — the sender, receiver, and payload you expect the attestation to cover. A valid signature alone is not enough: the token must also be bound to the provided context.
Verification tries every configured key until one verifies the signature: the local key first (when configured), then each trusted peer issuer’s keys. The token’s header and payload are only read after a candidate key verifies the signature, and the verified iss and kid are bound to the key that verified them — routing never trusts unverified claims.

Verification outcomes


Tamper detection

The sub claim contains a SHA-256 hash of the original payload:
If the original payload is modified after signing, the hash will not match the sub claim — even though the JWT signature itself is still valid. Always verify both the JWT signature and the payload hash.

Publishing the public key (JWKS)

External consumers verify attestations by fetching the public key. A2ACryptoService.get_public_key_jwk() returns the current key in JWK format:
The FastAPI gateway exposes this at GET /.well-known/jwks.json so downstream services and auditors can fetch keys over HTTP.

Cross-service verification

Because every replica loads the same PEM from QWED_A2A_SIGNING_KEY_PEM, they all derive the same key_id and can verify each other’s tokens:
Sharing a PEM is only for replicas of one logical deployment. To verify tokens from a different deployment, use trusted issuers instead of sharing private keys.
If you rotate QWED_A2A_SIGNING_KEY_PEM, attestations signed with the previous key become unverifiable once the new JWKS is served — the endpoint only publishes the current key. To avoid rejecting in-flight attestations during rotation, keep the previous key accessible until all tokens signed with it expire (check their exp claim). Future releases may add multi-key JWKS support for seamless rotation.

Cross-deployment verification (trusted issuers)

By default an attestation verifies only against the issuing deployment’s own key. A token from another deployment fails closed as an unknown issuer, so sharing one private key across deployments — which would let any agent mint attestations as any other — is never required and never works. To verify attestations from a peer deployment, register the peer as a trusted issuer with its deployment ID and public JWKS. Copy the JWKS entry verbatim from the peer’s /.well-known/jwks.json endpoint:
Replace the kid/x/y placeholders with the peer’s real values. The environment variable is re-read on every verification, so key rotation takes effect without a restart. You can also pass the mapping directly to verify_attestation — the explicit argument takes precedence over the environment variable:
Trusted-issuer verification fails closed on every mismatch:

Verifier-only nodes

A node that only verifies peer attestations doesn’t need a signing key of its own. With QWED_A2A_SIGNING_KEY_PEM unset and trusted issuers configured, verify_attestation verifies peer tokens normally — only sign_verdict, get_public_key_jwk, and the interceptor still require the local key. With neither a local key nor trusted issuers, verification returns No verification keys available (no local key, no trusted issuers).

Replay protection (jti lifecycle)

Each attestation’s jti (the trace_id) can be consumed once per verifying instance. A2ACryptoService keeps two separate records:
  • Issuance recordtrace_id values this instance has signed. Signing a duplicate trace_id within its validity window raises ValueError at sign_verdict: each attestation needs a unique jti, and minting two tokens under one jti would poison every consumer’s replay registry.
  • Consumption registryjti values this instance has verified. The first verification of a token succeeds; presenting the same token again returns Replay detected: jti already seen.
Because issuance and consumption are tracked separately, an issuer can verify its own token once — signing no longer consumes the replay slot. Peer-issued tokens are namespaced by issuer in the consumption registry, so two deployments legitimately reusing the same trace ID never shadow each other.

Replay scope and multi-worker deployments

The default consumption registry is process-local. Two service instances in one process each keep their own registry; two workers in two processes share nothing. A token replayed against a different worker is not detected by the default registry.
For cross-worker replay protection, inject a shared registry via the jti_registry constructor argument:
An injected registry must satisfy the ReplayRegistry contract:
  • check_and_register(jti, now=..., *, valid_until=...) with atomic insert-if-absent semantics — two workers racing on the same jti must not both be accepted.
  • Retention covering each token’s full lifetime: honor valid_until and never evict a live token’s slot.
  • Thread safety for in-process concurrent use.
  • A ttl_seconds property reporting the retention window.
The constructor fails closed on unverifiable retention: a registry whose ttl_seconds is missing, non-numeric, non-finite, or shorter than validity_seconds raises ValueError at construction.

Fail-closed behavior

QWED A2A treats missing or invalid signing keys as an unrecoverable configuration error, not a soft warning. There is no ephemeral-key fallback. The FastAPI gateway surfaces these as HTTP 503 Signing key unavailable on both /a2a/intercept and /.well-known/jwks.json so orchestrators can detect a misconfigured deployment before it accepts traffic.