Design & architecture

Design & architecture

How the library is structured and why certain design decisions were made.


Verification pipeline

Verification pipeline: CBOR decode, parse authenticator data, verify certificate chain, verify nonce, extract public key, return AttestationResult.

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/:

ModuleResponsibility
attestation.tsverifyAttestation() + custom CBOR decoder for Apple's malformed receipt headers
assertion.tsverifyAssertion() — lightweight path using cborg for CBOR
certificate.tsX.509 certificate chain verification, nonce extraction, public key extraction via asn1js + @noble/curves (P-384) + WebCrypto
authdata.tsBinary parser for authenticator data (rpIdHash, flags, signCount, AAGUID, credentialId)
der.tsDER ↔ raw r||s signature conversion (WebCrypto requires raw format)
constants.tsApple root CA PEM, production/development AAGUIDs, nonce extension OID
errors.tsAttestationError / AssertionError with typed error codes
utils.tsByte helpers (concat, constant-time compare), base64/UTF-8 coercion, PEM import/export
with-attestation.tswithAttestation() middleware wrapper
with-assertion.tswithAssertion() 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:

OperationCostWhen it runs
verifyAssertion~116 µsEvery protected request
verifyAttestation~6.1 msOnce per device, ever
decodeAttestationCbor~6.5 µsInside 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):

EndpointMedianp95
Plain3.5 ms4.5 ms
withAssertion8.9 ms12.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:

MetricValue
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 via deno 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:crypto imports
  • Constant-time comparisons for any security-sensitive byte comparison
  • Update CHANGELOG.md via conventional commits (release-please handles this)
Previous
Types & error codes