Design & architecture
Design & architecture
How the library is structured and why certain design decisions were made.
Verification pipeline
Attestation passes through the full pipeline: CBOR decoding, X.509 certificate chain validation against Apple's root CA, nonce verification, public key extraction, and authenticator data checks (rpIdHash, AAGUID, credentialId, signCount).
Assertion takes a shorter path: CBOR decoding, authenticator data parsing, counter check, and ECDSA signature verification against the stored public key.
Module map
The library is 10 focused source files under packages/lib/src/:
| Module | Responsibility |
|---|---|
attestation.ts | verifyAttestation() + custom CBOR decoder for Apple's malformed receipt headers |
assertion.ts | verifyAssertion() — lightweight path using cborg for CBOR |
certificate.ts | X.509 certificate chain verification, nonce extraction, public key extraction via asn1js + @noble/curves (P-384) + WebCrypto |
authdata.ts | Binary parser for authenticator data (rpIdHash, flags, signCount, AAGUID, credentialId) |
der.ts | DER ↔ raw r||s signature conversion (WebCrypto requires raw format) |
constants.ts | Apple root CA PEM, production/development AAGUIDs, nonce extension OID |
errors.ts | AttestationError / AssertionError with typed error codes |
utils.ts | Byte helpers (concat, constant-time compare), base64/UTF-8 coercion, PEM import/export |
with-attestation.ts | withAttestation() middleware wrapper |
with-assertion.ts | withAssertion() middleware wrapper |
Key design decisions
WebCrypto only
The library uses crypto.subtle exclusively — no node:crypto imports. This is a hard requirement: Deno's node:crypto compatibility layer is incomplete (X509Certificate.prototype.verify() throws ERR_NOT_IMPLEMENTED), and Supabase Edge Functions don't guarantee Node.js API availability. WebCrypto is the only crypto API that works reliably across Deno, Deno Deploy, and Supabase.
No pkijs
pkijs is the standard WebCrypto-based X.509 library, but it crashes in Supabase's runtime. At module load time, pkijs calls initCryptoEngine() → setEngine(self.crypto.name, ...). In Supabase, self.crypto.name is undefined, and the engine tries to assign to globalThis['undefined'], which is read-only. This causes a fatal TypeError before any user code runs.
The library uses asn1js (pkijs's underlying ASN.1 parser, which has no initialization side effects) combined with direct WebCrypto calls for signature verification.
@noble/curves for P-384
Apple's intermediate certificate uses a P-384 key to sign with SHA-256. Deno's WebCrypto doesn't support P-384+SHA-256 (verify throws for this combination). The library uses @noble/curves/p384 for this single verification step during attestation. This dependency is only loaded via the attestation path — the assertion subpath doesn't need it.
Custom CBOR decoder for attestation
Apple's CBOR encoding of the attestation receipt field has incorrect length headers (overstated by ~21 bytes). Standard CBOR libraries like cborg fail to decode this. The attestation module includes a strict structural parser: it walks the maps entry by entry with bounds-checked headers, accepts keys in any order, and rejects duplicate keys, unknown keys, indefinite lengths, and trailing bytes. The one tolerated malformation is the overstated receipt length, repaired in a single documented code path that scans backward from the declared end for the next expected key.
The assertion path uses cborg normally — Apple's assertion CBOR encoding is well-formed.
PEM string output
Public keys are returned as PEM strings rather than CryptoKey objects. This allows stateless edge functions to serialize keys to a database and deserialize them on the next request without managing CryptoKey lifecycle.
Constant-time comparisons
All nonce, hash, and key comparisons use constant-time byte comparison (constantTimeEqual). This prevents timing attacks where an attacker measures response times to learn partial information about expected values.
Subpath exports
The library exports four entry points:
.— everything./attestation— attestation verification + types./assertion— assertion verification +withAssertion+ types./supabase—createSupabaseAdapter()storage callbacks
The assertion subpath avoids importing asn1js and @noble/curves, keeping the bundle minimal for edge functions that only verify assertions (the hot path).
Performance
Three measurements, all reproducible — the first two on an Apple M2 Max (Deno 2.1.5), the third on a physical iPhone (August 2026):
Compute cost — cd packages/lib && deno task bench runs Deno.bench over the verification paths with no network:
| Operation | Cost | When it runs |
|---|---|---|
verifyAssertion | ~116 µs | Every protected request |
verifyAttestation | ~6.1 ms | Once per device, ever |
decodeAttestationCbor | ~6.5 µs | Inside each attestation |
Attestation is ~50× more expensive than assertion, and it doesn't matter: it runs exactly once per device registration. The per-request cost is the assertion path.
End-to-end overhead — the demo ships an A/B benchmark (demo/supabase-expo-demo/supabase/tests/bench-ab.ts) hitting two edge functions that perform the identical demo_events insert, one plain and one wrapped in withAssertion, against a local supabase start stack (N=100 alternating sequential requests after warmup):
| Endpoint | Median | p95 |
|---|---|---|
| Plain | 3.5 ms | 4.5 ms |
withAssertion | 8.9 ms | 12.1 ms |
Span-level breakdown of the ~5.4 ms median delta: device-key read 0.8 ms, sign-count CAS write 2.2 ms, ECDSA verification 0.3 ms, header extraction 0.02 ms — plus ~2.0 ms for the demo's optional in-handler assertion-challenge consume, which plain withAssertion users don't pay. Storage round-trips dominate; crypto is noise. The numbers come from a local Docker stack; hosted Supabase adds network latency to every span equally, so the relative story holds while absolute numbers grow.
On-device — the demo app's Benchmark button runs the same A/B from a physical iPhone (iPhone 17 Pro, iOS 26.6, LAN Wi-Fi, N=50) and adds the client-side costs the server can't see:
| Metric | Value |
|---|---|
generateAssertionAsync (Secure Enclave sign) | ~18 ms median |
| Protected vs unprotected request, round-trip delta | ~16 ms median |
| Full protected flow (challenge + sign + request) | ~75 ms median |
generateKeyAsync (once per device) | ~15–20 ms |
attestKeyAsync (Apple server round-trip, once per device) | ~0.7–1.1 s |
verify-attestation round-trip (once per device) | ~50–150 ms |
The user-perceived cost of protecting a request is ~16 ms of round-trip delta plus ~18 ms of Secure Enclave signing. The demo's full flow adds a ~20 ms challenge fetch, which apps can avoid by pre-fetching challenges or relying on the sign counter alone. Apple's attestKeyAsync is the only slow operation and runs once per device at registration — keep it out of hot paths and UX-critical moments.
Distribution
- JSR (primary):
jsr:@bradford-tech/supabase-integrity-attest— published viadeno publish - npm (secondary):
@bradford-tech/supabase-integrity-attest— built from Deno source via@deno/dnt - Releases: Automated via release-please on push to
main
Contributing
Running tests
cd packages/lib
deno task check # Format check + lint + test (CI gate)
deno task fix # Auto-format + auto-fix lint + test
deno task test # Tests only (no network access)
Project structure
packages/lib/
mod.ts # Full public API
attestation.ts # Attestation subpath export
assertion.ts # Assertion subpath export
src/ # Implementation modules
tests/ # Test files + fixtures
scripts/ # Build scripts (npm build via dnt)
PR expectations
- All tests pass (
deno task check) - No
node:cryptoimports - Constant-time comparisons for any security-sensitive byte comparison
- Update CHANGELOG.md via conventional commits (release-please handles this)