> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qwedai.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Cryptographic attestations

> How a QWED server signs verified answers as ES256 JWT attestations, and how you verify them against its JWKS with the Python, TypeScript, Go and Rust SDKs.

## What are attestations?

An **attestation** is a token the QWED server signs to show it reached a verdict. It:

* Uses **ES256 (ECDSA P-256)** signatures
* Is formatted as a **JWT**, with header `typ` `qwed-attestation+jwt`
* Can be verified by anyone who holds the server's public keys, published at [`GET /.well-known/jwks.json`](https://docs.qwedai.tech/api/endpoints#attestation-endpoints)

A token covers only what it signs: the verdict's status, the answer's `proof_ref`, the statement's hash and, on a route that decides one, the admission. It doesn't cover other fields of the response, such as `agent_message`.

## Getting an attestation

Every `VERIFIED` answer carries `attestation`. You don't need to ask for it:

```python theme={null}
result = client.verify_math("2+2=4")

print(result.status)       # "VERIFIED"
print(result.proof_ref)    # "sha256:…", the evidence commitment the token binds
print(result.attestation)  # the signed JWT
print(result.admission)    # "ADMIT" or "BLOCKED", on routes that decide one
```

The maths, code, SQL, and natural-language routes, the agent route, and their batch items decide an admission, and their tokens sign it as `qwed.admission`. Routes that decide none (logic, fact, stats, image, RAG, process, consensus) sign none.

