Skip to content

Draw the device serial per install, in both the app and the exporter - #182

Merged
parawanderer merged 8 commits into
mainfrom
feat/per-install-serial
Sep 12, 2026
Merged

parawanderer merged 8 commits into
mainfrom
feat/per-install-serial

Conversation

@parawanderer

@parawanderer parawanderer commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Based on main, but it carries #177's three commits until that merges — the diff collapses on its own.

It was opened against #177's branch and that was a mistake: every workflow here is gated pull_request: branches: [main], so the APK build, the static checks, the bridge tests, the JVM suite and the emulator suite all matched nothing, and the page showed one green tick from the only workflow with no branch filter. Written up as rule 16 in AGENTS.md.

Every install of this project presents Apple a serial in X-Apple-I-SRL-NO, and until now it was a constant: 0PENTAGVIEWR from every copy of the app, 0PENTAGXPORT from every copy of the exporter. One serial, thousands of machine identities, thousands of Apple IDs, every continent, simultaneously. Real hardware does not look like that.

That is a candidate explanation for the Grand Slam 503s in #168, #176 and #181 — refusals that some accounts never recover from, at three different call sites (login, td_2fa_submit, request_pet). It is consistent with the one detail that otherwise does not fit: @crishpeen cleared device-identity.json and it did not help, which is what you would expect if the matched feature were the serial, because clearing that file regenerates the uid and devid and leaves the serial alone.

It is a hypothesis, and it is written down as one in identity.EXPORTER_SERIAL and AdiDeviceIdentity.LEGACY_SERIAL. Nothing here confirms it against Apple. What it does do is make it testable for free: an affected user reinstalling now draws a different serial rather than the same one.

What changed

Before After
App APP_SERIAL = "0PENTAGVIEWR" in Python, copied in Java 0PENTAGV + 4, drawn with SecureRandom, stored in LocalAnisette.PREFERENCES
Exporter EXPORTER_SERIAL = "0PENTAGXPORT" 0PENTAGX + 4, drawn with secrets, stored in device-identity.json
Source of truth a constant on each side, pinned equal by a test Java stores it, Python reads it across the bridge

The alphabet is ACDEFGHJKLMNPQRTUVWXY34679 — uppercase alphanumeric, with the pairs a person comparing two screens would confuse left out. Nobody types a serial; they read it off the app and look for it in their Apple device list, and the only failure mode is looking at the right row and believing it is the wrong one.

An install that already has an identity keeps its serial. Three keys and no serial key can only be an install that has been presenting the old constant for its whole life, and changing it now registers a second device beside the row the user already recognises, possibly costing them a sign-in, to change a label. It is not back-filled either, so IEWR and PORT stay undrawable — both contain a letter the alphabet excludes — which means an I or an O in a serial is, by construction, an install from before this change. That is worth having when reading a bug report.

Two defects this found in its own first draft

Both were in the exporter, both would have shipped:

  • The line building the Anisette provider was dropped in an earlier edit. An account built without one failed on an attribute of None. Nothing caught it because every other test in the suite passes a provider in.
  • remember() re-derived the serial from disk. A first run has no file, so it drew a second serial and stored that — and the next run would have introduced itself to Apple as a different device than the one just registered, adding a device-list entry every other export. It reads account.serial now.

Both have tests that fail with the defect reinstated.

Tests

Suite Covers
DrawingTheSerialTest (JVM) 500 draws, each with a fresh SecureRandom — the actual first-run case — produce >450 distinct serials. A shared instance with a good spread would prove nothing, since that is the shape that hides a fixed seed
LocalAnisetteIdentityTest the legacy install keeps its serial and is not back-filled; a fresh install draws, stores and re-reads one; the screen's read-only path mints nothing
IdentityBridgeTest, PythonPackagingTest Python asks Java rather than holding a copy, and the fallback is not mistaken for an answer
FetchFromICloudFlowTest the stored serial reaches both the tile and the prose on the registered-device screen
test_serial_is_per_install.py shape, spread across 500 draws and 5 separate processes, random.seed(0) not pinning it, the three serial_from cases, the round trip through the identity file, and both defects above

The fakes answer a drawn serial rather than the old constant throughout, so no test can pass against code that never reached the bridge and fell back.

Not in this PR

The wiki is updated locally but unpushed — it describes released behaviour, and pushing it before this ships would make it wrong for everyone on 1.1.0. The five pages that name a full serial now match on the first eight characters, with a note for installs that show the old values exactly.

Interactively co-authored by Claude Code and @parawanderer

parawanderer and others added 5 commits September 6, 2026 11:33
Issue #176, and it is the same 503 as #168 at two call sites nothing
covered.

That log is worth reading, because half of it is the previous fix
working. Apple refused the 2FA submit; the exporter waited, requested a
new code, and signed in - GSA authentication successful, LOGGED_IN. Then
open_client called request_pet one second later, got another 503, and
that one reached the wizard's catch-all: an exception type, a log path,
and a link to the issue tracker. The next attempt a minute later was
refused at login and did the same. So the user filed an issue, because
the program asked them to, about Apple having a bad three minutes.

