mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
An iPhone could stop receiving push permanently after the gateway rejected its enrollment, even after a reinstall. Before sending an enrollment, iOS saves the exact signed request in the Keychain and replays those bytes until the gateway answers, so a lost response cannot orphan a committed installation. The saved request was replaced only on `404`. When the gateway rejected it with `401 invalid_attestation` (or `400 invalid_request`), each later attempt replayed the same rejected bytes for the saved request's whole lifetime (up to 30 days). The Keychain survives reinstall, so reinstalling did not help. We saw this on an internal device: in production, its gateway requests failed with repeated `401`s and it never got an installation. The gateway returns `401` before it looks up a committed installation, so replaying the same bytes can never recover one. iOS now replaces a saved request rejected this way with a fresh challenge and App Attest key, once the request is old enough. The first replacement waits an hour, and the wait doubles with each further rejection. Every replacement creates and attests a new key, so without the wait a device the gateway keeps rejecting would create a key on every retry. With it, such a device creates about ten keys over the saved request's 30-day lifetime, yet still recovers on its own once the cause is fixed. Requests saved by earlier builds carry no preparation time and are replaced right away, which recovers devices that are already stuck. Each replacement attempt is saved before any key is generated, and the rejected request stays saved until the fresh request overwrites it. A failed challenge fetch, a failed attestation or an app restart therefore waits instead of creating another key. If the fresh request also fails, the error still propagates and that request is saved for the next attempt. A `404` (the challenge is gone and nothing committed) still replaces right away, as before, so ordinary lost requests recover at once. A `409` or `404` answer to a request marks it as rejected. A conflict with a live installation for the same token consumes the challenge, and a fresh request answered `404` has outlived its own, so either replay then returns `404`. That `404` waits like a rejection, so neither case can create a key on every retry. This covers a request that did commit, whose response was lost and whose replay arrived after its attestation certificate expired. Enrollment then succeeds once the old installation expires. Recovering that installation itself would need the gateway to match an exact replay before checking certificate validity. That is out of scope here. `400` keeps the saved request, as before: it can be a temporary policy rejection (for example a lowered lifetime limit) of a request that did commit. Transient failures (for example `5xx` or network loss) also keep it. Companion gateway PR [#8630](https://github.com/block/buzz/pull/8630) logs which App Attest check rejected a device, so a `401` like this can be diagnosed from gateway logs. ### Validation - Added tests: one table-driven driver test. It loses the first response, then has the replay answered with `400`, `401`, `404` or `503`, at various ages, and the fresh request answered with `201`, `401`, `404` or `409`, or lost. Each row retries twice and checks the saved request and the number of App Attest keys generated. Rows that keep being rejected then check that the wait doubles, and that a replacement whose attestation fails after generating its key records the attempt, so the next retry waits even with a new driver instance. An ordinary `404` replacement whose attestation fails is checked the same way. With the source change reverted, the `401` rows fail. Removing any of these guards also fails the test: the age limit, the doubling, the immediate replacement for older builds' requests, saving each attempt, the `404` and `409` marks, keeping `404` out of the count, or keeping `400`. - The full BuzzPushKit suite passes locally (97 XCTest and 65 Swift Testing tests). - Not exercised on a device. The replay path needs a real device whose saved enrollment the gateway rejects, and the simulator does not support App Attest. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>