Skip to content

Export AirTags from iCloud, so no Mac is needed - #65

Merged
parawanderer merged 131 commits into
mainfrom
feat/native-tag-fetching
Aug 15, 2026
Merged

parawanderer merged 131 commits into
mainfrom
feat/native-tag-fetching

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

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:

File
01-authentication.md SRP, GSA, the client identity strings
02-mobileme-delegate.md delegation and service tokens
03-keychain-trust.md escrow recovery and the trust circle join (the largest, 2,033 lines)
04-cloudkit.md zones, records, paging
05-pcs-decryption.md key shares, view keys, record keys
06-output.md the export bundle layout
GAPS.md what is still open, and what each round settled

escrow-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.

Module
bundle.py builds the bundle and validates it. UUID shape, key material, naming/alignment join agreement, no duplicates
zipsink.py writes the zip, deterministically. Optional AES-256 encryption via pyzipper
passcode.py Crockford base32 codes, with the normalisation that folds O/I/L back to digits
keyfiles.py parses OpenHaystack .keys, Macless Haystack devices.json, FindMy.py JSON, and bare key lists
hardware.py identifies an accessory from its record: AirTag, Apple model families, AirPods including which unit, third-party makers from the Bluetooth SIG registry

Format version stays 0.0.2 unless a self-generated tag is present, in which case it is 0.0.3 and
the bundle carries CustomAccessories/<id>.json. A plist-only export is byte-identical to what the
previous exporter wrote.

3. The exporter (python/exporter/, renamed from python/main/)

New CLI (python -m exporter.cli). Interactive checkbox selection, falling back to a numbered
list 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 never
signs 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-test
that 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 an
account'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 into OpenTagViewer.wizard: and OpenTagViewer.cli: (was
OpenTagViewer.app:), uv with a lockfile pinning FindMy.py by commit, unused dependencies dropped.

4. CI and release plumbing

  • macos-exporter-python.yml builds a matrix: macOS arm64 and x86_64, Windows, Linux. exporter-v
    is the tag prefix now, macos-exporter-v still resolves.
  • macos-scripts-python.yml runs uv --frozen, drops Python 3.9 (FindMy.py needs 3.10), tests
    both suites.
  • release_version.py gains --kind exporter.

5. Android

Deliberately small. Two bridge functions in main.py calling the shared heuristic, tests for them,
new format-0.0.3 fixtures, and a fixtures README.

What is not done, and is not claimed:

  • The Chaquopy srcDir for python/opentagviewer_export/ is not wired, because python/ also
    contains a top-level test package that would shadow the standard library's. So the two new
    bridge functions return None in the APK today. They catch and log rather than fail.
  • app/build.gradle.kts installs FindMy.py from a branch, not a commit pin. Its own comment
    says to pin before any build that leaves the machine. That still needs doing.
  • app/src/test/python/requirements.txt still pins FindMy==0.9.8, out of step with the above.
  • The app cannot open an encrypted zip. The wizard therefore writes plain ones; the CLI needs
    --no-password.

docs/android-import-handover.md describes 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, and docs/README.md
indexing 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

  • 343 Python tests pass, 2 skipped (they need macOS 14 or older). flake8 gate clean.
  • The exporter was run end to end against a real Apple account, and PyInstaller builds were made
    and used.
  • The old macOS decryptor still works after the rename: it decrypts all three record types,
    rewrites .record to .plist, and its output feeds build_export.
  • Nothing Android was compiled. The machine this was written on has no Android toolchain.
  • get_key_fallback, which reads the macOS keystore, needs macOS 14 or older and was not run.

Merging this puts VERSION = "1.1.0" on main, which is what an exporter-v1.1.0 release then
tags.


PR description summarised by Claude Code.

parawanderer and others added 30 commits August 13, 2026 17:10
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.
parawanderer and others added 7 commits August 15, 2026 15:42
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.
…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 and others added 5 commits August 15, 2026 19:19
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>
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>
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>
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
parawanderer merged commit 06600c4 into main Aug 15, 2026
7 checks passed
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

1 inactive deployment
Android Build — 0b0919f8 Deployed Aug 15, 2026 by parawanderer via build #69
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant