diff --git a/AGENTS.md b/AGENTS.md index 11d3f37e..4c7f657e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,23 @@ python -m venv .venv && .venv/bin/pip install "FindMy==" the pre-fix stub back and requires the job to fail. `native-stubs.yml` checks the stubs' exports and return conventions on their own, in seconds. +- **The machine id carries its provenance, and Grand Slam reads it.** The libraries are Apple's + Android ADI binaries, and the `X-Apple-I-MD-M` they mint says so. Since 2026-10-12 the + GsService2 handler refuses SRP init with `ec=-80045` when that machine id is presented under a + client-info claiming macOS or iPhone — before the password, before the OTP (a wrong OTP still + gets `-80045`; a missing one gets `-80010`). An Android client-info over the same machine id is + answered `ec=0`. That is why `AdiDeviceIdentity` claims a Pixel 7 and why `canSignIn()` exists: + provenance is bound at provisioning, so a Mac-provisioned machine presented as Android is + answered `hsc=434` — the profile must be set before `ADISetProvisioningPath`, and moving an + install means wiping `anisette/provisioning`, not just the preference. The field-level matrix + is reproducible with `scripts/probe_gsa_signin.py --real-init`, which needs no credentials. +- **`gsd.apple.com` chains to Apple Root CA - G3; nothing else in the world does.** The two + provisioning endpoints the URL bag points at are served under a root that is in no shipped + trust store — not Android's, not the anisette package's bundled one. It is in + `network_security_config.xml` for `gsa` and `gsd` only, as a PEM in `res/raw` so a diff can + review it. Remove it and provisioning fails the handshake; every public Anisette server + hitting `-45003`/502 in October 2026 was this. + ### 5. Never bundle an AMap API key AMap issues keys per developer account, bound to a package name and signing fingerprint, and diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java index 3cf5a07f..12943c80 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java @@ -1,6 +1,7 @@ package dev.wander.android.opentagviewer.anisette; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; @@ -101,9 +102,12 @@ public void everyProfileIsInternallyConsistent() throws Exception { for (final Hardware hardware : Hardware.values()) { final JSONObject json = new JSONObject(hardware.toJson()); final boolean phone = json.getString("model").startsWith("iPhone"); + final boolean android = "Android".equals(json.getString("os_name")); assertEquals(hardware + " names a phone in one field and a Mac in another", phone, "iPhone OS".equals(json.getString("os_name"))); + assertEquals(hardware + " names Android in one field and Apple hardware in another", + android, "Pixel 7".equals(json.getString("model"))); assertEquals(hardware + "'s user agent describes a different release", hardware.userAgent(), "akd/1.0 CFNetwork/" + hardware.cfnetwork() @@ -114,8 +118,34 @@ public void everyProfileIsInternallyConsistent() throws Exception { /** Two profiles that were the same machine would make the distinction meaningless. */ @Test public void theProfilesAreActuallyDifferentMachines() { - assertNotEquals(Hardware.LEGACY_MAC.toJson(), Hardware.IPHONE.toJson()); - assertNotEquals(Hardware.LEGACY_MAC.clientInfo(), Hardware.IPHONE.clientInfo()); + final Hardware[] all = Hardware.values(); + for (int i = 0; i < all.length; i++) { + for (int j = i + 1; j < all.length; j++) { + assertNotEquals(all[i] + " and " + all[j] + " are one machine", + all[i].toJson(), all[j].toJson()); + assertNotEquals(all[i] + " and " + all[j] + " are one machine", + all[i].clientInfo(), all[j].clientInfo()); + } + } + } + + /** + * The Android profile is the exact string the Grand Slam handler was verified to accept. + * + *

Verified on 2026-10-14 with {@code scripts/probe_gsa_signin.py --real-init}: a machine + * id provisioned under this client info, presented with this client info, is answered + * {@code ec=0} and an SRP challenge; the same machine id under the Mac's is answered + * {@code ec=-80045}. It is also the string xtool shipped in xtool-org/xtool#337. One + * character of drift here is a sign-in nobody can complete, so the whole string is pinned. + */ + @Test + public void theAndroidProfileIsTheOneAppleAccepts() { + assertEquals(" ", + Hardware.ANDROID.clientInfo()); + assertTrue(Hardware.ANDROID.canSignIn()); + assertFalse(Hardware.LEGACY_MAC.canSignIn()); + assertFalse(Hardware.IPHONE.canSignIn()); } /** @@ -143,20 +173,20 @@ public void everyProfileSuppliesTheSixFieldsFindMyExpects() throws Exception { } /** - * A fresh install claims the Mac. + * A fresh install claims the Android phone. * - *

It claimed the iPhone until Apple began answering 401 to {@code get_2fa_methods} for - * clients presenting that profile - provisioning and password auth both succeeded, and the - * very next request did not. The desktop exporter makes the same call against the same - * account and is answered, and this profile is byte-identical to what it provisions with. + *

Since 2026-10-12 the Grand Slam handler refuses SRP init for a Mac or iPhone claim + * over the Android-provisioned machine id this app's ADI libraries mint + * ({@code ec=-80045}); the Android claim is answered. See + * {@code AdiDeviceIdentity#generate()} and issue #248. * - *

So this asserts a decision taken from evidence, not a preference. If it ever changes - * back, that has to be because the 2FA question was answered - not because an iPhone icon - * looks better in a device list. + *

So this asserts a decision taken from evidence, not a preference. If it ever changes, + * that has to be because the handler's rule changed - re-run the probe first. */ @Test - public void afreshIdentityIsTheMacTheExporterAlsoUses() { - assertEquals(Hardware.LEGACY_MAC, AdiDeviceIdentity.generate().hardware()); + public void afreshIdentityIsTheAndroidPhoneAppleStillAccepts() { + assertEquals(Hardware.ANDROID, AdiDeviceIdentity.generate().hardware()); + assertEquals(Hardware.ANDROID, Hardware.DEFAULT); } /** diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java index edee27b0..fc43618f 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java @@ -2,7 +2,6 @@ import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -25,14 +24,16 @@ * What an upgrade does to an identity that already exists. * *

