fix(auth): compare App Attest key ids as bytes - #5052
Conversation
Apple's `DCAppAttestService.generateKey` returns the key id in standard base64. The verifier re-encoded the credential id from authData as base64url and compared the strings, so the padding alone made every first-time iOS attestation fail with KEY_ID_MISMATCH. Under enforce mode that surfaced as "Your device can't be verified" on every iOS sign-in. Compare the decoded bytes instead. Node's base64 decoder accepts both alphabets, so either wire form verifies. The credential-id check now runs before the nonce hash, which is both cheaper and reachable in tests. The client stored the key id as soon as `attestKeyAsync` resolved, which says nothing about the server persisting it, so a refused device then asserted against an unknown key forever. Clear the stored id when the server refuses admission, in `postAuth`, where every native auth POST already routes through.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryIncremental review of commit Files Reviewed (2 files)
Previous Review Summary (commit 177ad68)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 177ad68)Status: 1 Issue Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
The core fix is correct: comparing decoded bytes instead of re-encoded strings resolves the base64/base64url padding mismatch that blocked every iOS attestation, Node's decoder accepting both alphabets keeps either wire form working, and the mobile key-id cleanup in Files Reviewed (5 files)
Reviewed by kimi-k3 · Input: 50.1K · Output: 7.1K · Cached: 271.4K Review guidance: REVIEW.md from base branch |
The credential-ID parse reads a uint16 at offset 53, so it needs 55 bytes, but the only length guard allowed 37. Moving that parse ahead of the nonce check made offsets 37 to 54 reachable with crafted authData, where readUInt16BE throws a RangeError and the route reports a 500 instead of refusing admission. Raise the floor to 55, the shortest authData that can hold the credential-ID length prefix. A real attestation always exceeds it.
Problem
Every iOS sign-in fails with "Your device can't be verified" on the latest TestFlight build.
DCAppAttestService.generateKeyreturns the key id in standard base64, and@expo/app-integrityforwards it verbatim (ios/IntegrityModule.swift:17). The verifier re-encoded the credential id fromauthDataas base64url and compared the two strings. For a 32-byte key id that is 44 chars with a=pad versus 43 chars with none, so the comparison can never succeed.Result: every first-time iOS attestation returns
KEY_ID_MISMATCH→ADMISSION_REQUIRED. Underenforcemode that is a hard sign-in block. Android is unaffected — Play Integrity needs no persistent key row.The existing test built its fixture key id with
.toString('base64url'), so it agreed with the bug instead of catching it.Evidence
Axiom
verceldataset. From 16:52:57 UTC everyadmission-challenge→tokenpair is a 403, roughly 200 ms apart:Challenge traffic begins at 13:00 UTC with no 403s until ~16:52, which matches the mode moving from
reporttoenforce.Changes
native-admission-apple.ts— compare the decoded bytes. Node's base64 decoder accepts both alphabets, so either wire form verifies. The credential-id check now runs before the nonce hash: it is cheaper, and it is reachable in tests.auth-fetch.ts— clear the stored key id when the server refuses admission. The client stored the id as soon asattestKeyAsyncresolved, which says nothing about the server persisting it, so a refused device asserted against an unknown key forever. The guard sits inpostAuth, where every native auth POST already routes through, so no future sign-in path can forget it.Tests
5 new tests. 3 of them fail without the encoding fix.
The 4 mobile typecheck errors (
watermarkEventId, kilo-chat arity) are pre-existing on cleanmainfrom a stale@kilocode/trpcdist, not from this change.Rollout
Devices that already attempted a sign-in hold a key id the server never persisted, so the server fix alone does not recover them — they need this mobile change in a new build, or an app reinstall to clear the keychain item.
Setting
NATIVE_ADMISSION_MODE=reportunblocks every iOS user immediately, independent of this PR.