The obstacle was that FindMy.py folded every non-OK status into
UnhandledProtocolError, whose meaning is "Apple said something this
library does not model" - which is a bug and is worth reporting. Nothing
downstream could tell that apart from a refusal without parsing the
status back out of the message.

So the fork gained AppleServiceUnavailableError, carrying the status and
subclassing UnhandledProtocolError so existing handlers keep working, and
raises it for 429 and 5xx from both sign-in requests. FindMy.py is
re-pinned in all four places per rule 14, verified by installing the new
pin and running the bridge suite against it.

Here, apple_is_declining walks the cause chain, because the two call
sites arrive differently: log_in raises it plainly and open_client raises
it several frames down, wrapped. Only the second reached the catch-all,
which is why it was the one that produced the issue. The wizard catches
the plain case by type and the wrapped case in _report_unexpected; the
CLI catches it ahead of its UnhandledProtocolError handler.

Nothing retries. The 2FA path can, because it knows what to re-do - the
code is spent and a new one can be requested - and that is left alone
because #176 shows it working. A refused login or request_pet has nothing
to re-do but the whole sign-in, so it says what happened and stops.

Twelve tests here, nineteen in the fork. All verified failable: matching
by type without walking the chain turns two red, and putting the issue
link back in the message turns two more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The exporter half of this landed with the pin bump; the app reaches the
same library and had nothing for the new type. It also had a regression
waiting, which is the part worth reading first.

**ACodeAppleAlreadyTook.spentIt matched the string "UnhandledProtocolError"
in the bridged message.** Its own docstring says there is nothing better
to match on "until the fork grows a transient-failure type". The fork now
has one, and it is a subclass - so a real 503 arrives as
AppleServiceUnavailableError, the match fails, and the 2FA wait-and-retry
silently stops firing. Every test in that file writes the old name into
its own fixture, so all eight checks stayed green over it. A test that
supplies the string it is looking for cannot notice the string changing.
It now matches both names, and three tests cover the one that is real.

Beyond that, a 503 outside the 2FA path had no classification at all:

- classifyLoginFailure sent it to REASON_UNKNOWN, so the login screen
  echoed "The Grand Slam request was refused with HTTP 503" verbatim.
  Correct, unreadable, and indistinguishable from a bug here, which is
  how issue #176 came to be filed.
- icloud_bridge._unexpected did the same, so the fetch screen fell to its
  default branch and put the same text on screen.

Both now report apple_declined, which the Java side maps to
ICloudFailure.APPLE_DECLINED and PythonAccountLoginException's matching
reason. Deliberately not CREDENTIALS_REJECTED: that ends in a forced
sign-out, and signing somebody out over a fault that clears in minutes is
rule 15's mistake in the direction that costs a working session. Also
deliberately not REASON_NETWORK, because the advice differs - a network
failure is usually the phone's and worth checking, and this one is not.

The login screen gets a sentence saying it is Apple's fault and the
password is fine. The fetch screen reaches the retry container it already
had, with wording that says which of the two failures it is rather than
the raw detail.

Fourteen tests. Three JVM for the regression, five JVM for the mapping
and the sign-out predicate, six Python across both classifiers, and two
Espresso - one per screen - asserting the sentence is shown and that the
status code and "Grand Slam" are not.

Three strings in ten locales via add_strings.py.

Not verified locally: no Android SDK here, so the JVM and emulator suites
are CI's to run. The Python bridge suite passes at 239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five people have now reported this and only @parawanderer has confirmed
it resolving. crishpeen on #168 says the opposite: every attempt, every
2FA method, device-identity.json cleared, still refused - and their log
shows login succeeding and only request_pet getting the 503.

The message said it "usually clears on its own within a few minutes",
which was one confirmed recovery stated as a rule. Somebody it does not
clear for would read that and wait.

It now says trying again shortly is worth doing, that it has been seen to
last, and asks for a report with the log when it keeps refusing. The 2FA
message said the same thing and said it immediately after demonstrating
that two waits had not cleared it, which was incoherent as well as
unsupported.

Both Android strings reworded through --show and --replace across ten
locales.

The test asserting no bug report is mentioned is narrowed to what it was
actually protecting: the dialog must not present this as a defect here,
which is what linking the issue tracker did. Asking for a report when it
persists is correct and now asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`EXPORTER_SERIAL` was a constant, so every copy of this program anywhere presented Apple the
same `X-Apple-I-SRL-NO` while presenting a *different* machine identity: one serial against
thousands of device ids and thousands of Apple IDs, from every continent, at once. Real
hardware does not look like that.

That is the leading suspect for the Grand Slam 503s in #168, #176 and #181, which some accounts
never recover from - and one reporter cleared their device identity to no effect, which is
exactly what would happen if the serial were the part being matched on, since clearing the file
regenerates the ids and not the serial.

It is a hypothesis and is written down as one in `identity.EXPORTER_SERIAL`. The cheap way to
test it is this change: an affected user deleting their identity file now draws a different
serial rather than the same one.

- `generate_serial()` draws four characters after `0PENTAGX` from an alphabet with the
  confusable pairs left out, using `secrets` rather than `random` so that two runs in the same
  second cannot collide and no seed can pin it
