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


Verifying an attestation

verify_attestation() requires an AttestationContext describing the request the token is being presented against. A cryptographically valid signature is not enough — the token must also be bound to the current sender, receiver, payload, and (optionally) session. Detached attestations replayed against a different message are rejected.
Breaking change in July 2026. The single-argument form verify_attestation(token) has been removed. All callers must now pass an AttestationContext. See the migration snippet below.

The AttestationContext dataclass

The verifier hashes payload internally with the same deterministic method as sign_verdict() (json.dumps(..., sort_keys=True, default=str)), so callers never handle raw hashes.

Verification steps

verify_attestation() runs these checks in order — the first failure short-circuits and returns a structured error:
  1. Cryptographic signature (ES256 / ECDSA P-256).
  2. Expiry (exp claim).
  3. Required claims present (iss, sub, iat, exp, jti).
  4. Structural validation of the qwed_a2a claim block.
  5. Deployment context — the deployment_id claim matches this instance.
  6. Context binding — sender, receiver, payload hash, and (if provided) session_id all match the AttestationContext.
  7. jti replay check — rejects previously seen trace IDs.
The replay check deliberately runs last, after context binding. This prevents an out-of-context token — for example one replayed against the wrong receiver — from polluting the jti registry and locking out a legitimate future presentation of the same token in its correct context.

Call signature

is_valid=True guarantees the token is cryptographically sound and bound to the supplied context — not just that its signature is valid.

Verification outcomes

Migrating to context-bound verification

If you’re upgrading from the pre-context release, update every verify_attestation() call site to construct an AttestationContext from the same fields you signed with:
The signing side (sign_verdict()) is unchanged.

Tamper detection

The sub claim contains a SHA-256 hash of the original payload:
verify_attestation() performs this hash comparison internally as part of context binding — as long as you pass the current payload in the AttestationContext, a modified payload will be rejected with "Attestation payload hash mismatch — detached attestation rejected". You only need to hash content directly when you want the digest for logging or out-of-band comparison.

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

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.