/** * Offline Meridian-Receipt verifier (Node 22+ / Workers WebCrypto). * * Canonical bytes are recursively sorted-key JSON with no whitespace, * excluding `sig`. This is NOT RFC 8785 JSON Canonicalization Scheme (JCS). * * After the signature verifies, also confirm the on-chain USDC Transfer on * Base (eip155:8453) whose tx hash, from, to, and value match receipt.tx, * receipt.payer, receipt.pay_to, and receipt.amount. * * JWKS keys must include kty "OKP" and crv "Ed25519". alg, when present, * must be EdDSA or Ed25519 (live keys use alg "Ed25519"). * * `sig` is strict standard base64 or canonical base64url: padding required, * no whitespace, and re-encoding must match the input exactly. Worker-issued * receipts use standard base64 (`btoa`). * * Timestamps (receipt `issued_at`; key `not_before` / `retired_at` / * `revoked_at`) must be ISO 8601 UTC instants ending in Z. */ export function stableJson(value: unknown): string { if (value == null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; const obj = value as Record; const keys = Object.keys(obj).sort(); return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`).join(",")}}`; } export function receiptCanonicalBytes(receipt: Record): Uint8Array { const unsigned: Record = {}; for (const key of Object.keys(receipt)) { if (key === "sig") continue; unsigned[key] = receipt[key]; } return new TextEncoder().encode(stableJson(unsigned)); } /** ISO 8601 UTC instant ending in Z (optional 1–9 fractional-second digits). */ const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$/; export function isIsoInstant(value: unknown): value is string { return typeof value === "string" && ISO_INSTANT_RE.test(value) && !Number.isNaN(Date.parse(value)); } /** Canonical standard base64 or base64url. Re-encoding must match exactly. */ export function decodeStrictB64(text: string): Uint8Array | null { if (typeof text !== "string" || text.length === 0) return null; if (/\s/.test(text)) return null; const isUrl = /^[A-Za-z0-9_-]+={0,2}$/.test(text); const isStd = /^[A-Za-z0-9+/]+={0,2}$/.test(text); if (!isUrl && !isStd) return null; if (text.length % 4 !== 0) return null; const normalized = text.replace(/-/g, "+").replace(/_/g, "/"); let binary: string; try { binary = atob(normalized); } catch { return null; } const reStd = btoa(binary); const reencoded = isUrl ? reStd.replace(/\+/g, "-").replace(/\//g, "_") : reStd; if (reencoded !== text) return null; const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); return out; } export type ReceiptKey = { kid?: unknown; x?: unknown; kty?: unknown; crv?: unknown; alg?: unknown; status?: unknown; not_before?: unknown; retired_at?: unknown; revoked_at?: unknown; }; export type KeyWindowResult = { ok: true } | { ok: false; reason: string }; function timestampField( value: unknown, ): { kind: "absent" } | { kind: "invalid" } | { kind: "ok"; ms: number } { if (value === undefined) return { kind: "absent" }; if (!isIsoInstant(value)) return { kind: "invalid" }; return { kind: "ok", ms: Date.parse(value) }; } export function keyCoversIssuedAt(key: ReceiptKey, issuedAt: string): boolean { return keyWindowCheck(key, issuedAt).ok; } export function keyWindowCheck(key: ReceiptKey, issuedAt: string): KeyWindowResult { if (!isIsoInstant(issuedAt)) return { ok: false, reason: "invalid_issued_at" }; const issued = Date.parse(issuedAt); const notBefore = timestampField(key.not_before); const retiredAt = timestampField(key.retired_at); const revokedAt = timestampField(key.revoked_at); if (notBefore.kind === "invalid" || retiredAt.kind === "invalid" || revokedAt.kind === "invalid") { return { ok: false, reason: "invalid_key_timestamp" }; } if (key.status === "revoked" || revokedAt.kind === "ok") { return { ok: false, reason: "key_revoked" }; } if (key.status === "retired" && retiredAt.kind !== "ok") { return { ok: false, reason: "retired_without_retired_at" }; } if (key.status !== undefined && key.status !== "active" && key.status !== "retired") { return { ok: false, reason: "invalid_key_status" }; } if (notBefore.kind === "ok" && issued < notBefore.ms) { return { ok: false, reason: "key_outside_validity_window" }; } if (retiredAt.kind === "ok" && issued >= retiredAt.ms) { return { ok: false, reason: "key_outside_validity_window" }; } return { ok: true }; } export type VerifyReceiptResult = | { ok: true } | { ok: false; reason: string }; const KEY_ALG_OK = new Set(["Ed25519", "EdDSA"]); export async function verifyMeridianReceipt( receipt: Record, keysDoc: { keys?: unknown }, ): Promise { if (typeof receipt.sig !== "string" || !receipt.sig) { return { ok: false, reason: "missing_sig" }; } if (receipt.alg !== "Ed25519") return { ok: false, reason: "unsupported_alg" }; if (typeof receipt.kid !== "string" || !receipt.kid) { return { ok: false, reason: "missing_kid" }; } if (typeof receipt.issued_at !== "string") { return { ok: false, reason: "missing_issued_at" }; } if (!Array.isArray(keysDoc.keys)) { return { ok: false, reason: "invalid_keys" }; } const rawKeys = (keysDoc.keys ?? []) as unknown[]; if (rawKeys.some((item) => item == null || typeof item !== "object")) { return { ok: false, reason: "invalid_keys" }; } const keys = rawKeys as ReceiptKey[]; const matches = keys.filter((item) => item.kid === receipt.kid); if (matches.length > 1) return { ok: false, reason: "duplicate_kid" }; const key = matches[0]; if (!key || typeof key.x !== "string") return { ok: false, reason: "unknown_kid" }; if (key.kty !== "OKP") return { ok: false, reason: "bad_kty" }; if (key.crv !== "Ed25519") return { ok: false, reason: "bad_crv" }; if (key.alg !== undefined && !KEY_ALG_OK.has(String(key.alg))) { return { ok: false, reason: "bad_alg" }; } const window = keyWindowCheck(key, receipt.issued_at); if (!window.ok) return window; const sig = decodeStrictB64(receipt.sig); if (!sig) return { ok: false, reason: "bad_sig_encoding" }; try { const verifyKey = await crypto.subtle.importKey( "jwk", { kty: "OKP", crv: "Ed25519", x: key.x, ext: true }, { name: "Ed25519" }, false, ["verify"], ); const ok = await crypto.subtle.verify( { name: "Ed25519" }, verifyKey, sig, receiptCanonicalBytes(receipt), ); return ok ? { ok: true } : { ok: false, reason: "bad_signature" }; } catch { return { ok: false, reason: "verify_error" }; } }