A [Verification Context](https://docs.qwedai.tech/specs/verification-context.md#version-1-1-and-the-issuer-attestation) document carries its own attestation, of type `qwed-context+jwt`, which signs every other field of the document. Verify it with `verify_document_attestation()`, described on the [Python SDK](https://docs.qwedai.tech/sdks/python.md#verification-context) page.

## Verifying attestations

### With the client

The client fetches the server's JWKS, caches it for five minutes, and checks the token against the answer:

```python theme={null}
from qwed_sdk import AttestationError

def admitted(result, query):
    """True only for an answer whose attestation verifies and that is admitted."""
    if not result.attestation:
        return False  # not VERIFIED, so nothing to act on
    try:
        client.verify_attestation(result, query=query)
    except AttestationError:
        return False  # fail closed: an answer that doesn't verify is not trusted
    return result.admission == "ADMIT"

result = client.verify_math("2+2=4")
if not admitted(result, "2+2=4"):
    raise RuntimeError("not a verified, admitted answer")
```

Treat a missing attestation, or one that doesn't verify, as a refusal. Never act on the unchecked result.

`verify_attestation()` returns the verified claims, or raises `AttestationError`. It checks:

* The signature, with an ES256 key in the JWKS, and that the verified header's `kid` names that key.
* The issuer, and that the token has not expired (nor is used before any `nbf`).
* That it attests a `VERIFIED` result.
* That its `qwed.proof_hash` is the answer's `proof_ref`, so it's this answer's token.
* When you pass `query`, that its `qwed.query_hash` is the SHA-256 of that statement. For a natural-language answer, pass the translated expression the answer reports (`translated_expression` in its developer fields), not your question: the verdict covers that expression.
* On a route that decides an admission, that the token signs the answer's `admission`.

If no cached key verifies the token, the client fetches the keys once more, at most once every 30 seconds, to pick up a rotated key. If it can't fetch them, it raises `KeysUnavailableError`, a subclass of `AttestationError`. `KeyMismatchError` means no key in the JWKS verifies the token.

The async client has the same method: `await client.verify_attestation(result, query=...)`.

### Offline, or against keys you pinned

```python theme={null}
from qwed_sdk import verify_attestation

claims = verify_attestation(
    result.attestation,
    jwks,                      # the JWKS you hold
    proof_ref=result.proof_ref,
    query="2+2=4",             # optional
    admission=result.admission,  # optional; None checks that the token signs none
)
```

Fetching the JWKS from the server you asked trusts that server's TLS. Pass a JWKS you pinned to verify against a known key.

### Other SDKs

The TypeScript, Go, and Rust SDKs make the same checks, with the same key caching:

| SDK | With the client | Offline, with a JWKS you hold |
| - | - | - |
| TypeScript | `await client.verifyAttestation(result, query)` | `verifyAttestation(token, jwks, { proofRef, query, admission })` |
| Go | `client.VerifyAttestation(ctx, result, qwed.AttestationOptions{Query: ...})` | `qwed.VerifyAttestation(token, &jwks, proofRef, opts)` |
| Rust | `client.verify_attestation(&result, &AttestationOptions::new().query(...)).await` | `qwed::verify_attestation(token, &jwks, proof_ref, &options)` |

Each SDK's README has the details: [TypeScript](https://github.com/QWED-AI/qwed-verification/blob/main/sdk-ts/README.md#attestations), [Go](https://github.com/QWED-AI/qwed-verification/blob/main/sdk-go/README.md), [Rust](https://github.com/QWED-AI/qwed-verification/blob/main/sdk-rust/README.md).

> [!NOTE]
>
> A token says the server reached the verdict and, where it signs one, the admission. Act on the admission: a verified `BLOCKED` is still `BLOCKED`.

## Signing keys

The server signs with one ES256 key.

* **Configured key.** Set `QWED_SIGNING_KEY` (PEM text) or `QWED_SIGNING_KEY_FILE` (a path to a PEM file). The key survives restarts and every replica shares it. Its `kid` is `<issuer>#<RFC 7638 thumbprint>`, the same on every restart and replica. A key that can't be loaded stops the server at startup.
* **Generated key.** Without either variable, the server generates a key per process and logs `attestation.ephemeral_key` at startup. Its tokens stop verifying after a restart, and on another replica.

Tokens last `QWED_ATTESTATION_TTL_SECONDS` (24 hours by default, from 60 seconds to 30 days). Rotating the key stops old tokens from verifying against the server's new key. Callers that still hold the old public key, cached for up to five minutes or pinned, can verify those tokens until they expire, so rotation is not an immediate revocation. Revocation is held per process and is not shared, so a token's lifetime is what bounds its use.

`AttestationService.get_issuer_info()` reports the key's lifecycle: `key_continuity_policy` (`"ephemeral"` or `"configured"`), `key_generated_at` (`null` for a configured key), and `key_loaded_at`. QWED logs `attestation.key_generated` on every new key generation.

## Attestation structure

### Header

```json theme={null}
{
  "alg": "ES256",
  "typ": "qwed-attestation+jwt",
  "kid": "did:qwed:node:local#Nm8rQ1x…"
}
```

### Payload

```json theme={null}
{
  "iss": "did:qwed:node:local",
  "sub": "sha256:3f2a…",
  "iat": 1761465600,
  "exp": 1761552000,
  "jti": "att_9c1d2e3f4a5b",
  "qwed": {
    "version": "1.0",
    "result": {
      "status": "VERIFIED",
      "verified": true,
      "engine": "math",
      "confidence": 1.0
    },
    "query_hash": "sha256:3f2a…",
    "proof_hash": "sha256:9b7c…",
    "admission": "ADMIT"
  }
}
```

`qwed.admission` is present only on routes that decide an admission. It is `ADMIT` or `BLOCKED`, and the server signs `ADMIT` only for a `VERIFIED` result that is verified and bound to its proof.

## Issuing attestations

This section is for code that runs QWED's engines in-process and signs its own results.

### Fail-closed contract

> [!WARNING]
>
> `create_verification_attestation()` **never returns `None`**. It always returns an `AttestationResult` with `status` set to `ISSUED`, `BLOCKED`, or `UNVERIFIABLE`. Check `result.is_issued` before you treat the attestation as valid. A missing or failed attestation must hard-block the verification path. Never downgrade it to `VERIFIED`.

#### `AttestationResult`

**status** `AttestationStatus` *required*

Lifecycle state of the attestation. One of `ISSUED`, `BLOCKED`, or `UNVERIFIABLE`.

**token** `string | None`

Signed JWT string. Present only when `status == ISSUED`; `None` otherwise.

**error\_code** `string | None`

Machine-readable failure code, or `None` on success. See the table below.

**error** `string | None`

Human-readable failure detail. `None` on success.

**is\_issued** `bool`

Property. `True` only when `status is AttestationStatus.ISSUED`. You must check this flag before you use `token`.

#### `AttestationStatus` values

| Status | Meaning | `token` | `error_code` |
| - | - | - | - |
| `ISSUED` | QWED signed the attestation. | JWT string | `None` |
| `BLOCKED` | Crypto is available but signing failed (key error, JWT error). Hard block. | `None` | `SIGNING_FAILURE` |
| `BLOCKED` | `verified=True` without a `proof_data` artifact or a `proof_hash`. The crypto layer refuses to sign. | `None` | `VERIFIED_WITHOUT_PROOF` |
| `BLOCKED` | `proof_hash` is not `sha256:` followed by 64 hex characters. | `None` | `INVALID_PROOF_HASH` |
| `BLOCKED` | `admission` is not `ADMIT` or `BLOCKED`, or is `ADMIT` for a result that is not `VERIFIED`, verified, and proven. | `None` | `INVALID_ADMISSION` |
| `UNVERIFIABLE` | `cryptography` / `PyJWT` not installed. QWED did not attempt signing. | `None` | `CRYPTO_UNAVAILABLE` |

#### Caller pattern

```python theme={null}
from qwed_new.core.attestation import create_verification_attestation

result = create_verification_attestation(
    status="VERIFIED",
    verified=True,
    engine="math",
    query="2+2=4",
    proof_hash=diagnostic.proof_ref,  # or proof_data=json.dumps(evidence, sort_keys=True)
    admission="ADMIT",                # optional: the admission you decided
)

if not result.is_issued:
    # Fail-closed: do not proceed as VERIFIED without a signed attestation
    raise RuntimeError(f"Attestation unavailable [{result.error_code}]: {result.error}")

use(result.token)
```

### Issuance-side proof enforcement

`create_verification_attestation()` won't sign a `VERIFIED` verdict that has no proof. If you pass `verified=True` with neither `proof_data` nor `proof_hash`, it returns:

```python theme={null}
AttestationResult(
    status=AttestationStatus.BLOCKED,
    token=None,
    error_code="VERIFIED_WITHOUT_PROOF",
    error="VERIFIED status requires proof_data — cannot sign attestation without proof artifact",
)
```

A caller can't obtain a signed attestation for a claim it has no proof of. `UNVERIFIABLE` and `BLOCKED` verdicts are unaffected. Pass the diagnostic's `proof_ref` as `proof_hash`, or the JSON string your engine hashed into it as `proof_data`, so the token's `qwed.proof_hash` claim matches the result on the consuming side.

## Server-side verification

Inside the server, `AttestationService.verify_attestation(token)` returns `(is_valid, claims, error)` and checks the token against the server's own key.

### Uniform rejection error

Every rejection returns the same `"Invalid token"` message. The result never says *why* a token was rejected: expired, revoked, malformed, oversized, untrusted-issuer, and unsupported-external-issuer tokens all look the same from the outside.

```python theme={null}
is_valid, claims, error = service.verify_attestation(bad_jwt)
# is_valid = False
# claims   = None
# error    = "Invalid token"
```

Telling `"Attestation has expired"` apart from `"Untrusted issuer: did:...:X"` or `"Attestation has been revoked"` would let an attacker enumerate the trusted-issuer registry, probe token expiry, and confirm revocation state from the response text. The reason goes only to the server's debug log.

> [!WARNING]
>
> The signature is verified **before** the issuer is checked. An unknown-issuer token that isn't signed by the server's own key is rejected on signature grounds first, so the issuer check never runs and can't be used to probe the trust list. Order your own custom validation the same way if you extend `verify_attestation`.

## Enforce the trust boundary

Release gates and any code path that admits a `VERIFIED` result MUST route through `enforce_trust_decision()` before acting on it. It is the single consumption-side entry point that binds a verification result to its attestation token and fails closed on any mismatch. Don't consume `DiagnosticResult.status == VERIFIED` directly.

```python theme={null}
from qwed_new.core import enforce_trust_decision

decision = enforce_trust_decision(
    result,                              # DiagnosticResult from the engine
    attestation_token=token,             # JWT from create_verification_attestation
    require_attestation=True,            # mandatory policy
    query="2+2=4",                       # the statement the verdict covers, for query_hash binding
    admission="ADMIT",                   # optional: the admission the token must sign
)

if decision.status.name == "VERIFIED":
    proceed()
else:
    # BLOCKED / UNVERIFIABLE: do not admit the result
    reject(decision)
```

### Parameters

**result** `DiagnosticResult` *required*

The verification result returned by the engine. `enforce_trust_decision` passes through any already-fail-closed status (`BLOCKED`, `UNVERIFIABLE`) unchanged, and only applies attestation checks to `VERIFIED`.

**attestation\_token** `string | None`

The JWT emitted by `create_verification_attestation()`. May be `None`, but if `require_attestation=True` and the result is `VERIFIED`, a missing token blocks the decision.

**require\_attestation** `bool` (default: True)

When `True` (default, mandatory policy), a `VERIFIED` result without a valid token is downgraded to `BLOCKED`. When `False` (advisory policy), attestation is best-effort: a missing token still passes, but a present-and-invalid token still blocks.

**trusted\_issuers** `list[str] | None`

Optional list of trusted issuer DIDs. Defaults to the service's own issuer. The server verifies only tokens signed by its own key: external issuers are not supported.

**query** `string | None`

Optional statement the verdict covers. For a natural-language verdict, that's the translated expression, not the user's question. When provided, the token's `qwed.query_hash` claim must equal `sha256(query)`, otherwise the decision is blocked. Without it, query binding is not checked.

**admission** `string | None`

Optional admission, `ADMIT` or `BLOCKED`. When provided, the token must sign this admission as `qwed.admission`, otherwise the decision is blocked.

### Detached result snapshot

`enforce_trust_decision()` always returns a **new** `DiagnosticResult`, never the reference you passed in. Before running any validation, it takes an isolated snapshot of `developer_fields` (recursive rebuild admitting only immutable scalars, `AdvisoryCheck`, and JSON-safe containers), so:

* **The value you validate is the value you return.** Concurrent code holding the original `result` reference can't mutate `developer_fields` between the enforcement check and your admission decision, closing a TOCTOU window that `frozen=True` alone did not cover.
* **The returned result does not alias caller data.** Downstream code can safely add breadcrumbs to `decision.developer_fields` without leaking into the original object.

If snapshotting fails, for example when `developer_fields` contains an unsupported value type or an object whose `__deepcopy__` refuses to copy, `enforce_trust_decision()` fails closed with a `BLOCKED` result carrying `constraint_id="trust_gate.diagnostic_snapshot_failed"`. Only the exception type is recorded in the audit log; caller-supplied error text is never surfaced.

> [!TIP]
>
> Keep `developer_fields` values to JSON-safe types (strings, numbers, booleans, `None`, lists, dicts) plus `AdvisoryCheck`. Anything else, such as custom class instances, generators, or open file handles, is rejected by the snapshotter and blocked at the trust boundary.

### Claims binding

When a token is present, `enforce_trust_decision` checks these bindings against the result before admitting it:

| Claim | Bound to | Blocked when |
| - | - | - |
| `qwed.result.status` | `result.status.value` | Token status does not match the engine's status. |
| `qwed.query_hash` | `sha256(query)` | `query` is provided and does not match the token's query hash (for natural language, the translated expression). |
| `qwed.proof_hash` | `result.proof_ref` | Token proof hash does not match the diagnostic's proof reference. |
| `qwed.admission` | `admission` | `admission` is provided and the token signs a different one, or none. |

Every block decision is logged at `WARNING` with a structured `trust_gate.blocked` event (`constraint_id`, `reason`, `policy`) so auditors can replay the enforcement trail.

### Fail-closed matrix

| Result status | Attestation token | Policy | Decision |
| - | - | - | - |
| `VERIFIED` | Missing proof on issuance | Any | Issuance returns `BLOCKED` with `VERIFIED_WITHOUT_PROOF`: no token exists. |
| `VERIFIED` | None | `require_attestation=True` | **BLOCKED** (`trust_gate.mandatory_attestation_missing`). |
| `VERIFIED` | None | `require_attestation=False` | Passes through as `VERIFIED` (advisory mode). |
| `VERIFIED` | Invalid (bad signature, expired, revoked, untrusted issuer, malformed) | Either | **BLOCKED** (`trust_gate.invalid_attestation_token`, generic `"Invalid token"` surface). |
| `VERIFIED` | Valid, but its claims are missing or malformed | Either | **BLOCKED** (`trust_gate.claims_missing`). |
| `VERIFIED` | Valid, but claims disagree with result | Either | **BLOCKED** (`trust_gate.claims_status_mismatch` / `claims_query_mismatch` / `claims_proof_mismatch` / `claims_admission_mismatch`). |
| `VERIFIED` | Verifying the token raised an error | Either | **BLOCKED** (`trust_gate.attestation_verification_error`). |
| Any | Any | Either | **BLOCKED** (`trust_gate.diagnostic_snapshot_failed`) when `developer_fields` can't be snapshotted (unsupported value type, uncopyable object). |
| `VERIFIED` | Valid and claims match | Either | Passes through as `VERIFIED` on a detached snapshot. |
| `UNVERIFIABLE` / `BLOCKED` | Any | Either | Pass through unchanged (on a detached snapshot): attestation checks don't apply. |

> [!TIP]
>
> The server's own routes enforce attestation as mandatory. In your own code, you can start with `require_attestation=False` while engines are still being migrated to emit proof, then flip it to `True` once every code path that emits `VERIFIED` signs with a proof. That gives you the audit trail without breaking existing gates on day one.

## Use cases

1. **Audit trails**: keep the token with the answer to show the server verified it.
2. **Compliance**: a signed, time-bounded record of each verdict and admission.
3. **Downstream checks**: services that receive an answer verify it against the server's JWKS before acting on it.


## Related topics

- [QWED Security GitHub App: deterministic PR verification](https://docs.qwedai.tech/advanced/github-app.md)
- [Changelog](https://docs.qwedai.tech/changelog.md)
- [QWED Protocol: deterministic verification for LLMs](https://docs.qwedai.tech/whitepaper.md)
- [API endpoints](https://docs.qwedai.tech/api/endpoints.md)
- [QWED A2A zero-trust protocol for agent-to-agent verification](https://docs.qwedai.tech/a2a/overview.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.