This is the half nobody can test by hand. Reaching the interesting case for real - * means having installed a build from before hardware profiles existed, signed in to a real - * Apple account on it, and then upgrading - and the way it fails is that Apple stops - * recognising the machine hours later, on somebody else's phone, with nothing in a log to say - * why. So the older shapes are written into {@link LocalAnisette#PREFERENCES} directly and read - * back through the same door the app uses. + * means having installed an older build, signed in to a real Apple account on it, and then + * upgrading - and the way it fails is that Apple stops recognising the machine hours later, on + * somebody else's phone, with nothing in a log to say why. So the older shapes are written into + * {@link LocalAnisette#PREFERENCES} directly and read back through the same door the app uses. * - *

The rule being tested is one sentence: an install that already has an identity keeps - * the machine it has always claimed. Everything below is a way of getting that wrong. + *

The rule being tested is two sentences. An install whose profile can still sign in + * keeps the machine it has always claimed - its ids, its serial, its profile. An install + * whose profile Apple refuses is replaced, once, by one that can: since 2026-10-12 that is + * every install claiming the Mac or the iPhone, see {@link AdiDeviceIdentity#generate()}. + * Everything below is a way of getting one of those wrong. */ @RunWith(AndroidJUnit4.class) public class LocalAnisetteIdentityTest { @@ -73,6 +74,7 @@ private LocalAnisette subject() { true); } + /** Three keys and no profile: an install from before there was a choice. */ private void writeTheOldShape() { assertTrue(this.preferences.edit() .putString(LocalAnisette.KEY_DEVICE_ID, OLD_DEVICE_ID) @@ -81,73 +83,110 @@ private void writeTheOldShape() { .commit()); } + /** The old ids under an explicit profile, as an install that chose one would have them. */ + private void writeTheOldShapeAs(final AdiDeviceIdentity.Hardware hardware) { + writeTheOldShape(); + assertTrue(this.preferences.edit() + .putString(LocalAnisette.KEY_HARDWARE, hardware.name()) + .commit()); + } + private static String modelOf(String json) throws Exception { return new JSONObject(json).getString("model"); } + private void assertTheOldIdsWereReplaced() { + assertNotEquals(OLD_DEVICE_ID, this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); + assertNotEquals(OLD_ADI_ID, this.preferences.getString(LocalAnisette.KEY_ADI_ID, null)); + assertNotEquals(OLD_LOCAL_USER, + this.preferences.getString(LocalAnisette.KEY_LOCAL_USER, null)); + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.name(), + this.preferences.getString(LocalAnisette.KEY_HARDWARE, null)); + } + + private void assertTheOldIdsWereKept() { + assertEquals(OLD_DEVICE_ID, this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); + assertEquals(OLD_ADI_ID, this.preferences.getString(LocalAnisette.KEY_ADI_ID, null)); + assertEquals(OLD_LOCAL_USER, this.preferences.getString(LocalAnisette.KEY_LOCAL_USER, null)); + } + /** * The one that matters. * *

Three keys and no hardware key can only mean an install from before there was a - * choice, and the only thing this app ever claimed then was the Mac. Defaulting it to the - * new profile instead would present Apple with a different machine on an existing session: - * a sign-in the user did not ask for, and a second device-list entry beside a *Remove from - * Account* button. + * choice, and the only thing this app ever claimed then was the Mac - which Apple no longer + * lets sign in. Keeping it would be an install that can never sign in again, so it is + * replaced by the one profile that can, ids and all: a machine id provisioned under the + * Mac claim and presented under the Android one is answered with a re-sync demand, so a + * half-migration that kept the ids and changed the claim would not work either. */ @Test - public void anIdentityWrittenBeforeProfilesExistedIsStillTheMac() throws Exception { + public void anIdentityWrittenBeforeProfilesExistedIsReplacedByTheAndroidPhone() + throws Exception { writeTheOldShape(); - assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.toJson(), + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.toJson(), subject().hardwareProfileJson()); - assertEquals("MacBookPro13,2", modelOf(subject().hardwareProfileJson())); + assertEquals("Pixel 7", modelOf(subject().hardwareProfileJson())); + assertTheOldIdsWereReplaced(); + } + + /** Nothing stored at all is a genuinely new install - and it claims the Android phone. */ + @Test + public void afreshInstallIsTheAndroidPhone() throws Exception { + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.toJson(), + subject().hardwareProfileJson()); + assertEquals("Pixel 7", modelOf(subject().hardwareProfileJson())); } /** - * Nothing stored at all is a genuinely new install - and it claims the Mac. + * An install that already claims the Android phone keeps everything it has. * - *

It claimed an iPhone until Apple answered 401 to {@code get_2fa_methods} for clients - * presenting that profile. See {@code AdiDeviceIdentity#generate()}. + *

This is the rule the replacement must not break: re-identifying an install that + * works would make its session look like a new machine, which is what two-factor + * authentication exists to notice. */ @Test - public void afreshInstallIsTheMac() throws Exception { - assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.toJson(), + public void anInstallThatCanSignInKeepsItsIdentity() throws Exception { + writeTheOldShapeAs(AdiDeviceIdentity.Hardware.ANDROID); + + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.toJson(), subject().hardwareProfileJson()); - assertEquals("MacBookPro13,2", modelOf(subject().hardwareProfileJson())); + assertTheOldIdsWereKept(); } /** - * Reading the profile must not quietly rewrite an existing install's identity. + * A replaced identity is written down, so the replacement happens exactly once. * - *

Persisting {@code LEGACY_MAC} on read would be harmless today and a trap tomorrow: the - * derivation is what encodes "this predates the choice", and writing it down converts a - * fact that can be re-derived into stored state that can be wrong. + *

Left unwritten, every read would see the legacy shape again and mint another machine, + * which is the per-login regeneration the whole class exists to prevent. */ @Test - public void readingALegacyProfileDoesNotWriteOneOverTheTopOfIt() { + public void thereplacementIsRecordedSoItHappensOnce() { writeTheOldShape(); subject().hardwareProfileJson(); + final String replacedDeviceId = + this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null); - assertFalse("the absence of this key is what marks a legacy install", - this.preferences.contains(LocalAnisette.KEY_HARDWARE)); - assertEquals(OLD_DEVICE_ID, this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); - assertEquals(OLD_ADI_ID, this.preferences.getString(LocalAnisette.KEY_ADI_ID, null)); - assertEquals(OLD_LOCAL_USER, - this.preferences.getString(LocalAnisette.KEY_LOCAL_USER, null)); + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.name(), + this.preferences.getString(LocalAnisette.KEY_HARDWARE, null)); + subject().hardwareProfileJson(); + assertEquals("asked again, the replacement was kept rather than replaced again", + replacedDeviceId, this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); } /** * A fresh install's profile is written down, and written down with the identity. * *

If the three keys were persisted and the fourth were not, the very next read would see - * the legacy shape and decide this brand-new install was a Mac. + * the legacy shape, decide this brand-new install was a Mac, and replace it. */ @Test public void afreshInstallRecordsWhatItDecided() { subject().hardwareProfileJson(); - assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.name(), + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.name(), this.preferences.getString(LocalAnisette.KEY_HARDWARE, null)); assertNotNull(this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); assertNotNull(this.preferences.getString(LocalAnisette.KEY_ADI_ID, null)); @@ -173,41 +212,40 @@ public void theIdentityIsGeneratedOnceAndThenKept() { /** * A stored profile this version has never heard of falls back rather than throwing. * - *

The bad outcome is re-identifying that install; the worse one is an app that cannot - * start at all, which is what a {@code valueOf} straight off stored text would give after a - * profile was renamed or removed. It falls back to the Mac because the keys beside it say - * this install is not new. + *

The fallback is the Mac, because the keys beside it say this install is not new - and + * the Mac cannot sign in, so it is then replaced like any other. The outcome is a + * re-identified install, which is bad but recoverable; a {@code valueOf} straight off + * stored text would give an app that cannot start at all. */ @Test public void anUnrecognisedStoredProfileFallsBackInsteadOfCrashing() { writeTheOldShape(); this.preferences.edit().putString(LocalAnisette.KEY_HARDWARE, "VISION_PRO").commit(); - assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.toJson(), + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.toJson(), subject().hardwareProfileJson()); + assertTheOldIdsWereReplaced(); } - /** A profile stored explicitly is honoured, which is the whole point of storing it. */ + /** The iPhone was never a fresh install's default, but an install that has it is refused too. */ @Test - public void astoredProfileIsUsedAsWritten() { - writeTheOldShape(); - this.preferences.edit() - .putString(LocalAnisette.KEY_HARDWARE, AdiDeviceIdentity.Hardware.IPHONE.name()) - .commit(); + public void astoredIphoneIsReplacedAsWell() { + writeTheOldShapeAs(AdiDeviceIdentity.Hardware.IPHONE); - assertEquals(AdiDeviceIdentity.Hardware.IPHONE.toJson(), subject().hardwareProfileJson()); + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.toJson(), subject().hardwareProfileJson()); + assertTheOldIdsWereReplaced(); } /** * The ids handed to FindMy.py are the ones stored, not new ones. * - *

This is the whole alignment in one assertion: what a legacy install already told Apple + *

This is the whole alignment in one assertion: what an install already told Apple * during provisioning is what its next sign-in claims to be. Minting a pair here instead * would put it back to talking to Apple as two devices, silently. */ @Test public void thestoredIdsAreTheOnesHandedOver() throws Exception { - writeTheOldShape(); + writeTheOldShapeAs(AdiDeviceIdentity.Hardware.ANDROID); final JSONObject ids = new JSONObject(subject().deviceIdsJson()); @@ -222,14 +260,13 @@ public void thestoredIdsAreTheOnesHandedOver() throws Exception { */ @Test public void theuidIsHandedOverUnencoded() throws Exception { - writeTheOldShape(); - final AdiDeviceIdentity.Hardware profile = AdiDeviceIdentity.Hardware.LEGACY_MAC; + writeTheOldShapeAs(AdiDeviceIdentity.Hardware.ANDROID); final String handedOver = new JSONObject(subject().deviceIdsJson()).getString("uid"); assertEquals(OLD_LOCAL_USER, handedOver); - assertEquals("this install's header convention is raw, so these coincide here", - profile.localUserHeader(OLD_LOCAL_USER), handedOver); + assertNotEquals("this is the header form, and handing it over would encode it twice", + AdiDeviceIdentity.Hardware.ANDROID.localUserHeader(OLD_LOCAL_USER), handedOver); } /** A fresh install's ids are stable too - asking twice must not mint a second device. */ @@ -243,10 +280,9 @@ public void afreshInstallsIdsAreStableAcrossCalls() { /** * A fresh install hands over exactly what it stored, which is where a double-encode hides. * - *

The legacy tests above cannot see this one: that profile sends its local user id raw, - * so the stored value and the header value coincide and a bug that returned the header form - * would pass. On a fresh install they differ - base64 against the UUID - and only the UUID - * is correct, because FindMy.py encodes it again on the way out. + *

The Android profile sends its local user id as base64 of the stored UUID, so the + * stored value and the header value differ - and only the stored one is correct, because + * FindMy.py encodes it again on the way out. */ @Test public void afreshInstallHandsOverTheStoredIdsAndNotTheHeaderForms() throws Exception { @@ -257,7 +293,7 @@ public void afreshInstallHandsOverTheStoredIdsAndNotTheHeaderForms() throws Exce assertEquals(this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null), ids.getString("devid")); assertNotEquals("this is the header form, and handing it over would encode it twice", - AdiDeviceIdentity.Hardware.IPHONE.localUserHeader(ids.getString("uid")), + AdiDeviceIdentity.Hardware.ANDROID.localUserHeader(ids.getString("uid")), ids.getString("uid")); } @@ -265,8 +301,7 @@ public void afreshInstallHandsOverTheStoredIdsAndNotTheHeaderForms() throws Exce * A partly written identity is not a legacy install. * *

SharedPreferences applies asynchronously, so a process killed mid-write can leave one - * key and not the others. Treating that as "from before profiles existed" would hand a - * brand-new install the Mac, permanently. + * key and not the others. It is treated as absent and a fresh identity written. */ @Test public void ahalfWrittenIdentityIsTreatedAsAbsent() { @@ -274,7 +309,7 @@ public void ahalfWrittenIdentityIsTreatedAsAbsent() { .putString(LocalAnisette.KEY_DEVICE_ID, OLD_DEVICE_ID) .commit()); - assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.toJson(), + assertEquals(AdiDeviceIdentity.Hardware.ANDROID.toJson(), subject().hardwareProfileJson()); // Not the point of this test, but worth pinning: it wrote a *new* identity rather than // adopting the orphaned device id. @@ -285,14 +320,14 @@ public void ahalfWrittenIdentityIsTreatedAsAbsent() { /** * An install from before serials were drawn keeps the one it has been presenting. * - *

Same shape as the hardware profile, and the same reason: three keys and no serial can - * only be an install that has been telling Apple {@code 0PENTAGVIEWR} for its whole life. - * Drawing it a new one now registers a second device beside the row the user already - * recognises, and may cost them a sign-in - to change a label. + *

Three keys and a profile that can sign in, but no serial, can only be an install that + * has been telling Apple {@code 0PENTAGVIEWR} for its whole life. Drawing it a new one now + * registers a second device beside the row the user already recognises, and may cost + * them a sign-in - to change a label. */ @Test public void anIdentityWrittenBeforeSerialsWereDrawnStillPresentsTheOldOne() { - writeTheOldShape(); + writeTheOldShapeAs(AdiDeviceIdentity.Hardware.ANDROID); assertEquals(AdiDeviceIdentity.LEGACY_SERIAL, subject().serial()); } @@ -300,7 +335,7 @@ public void anIdentityWrittenBeforeSerialsWereDrawnStillPresentsTheOldOne() { /** And it is not back-filled, so it stays distinguishable from a serial that was drawn. */ @Test public void thelegacySerialIsNotWrittenBackOverTheOldShape() { - writeTheOldShape(); + writeTheOldShapeAs(AdiDeviceIdentity.Hardware.ANDROID); subject().serial(); @@ -308,6 +343,23 @@ public void thelegacySerialIsNotWrittenBackOverTheOldShape() { this.preferences.getString(LocalAnisette.KEY_SERIAL, null)); } + /** + * A replaced identity draws a serial with the rest of it. + * + *

It is a new device-list row either way, so there is nothing for the old serial to + * keep recognisable - and the old row is the one the user is about to see a second entry + * beside, which a different serial at least makes tellable apart. + */ + @Test + public void areplacedIdentityDrawsASerial() { + writeTheOldShape(); + + final String drawn = subject().serial(); + + assertTrue(drawn.startsWith(AdiDeviceIdentity.SERIAL_PREFIX)); + assertNotEquals(AdiDeviceIdentity.LEGACY_SERIAL, drawn); + } + /** * A fresh install draws one, and keeps it. * @@ -331,7 +383,7 @@ public void afreshInstallDrawsASerialAndThenKeepsIt() { /** A stored serial is presented unchanged, whatever it is. */ @Test public void astoredSerialIsWhatGoesToApple() { - writeTheOldShape(); + writeTheOldShapeAs(AdiDeviceIdentity.Hardware.ANDROID); assertTrue(this.preferences.edit() .putString(LocalAnisette.KEY_SERIAL, "0PENTAGVK7QX").commit()); @@ -362,4 +414,20 @@ public void showingTheSerialShowsTheOneThisInstallWillSend() { assertEquals("the screen named a serial Apple never saw for this user", drawn, LocalAnisette.serialToShow(this.context)); } + + /** + * The screen shows the profile the next load will actually use, not the one about to go. + * + *

A stored Mac is replaced on the next identity read; telling the user to look for a + * MacBook row would describe the entry they are about to see a Pixel appear beside. + */ + @Test + public void showingTheProfileShowsTheOneThisInstallWillSend() { + writeTheOldShape(); + + assertEquals(AdiDeviceIdentity.Hardware.ANDROID, LocalAnisette.profileToShow(this.context)); + assertNull("reading it for a label must not replace anything", + this.preferences.getString(LocalAnisette.KEY_HARDWARE, null)); + assertEquals(OLD_DEVICE_ID, this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java index 5e668fc8..e6ba1cc0 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java @@ -57,20 +57,25 @@ public AdiDeviceIdentity(String uniqueDeviceIdentifier, String adiIdentifier, /** * A fresh identity. Call once, persist, never call again. * - *

The Mac, not the iPhone, and that is a retreat from evidence rather than a - * preference. Claiming an iPhone provisioned fine and signed in fine, and then Apple - * answered 401 to the very next request - {@code get_2fa_methods}, asking which phone - * numbers could receive a code. The desktop exporter makes the same call against the same - * account and is answered, and the largest remaining difference between them was this. + *

The Android phone, because since 2026-10-12 nothing else gets past sign-in. + * The ADI libraries this app provisions with are Apple's Android binaries, taken + * from the Apple Music APK, and the machine id they mint says so. Apple's Grand Slam + * handler now compares that provenance against the platform {@code X-MMe-Client-Info} + * claims: a Mac or iPhone claim over an Android-minted machine id is refused at SRP init + * with {@code ec=-80045}, before any credential is looked at. A client-info naming Android + * over the same machine id is answered with the SRP challenge. Localised with + * {@code scripts/probe_gsa_signin.py --real-init}, issue #248; the same change fixed the + * identical lockout in xtool (xtool-org/xtool#337). * - *

An iPhone is a trusted device, so a client claiming to be one asking where to - * send an SMS code is a question a real iPhone would not ask. That is a guess at the - * mechanism; what is not a guess is that this profile works and that one did not get past - * sign-in, and a nicer icon is not worth an app nobody can log into. + *

The provenance is bound at provisioning, not at sign-in: a machine id provisioned + * under a Mac claim and then presented with an Android one is answered {@code hsc=434}, + * a re-sync demand. So the profile has to be in place before ADI is provisioned, + * which is why {@link LocalAnisette} re-provisions an install it moves to this profile. * - *

These values are byte-identical to what the {@code anisette} package provisions with, - * which is what the exporter uses - so the app and the working program now introduce - * themselves to Apple as the same machine. + *

{@link Hardware#LEGACY_MAC} is what the {@code anisette} package and the desktop + * exporter still claim, and what every install before this shipped as. It was the right + * answer for as long as Apple did not look; it is kept so an install that has it can be + * recognised and moved, and for the record of why the iPhone was not chosen either. */ /** * The eight characters every serial this app presents begins with. @@ -167,11 +172,12 @@ private static String hex(SecureRandom random, int bytes) { /** * The machine this install claims to be, chosen once and then kept. * - *

Two profiles, and which one an install has is not a preference: it is part of + *

Three profiles, and which one an install has is not a preference: it is part of * what Apple binds a session to, so moving an install from one to the other costs that user - * a sign-in and leaves a second entry in their device list. An install that already has an - * ADI identity keeps {@link #LEGACY_MAC}, and so, for now, does a fresh one - see - * {@link #generate()} for why {@link #IPHONE} is built but not chosen. + * a sign-in and leaves a second entry in their device list. That cost is paid exactly once, + * deliberately, for every install still on {@link #LEGACY_MAC} or {@link #IPHONE}: neither + * can sign in any more, and a session that cannot be renewed is worth less than one + * re-login - see {@link #generate()} and {@link #ANDROID}. * *

Each carries all six parts because they describe one real release and move * together - model, OS, build, CFNetwork and Darwin. FindMy.py's {@code DeviceIdentity} @@ -263,6 +269,41 @@ public String localUserHeader(String localUserUuid) { return Base64.encodeToString( localUserUuid.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); } + }, + + /** + * A Pixel 7 on Android 14 - the platform the ADI libraries actually are. + * + *

The only profile Apple's Grand Slam handler accepts a sign-in from since + * 2026-10-12, see {@link AdiDeviceIdentity#generate()}. The model and build are + * the ones xtool shipped in its fix (xtool-org/xtool#337); provisioning and SRP init + * under this exact string were verified from this repository against a freshly + * provisioned machine id and answered {@code ec=0}. + * + *

CFNetwork and Darwin are {@link #LEGACY_MAC}'s. They reach Apple only in the + * provisioning {@code User-Agent}, which the verified exchange sent unchanged beside + * the Android client info - the handler evidently reads the client info and not the + * agent - and inventing an Android CFNetwork build would be a value nothing real sends. + * + *

Local user id and its header follow {@link #IPHONE}: a UUID, base64 on the wire, + * so that provisioning and FindMy.py's sign-in carry the same bytes. + */ + ANDROID( + "Pixel 7", + "Pixel 7", "Android", "14", "UQ1A.240205.002", + "1404.0.5", "22.3.0", + "com.apple.akd/1.0") { + + @Override + String newLocalUserId(SecureRandom random) { + return UUID.randomUUID().toString().toUpperCase(Locale.ROOT); + } + + @Override + public String localUserHeader(String localUserUuid) { + return Base64.encodeToString( + localUserUuid.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + } }; /** @@ -292,7 +333,17 @@ public String localUserHeader(String localUserUuid) { * would put one machine on screen and register another - which is the failure rule 11 is * about, arriving in the one place the user can actually see it. */ - public static final Hardware DEFAULT = LEGACY_MAC; + public static final Hardware DEFAULT = ANDROID; + + /** + * Whether Apple's Grand Slam handler still lets this profile sign in. + * + *

Read by {@link LocalAnisette} to decide whether a stored identity is worth keeping. + * Only {@link #ANDROID} is: see {@link AdiDeviceIdentity#generate()}. + */ + public boolean canSignIn() { + return this == ANDROID; + } private final String marketingName; private final String model; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java index dfad9798..14936564 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/LocalAnisette.java @@ -316,7 +316,7 @@ private ApplesLibrary load(File libraryDir, NativeLoadGuard guard) throws Except // Read before the library is opened rather than after: it touches only preferences, and // moving it lets the guarded window below hold exactly the calls into Apple's code. final AdiDeviceIdentity deviceIdentity = loadOrCreateIdentity(); - final File provisioningDir = new File(this.context.getFilesDir(), "anisette/provisioning"); + final File provisioningDir = provisioningDirectory(); // **The window is the load and the initialise, and nothing longer.** Both are native, // both take milliseconds, and #232's crash is in the first. Provisioning is left out on @@ -386,13 +386,33 @@ public void clear() { }; } + /** Where ADI keeps the machine it provisioned; wiped when the identity has to change. */ + private File provisioningDirectory() { + return new File(this.context.getFilesDir(), "anisette/provisioning"); + } + /** - * The device identity, generated once and then kept forever. + * The device identity, generated once and then kept - for as long as Apple lets it sign in. * *

Regenerating it would make every login look like a brand new machine to Apple, which * is exactly what two-factor authentication exists to notice. So it is written on first * use and read thereafter. * + *

With one exception, and it is the exception that unbroke sign-in. An identity + * whose profile Apple refuses outright - everything but + * {@link AdiDeviceIdentity.Hardware#ANDROID} since 2026-10-12, see + * {@link AdiDeviceIdentity#generate()} - is replaced rather than kept, and the machine ADI + * provisioned under it is wiped so the next load provisions afresh under the new claim. + * That re-identifies the install: a session established as the Mac stops being recognised + * and the user signs in again, under a profile that can. Kept, it would be a session that + * works until it expires and an install that can never sign in again; xtool made the same + * call for the same reason (its provider-id bump forces a re-provision on every install). + * + *

It has to happen here and not later, because this runs before Apple's library is + * opened: ADI reads its provisioning from the directory it is pointed at, and a directory + * emptied before {@code ADISetProvisioningPath} is a machine that is not provisioned yet. + * Emptied after, the loaded library would keep answering from memory. + * *

SharedPreferences rather than the DataStore the rest of the app uses: this runs on a * background thread inside a blocking sequence, and does not want an Rx round trip in the * middle of it. @@ -406,21 +426,24 @@ private AdiDeviceIdentity loadOrCreateIdentity() { final String localUser = preferences.getString(KEY_LOCAL_USER, null); if (deviceId != null && adiId != null && localUser != null) { - // **An install that already has an identity keeps the machine it has always - // claimed to be.** The absence of the hardware key is what says "from before - // profiles existed", and that can only mean the Mac: it is the only thing this app - // ever sent. Defaulting a pre-existing install to the new profile instead would - // re-identify it to Apple, which costs the user a sign-in and leaves a second - // device-list entry - to change the icon on a row they already recognise. + // The absence of the hardware key is what says "from before profiles existed", and + // that can only mean the Mac: it is the only thing this app ever sent then. final AdiDeviceIdentity.Hardware hardware = hardwareFrom(preferences.getString(KEY_HARDWARE, null), AdiDeviceIdentity.Hardware.LEGACY_MAC); - // Same argument for the serial, for the same reason: an install with an identity - // and no stored serial has been telling Apple it is 0PENTAGVIEWR, and drawing it a - // new one now would register a second device beside the row it already has. - return new AdiDeviceIdentity(deviceId, adiId, localUser, - serialFrom(preferences), hardware); + if (hardware.canSignIn()) { + // An install with an identity and no stored serial has been telling Apple it is + // 0PENTAGVIEWR, and drawing it a new one now would register a second device + // beside the row it already has. + return new AdiDeviceIdentity(deviceId, adiId, localUser, + serialFrom(preferences), hardware); + } + + Log.w(TAG, "stored identity claims " + hardware + ", which Apple no longer lets sign" + + " in; replacing it and re-provisioning as " + + AdiDeviceIdentity.Hardware.DEFAULT + " - this costs one sign-in"); + deleteRecursively(provisioningDirectory()); } final AdiDeviceIdentity fresh = AdiDeviceIdentity.generate(); @@ -437,6 +460,19 @@ private AdiDeviceIdentity loadOrCreateIdentity() { return fresh; } + /** Removes a directory and everything under it; a path that does not exist is fine. */ + private static void deleteRecursively(final File path) { + final File[] children = path.listFiles(); + if (children != null) { + for (final File child : children) { + deleteRecursively(child); + } + } + if (path.exists() && !path.delete()) { + Log.w(TAG, "could not delete " + path + "; ADI may keep answering as the old machine"); + } + } + /** * Which machine this install presents as, for showing the user - and nothing else. * @@ -451,10 +487,14 @@ private AdiDeviceIdentity loadOrCreateIdentity() { * {@code ensureReady}, which is the expensive one. */ public static AdiDeviceIdentity.Hardware profileToShow(final Context context) { - return hardwareFrom( + final AdiDeviceIdentity.Hardware stored = hardwareFrom( context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) .getString(KEY_HARDWARE, null), AdiDeviceIdentity.Hardware.DEFAULT); + // A profile that can no longer sign in is what the next load replaces - see + // loadOrCreateIdentity - so showing it would describe a row that is about to be joined + // by another. The one the user will actually be asked to recognise is the default. + return stored.canSignIn() ? stored : AdiDeviceIdentity.Hardware.DEFAULT; } /** diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 5bab609f..d7259870 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -33,6 +33,157 @@ import identity as app_identity +def _claimAppleMusicAtMobileme() -> None: + """ + Make the mobileme delegate request claim the one Apple client that exists on + Android: Apple Music. + + The stock request identifies as `accountsd` (`com.apple.AOSKit/282 + (com.apple.accountsd/113)`) with an `iCloudHelper` user agent - both macOS + daemons - while the identity's platform half already claims a Pixel running + Android. An impossible pairing, and the same class of inconsistency Grand + Slam answers with -80045. Whether `com.apple.mobileme`'s `status=1` is that + check repeated at the delegate layer is what flipping these constants tests: + same machine, same account, different claim. + + Module-level so it covers every path to `_login_mobileme` - the initial + login, the 2FA `submit`, and `complete_login` after terms - and done at + import so it precedes all of them. + """ + import findmy.reports.account as _account_module + _account_module._ACCOUNTSD_BUNDLE = \ + "com.apple.AuthKit/1 (com.apple.android.music/4.9.6)" + _account_module._ICLOUD_HELPER = "com.apple.android.music/4.9.6" + + +_claimAppleMusicAtMobileme() + + +def _probeMobilemeExchange() -> None: + """ + Log what the mobileme delegate exchange carries and receives - lengths and + key names only, never values. + + Two numbers decide between the live theories for `status=1`: if + `com.apple.gs.idms.pet` is absent from the GSA token set, the request below + goes out authenticating with an empty password and the refusal is mechanical + - hoorayman's log line shows `idms_pet_len=523`, so this build reports the + same field for a direct comparison. And if the request is complete, the + response's own keys say what a refusal actually contains: `dsid` present + means the account record was found and only the delegate declined it, which + is a different shape from the request being refused outright. + """ + import findmy.reports.account as _account_module + from findmy.util.http import HttpSession + + original_mobileme = _account_module.AsyncAppleAccount._login_mobileme + + async def _probed_mobileme(self): # noqa: ANN001 + state_data = getattr(self, "_login_state_data", {}) or {} + print( + "[MOBILEME-PROBE] enter " + f"idms_pet_len={len(state_data.get('idms_pet') or '')} " + f"adsid_len={len(state_data.get('adsid') or '')} " + f"idms_hb_len={len(state_data.get('idms_hb') or '')} " + f"state_keys={sorted(state_data.keys())}" + ) + return await original_mobileme(self) + + _account_module.AsyncAppleAccount._login_mobileme = _probed_mobileme + + original_post = HttpSession.post + + def _describe_value(key, value): + if isinstance(value, str) and len(value) > 60: + return f"str[{len(value)}]" + if isinstance(value, dict): + return f"dict keys={sorted(map(str, value.keys()))}" + return repr(value) + + async def _sniffing_post(self, url, **kwargs): # noqa: ANN001, ANN002, ANN003 + resp = await original_post(self, url, **kwargs) + url_text = str(url) + + if "GsService2" in url_text or "gsa.apple.com" in url_text: + try: + gsa = resp.plist() + response = gsa.get("Response", gsa) + status = response.get("Status", {}) + print( + "[GSA-PROBE] hsc=%r au=%r ec=%r response_keys=%s spd_is_bytes=%r" + % ( + status.get("hsc"), + status.get("au"), + status.get("ec"), + sorted(map(str, response.keys())), + isinstance(response.get("spd"), bytes), + ) + ) + except Exception as parse_error: + print(f"[GSA-PROBE] unreadable response: {parse_error!r}") + return resp + + if "loginDelegates" not in url_text: + return resp + try: + data = resp.plist() + except Exception as parse_error: + print(f"[MOBILEME-PROBE] response not a plist: {parse_error!r}") + return resp + for key, value in sorted(data.items(), key=lambda item: str(item[0])): + print(f"[MOBILEME-PROBE] {key}: {_describe_value(key, value)}") + delegates = data.get("delegates") + if isinstance(delegates, dict): + for name, delegate in delegates.items(): + if isinstance(delegate, dict): + print( + f"[MOBILEME-PROBE] {name}: " + f"keys={sorted(map(str, delegate.keys()))} " + f"status={delegate.get('status')!r} " + f"status-message={delegate.get('status-message')!r}" + ) + return resp + + HttpSession.post = _sniffing_post + + # The complete-step `spd` arrives AES-encrypted under the SRP session key and + # is only readable after decryption, which the fork does through + # decrypt_spd_aes_cbc. What the token set actually contains answers why the + # pet is missing: an absent `t` means this identity is issued no tokens at + # all, while differently named entries would mean the fork reads the wrong + # key. Token names are a fixed vocabulary of Apple service ids - the values + # are credentials and stay unlogged. + import findmy.util.crypto as _crypto_module + import findmy.util.parsers as _parsers_module + + original_decrypt = _crypto_module.decrypt_spd_aes_cbc + + def _probed_decrypt(session_key, data): # noqa: ANN001 + plain = original_decrypt(session_key, data) + try: + spd = _parsers_module.decode_plist(plain) + tokens = spd.get("t", {}) if isinstance(spd, dict) else {} + token_summary = { + name: sorted(map(str, entry.keys())) if isinstance(entry, dict) else type(entry).__name__ + for name, entry in tokens.items() + } + print( + "[GSA-PROBE] spd_keys=%s token_names=%s" + % (sorted(map(str, spd.keys())), token_summary) + ) + except Exception as parse_error: + print(f"[GSA-PROBE] spd not readable: {parse_error!r}") + return plain + + # Patched on the module the account code resolves it through: account.py + # calls `util.crypto.decrypt_spd_aes_cbc`, so the attribute on that module + # is what the call site sees. + _crypto_module.decrypt_spd_aes_cbc = _probed_decrypt + + +_probeMobilemeExchange() + + class TwoFactorMethods(Enum): UNKNOWN = 0 TRUSTED_DEVICE = 1 @@ -303,9 +454,14 @@ def loginSync(email: str, password: str, anisetteServerUrl: str, # before there was a choice keeps the Mac its ADI was provisioned with, and a fresh one # is an iPhone. Passed even when local Anisette turns out to be unusable, because a # sign-in relayed through a server must still claim the same machine. + # Experiment: force the remote server so the machine id presented is not + # provisioned by the local Android ADI libraries. If com.apple.mobileme + # answers status=0 here where the same account got status=1 under the + # local identity, the delegate gate keys on ADI provenance rather than on + # anything the request headers claim. anisette = _anisetteProvider( anisetteServerUrl, - localAnisette, + None, **app_identity.identityForNewSession(localAnisette), ) # And the two ids the same install already used when it provisioned ADI, so this is one @@ -398,9 +554,31 @@ def exportToString(account: AppleAccount) -> str: cannot hand it over and should not be asked to. A state name is not sensitive. """ print(f"Storing account, login state: {account.login_state}") + _logServiceTokenNames(account) return json.dumps(account.to_json()) +def _logServiceTokenNames(account: AppleAccount) -> None: + """ + Which iCloud service tokens this sign-in was issued, by name only. + + **Names, never values.** Whether a token exists is the diagnostic - whether an + Android identity is issued `searchPartyToken` at all is the open question in + issue #248 and malmeloo/FindMy.py#278, and the only way it gets answered is + somebody looking at a live response - while a token's value is a credential + that must not reach a log file. Read here rather than in `loginSync`: the + mobileme delegate exchange that issues them also runs inside a 2FA `submit`, + which this bridge never sees, so the export path - which Java calls on every + completed sign-in - is the one place all flows pass through. Guarded because + `service_tokens` raises on anything short of LOGGED_IN. + """ + try: + names = sorted(account.service_tokens.keys()) + print(f"Sign-in issued {len(names)} service token(s): {names}") + except Exception: + print(f"Could not read the issued service tokens: {traceback.format_exc()}") + + # The anisette provider type accounts are stored with. # # FindMy's own `aniLocal` is not it: that one needs the unicorn CPU emulator to run Apple's ADI @@ -743,6 +921,7 @@ def acceptTerms(account: AppleAccount, pageId: str) -> str: state = account.complete_login() print(f"All terms accepted; signing in ended at {state}") + _logServiceTokenNames(account) return json.dumps({ "ok": True, diff --git a/app/src/test/python/test_identity.py b/app/src/test/python/test_identity.py index d2b0d778..7b9c1ec7 100644 --- a/app/src/test/python/test_identity.py +++ b/app/src/test/python/test_identity.py @@ -24,7 +24,7 @@ from findmy.reports import AppleAccount, RemoteAnisetteProvider from findmy.reports.anisette import CLIENT_IDENTITY, CLIENT_SERIAL, DeviceIdentity -# What Java sends for each of its two profiles. Copies, and only usable as copies: nothing here +# What Java sends for each of its three profiles. Copies, and only usable as copies: nothing here # can reach the Java enum, so these say "given this shape, do that". That the shapes are the # ones Java actually produces is asserted on device, by IdentityBridgeTest. LEGACY_MAC = { @@ -45,6 +45,17 @@ "darwin": "23.4.0", } +# A fresh install since 2026-10-14: the one profile Apple's Grand Slam handler still answers an +# SRP init for over an Android-provisioned machine id (issue #248). +ANDROID = { + "model": "Pixel 7", + "os_name": "Android", + "os_version": "14", + "os_build": "UQ1A.240205.002", + "cfnetwork": "1404.0.5", + "darwin": "22.3.0", +} + UID = "9E1D0C4B-77A2-4E3F-8D51-2B6A0F9C3D74" DEVID = "1A2B3C4D-5E6F-4071-8293-A4B5C6D7E8F9" @@ -159,15 +170,31 @@ def test_the_machine_is_whichever_one_java_says_this_install_is(self): constant any more. Java sends the same six values in the client info it provisions ADI under. Which six - depends on the install — one that predates the choice keeps the Mac, a fresh one is an - iPhone — so a copy on this side would be right for one of them and wrong for the other. + depends on the install — Java decides, and replaces a profile Apple refuses — so a copy + on this side would be right for one install and wrong for another. """ assert identity.hardwareProfile(Bridge(LEGACY_MAC)) == DeviceIdentity(**LEGACY_MAC) assert identity.hardwareProfile(Bridge(IPHONE)) == DeviceIdentity(**IPHONE) + assert identity.hardwareProfile(Bridge(ANDROID)) == DeviceIdentity(**ANDROID) - def test_neither_profile_is_the_librarys(self): + def test_no_profile_is_the_librarys(self): assert DeviceIdentity(**LEGACY_MAC) != CLIENT_IDENTITY assert DeviceIdentity(**IPHONE) != CLIENT_IDENTITY + assert DeviceIdentity(**ANDROID) != CLIENT_IDENTITY + + def test_the_android_profile_renders_the_client_info_apple_accepts(self): + """ + The exact string verified against the handler with probe_gsa_signin.py --real-init. + + Rendered through FindMy.py's own composer, because that is what reaches Apple at + sign-in: Java's copy of the string is pinned in AdiDeviceIdentityTest, and this pins + that the six values survive the bridge and come out as the same bytes. + """ + from findmy.reports.anisette import _AKD_BUNDLE + + assert DeviceIdentity(**ANDROID).client_info(_AKD_BUNDLE) == ( + " " + ) def test_a_new_login_uses_it_on_both_transports(self): """ diff --git a/scripts/probe_gsa_signin.py b/scripts/probe_gsa_signin.py index 8a109677..a46fd06e 100644 --- a/scripts/probe_gsa_signin.py +++ b/scripts/probe_gsa_signin.py @@ -40,6 +40,13 @@ # Several variants in one run, compared against each other python scripts/probe_gsa_signin.py --set Accept="text/html" --unset Accept + # The handler layer: a structurally real SRP init, three Anisette variants -- + # none, junk, provisioned -- each reported as the response plist's ec + python scripts/probe_gsa_signin.py --real-init + python scripts/probe_gsa_signin.py --real-init --anisette-junk + python scripts/probe_gsa_signin.py --real-init --anisette-json anisette.json \ + --cpd-unset X-Apple-I-SRL-NO --cpd-unset X-Apple-I-MD + Each probe is named by its variant and reported as `through` or `refused`, with the full answer (status, content type, length, and the first bytes of the body, which is where an `ec=` code or an HTML error page identifies itself). Repeating the same variant twice in @@ -65,34 +72,106 @@ RETRY_SLEEP_S = 2 BODY = b"t" -"""The junk body every probe carries. Identical across variants by design. - -**Where the extension point is.** This probes the edge only: an edge rule reads headers -(and perhaps a body's shape, which is why the body is fixed), while the GsService2 -*handler* behind it reads the body's content -- the SRP exchange and the Anisette data -inside it. A refusal that arrives as HTTP 200 with `ec=-80045` in a plist body is the -handler, not the edge, and this script cannot see it: with a junk body it only ever asks -"was the request let in", never "would it authenticate". The 2026-10-12 refusal is -believed to be that kind (xtool's provisioning succeeds, then GsService init is refused), -which is why the edge probes pass while every real sign-in fails. - -To probe the handler instead, the body has to be a structurally real SRP init: - - {"o": "init", "u": , "ps": ["s2k", "s2k_fo"], "A2k": } - -built the way `AsyncAppleAccount._gsa_authenticate` in the pinned FindMy.py builds it -(`srp.User(...).start_authentication()` for `A2k`), with the Anisette values carried in -the same body (`X-Apple-I-MD`, `X-Apple-I-MD-M` and the rest -- see -`BaseAnisetteProvider.headers`). None of that needs a password: init runs before any -credential is checked, so a probe at this layer is still not a sign-in attempt. A -`--real-init` mode would build that body, send it, and parse the response plist's `ec` --- `ec=0` means the handler took the exchange and it is no longer the edge's answer that -matters; `-80045` means the handler refused, and *then* varying the Anisette contents -(fresh vs. stale provisioning, serial present vs. absent) is the experiment that localises -the rule. Vary the body through the same `--set`/`--unset` mechanics by treating those -keys as body fields rather than headers. +"""The junk body every edge probe carries. Identical across variants by design. + +**Where the extension point was.** The edge probes only ever asked "was the request +let in"; the GsService2 *handler* behind the edge reads the body's content -- the SRP +exchange and the Anisette data inside it -- and a refusal that arrives as HTTP 200 with +`ec=-80045` in a plist body is the handler, not the edge. `--real-init` reaches that +layer: it builds a structurally real SRP init, parses the response plist's `ec`, and +lets the Anisette contents be varied through `--cpd-set`/`--cpd-unset`. A variant that +flips the answer from `-80045` to anything else is the rule's location. """ +# A synthetic Apple ID, not a sign-in: init runs before any password is checked, so +# probing with it is not an authentication attempt against any account. +DEFAULT_PROBE_APPLE_ID = "probe-nonexistent@example.com" + + +def _real_init_body( + apple_id: str, + cpd: dict, +) -> bytes: + """The structurally real SRP init body `AsyncAppleAccount._gsa_authenticate` sends. + + Identical shape to the library's: `srp.User.start_authentication()` mints a fresh + `A2k` every call, so two runs are never byte-identical -- which is also true of real + sign-ins, and means the handler cannot be answering on body equality. + """ + import plistlib + + import srp + + usr = srp.User(apple_id, b"", hash_alg=srp.SHA256, ng_type=srp.NG_2048) + _, a2k = usr.start_authentication() + body = { + "Header": {"Version": "1.0.1"}, + "Request": { + "cpd": cpd, + "A2k": a2k, + "u": apple_id, + "ps": ["s2k", "s2k_fo"], + "o": "init", + }, + } + return plistlib.dumps(body) + + +def _baseline_cpd() -> dict: + """The constant `cpd` flags `BaseAnisetteProvider.get_cpd` always sets. + + Without `--anisette-json` or `--anisette-junk` this is the whole `cpd` -- the + "no Anisette data at all" variant, the cheapest possible question: does the + handler even reach the Anisette check before refusing? + """ + return { + "bootstrap": True, + "icscrec": True, + "pbe": False, + "prkgen": True, + "svct": "iCloud", + } + + +def _junk_anisette() -> dict[str, str]: + """Anisette-shaped values that are provably not provisioned data. + + The "present but wrong" variant: every field a real `get_cpd` emits, filled + deterministically. If this and the missing-fields variant answer alike while a + provisioned set answers differently, the handler is reading the values; if all + three answer `-80045`, the refusal precedes the Anisette check. + """ + return { + "X-Apple-I-Client-Time": "2000-01-01T00:00:00Z", + "X-Apple-I-TimeZone": "UTC", + "loc": "en_US", + "X-Apple-Locale": "en_US", + "X-Apple-I-MD": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "X-Apple-I-MD-LU": "AAAAAAAAAAA=", + "X-Apple-I-MD-M": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAA", + "X-Apple-I-MD-RINFO": "17106176", + "X-Mme-Device-Id": "00000000-0000-0000-0000-000000000000", + "X-Apple-I-SRL-NO": "PROBE000000", + } + + +def _load_anisette_json(path: str) -> dict: + """Provisioned Anisette fields exported by a live provider. + + Dump format is a flat string map, e.g. produced next to a working install by + `await anisette.get_headers(uid, devid)` serialised to JSON -- for this project, + the local provider behind `ani_libs.bin` on the backend host. + """ + import json + + with open(path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + msg = f"{path}: expected a JSON object of header names to values" + raise SystemExit(msg) + return {str(k): str(v) for k, v in data.items()} + @dataclass(frozen=True) class Answer: @@ -103,6 +182,8 @@ class Answer: length: int server: str body_head: str + # The unabridged response -- the handler's plist can exceed body_head's window. + raw: bytes = b"" @property def refused(self) -> bool: @@ -122,7 +203,7 @@ def _baseline_headers(client_info: str) -> dict[str, str]: } -def send(headers: dict[str, str]) -> Answer: +def send(headers: dict[str, str], body: bytes = BODY) -> Answer: """One request, retried on connection failure but never on a refusal.""" import urllib.error import urllib.request @@ -132,7 +213,7 @@ def send(headers: dict[str, str]) -> Answer: tls = apple_trust_context() last: Exception | None = None for attempt in range(ATTEMPTS): - request = urllib.request.Request(GSA, data=BODY, headers=headers, method="POST") + request = urllib.request.Request(GSA, data=body, headers=headers, method="POST") try: with urllib.request.urlopen(request, timeout=TIMEOUT_S, context=tls) as response: return _answer(response.status, response.headers, response.read()) @@ -155,17 +236,44 @@ def _answer(status: int, headers, body: bytes) -> Answer: # or a plist's opening tag. 160 bytes is enough to tell those apart and small # enough to quote whole in an issue. body[:160].decode("utf-8", errors="replace").replace("\n", " "), + body, ) -def report(name: str, answer: Answer) -> None: - """One line per probe, plus the body head when it is an HTML refusal.""" - verdict = "refused" if answer.refused else "through" +def _handler_verdict(body: bytes) -> str | None: + """The plist `Status` a real init gets, or None when the answer is not a plist. + + `ec=0` means the handler took the exchange; `-80045` is the 2026-10-12 refusal. + `em`/`au` are quoted when Apple sends them, because `em` is the only sentence the + handler ever writes back. + """ + import plistlib + + try: + parsed = plistlib.loads(body) + except Exception: + return None + status = (parsed.get("Response") or {}).get("Status") or {} + if not isinstance(status, dict) or "ec" not in status: + return None + parts = [f"ec={status['ec']}"] + for key in ("em", "au"): + if status.get(key) is not None: + parts.append(f"{key}={status[key]!r}") + return " ".join(parts) + + +def report(name: str, answer: Answer, is_real_init: bool = False) -> None: + """One line per probe: the handler's `ec` when the request was a real init, + through/refused for edge probes, plus the body head on an HTML refusal.""" + verdict = _handler_verdict(answer.raw) if is_real_init else None + if verdict is None: + verdict = "refused" if answer.refused else "through" print( f"{name}: {verdict} (HTTP {answer.status}, {answer.content_type or 'no content type'}, " f"{answer.length} bytes, Server {answer.server or '-'})", ) - if answer.refused: + if verdict == "refused": print(f" body: {answer.body_head}") @@ -190,6 +298,36 @@ def main() -> int: "--repeat", type=int, default=1, metavar="N", help="Send each variant N times, to see whether the edge is in a steady state.", ) + parser.add_argument( + "--real-init", action="store_true", + help="Probe the GsService2 handler instead of the edge: send a structurally " + "real SRP init body and report the response plist's Status.ec. " + "Still no account -- init runs before any password is checked.", + ) + parser.add_argument( + "--apple-id", metavar="EMAIL", default=DEFAULT_PROBE_APPLE_ID, + help="The `u` value of the init. Default: a synthetic address, used so the " + "probe is never mistaken for a sign-in against a real account.", + ) + parser.add_argument( + "--anisette-json", metavar="FILE", + help="JSON object of provisioned Anisette fields to merge into `cpd` " + "(e.g. a dump of a live provider's get_headers output).", + ) + parser.add_argument( + "--anisette-junk", action="store_true", + help="Fill `cpd`'s Anisette fields with deterministic junk -- the " + "'present but not provisioned' variant.", + ) + parser.add_argument( + "--cpd-set", action="append", default=[], metavar="NAME=VALUE", dest="cpd_sets", + help="Set or override a `cpd` field in a real-init body. Repeatable; " + "each becomes its own variant, like --set does for headers.", + ) + parser.add_argument( + "--cpd-unset", action="append", default=[], metavar="NAME", dest="cpd_unsets", + help="Remove a `cpd` field in a real-init body. Repeatable.", + ) args = parser.parse_args() from findmy.reports.anisette import _AKD_BUNDLE, CLIENT_IDENTITY @@ -208,10 +346,36 @@ def main() -> int: headers.pop(name, None) variants.append((f"unset {name}", headers)) + cpds: list[tuple[str, dict]] = [] + if args.real_init: + cpd = _baseline_cpd() + if args.anisette_json: + cpd.update(_load_anisette_json(args.anisette_json)) + if args.anisette_junk: + cpd.update(_junk_anisette()) + cpds.append(("cpd:baseline", cpd)) + for pair in args.cpd_sets: + name, _, value = pair.partition("=") + variant = dict(cpd) + variant[name] = value + cpds.append((f"cpd-set {pair}", variant)) + for name in args.cpd_unsets: + variant = dict(cpd) + variant.pop(name, None) + cpds.append((f"cpd-unset {name}", variant)) + for name, headers in variants: - for attempt in range(args.repeat): - label = name if args.repeat == 1 else f"{name} [{attempt + 1}/{args.repeat}]" - report(label, send(headers)) + if args.real_init: + for cpd_name, cpd in cpds: + label = f"{name} | {cpd_name}" if len(cpds) > 1 else name + body = _real_init_body(args.apple_id, cpd) + for attempt in range(args.repeat): + suffix = f" [{attempt + 1}/{args.repeat}]" if args.repeat > 1 else "" + report(label + suffix, send(headers, body), is_real_init=True) + else: + for attempt in range(args.repeat): + label = name if args.repeat == 1 else f"{name} [{attempt + 1}/{args.repeat}]" + report(label, send(headers)) return 0