Lock exported bundles by default, and tell the user the code - #150
Merged
Conversation
The wizard has never password-protected an export. That was not an oversight: wizard.py hard-coded password=None with a comment saying what would change it - "an Android release containing zip4j, not a change here". Before that the app could not decrypt anything at all, so a locked bundle was a file nobody's installed app could open, and the people worst affected were recipients, who did not choose the exporter's version and could not fix it from their side. App 1.1.0 reads them, so the blocker is retired rather than overridden. The CLI has locked by default since it existed, so this also ends the state where two entry points in one repo, on one VERSION, did opposite things. The checkbox stays for the case the blocker was about. Anyone on an app older than 1.1.0 still cannot open a locked bundle, and a sender has no way to know which version the recipient runs. **The code needs a window of its own, because nothing stores it.** The zip keeps only what AES needs to verify it; the log does not have it; the program forgets it. So the one moment it exists in a readable form has to be deliberate: modal, dismissed on purpose, and not behind a window that destroys itself. It is selectable and copyable because twelve characters read off a screen and typed into a chat window is exactly the case Crockford's alphabet exists to survive - and the program can do it without the transcription error. It says the two things that decide whether the bundle is safe: the code cannot be recovered, and it has to travel separately from the file. A code sent in the same message as the bundle is in the same backup as the bundle, and an exported accessory cannot be withdrawn except by unpairing it. _write_it also stops destroying the window when the write fails. A full disk or a removable drive pulled mid-write used to cost the whole selection and another read of the account. Twelve tests, mutation-checked. The first four cover the wiring; the last four drive the dialog itself, which the first version of this did not - it mocked _show_the_code out entirely, leaving the only place the code is ever displayed with no coverage at all. A dialog rendering the wrong string or a Copy button copying nothing would have passed. That needed a seam: _show_the_code ends in wait_window, so a test calling it hangs; building the window is now separate from waiting on it. Nothing asserted password=None either, so this default could have flipped in either direction unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 15:27 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 15:27 — with
GitHub Actions
Inactive
They have separate tags and separate workflows, which makes them look independent. They are not: exporter 1.4.0 writes locked bundles and an app older than 1.1.0 cannot decrypt one at all. Publish the exporter first and every bundle written that day is unopenable by whoever receives it - and the recipient is the one person in that transaction who chose none of it and can fix none of it. Nothing enforces this. release_version.py checks a tag against a version, not one release against another, so it is a thing to remember, which is why it is written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 15:31 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 15:31 — with
GitHub Actions
Inactive
…undles-by-default
The third producer of this format, beside the desktop wizard and its CLI. It writes through the same opentagviewer_export.build_export they do and stamps via: OpenTagViewer.android:<versionName>, so a zip still says which of the three built it - which is the whole reason that package is shared rather than copied. **Python owns the format, Java owns the container.** build_export returns the files in memory; the package's own sink needs pyzipper for an encrypted archive, which is not in Chaquopy's pip list and pulls a native crypto dependency. zip4j is already here for reading locked bundles and writes them too, so neither side grows a dependency for the other. Bytes cross as base64 inside JSON. A plist is not UTF-8 and an OwnedBeacons record carries the accessory's private key, so anything lossy in the crossing produces a bundle that imports cleanly and then locates nothing - discovered days later, by the recipient, after the sender has deleted their copy. AES-256 under the WinZip scheme, matching zipsink.py and what the importer already opens. Not ZipCrypto: broken since the nineties, and a bundle protected by it would be protected in name only while passing every other test here. The listing is not encrypted, only the entries - a property of the format worth knowing before treating the file as opaque. **An unusable key alignment record no longer costs the whole export.** The format layer refuses one and says what to do about it - "pass no alignment record at all rather than an unreadable one: the import is then slow, not broken" - so the bridge acts on that rather than relaying it, retries without them, and reports a warning. Refusing everything because an optional record is malformed trades something that works badly for nothing at all. A genuinely broken accessory is still refused, rather than becoming a silent partial export. BundlePasscode gains generate() and format(). It only ever read codes before; SecureRandom because the zip format fixes PBKDF2 at 1000 iterations, so the code itself is the whole of the security. The builder sits behind an interface for the usual reason and one more: an export that throws leaves somebody with no file and no explanation, having just decided to share the keys to their tags. That path needs driving in a test, and breaking the interpreter on demand is not a thing. Eleven bridge tests on plain CPython, eleven container tests on the JVM - nothing here needs a device. Mutation-checked: writing ZipCrypto instead of AES reddens exactly the test that names it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Export Tags has sat in the selection menu, listed and disabled, since the menu existed - with a comment saying "the XML disables it; this is where to stop doing that". The shared export package is on the phone now, so it stops. Pick tags, choose where the file goes, and read the code that opens it. Sharing rather than backing up: an owner signed into their own account does not need a bundle, and this is what a bundle is actually for. The act is irreversible - exported key material cannot be withdrawn, and the only way to revoke it is to unpair the accessory. **Three failures, three answers, and the difference is the whole point.** A tag with no naming record is named in a toast: the importer inner-joins the two records and drops what it cannot pair, so exporting one produces a bundle that imports successfully and contains nothing - the sender is told it worked and the recipient finds out it did not. A file that will not write is the disk, and says so. Anything else is the app failing at something it should manage, and only that reaches the report page, which is worth nothing if it also turns up for the first two. **The write is a map, not something done inside subscribe, and that was a crash.** Rx cannot deliver an exception thrown in the onNext consumer to the onError consumer, so it goes to RxJavaPlugins.onError and takes the process with it. The first version had the write in the consumer: an export that failed killed the app instead of showing anything. Found by driving the real flow - the earlier test started the report page itself and asserted the intent, which proves the page exists rather than that the app reaches it. describe(Throwable) unwraps to the root cause for the same reason: Rx wraps what a map throws, so the page would have said "RuntimeException" and buried the sentence a reporter needs. It also moved to ErrorReportActivity, since two screens writing that string differently makes two reports of one bug look like two bugs. source_user is the device model, never the Apple ID. The shared package asks for a label and is right to: that string travels inside a file going to another person and often onward from there. Shot no longer writes a screenshot that is entirely one colour. UiAutomation.takeScreenshot() needs a display and returns black on the headless managed device, silently - five screens were captured that way and every one was black, with nothing in the run saying so. A missing file is noticed; a black one is mistaken for a screen that renders nothing. It gained decor-view capture, which works on both kinds of device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two halves had never met. The container test proves zip4j round-trips bytes; the bridge tests prove Python builds the right files. Both pass while the format is wrong, because neither had ever handed its output to the thing that reads it - and the format is where a mistake hides: a directory named in the singular, a naming record filed under the wrong identifier, a manifest that fails schema validation. Nothing is faked. The real Chaquopy builder runs, zip4j writes a real AES archive, and AppleZipImporterUtil opens it exactly as it opens a bundle from the desktop exporter. It found something on its first run - a fixture with a privateKey and nothing else was refused with "missing sharedSecret ... the accessory would fail conversion on the recipient's phone rather than here". A fixture bug rather than a product one, but it is precisely the failure this exists for, demonstrated: a partial record writes cleanly and fails at the other end, and nothing else in the stack says a word about it. Nine cases. The two worth naming: the naming record is asserted separately from the accessory count, because the importer inner-joins them and a misfiled one is dropped silently while the import still reports success with nothing in it; and a code written down by hand - grouped, spaced, with O for 0 and I for 1 - still opens the archive, which proves Crockford folding survives to the bytes rather than only to a string comparison. Reading it with no code, and with the wrong one, both have to fail. Without those the whole class would stay green if BundleZipWriter quietly stopped encrypting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 16:38 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 16:38 — with
GitHub Actions
Inactive
The Information screen listed every producer on the install, which was both cramped - it landed between the version and "Free, Open-Source Software by:", reading as part of the same sentence - and misleading. A list can hold tags from several exports and from an Apple account at once, so one line summarising all of them answers a question nobody asked. The tag's own page already answers it precisely, so the template sends people there instead, and says what the three outcomes mean: - an exporter and version, which is the answer - "Not recorded - an older exporter", which dates the bundle and is useful - no such row at all, with Source reading "Read from your Apple account" - that tag never came from a file, so no exporter was involved That third case is the one worth spelling out. An account tag has no Import row, so the whole block is hidden rather than empty, and somebody looking for a version they were asked for finds nothing and has no idea whether that is an answer or a fault. getDistinctProducers goes with it, along with the two tests that covered the screen and the query. The error page keeps its line - it is context for a failure that just happened rather than a summary of the install - but it now says "most recently imported from", because it reads getMostRecent and should not imply it speaks for every tag. Also: 32dp under the device info scroll area, with clipToPadding=false. Vendor Id was the last row and sat level with the gesture bar, which reads as a screen cut off mid-list. Inside the scrolling area, so nothing above it is lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 16:47 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 23, 2026 16:47 — with
GitHub Actions
Inactive
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.
Bundles now arrive locked, and the wizard tells you the code
Part of 1.1.0. Bumps
apptoversionCode 4/versionName 1.1.0and the exporter to1.4.0 — a minor rather than a patch, because every recipient notices.
The wizard never locked anything, and that was deliberate
wizard.pyhard-codedpassword=None, with a comment naming what would change it:Before zip4j the app could not decrypt anything at all, so a locked bundle was a file nobody's
installed app could open — and the people worst affected were recipients, who did not choose
the exporter's version and could not fix it from their side.
App 1.1.0 reads them. So the blocker is retired rather than overridden.
This also ends a state worth naming: the CLI has locked by default since it existed
(
cli.py:863), while the wizard never did. Two entry points, one repo, oneVERSION, oppositebehaviour.
Release ordering is now a constraint, not a preference
Once this ships, a bundle from a 1.4.0 wizard cannot be opened by app 1.0.5 — that app has no
AES support at all and fails with a message about the zip rather than about a code. So the Android
1.1.0 release has to be published before the exporter 1.4.0 release, not merely in the same
milestone.
The
Lock with a codecheckbox exists for exactly that gap, and stays: a sender has no way to knowwhich version their recipient runs.
The code gets a window, because nothing stores it
The zip keeps only what AES needs to verify it. The log does not have it. The program forgets it.
So the one moment it exists in readable form has to be deliberate — modal, dismissed on purpose,
not behind a window that destroys itself.
It is selectable and has a Copy the code button, because twelve characters read off a screen
and typed into a chat window is precisely the case Crockford's alphabet exists to survive, and the
program can do it without the transcription error. It says the two things that decide whether the
bundle is safe: the code cannot be recovered, and it must travel separately from the file — a code
sent in the same message as the bundle is in the same backup as the bundle.
Also: a failed write no longer destroys the window. A full disk used to cost the whole selection
and another read of the account.
Testing
Twelve tests, every one mutation-checked:
test_the_checkbox_starts_tickedtest_the_code_is_shown_and_it_is_the_one_that_was_usedtest_thecodeIsOnScreen_grouped_for_reading""test_copyPutsTheCodeOnTheClipboardThe first version of these tests was insufficient and is worth flagging. It mocked
_show_the_codeout entirely, so it proved the wiring and left the only place the code is everdisplayed with no coverage at all — a dialog rendering the wrong string, or a Copy button copying
nothing, would have passed. Fixing it needed a seam:
_show_the_codeends inwait_window, so atest calling it hangs. Building the window is now separate from waiting on it.
Also worth saying: nothing asserted
password=Nonebefore, so this default could have flippedin either direction unnoticed.
Not in this PR
the same
opentagviewer_export, stampsvia: OpenTagViewer.android:1.1.0, and needs this samecode screen on Android. Design note for then:
build_exportreturns in-memory entries, so Pythonbuilds them and zip4j writes the AES zip in Java —
pyzipperis not in Chaquopy's pip listand pulls a native crypto dependency.
Not verified
The dialog is asserted, not eyeballed — no screenshot of the running wizard is in this PR. And
nothing here was tested end-to-end against a real Android import of a locked bundle written by
this build; the app's side of that is covered by its own tests against fixtures.
🤖 Generated with Claude Code
And the app became the third producer
Since the original description: the app writes bundles of its own.
Export Tagshad sat in theselection menu, listed and disabled, with a comment reading "the XML disables it; this is where
to stop doing that". It stops.
Python owns the format, Java owns the container.
build_exportreturns the files in memory;its own sink needs
pyzipper, which is not in Chaquopy's pip list and pulls a native cryptodependency. zip4j is already here for reading locked bundles and writes them too — so neither
side grows a dependency, and there is still one implementation of the format across all three
producers. Bundles stamp
via: OpenTagViewer.android:1.1.0.Three failures, three answers
Only the third earns the page. It is worth nothing if it also appears for the first two.
Two bugs the tests found, both real
An export that failed crashed the app. The write was inside the
subscribeonNext consumer,and Rx cannot deliver an exception thrown there to the onError consumer — it goes to
RxJavaPlugins.onErrorand takes the process with it. So the failure handling asked for in reviewdid the exact opposite of its job. Found by driving the real flow; an earlier version of the test
started the report page itself and asserted the intent, which proves the page exists rather than
that the app reaches it.
Shotwas silently writing black PNGs.UiAutomation.takeScreenshot()needs a display andreturns an all-black bitmap on the headless managed device, without failing. Five screens were
captured that way and every one was black. It now refuses to write a frame that is entirely one
colour, and gained decor-view capture that works on both kinds of device.
The round trip
WhatWeExportWeCanImportTest— nine cases, nothing faked: real Chaquopy builder, real AESarchive, real
AppleZipImporterUtilincluding schema validation. It caught a partial accessoryrecord on its first run, which is exactly the class of bug that otherwise surfaces on somebody
else's phone days later.
Also pinned there: a code written down by hand — grouped, spaced,
Ofor0andIfor1—still opens the archive. And reading with no code, or the wrong one, must fail; without those the
class would stay green if the writer quietly stopped encrypting.
Verified
Full instrumented suite 618 tests, 0 failed before this last commit; the round-trip class 9/9
and the export flow 4/4 after it. JVM, Python and
--check(298 strings × 10 locales) all clean.Still not verified
No real Apple account and no real AirTag were involved. The bundle is proved importable by this
app; it has not been imported by the desktop tooling, nor a bundle from this app opened on another
phone by hand.