- an install that already has an identity keeps what it has: a stored serial is used as-is, and
  a stored identity with no serial keeps `0PENTAGXPORT`, because changing the serial on a
  working install costs a second device-list entry and possibly a sign-in
- `device.save` omits the key rather than defaulting it, so a save from a path that does not
  know the serial cannot blank a stored one

Two defects found while testing it, both of which would have shipped:

- the line building the Anisette provider was dropped, so an account built without one failed on
  an attribute of None. No test caught it because every other test passes a provider in
- `remember()` re-derived the serial from disk, which on a first run means there is no file - so
  it drew a *second* serial and stored that, and the next run would have introduced itself as a
  different device than the one just registered. It reads `account.serial` now, which is the
  only source that cannot disagree with what was sent

`test_serial_is_per_install.py` covers the shape, the spread across 500 draws and 5 separate
processes, the three `serial_from` cases, the round trip through the identity file, and both
defects above.
Same change as the exporter's, on the other half of the project, and for the same reason:
`APP_SERIAL` was a constant, so `0PENTAGVIEWR` reached Apple from every install of this app in
the world at once, against thousands of different machine identities. See #168, #176 and #181.

The serial is now drawn once - `0PENTAGV` plus four characters from an alphabet that leaves out
the pairs a person comparing two screens would confuse - and persisted beside the rest of the
device identity in `LocalAnisette.PREFERENCES`. `SecureRandom`, seeded by the platform and not
pinnable from here, because a seedable generator would hand every fresh install the same serial
and reproduce the fingerprint this exists to break up.

**An install that already has an identity keeps its serial**, including the installs that
predate this and have no stored serial at all: three keys and no fourth can only be an install
that has been presenting `0PENTAGVIEWR` for its whole life, and drawing it a new one now
registers a second device beside the row the user already recognises. It is not back-filled
either, so `I` or `O` in a serial still means "from before this change" when reading a report.

**Python asks rather than holding a copy.** The two constants used to be pinned equal by
`IdentityBridgeTest`; a per-install value cannot be, and a second copy of it is exactly what
rule 11 is about - in the one field the user actually reads. `identity.appSerial` reads it
across the bridge, and `AnisetteSource.serial()` answers without ADI so that a sign-in relayed
through a remote server presents the same serial as one that was not.

`icloud_bridge` builds its CloudKit identity per session rather than per process, off
`account.serial`: a restored session keeps whatever established it, so a constant there would
be right for a fresh install and wrong for every other one, in the field CloudKit writes into
the escrow record and the recovery picker matches on.

The registered-device screen reads the stored value rather than a literal. A screen naming a
serial Apple never saw would send the user looking for a row that is not theirs, and conclude
the one in front of them belongs to somebody else - which is what gets it removed.

Tests:

- `DrawingTheSerialTest` (JVM, rule 13) takes a fresh `SecureRandom` per draw, which is the case
  being tested - a first run is one process making one draw - and asserts 500 of them produce
  more than 450 distinct serials. A single shared instance producing a good spread would prove
  nothing, since that is the shape that hides a fixed seed
- `LocalAnisetteIdentityTest` covers the legacy install, the fresh draw, that it is stored and
  re-read, and that the screen's read-only path mints nothing
- the fakes answer a *drawn* serial rather than the old constant, so a test cannot pass against
  code that never reached the bridge and fell back

AGENTS.md rule 11 gains the argument, so this does not get put back to a constant.
@parawanderer
parawanderer changed the base branch from fix/apple-declining-is-not-a-bug to main September 12, 2026 12:30
#182 was opened against another branch to stack it on #177, and reported one green check - the
Windows binary build, the only workflow with no branch filter. Every workflow that matters is
gated `pull_request: branches: [ "main" ]`, so the APK build, the static checks, the Chaquopy
bridge tests, the JVM suite and the emulator suite all matched nothing and never ran.

It does not present as skipped, it presents as passing, which is the part worth a rule.
#173 and #154 landed while this was open. Both sides had appended to strings.xml, in all ten
locales, at the same point in the file - so every conflict is additive and both sets are kept.
`add_strings.py --check` passes at 370 strings across 9 translated locales.
#154 landed a paragraph saying the serial `0PENTAGVIEWR` is the only thing distinguishing this
app's device-list entry from real hardware, and the exporter's is `0PENTAGXPORT`. Both literals
stop being true forty lines above the bullets in this change, which say the serial is drawn per
install. Git merged the two happily, because they are in different paragraphs - a contradiction
with no conflict marker anywhere.

The paragraph is otherwise right and is what #154 is about, so only the two literals go: the
hardware profile is still always `LEGACY_MAC`, and the serial is still the only distinguishing
field. What it is is now answered in one place.
@parawanderer
parawanderer merged commit 3a70a20 into main Sep 12, 2026
8 checks passed
@crishpeen crishpeen mentioned this pull request Sep 12, 2026
1 task done

This branch was successfully deployed

1 active deployment
Android Build — 158fda4a Deployed Sep 12, 2026 by parawanderer via build #226
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant