Status: Draft — locks the signed payload format for Milestone #33. Owner: CTO Last reviewed: 2026-04-17
A forensic attestation is a cryptographically signed statement that AI Identity issues at session close. It commits to a specific range of audit log entries so a third party — an auditor, a customer's compliance team, a regulator — can verify after the fact that the chain has not been tampered with, without having to operationally trust AI Identity.
This document defines the v1 wire format. It does not cover the signing pipeline (#263), the retrieval API (#264), public key distribution (#265), CLI verify (#266), or the trust model (#267) — those ship as separate work and consume this format.
- Third-party verifiable — with nothing more than (a) the published public key, (b) the audit log rows for the committed range, (c) the org's HMAC verify key, a verifier can answer "did AI Identity sign this chain, and does it match what I have?" — offline, no API calls.
- Stable canonical bytes — the signed payload must hash the same way on every machine, every Python version, every JSON library. A signature that fails on round-trip is worse than no signature.
- Forward-compatible schema — a v2 verifier must be able to read a
v1 attestation, and a v1 verifier must fail loudly (not silently
accept) when it sees a v2.
schema_versionis the gate. - Envelope is a standard, not a snowflake — we use DSSE
(Dead Simple Signing Envelope) so existing tooling (
cosign, in-toto verifiers, the sigstore ecosystem) can validate our attestations without bespoke code.
- We do not re-hash the audit chain. The existing HMAC chain
(see
common/audit/writer.py) already produces a tailentry_hashthat transitively commits to every prior row viaprev_hashlinkage. The attestation simply captures that tail hash and signs over it. - We do not bundle the audit rows into the attestation. The attestation is small (~500 bytes signed) and points at the rows by ID range. Bundling would inflate storage for no added guarantee — the verifier must still walk the chain to verify the committed hash.
- We do not model a
Sessiontable here.session_idis an opaque producer-supplied identifier. How sessions come to exist (explicit DB table, derived from agent_id + time window, etc.) is decided in #263.
The signed content is a JSON object with these fields, all required:
| Field | Type | Notes |
|---|---|---|
schema_version |
integer | 1 for this format. A verifier MUST reject a version it does not understand. |
session_id |
string (UUID) | Opaque producer identifier for the session being attested. |
org_id |
string (UUID) | The org whose HMAC key was used to build the audit chain. Needed for downstream chain verification. |
evidence_chain_hash |
string (hex) | The entry_hash of the last audit log row in the committed range. 64 hex chars (SHA-256). |
first_audit_id |
integer | First audit_log.id included in the committed range (inclusive). |
last_audit_id |
integer | Last audit_log.id included in the committed range (inclusive). Its entry_hash == evidence_chain_hash. |
event_count |
integer | Number of audit rows in the range (last_audit_id - first_audit_id + 1 if contiguous). |
session_start |
string (RFC 3339) | UTC, Z suffix (e.g. 2026-04-17T13:42:00Z). Start of the session window. |
session_end |
string (RFC 3339) | UTC, Z suffix. End of the session window (and the point at which the attestation was issued). |
signed_at |
string (RFC 3339) | UTC, Z suffix. The wall-clock time the signer produced the signature. |
signer_key_id |
string | Fully-qualified KMS key-version resource name. See below. |
We use the full GCP KMS key-version resource path as the key id:
projects/{PROJECT}/locations/{REGION}/keyRings/{KEYRING}/cryptoKeys/{KEY}/cryptoKeyVersions/{VERSION}
This is stable across key rotations (old versions stay enabled for historical verification) and is directly consumable by the JWKS endpoint defined in #265.
{
"schema_version": 1,
"session_id": "b8f2c1a0-4e6d-4e2a-9f1a-3c2b0d4e8f7a",
"org_id": "f1e2d3c4-b5a6-4798-8877-66554433abcd",
"evidence_chain_hash": "3b7e0a6f4a9d8c2e5b1f0d3c6a8b9e2d1f4c7a0b3d6e9f2a5c8b1d4e7a0b3c6d",
"first_audit_id": 104821,
"last_audit_id": 104827,
"event_count": 7,
"session_start": "2026-04-17T13:42:00Z",
"session_end": "2026-04-17T13:47:30Z",
"signed_at": "2026-04-17T13:47:30Z",
"signer_key_id": "projects/project-8bbb04f8-fda8-462e-bc2/locations/us-east1/keyRings/ai-identity-forensic/cryptoKeys/session-attestation/cryptoKeyVersions/1"
}JCS — JSON Canonicalization Scheme (RFC 8785).
The Python implementation uses the rfc8785 package. Canonicalization
is deterministic (sorted keys, no insignificant whitespace, canonical
number format, NFC-normalized strings) so the same payload always
produces the same bytes, on any conforming implementation.
Why not json.dumps(..., sort_keys=True, separators=(",", ":")) (which
is what the existing HMAC audit chain uses)? — Two reasons:
- Third-party verifiers will expect JCS. Auditors verifying an attestation from Python/Go/Rust/JS should each get the same bytes.
- Edge cases. Unicode escape forms, number formats (
1.0vs1), and negative zero are not canonicalized bysort_keys. Our payload schema is tight enough that it wouldn't hit these cases in practice today, but hardcoding a brittle choice into a signed format is exactly the kind of decision that comes back to bite.
The HMAC audit chain keeps its existing sort-keys scheme — it's a separate layer and changing it would break every existing row. JCS applies only to the attestation envelope.
ECDSA with curve P-256 and SHA-256 — matches the KMS key algorithm
EC_SIGN_P256_SHA256 provisioned in #261.
- Signature encoding: ASN.1 DER (as returned by GCP KMS
AsymmetricSign). DSSE base64-encodes the DER bytes for transport. - Public key format: PEM
SubjectPublicKeyInfo(the format GCP KMSGetPublicKeyreturns).
The signed attestation is delivered as a DSSE envelope:
{
"payloadType": "application/vnd.ai-identity.attestation+json",
"payload": "<base64(canonical-json-of-payload)>",
"signatures": [
{
"keyid": "projects/.../cryptoKeys/session-attestation/cryptoKeyVersions/1",
"sig": "<base64(DER-encoded ECDSA signature)>"
}
]
}Per the DSSE spec, the bytes that get signed are NOT the payload bytes directly — they are the Pre-Authentication Encoding:
PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
Where SP is a single ASCII space (0x20) and LEN(x) is the ASCII
decimal representation of the byte-length of x. All strings are
UTF-8. Both type and body are included length-prefixed to prevent
a signature produced over one (type, body) from being replayed as
valid over a different (type', body') where the concatenations happen
to collide. This is the whole point of PAE: domain separation at zero
additional cryptographic cost.
A verifier with access to (a) the DSSE envelope, (b) the signer's
public key PEM, (c) the audit log rows for [first_audit_id, last_audit_id], and (d) the org's HMAC verify key performs the
following, failing closed on any mismatch:
- Parse the envelope. Confirm
payloadType == "application/vnd.ai-identity.attestation+json". Base64-decodepayloadto get the canonical JSON bytes, parse to a dict. - Schema check. Confirm
schema_version == 1. Reject any unexpected version — a verifier that silently accepts unknown versions defeats the purpose of versioning. - Signature check. Reconstruct
PAE(payloadType, payload_bytes), compute its SHA-256, verify the DER-encoded ECDSA signature against the public key. On failure → REJECT. - Chain check. Walk the audit log rows in the committed range and
compute the HMAC chain as described in
common/audit/writer.py:verify_chain. The last row's computedentry_hashMUST equalevidence_chain_hashin the payload. On failure → REJECT. - Range sanity.
event_count == last_audit_id - first_audit_id + 1AND every row in the range has the claimedorg_id. On failure → REJECT.
If all five pass, the attestation is valid. This means: AI
Identity committed to this exact set of audit rows at signed_at
using the key identified by signer_key_id, and the rows have not
been tampered with since.
KMS asymmetric signing keys are rotated operator-triggered (see
scripts/setup-forensic-signer.sh —
no scheduled rotation for asymmetric keys). Old cryptoKeyVersions/N
stay in state ENABLED so their public keys remain resolvable.
The JWKS endpoint defined in #265 publishes all historical key
versions with their created_at / not_before / not_after metadata,
so a verifier processing an attestation signed five years ago can
still fetch the exact public key that signed it.
This is why signer_key_id pins the specific version, not just the
key. A key_id of .../cryptoKeys/session-attestation would be
ambiguous after the first rotation.
An attestation commits to a range of audit_log rows by ID. Retention
policies (v0.5.0) prune audit_log rows on a schedule, so some of the
rows an attestation committed to will, in time, no longer exist. The
envelope stays cryptographically valid either way; the question is
whether the chain it commits to is still accountable.
Two mechanisms keep it accountable:
- Eager resolution at sign time. The signer resolves the
[first_audit_id, last_audit_id]range to a concrete list of row IDs at the moment of signing and stores that list (audit_log_ids) alongside the envelope. It is the frozen statement of what existed when the attestation was made. - Cross-check against tombstones on every read. Every prune leaves
a tombstone (
retention_tombstones, one row per pruned audit row) and a chained receipt event. Reading an attestation reconciles its frozen list against the live table: each ID is present (its row survives), tombstoned (a tombstone covers it whoseentry_hashmatches the leaf of the signed checkpoint it cites and whose receipt is itself chained), or unaccounted. Theretentionblock on the attestation response carries the three counts, the unaccounted IDs, andcomplete, which is true only when nothing is unaccounted.
An unaccounted ID is a deletion under a signed statement that no receipt explains. That is what unauthorized deletion looks like, and it is reported as such rather than as a plausible but unreachable range.
This replaces the earlier guardrail that audit-log retention had to be
at least as long as attestation retention. Retention may be shorter
than an attestation's life, because the attestation stays verifiable
through the receipts: the rows are gone, the proof that they existed
with those hashes under a signed checkpoint is not. The offline
equivalent lives in Case File bundles as retention/tombstones.json
(see retention-tombstones-in-exports.md).
The attestation table itself holds the envelope indefinitely; it is
small (about 1 KB) and is the artifact customers and auditors reference.
Tombstones and receipts are likewise retained indefinitely. Row purges
happen against audit_log, never against attestations or receipts.
session_start and session_end are producer-reported timestamps, and
nothing in the signing pipeline forbids signing a session whose window
closed days or weeks ago. This is deliberate:
- The HMAC audit chain is append-only and tamper-evident. Rows from two weeks ago are as trustworthy as rows from two minutes ago — the chain's integrity is independent of when we ask about it.
- Some operator flows (batch ingest, customer-initiated forensic request, regulator subpoena) legitimately need to attest to a window that has already closed.
The signed_at field is the signer's wall clock, not the session
window. Verifiers should read signed_at as "when AI Identity
committed to this evidence" and session_start/session_end as "the
window the evidence covers." A large gap between them is a flag
worth noticing but not a verification failure.
Overlapping sessions — two attestations whose [first_audit_id, last_audit_id] ranges intersect — are allowed at this layer. The
evidence chain is still sound for each. Detection of overlap as a
policy concern is deferred to operator tooling (flagged for a PM
follow-up out of #263).
Worth being explicit — an attestation is a claim about audit log integrity, nothing more:
- It does not prove the events themselves were "correct" — only that they were recorded and not subsequently altered.
- It does not prove the agent did anything in particular — it proves the gateway saw a specific sequence of allow/deny decisions.
- It does not prove timing within the session;
session_startandsession_endare producer-reported. - It does not cover events outside the
[first_audit_id, last_audit_id]range, even if they belong to the samesession_id.
These boundaries get expanded in the trust-model doc (#267) for an audit-ready audience.
The format spec above is intentionally narrow. For the questions a verifier, auditor, or operator actually asks:
trust-model.md— what a valid attestation proves and does not prove, what to show a SOC 2 auditor, how to respond to a suspected key compromise.key-rotation.md— operator runbook for planned and emergency key rotation, post-rotation verification, and when (not) to destroy a key.
For reference — the Milestone #33 items that shipped this format:
- #261 — KMS forensic signer keyring, SA, Workload Identity
- #262 — this document + pydantic model + DSSE helpers
- #263 — sign on session close (
POST /api/v1/attestations) - #264 — retrieval API (
GET /api/v1/sessions/{id}/attestation) - #265 — JWKS (
/.well-known/ai-identity-public-keys.json) - #266 — CLI verify (
ai_identity_verify attestation) - #267 — this section + the companion trust-model and rotation docs