Receipt v2 reference
Receipt members, signed bytes, lifecycle and the verifier algorithm.
Receipt
Last updated 24 September 2026
A receipt is the issuer's countersigned record of a seen-and-signed ceremony. Two versions exist. The canonical form and the signature scheme are chosen by the receipt's v field, never by configuration.
Version 2 (current)
{
"v": 2,
"issuer": "manav.id",
"kid": "k_2026_09", // one kid names a hybrid key pair (Ed25519 + ML-DSA-87)
"principal": "manav:human:7f3a9c1d4b8ee21b",
"action": "payment.release",
"stmt_hash": "sha256:…", // SHA-256 of JCS(statement without rp_sig) — also the WebAuthn challenge
"render": [["Action","RELEASE PAYMENT"], ["Amount","$42,000.00 USD"], …], // exactly what the human was shown
"rp": { "id": "ap.acme.com", "kid": "acme-2026-09" },
"presence": { "tier": "P2", "aaguid": "…", "uv": true, "dwell_ms": 1840, "rendered_by": "issuer" },
"assertion_hash": "sha256:…", // SHA-256(authenticatorData || clientDataJSON || signature) of the stored assertion
"nonce": "Q1fTk2p9Yw3bC7dE",
"issuedAt": "2026-09-05T14:23:51Z",
"sig": { "ed25519": "<b64url 64 B>", "mldsa87": "<b64url 4,627 B>" }
}
Signed bytes: "manav-receipt/2" || 0x0A || JCS(receipt without sig) (see Canonical reference). Both signatures MUST verify; pq_signature_missing and pq_signature_invalid are distinct reject codes from bad_signature.
presence.tier is set from what actually ran: P1 no user verification, P2 platform authenticator with UV, P3 a roaming authenticator recognised in the FIDO Metadata Service (core/data/fido-mds-aaguids.json) with UV, P4 reserved for trusted-display devices (not issued in v1). Unknown AAGUIDs are P2, never higher.
Unknown fields are rejected. All times are RFC 3339 UTC with Z, no fractional seconds. Numbers are integers only.
Version 1 (frozen)
{v:1, issuer, kid, principal, action, payloadHash, assurance, nonce, issuedAt, sig} signed with Ed25519 alone over the legacy canonical form (core/canonical.php). Nothing new is issued as v1; the format exists so that anything signed before the migration keeps verifying (test group 17).
Publication and lifecycle
- Keys:
GET /v2/jwksand/.well-known/manav-keys/. The Ed25519 half iskty: OKP; the ML-DSA-87 half iskty: AKP, alg: ML-DSA-87, kid: <kid>.pq. Verifiers that only know OKP keep working on v1. - Revocation:
GET /v2/revoked(v1: kids and nonces, Ed25519) andGET /v2/revocations(v2: kids, grants, hosts, leases, RP kids; hybrid; strictly increasingseq). A verifier without a verifiable list MUST reject (revocation_list_unverifiable). - Freshness: when a receipt authorises something now, apply
maxAgeSeconds(default 900 in the demos). - Storage:
GET /v2/receipts/{nonce}returns the receipt and its statement;?assertion=1discloses the raw WebAuthn assertion for a dispute.
Verifier algorithm (reference: core/Verifier.php; ports: js/manav-crypto.js, packages/verify-*)
- Resolve
kidin the cached JWKS →unknown_kid. - Verify the revocation documents' own signatures; check kid and nonce →
revocation_list_unverifiable,kid_revoked,nonce_revoked. - Canonicalise without
sig(form by version); verify Ed25519, and for v2 also ML-DSA-87 →bad_signature,pq_signature_missing,pq_signature_invalid. - If the statement is supplied: recompute
stmt_hash; comparerenderbyte for byte →stmt_hash_mismatch,render_mismatch. - Policy:
tier_too_low,stale.
Returns { ok, reason, principal, action, render, presence, issuedAt, kid } — identical across implementations (tests/vectors/receipts).