Files
buzz/mobile/ios/BuzzPushKit
Tom Brow b929b2cf84 fix(mobile): replace a push enrollment the gateway rejected (#8629)
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>
2026-10-27 13:59:47 -07:00
..