/** * App Attest assertion verification. * * EXAMPLE CODE. Placeholders only. * * StoreKit answers "is this user entitled?" App Attest answers "is this even my * app talking?" The app attests a hardware-backed key once (the public key is * stored server-side, keyed by keyId), then signs an assertion over each * request. This file verifies that per-request assertion. * * The verifier is injectable, mirroring storekit.ts, so tests run offline with * a fake in place of a real device assertion and a stored public key. * * A production verifier: * 1. looks up the registered public key for keyId (e.g. in Workers KV), * 2. CBOR-decodes the assertion into { signature, authenticatorData }, * 3. checks rpIdHash === SHA-256("."), * 4. checks the signature counter is strictly increasing (replay defense), * 5. verifies signature over (authenticatorData || clientDataHash) with the * stored P-256 public key. * Apple documents the full procedure under DeviceCheck / App Attest. */ import type { Env } from "./env.ts"; export interface AppAttestConfig { teamId: string; bundleId: string; } export interface AppAttestAssertion { keyId: string; /** Base64 CBOR assertion produced by DCAppAttestService.generateAssertion. */ assertion: string; /** SHA-256 of the exact request payload the client signed over. */ clientDataHash: ArrayBuffer; } /** Resolves if the assertion is authentic; throws otherwise. */ export type AppAttestVerifier = ( assertion: AppAttestAssertion, cfg: AppAttestConfig, ) => Promise; /** App Attest is "configured" only when both identity values are present. */ export function appAttestConfig(env: Env): AppAttestConfig | null { if (!env.APP_ATTEST_TEAM_ID || !env.APP_ATTEST_BUNDLE_ID) return null; return { teamId: env.APP_ATTEST_TEAM_ID, bundleId: env.APP_ATTEST_BUNDLE_ID, }; } export const verifyAppAttestAssertion: AppAttestVerifier = async ( assertion, cfg, ) => { if (!assertion.keyId || !assertion.assertion) { throw new Error("missing App Attest assertion"); } // Structural check we can do without the stored key: the assertion must // CBOR-decode into authenticatorData whose rpIdHash binds to this App ID. const cbor = await import("cbor-x"); const decoded = cbor.decode(base64ToBytes(assertion.assertion)) as { authenticatorData?: Uint8Array; signature?: Uint8Array; }; if (!decoded.authenticatorData || !decoded.signature) { throw new Error("malformed App Attest assertion"); } const rpIdHash = decoded.authenticatorData.slice(0, 32); const appId = `${cfg.teamId}.${cfg.bundleId}`; const expected = new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appId)), ); if (!bytesEqual(rpIdHash, expected)) { throw new Error("App Attest rpId does not match this app"); } // The signature itself is verified against the public key registered during // the one-time attestation step, looked up by keyId. Wire your key store and // P-256 verification here. Until then, treat an unregistered key as untrusted. throw new Error("App Attest key store not configured"); }; function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; }