Repository navigation
Export AirTags from iCloud, so no Mac is needed - #65
Merged
Merged
Conversation
Specifies the network protocol for reading FindMy accessory key material
directly from iCloud, removing the macOS VM from the export path. Six
stages, in docs/findmy-export/.
Written as a clean room. The only working implementation of this flow is
SSPL-licensed with an exception granted to one other project, so it cannot
be reused. These documents describe Apple's wire protocol -- endpoints,
header sets, payload layouts, cryptographic constructions, state
transitions -- which is fact rather than anyone's expression, and carry no
type name, function name or code shape from any reference. The
implementation must be written by someone who has not read those
references; if something here is wrong, the fix is to amend the document.
Four stages are verified against a live Apple account, by probes written
from these documents rather than from any reference:
1 Authentication SRP handshake, server proof verified, 2FA, tokens
2 MobileMe delegate 11 iCloud service tokens
3 Keychain trust escrow record listing (read-only half)
4 CloudKit container, zones, 35 records fetched
Stage 3's recovery and join, and Stage 5's decryption, are specified but
unrun. Every claim is marked observed or derived, and open questions are
listed rather than papered over.
Two findings shape the design and are recorded prominently:
- Signing in registers a device on the account, and that registration is
what suppresses two-factor on later logins. It is the application's
identity, not residue, and should be kept and labelled rather than
churned.
- Escrow records outlive the devices that created them, are shown in no
Apple interface, and accumulate invisibly. They are the artefact that
actually needs cleaning up.
Also moves the native-Anisette proof-of-concept log out of an ignored
scratch directory into docs/, and adds the CLAUDE.md stub that imports
AGENTS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The implementation side raised sixteen gaps in docs/findmy-export/GAPS.md. All but one are now closed in the stage documents, by the specification side reading the references -- which is that side's job, and is the whole reason the split exists. Nothing was communicated to the implementer directly; the amendments live in the specifications. The most important change is a defect in the specification rather than a gap: Stage 5 documented the AES-GCM additional authenticated data as the ciphertext header alone. It is the header concatenated with a per-field context string of zone name, record name and field name. As written, every field decryption would have failed authentication with no diagnostic beyond "GCM error". Two consequences are now stated: decrypting a field needs the record's identity and not just its bytes, and each ciphertext is bound to its position. Also closed: the Cuttlefish function-invoke envelope and fetchViableBottles (Stage 3); the SP 800-108 KDF input and the RFC 6637 parameters, ciphertext layout and plaintext framing (Stage 5); which bits the master EC scalar keeps; what the structure HMAC covers; and every assumed protobuf field number (Stage 4). One assumption the implementation had made was wrong -- the RFC 6637 ciphertext is an MPI bit-count prefix and a compact point, not an X9.62 point. Five corrections accepted as raised: the numeric account identifier is available from Stage 2 and should be taken there; the PET is spent by Stage 2, which is an ordering constraint on the whole flow; the private key's v2 application tag is explicit; one persisted value may serve as both device UUID and UDID; and the iPhone identity is a worked example rather than a requirement. Still open: joining the trust circle after escrow recovery, Stage 3 6.7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 5 open question 1 asked whether a decrypted field is raw bytes or a wrapped EncryptedValue. It is both, chosen by declared type, and the answer falls out of arithmetic rather than needing a key. Ciphertext overhead is fixed at 30 bytes, so plaintext length is the observed length minus 30. For the four ENCRYPTED_BYTES fields of a MasterBeaconRecord that gives 85, 57, 32 and 32 -- exactly the key material sizes in the committed macOS cache fixture. Those decrypt to raw bytes. The scalar fields fit none of that and fit an EncryptedValue wrapper precisely: 36 bytes for a UUID string, 6 for "AirTag", a 9-byte Date holding a double, and two bytes for a varint that needs no length prefix. Also documents four response fields an earlier draft omitted, two of which arrive in practice: status at 4 and zoneAttributesChanges at 12. Still open: whether the date counts from 2001 or 1970. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3 6.7 was the last unwritten section and the blocker between recovered escrow material and usable keys. It now specifies the sequence and its cryptography. The shape is not obvious and explains why the step is five operations rather than one: the recovered blob is a bottled peer, the sealed identity of a device already in the circle. Recovering it lets the client borrow that device's identity only long enough for it to vouch for a new identity of its own, and it is the new identity that joins. Documents the HKDF-SHA384 derivation of the bottle's three keys with the account identifier as salt, the P-384 scalar conversion, the four verifications to perform before decrypting anything, the AES-256-GCM unsealing, and the vouch-then-join sequence. Calls out one hazard prominently: establish and joinWithVoucher differ by a single branch, and an implementation that falls back from the second to the first on error will silently destroy the user's existing trust circle. This project should never call establish. Still open, and now the largest gap in the set: the protobuf layouts of the peer, voucher and key-share messages that joinWithVoucher carries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stage documents record a number of inconsistencies -- four wire formats in one flow, two SRP variants in one login, commands spelled differently in the URL and the body, hand-padded fixed strings, unnamed constants. Read as a designed protocol these look like errors in the documentation, and a reader will reasonably go looking for the rule that explains them. There is no rule. This is strata, not architecture, and the inconsistency is the specification. Saying so up front saves the search. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3 6.9 gives field numbers for everything joinWithVoucher carries: the peer and its four signed blobs, the voucher, the bottle and its inner structures, and the key shares. This was the last outstanding message enumeration in the set. Several details are the kind that fail silently. SignedInfo's signature covers the serialised bytes rather than the parsed message, so a client must send back exactly what it signed. OTBottle reserves fields 3 to 7, and its inner bottle starts at field 3 with no fields 1 or 2. TlkShare declares wrapped keys, signatures and public keys as strings rather than bytes. And CuttlefishEstablishRequest carries the same four fields as the join request in a different order, which is one more reason the two must not be confused. The whole flow now has a written specification. What remains is that nothing from Stage 3 6 onward has been run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing terminator Two gaps raised by the implementation side. Stage 5 4 step 4 said only that "the structure is signed". It now gives the nested ObjectSignature that SignatureData.data decodes to, and the exact byte sequence the signature covers -- a hand-built concatenation rather than a re-encoded structure, with one asymmetry worth the warning: an absent symmKeyCount contributes four zero bytes while absent attributes or key lists contribute nothing. Also records which key verifies which of the three possible signatures, so the master EC key derivation now has something to verify against. Stage 4 3.5's status field is the paging terminator: 3 means the zone is fully synced. The implementation side guessed this from observing it on both a full and an empty page, and the guess was right. Worth more than saving a request -- a response can carry status 3 and still contain changes, so a client that stops on an empty page always pages once more than it needs to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An escrow record's label and its bottleID are different values: the label is com.apple.icdp.record.<peerId> and identifies the record, while bottleID is a UUID identifying the bottle inside it. The escrow proxy takes the label everywhere, and the label is what joins against a Cuttlefish bottle's id. Stage 3 7.1 said deletion addresses <bottleID>. That was wrong -- deleting by bottleID addresses nothing. Also documents that label is overloaded: a record class for get_records and get_club_cert, one specific record for srp_init, recover, enroll and delete. Separately, the README now asks implementations to report unmodelled fields permanently rather than removing the diagnostic once the known fields are covered. It has already found two gaps, one of which was a correctness bug rather than an omission: the paging loop stopped on an empty page, which coincided with completion only by luck. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from running the read-only half of Stage 3 against a live account. The .double suffix names a companion record that Apple's own clients create and this protocol's enrolment does not. A listing returns companions as ordinary entries, so a device that escrowed through a first-party client appears twice and a count of records is not a count of devices. That is the likeliest explanation for the records observed in pairs -- six attributable to two serials -- and it is testable without a further request, since the labels of a pair should differ only by the suffix. An individual partial entry from fetchViableBottles carries nothing, but the count does: three valid and eight partial corresponded exactly to three recoverable and eight described-but-unrecoverable records. It is a usable cross-check on the join rather than something to ignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A guess in 7.1 that records repeating per serial were records and their .double companions was wrong, and is now recorded as wrong. Across twelve records on a live account not one label ended in .double, and records sharing a serial carried entirely different peer digests. The companion mechanism is real and deletion still issues both calls; it is simply not what a repeated serial means. What a repeated serial does mean is now 5.2: a client that regenerated its peer identity every run while reusing a fixed serial. One serial appeared three times. So records count runs and serials count devices, and grouping by peer identity does not fix a miscount -- only the serial does. Also specifies something no version of this document said: the peer identifier embedded in a record's label is a SHA256-prefixed base64 digest, the same value CuttlefishPeer.hash carries. It follows that a label cannot be constructed from anything a user can see, so records must be located by listing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The listing already separates records that can be recovered from from those that cannot, and that distinction maps onto the risk exactly: deleting a non-viable record removes a capability nobody had, while deleting a viable one destroys a real device's ability to recover its keychain after a wipe. Observed on a live account, the two sorted themselves -- every record attributable to the retired macOS-VM route was among the non-viable ones, because those peers no longer exist in the circle. So the default is now to offer only non-viable records for deletion, with viable ones behind a deliberate separate step if offered at all. That is stronger than a confirmation prompt because it removes the dangerous option rather than asking the user to be careful around it. The serial confirmation remains underneath, because non-viability could be transient, and deletion should not be offered at all when a listing cannot be obtained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Several sections had drifted: two files had duplicate numbering, one question in Stage 5 had been garbled by an earlier renumber and carried another question's text, and Stage 4 listed three answered questions under "still open". Stage 3 still described its own section 6 as a sketch, which it has not been since the recovery flow was specified. Closed as answered: that bottles can be listed with no prior trust state, that the recovery exchange is now specified, and that operations are addressed to the gateway form of the CloudKit URL rather than the per-partition host. Added as newly visible rather than newly wrong: whether non-viability is ever transient, which bears on Stage 3 7.1 making it the primary guard on deletion; what the other status values mean; and whether an account limits how many devices it will register. Each stage now also states plainly whether it has ever been run, which was scattered across banners before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Escrow deletion is verified end to end against a live account, including the corrected label addressing. The operationally important finding is what the calls do not tell you: the service returns success for a delete that removed nothing, so a wrong label is indistinguishable at the call site from a correct one. The return value is therefore not evidence. The only proof a record is gone is to list again and find it absent, and an interface that offers deletion must do that rather than reporting what it attempted. This is the concrete form of the near-miss the earlier bottleID addressing would have caused: delete nothing, report success, leave the user believing their account was clean. Also records that the viability guard sorted debris from danger without the user having to be careful, and that a cleanup is not a fix -- whatever regenerates its identity per run is still doing so, and an interface should say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the records deleted from the live account dated from the preceding week, so the route producing them is still running. An interface that offers deletion should say so, or a user who has just cleaned up will reasonably conclude the problem is solved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3 6.1 described parsing an escrow blob and never described building one, which is the direction the recovery response needs. It now gives both, plus the three things that differ. The leading four bytes are the total message length rather than something to skip. Offsets are body-relative and there is one more than there are sections. And a section "sized to 20 bytes" means its ordinary length prefix and data, zero-padded until the whole occupies twenty bytes -- the declared length stays at the real value while the offsets must account for the padded footprint. Sending the value unpadded, or padded without the inner prefix, produces a message the server rejects without explanation. Records an observed srp_init response as a worked example, and notes that the header field the response sets to 165 arrives as 164. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3 6.2 called section 0 "a request identifier" while also describing the header as carrying a 16-byte request id, and 6.3 then said to echo back "the request identifier" without saying which. The two are not interchangeable and the plausible reading is the wrong one: padding a 16-byte value to 20 looks natural, but the value that gets padded is the 8-byte token in section 0, while the header's transaction id is carried back unchanged inside the header. They are now named differently -- exchange token and transaction id -- and 6.3 states that the response body has exactly two sections, what each holds, and that the padding is appended rather than prepended. Observed section sizes are recorded so a decoder can check itself against them. Raised by the implementation side, which stopped rather than trying variants: each attempt spends a real passcode against a real service and the failure is indistinguishable from a wrong passcode by design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documents never stated whether the device passcode is needed once or per fetch, which is the question that decides whether this feature is a one-time import or a standing connection. It is once. Stage 3 6.7.1 now lists what persists locally, what persists on the account, and what an ordinary later run actually does -- re-authenticate silently, refresh service tokens weekly, fetch changes, decrypt with keys already held. Stage 3 does not run again. A record naming a key not held is a cache miss to be resolved by re-syncing the views, not a failure. 6.7.2 records a consequence worth deciding deliberately: the bottle this client creates for itself is sealed under a password it chooses rather than the user's device passcode, so losing local state costs nothing if that password was kept. Deleting that record therefore trades an artefact on the user's account against the client's ability to recover itself, and the password must be stored as carefully as the keys it protects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3 6.7 step 5.3 said to fetch the recovered peer's key shares without saying how. The method is Cuttlefish fetchRecoverableTLKShares, taking the recovered peer's identifier, and 6.7.0 now specifies the messages and the four-step unwrap -- including that a wrapped key is base64 of an NSKeyedArchiver archive holding an ECIES ciphertext, an encoding used nowhere else here. The consequence is larger than the gap. A share is wrapped to the receiving peer's encryption key, and escrow recovery yields exactly that key for the recovered peer. So the view keys arrive before any peer is created, any voucher signed or any bottle enrolled. For a client that only wants to read, joining may be unnecessary entirely -- which would remove every write to the user's account except the device registration of Stage 1, and with it the escrow artefact the residue rules exist to manage. Not established, but cheap to establish: nothing up to that point writes anything, so trying costs a passcode and no account change. Recorded with the cost of not joining, which is that a non-member receives no new shares and may go stale across key rotations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two verifications in stage 3 look up a peer by identifier -- the sponsor of a recovered bottle, and the sender of a key share -- and no section said where that lookup gets its data. The implementation side reported having to use shares unverified as a result, and flagged it rather than hiding it. The directory is Cuttlefish fetchChanges, an incremental feed whose changes carry peers, each with its identifier and a permanent info holding its signing and encryption public keys. It is now 5.3, before recovery rather than inside it, because both later checks depend on it and because it explains why a recovery sequence begins by syncing. Skipping these checks is not a small weakening: an unverified bottle is one an attacker able to serve a response may have chosen, and an unverified share is key material of unknown origin trusted to decrypt the user's data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three constructions, none of which the document defined. A key share's signature covers seven of its own fields concatenated in a fixed order, and its integers are little-endian -- the only little-endian construction anywhere in this protocol, with everything else big-endian. The wrapped key contributes its decoded bytes rather than its base64 string. ECDSA over SHA-256. A bottle's two signatures both cover the raw serialised OTBottle bytes exactly as received, one verified under the escrowed signing key and one under the sponsoring peer's, both SHA-384. It is the only signed-data construction in the stage that is simply a field's bytes. And a correction to 6.9: a SignedInfo's signature does not cover its info alone but a type string prepended to it -- TPPB.PeerPermanentInfo, TPPB.PeerStableInfo, TPPB.PeerDynamicInfo or TPPB.Voucher, ASCII, with no separator. That prefix is what stops a blob of one kind being presented as another, so omitting it removes a protection rather than merely failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3 6.7.0 said fetchRecoverableTLKShares returns shares without saying what a share is, and 6.9 specifies a TlkShare message with numbered fields. Those are different things: the message is what a client sends when joining, while what it receives is a CloudKit record of type tlkshare whose values live in named fields. The distinction matters because getting it wrong fails silently. Protobuf skips unrecognised field numbers, so decoding the record as a TlkShare message yields every field empty -- which reads as a share with no sender rather than as a parse that found nothing. The implementation side hit exactly this and reported forty-one identical empty results. Also specifies the response wrapper, both record types by field name, and that the view keys are wrapped under the key recovered from the share rather than under a peer key. The record carries no service or keyId; the view name is one level up on the wrapper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3 5.3 described fetchChanges as a single request. It is a loop: call with the current token, add any peers found, take the returned token, and go again until a response carries no changes at all. That empty response is the only termination condition. A client issuing one request will often see nothing, because the first page need not carry peers -- so an empty directory after one call is the expected outcome of not looping rather than evidence the circle is empty. The implementation side hit exactly that. Also records that only the add field builds the directory, other change kinds being skipped, and that a .changeTokenExpired error means the stored token and the accumulated directory should both be discarded and the walk restarted from nothing, rather than treated as fatal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wrapped key expands to Apple's SFIESCiphertext with the ephemeral point, ciphertext and authentication code as separate named members, not one concatenated blob. Sizes from a real share, and the parameters that have been ruled out on a live account -- so the next attempt starts from what is left rather than repeating the search.
Every share failing the same way rules out per-share damage and nothing more -- a wrong key fails everywhere too. Written the other way it invites widening the parameter search when the receiver check is what would have answered it.
The wrapped key is AES-256-GCM under an X9.63 SHA-256 derivation with a sixteen-byte derived nonce and no additional data -- but SFCiphertext carries 113 bytes past the end of the ciphertext, so passing the whole member in fails authentication however right the parameters are. No search over cipher parameters can reach that, which is why one ran to 1,280 combinations against a key Apple's own record confirmed. The class keys are not ECIES either. They are AES-256-CMAC-SIV under the key the share yields, and the tlk member is not a fourth thing to decrypt -- the share's own plaintext is that key.
…ave worked 21 of 21 shares across every view, three 64-byte keys each, Manatee included -- and with nothing written to the account. The lesson is not the parameters. The correct construction was inside the space being searched the whole time, and failed there because SFCiphertext overruns the ciphertext: a tag rejects trailing rubbish exactly as it rejects a wrong parameter. A search over parameters cannot tell a wrong parameter from a right one applied to the wrong bytes, so widening it was the wrong instinct at every step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both ends are now built: Stage 3 yields the Manatee view's three keys read-only, and Stage 5 decrypts records given EC private keys. What is unspecified is the step between -- how a 64-byte AES-SIV view key reaches an item whose acct matches a protection structure's key reference. Raised rather than searched, for the reason J5 established: the unknown here is which bytes, not which parameters, and a search over parameters cannot tell those apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gap K: both ends were built and the middle was undescribed. A view key never becomes an EC private key -- it decrypts an item, and the item's v_Data contains the key PCS wants. Names the container and bundle (securityd, not cuttlefish), the zone, the currentitem pointer that makes the Find My key a direct lookup rather than a scan, the item record's fields, and the AES-SIV chain through parentkeyref. The additional data is where this goes wrong: a sorted map whose names are discarded, little-endian integers, and a wrappedkey entry that means the parent key's identifier rather than the field of the same name.
An Apple account is free and an Apple device is not, so exporting a tag to a friend without Apple hardware is a thing people want rather than a migration step. Both modes share every expensive part, so they are two sinks on one pipeline. The asymmetry worth designing around is that an export cannot be undone: keys are revocable only by unpairing the accessory. Hence an explicit list rather than a default of everything, a sink that structurally cannot hold account material, and no cloudKitMetadata passed through -- its system fields name the owner.
It is three fields the Android app already reads -- created, modified, and the device that registered the accessory -- not CloudKit's generic system fields, so it carries no user identity and belongs in an export rather than being stripped from one. Closes the open question about whether anything reads it. What keeps account material out of an export is then the format itself having no field for it, which is a stronger guarantee than a filter.
The first PyInstaller build of the iCloud exporter built cleanly and died at import: "Failed to load the Unicorn dynamic library". unicorn loads its native library through ctypes at runtime, so PyInstaller's analysis never sees it and leaves it out - and nothing about the build says so. It is there because local Anisette emulates Apple's ADI library, which is what lets signing in work without a third-party Anisette server. So `--collect-all unicorn` in all three build invocations, and in the spec. **The check that would have caught it ran everywhere except macOS**, which is where it happened: starting the binary and having it print its version was guarded by `if: matrix.os != 'macos'`, because macOS had a deployment-target check instead. It costs a second and it is the only thing that catches this class of failure, so it runs on every platform now. Also restores OpenTagViewer.spec, which those builds had quietly overwritten - invoking pyinstaller with a script path regenerates the spec named by --name, which is this one. The file now says so at the top, since the symptom is a hand-written file turning into a machine-written one with no warning. Measured, on this machine, arm64: 1.0.5 release zip 22 MB 1.1.0 release zip 68 MB (binary 34 MB) Almost all of the difference is unicorn, 41 MB on disk. Excluding the Bluetooth stack FindMy.py pulls in saves 1.6 MB, which does not pay for a binary that fails at runtime if the library ever reaches for the scanner - so nothing is excluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frozen app signed in and then failed with "[Errno 2] No such file or directory" - no filename, nothing to act on - several minutes and one Apple ID into the flow. FindMy.py keeps Apple's four pinned root certificates as `.crt` files beside its code, and anisette keeps `apple-root.pem` beside its own. PyInstaller bundles code, not the files next to it, so opening the keychain session found nothing to pin against. `--collect-data findmy --collect-data anisette` fixes it. **Starting was not a good enough smoke test**, which is the more useful half of this. `--version` passed on a binary that could not get past its first real call. `--self-test` reads the pinned roots - checking each against its own fingerprint, which the library does anyway - and looks for anisette's root, with no network and no account. CI runs both on every platform now. Anything else that starts reading files rather than modules belongs in it. Also: a windowed build has no stderr at all, so every log line and traceback was going nowhere - which is why the dialog could only say "[Errno 2]" with nothing after it. The app now logs to ~/Library/Logs/OpenTagViewer/exporter.log (a temp directory elsewhere), reports the exception type as well as its message, and names the log file in the dialog. Unhandled exceptions get an excepthook, since those vanished the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aster A onefile build unpacks its whole self to a temporary directory on every launch. For this one that is 34 MB and **seven seconds** before a window appears - measured, three runs, on the machine that built it. To somebody who has just double-clicked an icon, seven silent seconds is indistinguishable from a hang, and there is nothing on screen to say otherwise. onedir starts in 0.35 s. It costs about 7 MB in the download - 75 against 68 - and it drops a deprecation on the way: onefile combined with a macOS .app bundle is deprecated in PyInstaller 6 and an error in 7. The packaging steps move with it. macOS ships the .app alone, because COLLECT and BUNDLE each hold a complete copy in onedir mode and zipping both put every file in twice - 115 MB rather than 75. Windows and Linux ship the whole directory, since the executable alone does nothing without the interpreter and libraries beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… export The handover doc covered the app importing better. It missed the other direction, which changes what the zip is for. The app can run the whole iCloud pipeline itself - it already embeds FindMy.py, and exporter/icloud.py imports nothing a phone cannot have. That makes it a third producer, stamping OpenTagViewer.android through the same shared writer. The bigger consequence is that an owner then needs no export at all. Today somebody exporting their own tags to their own phone makes a round trip through a desktop for data the phone could fetch directly. Afterwards the zip is only what it should always have been: how you give a tag to another person. With a note not to document it until it works. The wiki tells every user to export, which stays correct right up until the app can do without one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The serial was stable and the rest of the identity was not. AsyncAppleAccount invents a device UUID when it is not given one, and this built a fresh account every run - so Apple saw a different machine each time, and an account's device list gained an entry per export. The docs claiming "one entry however many times you export" were wrong on the strength of the serial alone. So the identity is kept between runs, in exporter/device.py: the two ids Apple knows this installation by, and the Anisette provisioning data that makes this the same machine to its servers. Re-provisioning was the other half of looking new. **None of it is a credential**, which is why keeping it does not contradict the rule that this program stores none - it identifies the program, and its whole purpose is to stop the program looking like a stranger. That is enforced rather than intended: the file may hold three keys and nothing else, nothing named like a credential may appear at any depth, and a document that breaks either rule is refused rather than written. The account's own to_json carries the username and password beside the ids, so "store what the library serialises" is exactly the mistake available here - remember() picks two fields out of it instead. Failing to store is never fatal. It costs one more device-list entry next time, where failing an export that has already succeeded costs the export. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wizard's "Need Help?" opened the macOS-only wiki page, which no longer describes what it does on any machine newer than Sonoma. It now opens the page covering both routes, and the README's two links follow it. The old page keeps its name on purpose rather than being renamed to something truthful: binaries already released open that exact URL from their own help button, and a GitHub wiki does not redirect a renamed page. Renaming it would break help for everyone still running a copy they downloaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A debug print left on the branch where the output directory does not exist yet - so it fired on every normal first run, before anything had happened, and printed `foo`. Found by running the script end to end to answer whether it still works after the package rename. It does: it decrypts OwnedBeacons, BeaconNamingRecord and KeyAlignmentRecords, rewrites `.record` to `.plist`, keeps the directory layout, and its output feeds straight into `opentagviewer_export.build_export` to produce a bundle. The one part still unverified is `get_key_fallback`, which needs macOS 14 or older to read the keystore.
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 15:13 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 15:13 — with
GitHub Actions
Inactive
…d package Two failures the first CI run on this branch found, both real rather than CI quirks. **The CLI could not be tested without Tk.** `test_cli.py` imported `is_verification_code` from `exporter.wizard`, which imports tkinter - so ten tests failed on a runner whose Python has no `_tkinter`, and would have failed the same way on any headless machine somebody runs the CLI on. The two functions are pure string handling with nothing graphical in them, so they move to `exporter/codes.py` and both entry points import them from there. A test asserts in a fresh subprocess that the module does not drag tkinter in, because a later refactor moving them back beside the window would pass everything else and break only the machines the CLI exists for. **The CLI now actually checks the code.** The test's docstring said "in the window and on the CLI" and only the window enforced it: the CLI passed whatever was typed straight to Apple. It asks again instead, which is the point of the check - a rejected code costs another send and another wait. **pyright could not resolve `opentagviewer_export`.** It is neither installed nor on the path - Chaquopy packages it by path and the exporter runs it from the source tree - so both bridge imports in `main.py` were reported unresolved. `pyrightconfig.json` adds `python` to `extraPaths`. Kept in a config file rather than the workflow so the command CONTRIBUTING.md gives contributors is the one CI runs, and so Pylance resolves it in an editor. python/ itself stays unchecked; this only makes it importable. 344 tests pass, flake8 clean, and pyright drops from 8 errors to the 6 that are FindMy and NSKeyedUnArchiver, which CI installs.
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 15:39 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 15:39 — with
GitHub Actions
Inactive
Closing the progress window did nothing. The wizard sat on "Reading your accessories…" with no window left to close, and the only way out was to kill it. **The poll was scheduled on the window it was watching.** tkinter registers an `after` callback as a Tcl command owned by the widget it was scheduled on, and destroying a widget deletes its commands - so closing the progress window cancelled the very callback whose job was to notice it had gone. The branch that recorded `Cancelled` could never run. It polls `root` now, which outlives the window, and calls `quit` so `mainloop` returns. **The worker did not stop either**, and it does not have to: it holds the sign-in. But a coroutine half way through one still asks for a verification code, and that dialog was drawn over a window the user had already closed - a question about work they had just abandoned. `Asker.cancel` makes the next `ask` raise `Cancelled` on the worker thread instead, where it unwinds. Both are covered by tests that drive a real Tk loop, because what is wrong here *is* the event loop and a stand-in would not have it. The hang test carries a watchdog: without one, the failing case hangs the suite rather than reporting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
009aa26 fixed this for the window and left the CLI doing it. `icloud.remember` sat inside the `except MobileMeDelegateError` handler, so it ran only for an account with unaccepted terms - the ordinary sign-in, which is nearly every run, stored nothing. The identity was read back on the next run and there was never one to read, so each export invented a new device UUID and the account's device list gained another "MacBook Pro, 0PENTAGXPORT", each looking to Apple like a different machine that happens to share a serial. Next to a button offering to remove a device the user does not recognise, which breaks the session that was working. It moves out to cover both ways in. The identity store itself needed nothing: `make_account` has always called `device.load()`, so the CLI was reading a file it never wrote. **The same shape hid a second bug.** An exception raised inside an `except` block is not caught by a sibling `except` on the same `try`, so every way out of the terms handler - declining to fetch them, declining to accept them, `accept_terms` failing - skipped `await account.close()`, which is the exact case that close exists for. The result is "Unclosed client session" from asyncio after the real error, reading like a second, worse fault. The `try` is nested now, so the handler is inside what guards it. Both are tested against a fake account: three of the new tests fail on the commit before this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… ticks **Signing in was the price of opening the program.** The window read the account the moment it appeared, unasked, and a failure closed it - taking with it the "+ Add from key file…" button, which is the one thing here that needs no Apple account at all. Somebody whose tags are all self-generated - OpenHaystack, Macless Haystack - was shown an Apple error about tags that have nothing to do with Apple, and then nothing. So reading is a button. The list starts empty with "Sign in to Apple…" beside "+ Add from key file…", and nothing is signed into until it is asked for. A failed sign-in, or one cancelled half way through, now leaves the window standing with whatever was already added still in it and the button still there to try again. The button says which route it will take, because they cost different things: one asks for an Apple ID password and registers a device on the account, the other asks for neither. **Rebuilding the list threw away the selection.** `_add_key_file` ended in `_show([])`, which cleared every tick and re-inserted every row unticked - silently, mid-decision, on the screen whose whole job is to state what is about to be handed over. Nothing said so and the Export button stayed enabled, so the next click wrote a bundle holding one tag where three had been chosen. `_show` preserves ticks now, and remembers what could not be exported rather than taking it as an argument it is not given on a redraw. Which matters more than it did: the list is redrawn far more often now that reading is a button. Ticking moves into `_set_tick`, shared by the click handler and the redraw, so the set and the mark on screen cannot disagree. Covered by tests that drive real Tk widgets, skipped where there is no display - which includes CI, so CONTRIBUTING says to run the exporter suite on a desktop before trusting a green run over the window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `--add-keys` tag is read from disk, named by hand, and is in no Apple account - so it was the one thing in a run that could not be fetched again. The CLI appended those to the export *after* the account flow, which returns first when the account has nothing exportable or nothing is ticked. They were dropped without a word, and the next run asked for the names again. They are added whatever the account did now. "None of these" becomes a valid answer to the accessory list when key-file tags are already in hand, rather than an error, since it is a choice at that point rather than an empty run. **A source that could not be read is reported both ways.** The bundle is written, because reading it succeeded and the failure was somewhere else, and the run says which half is missing and exits 1 - a script should not hear success from a failed sign-in, and a file that is quietly missing its contents is worse than one that is missing them loudly. **`--source none` reads no account at all.** The window makes this obvious now by simply not signing in until asked; a terminal has no button to leave unpressed, so it wants saying. Without it, exporting a tag you generated yourself costs an Apple ID password, a device registered on your account and a screen-lock passcode, to fetch a list nothing is going to be taken from. It refuses to run with no `--add-keys` file, and `detect()` never returns it: reading no account is a thing somebody chooses, not something handed to them because their machine looked a certain way. Verified end to end - it is the one path here that needs no Apple account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
conftest.py told the next reader the opposite: that Chaquopy adds `python/` as a second source directory, "see the `sourceSets` block in app/build.gradle.kts". That block is empty. `import opentagviewer_export` fails inside the APK, so main.py's `identifyHardware` and `whereToLookUpHardware` return None there while passing every test on desktop CPython - which is exactly the shape of thing somebody debugs for an hour before doubting the comment. The gap is deliberate and stays: `../python` also holds the tkinter wizard and a top-level package called `test`, which would shadow the standard library's on a phone. docs/android-import-handover.md sets out the two ways out and this now points at it. Comment only. Nothing Gradle was touched, and nothing here was built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 17:26 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 17:26 — with
GitHub Actions
Inactive
The note added with them said the window tests skip on CI, which the first run of them contradicted: `MacOS Scripts` is a 3.10-to-3.13 matrix on macos-14, and its 3.13 has `_tkinter` and ran both files headlessly. The other three skip at collection. That is worth having right rather than roughly right, because the two readings of a green tick differ: three of those jobs say nothing whatever about the window. The counts each case produces are written down, since `-q` shows them and they are the quickest way to tell which run you are looking at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 17:32 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 17:32 — with
GitHub Actions
Inactive
Two things the list lost when it stopped being a `tk.Listbox`. **The colour.** The old one was `selectmode="multiple"`, so the platform coloured selected rows itself and a glance told you how much was about to leave your account. The table set `selectmode="none"` and put a glyph in a 30px column, which is a very small difference between a tag that is being handed over and one that is not, on the one screen where that is the entire point. A Treeview tag brings it back using the theme's own selection colours, looked up from the style map rather than written down here - so it is the colour this desktop already uses, greys out with the window like every other list, and is whatever is right on Windows and Linux. **The marks.** ☐/☑ are drawn small and low against the row's text and change width when ticked, which is what made them read as stray emoji. □/■ are one shape, filled or not, so a column of them lines up and only the fill moves. Ticking now sets three things - the set, the mark and the colour - so they move into `_set_tick` together, and a test asserts a redraw restores all three rather than two of them. **And the caption stopped contradicting the picture.** "Nothing is selected to begin with" was a fixed string, which nobody noticed while a small glyph was the only sign of a selection; under four coloured rows it is plainly wrong. It counts now - "2 selected. What you export cannot be taken back." - which also puts the number that matters next to the warning, at the moment of pressing Export. Checked on screen with a list of stand-in accessories, not only in tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 18:21 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 18:21 — with
GitHub Actions
Inactive
CI went red on 3.13 with `Fatal Python error: Aborted` inside `Garbage-collecting`, on a thread belonging to a test that had already passed, reported against whichever test happened to be running. Only 3.13 has Tk, and `fail-fast` cancelled the other three, so it read as two failures. Mine, in the test rather than the wizard. `run_with_progress` runs its coroutine on a daemon thread and never joins it - right for the program, since that thread may be half way through a network call and the window must not wait for it - but a test that returns while its worker is still exiting hands the next test a thread that may run a garbage collection. Freeing a Tk object there frees it from a thread Tcl was not created on, and Tcl aborts the process for that rather than raising. It is a race, which is why it passed here eight times and on three earlier CI runs. Teardown now joins the workers, destroys the window and collects on the main thread, in that order. The events those workers wait on are set in `finally`, so a failed assertion cannot leave one running. And it says so when it happens: teardown fails the test that leaked the thread, by name, instead of leaving the next one to abort under someone else's name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 18:26 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 15, 2026 18:26 — with
GitHub Actions
Inactive
saamdotexe
pushed a commit
to saamdotexe/OpenTagViewer
that referenced
this pull request
Oct 22, 2026
…fetching Export AirTags from iCloud, so no Mac is needed
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Exporting AirTags no longer needs a Mac. The exporter reads the same records out of iCloud, so it
runs on Windows, Linux and macOS 15+, and the bundle writer moves into a shared package the Android
app can use too.
106 files, +20,446 / -751. Roughly half of that is the protocol specification the rest is built on.
1. The protocol specification (
docs/findmy-export/, +8,342)A clean-room specification of how an iCloud client gets at FindMy accessory keys, written across
many rounds against a live-account probe harness. Six stages plus a gaps document:
01-authentication.md02-mobileme-delegate.md03-keychain-trust.md04-cloudkit.md05-pcs-decryption.md06-output.mdGAPS.mdescrow-roots/carries the four escrow root certificates next to the text that describes them.This is documentation only. Nothing in this PR implements the trust-circle write path.
2. Shared export package (
python/opentagviewer_export/, +2,958)One implementation of the bundle format, importable by both the desktop exporter and the app. Pure
Python, no tkinter, no Android types.
bundle.pyzipsink.pypasscode.pyO/I/Lback to digitskeyfiles.py.keys, Macless Haystackdevices.json, FindMy.py JSON, and bare key listshardware.pyFormat version stays
0.0.2unless a self-generated tag is present, in which case it is0.0.3andthe bundle carries
CustomAccessories/<id>.json. A plist-only export is byte-identical to what theprevious exporter wrote.
3. The exporter (
python/exporter/, renamed frompython/main/)New CLI (
python -m exporter.cli). Interactive checkbox selection, falling back to a numberedlist when there is no terminal to draw on. Flags:
--source {auto,icloud,local},--no-password,--add-keys,--accept-terms,--source-user,--anisette-url,-v/-vv.Dynamic route selection (
source.py). macOS 14 or older uses that Mac's own files and neversigns in. Everything else signs in to iCloud.
Rebuilt wizard on the same code the CLI runs. Table instead of aligned text, both credentials
asked for in one window, validated dialogs, windows centred, file logging, and a
--self-testthat works in a windowed build with no stderr.
iCloud terms are rendered to text and shown in a pager with accept/cancel, since Apple takes
agreement nowhere the exporter can reach.
Device identity is persisted (
device.py). Previously every run invented a device UUID, so anaccount's device list gained another "MacBook Pro, 0PENTAGXPORT" per export. The stored file holds
two UUIDs and the Anisette provisioning data, and a check refuses to write anything that looks like
a credential at any depth.
PyInstaller builds a directory rather than one file: 0.35s startup instead of 7.3s, at 75MB
instead of 68MB.
Also:
VERSION = "1.1.0",via:split intoOpenTagViewer.wizard:andOpenTagViewer.cli:(wasOpenTagViewer.app:), uv with a lockfile pinning FindMy.py by commit, unused dependencies dropped.4. CI and release plumbing
macos-exporter-python.ymlbuilds a matrix: macOS arm64 and x86_64, Windows, Linux.exporter-vis the tag prefix now,
macos-exporter-vstill resolves.macos-scripts-python.ymlruns uv--frozen, drops Python 3.9 (FindMy.py needs 3.10), testsboth suites.
release_version.pygains--kind exporter.5. Android
Deliberately small. Two bridge functions in
main.pycalling the shared heuristic, tests for them,new format-
0.0.3fixtures, and a fixtures README.What is not done, and is not claimed:
srcDirforpython/opentagviewer_export/is not wired, becausepython/alsocontains a top-level
testpackage that would shadow the standard library's. So the two newbridge functions return
Nonein the APK today. They catch and log rather than fail.app/build.gradle.ktsinstalls FindMy.py from a branch, not a commit pin. Its own commentsays to pin before any build that leaves the machine. That still needs doing.
app/src/test/python/requirements.txtstill pinsFindMy==0.9.8, out of step with the above.--no-password.docs/android-import-handover.mddescribes all four with the fix for each.6. Docs
docs/how-to-export-with-the-cli.md,docs/export-modes.md,docs/android-import-handover.md,docs/anisette-native-android.md, two requests written to the FindMy.py fork, anddocs/README.mdindexing them. AGENTS.md gains rule 11 (the app is one device). CONTRIBUTING.md gains the exporter
release procedure. Wiki pages are updated separately.
Follow-up
A later PR moves the iCloud route into the Android app itself. The pipeline is pure Python and
the app already embeds FindMy.py, so an owner signing in gets their tags with no export at all. The
zip then stops being a prerequisite and becomes the sharing mechanism. That PR also wires the
Chaquopy source set, pins FindMy.py by commit, and adds zip4j so encrypted bundles import.
Verification
and used.
rewrites
.recordto.plist, and its output feedsbuild_export.get_key_fallback, which reads the macOS keystore, needs macOS 14 or older and was not run.Merging this puts
VERSION = "1.1.0"onmain, which is what anexporter-v1.1.0release thentags.
PR description summarised by Claude Code.