release: package the extension, pin the Chrome extension id, and prove the wallet survives a reinstall (closes #310) #347

Merged
clawbot merged 1 commits from release/310-packaging-artifacts into next 2026-08-23 16:13:23 +02:00
Collaborator

Closes #310.

What changed

Chrome key. manifest/chrome.json now carries a fixed key — the public half of a 2048-bit RSA keypair, DER SPKI, base64. Without it an unpacked extension's id, and therefore its chrome.storage.local partition, is derived from the absolute load path: moving or re-cloning the checkout presents an empty wallet with no error. The id is now pinned at gipbhkogfopeahplcjhipkgpcimdpkip, asserted as a literal in tests/extensionId.test.js (recomputing it from whatever key happens to be in the manifest would pass after someone replaced the key, which is the change that orphans every wallet).

Where a packer would get the private key. It is NOT in this repo, no target generates one into the working tree, *.pem/*.key are gitignored, and tests/extensionId.test.js fails if such a file is ever committed. It is not needed to build, load or test anything here — it only signs a CRX. I generated the pair outside the repo and left the private half at /srv/code/_clawbot_secrets/autistmask-chrome-crx-key.pem, mode 0600, on this host. A packer would take it from there or from wherever you move it: chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>. Decision for you: if you would rather own a key you generated yourself, regenerate NOW, before any wallet exists — changing key later changes the extension id and orphans the storage partition.

make package / script/package. Runs make build first — deliberately, rather than re-implementing it: that target is the only audited path to a release build (receipt outside the repo, AUTISTMASK_DEBUG scrubbed from the verifier's environment, --expect release stated as an argument, script/discard-dist-on-failure on every step), and script/test-verify-build asserts that wiring by reading make -n back. A second path to dist/ for the release artifacts specifically is the last place that should exist. Then it writes:

release/autistmask-chrome-0.1.0.zip
release/autistmask-firefox-0.1.0.xpi
release/SHA256SUMS

Deterministic: entries sorted, timestamps fixed at the format epoch, compression level fixed. Verified by running make package twice and diffing SHA256SUMS — identical.

The dist/ root trap, handled explicitly. dist/styles.css sits outside both browser directories. The packager resolves every path referenced by the manifest and by every HTML document, requires each to be inside the archive, and fails hard on a reference that climbs out of the extension root; the dist/ root files are printed as deliberately not shipped rather than dropped by a glob:

Not shipped (dist/ root, outside every browser directory, and referenced by
nothing inside one): styles.css
autistmask-chrome-0.1.0.zip: 7 member(s), 1807533 bytes, sha256 90ee04bc...
autistmask-firefox-0.1.0.xpi: 7 member(s), 1807169 bytes, sha256 ac629b65...

The archive is then read back off disk, every member's CRC checked, every member's sha256 compared against the directory, and the self-containment check re-run against the archive's own contents. An archive nobody opened is a claim, not an artifact.

No new dependency. script/lib/zip.js is a ~200-line deterministic zip writer and reader on stdlib zlib. Output verified against Info-ZIP unzip -t and Python zipfile (both read all 7 members, no errors), and the XPI is loaded by a real Firefox in the suite below.

One version, enforced. script/lib/version.js requires package.json, manifest/chrome.json and manifest/firefox.json to agree, and build.js calls it before anything is emitted. Demonstration — package.json set to 0.2.0, manifests left at 0.1.0:

$ make package
Building AutistMask extension...
Build failed: the declared versions disagree, so this tree has no version:
package.json=0.2.0, manifest/chrome.json=0.1.0, manifest/firefox.json=0.1.0.
Set all of them to the same value: the manifests are what the browser reports
and package.json is what the About screen shows, and a build that picked one of
them would ship the disagreement.
make: *** [Makefile:45: package] Error 2

make check goes red on the same tree (tests/version.test.js, 2 failures). Reverted afterwards; the branch is at 0.1.0 everywhere.

BUILD_COMMIT dirty. Now <short>-dirty when git status --porcelain is non-empty, <short>-unknown when git answers the hash but not the status. Observed on a modified tree: commitHash: 'cef6aaa-dirty'. BUILD_COMMIT_FULL stays clean deliberately — it is the href of the About screen's commit link, and abc123-dirty is not a commit anyone can fetch. The displayed short hash carries the marker.

Browser test results

Both run on this branch after the rebase onto next at 669c443.

make test-e2e — exit 0. Existing suite 55/55, plus the new tests/e2e/storagePartition.js 5/5. Two unpacked loads from two different paths in one profile, the shipped manifest and then the same build with key stripped:

# with `key`:    /tmp/autistmask-keyed-a-MGYu4G/chrome -> gipbhkogfopeahplcjhipkgpcimdpkip
# with `key`:    /tmp/autistmask-keyed-b-YMM2Mi/chrome -> gipbhkogfopeahplcjhipkgpcimdpkip
# with `key`:    sentinel read back: written-by-the-first-load
# without `key`: /tmp/autistmask-bare-a-9MA6Pa/chrome -> goiibkladdekmnhflfmglfkkkmknjnil
# without `key`: /tmp/autistmask-bare-b-h5p3zu/chrome -> kjpehecppkpndiimfalijhkcpopedfoj
# without `key`: sentinel read back: null
# both keyed copies loaded at once: 1 extension id(s): gipbhkogfopeahplcjhipkgpcimdpkip

So: with key, storage IS shared across paths; without it, it is not — the second load reads null where the first wrote. Loading both keyed copies simultaneously in one profile yields one id, not two: Chrome does not load a second copy of an id it already has. The assertions are annotated as observations, so a Chrome that ever changes this fails the run rather than passing it.

make test-e2e-firefox — exit 0. Existing suite 8/8, plus the new tests/e2e/firefox/reinstall.js 7/7, which installs the packaged XPI (/work/release/autistmask-firefox-0.1.0.xpi) — the only place a real Firefox is asked to load the artifact that would actually be handed to someone. Two questions, two answers:

ok 1 - the artifact installs and reports the manifest's gecko id
ok 2 - a wallet created through the UI is in extension storage
ok 3 - the add-on installs again after a browser restart with the same id
ok 4 - after a restart and re-add, the extension does not come up as a fresh install
ok 5 - the vault, xpub and first address survive the restart unchanged
ok 6 - the vault still decrypts with the original password to the original recovery phrase
# after an explicit uninstall: {"present":false}
ok 7 - an explicit uninstall DESTROYS the vault (observed Firefox behaviour: Remove is irreversible, unlike a restart)

Finding, and a deviation from the issue's wording. The DoD says "install, create a wallet, uninstall, re-install, assert the vault is still there". I wrote that first and it FAILED: Firefox destroys an add-on's storage on an explicit uninstall, so the popup came back on the welcome screen with no wallet. That is correct browser behaviour and not a defect here, but it means the literal DoD assertion is false and I have not asserted it. What I assert instead is the operation the issue is actually about for Firefox — a browser restart on the same profile with the temporary add-on added again, which is what daily use is, since a temporary add-on is unloaded on exit. That passes, including decrypting the vault back to the original recovery phrase through the real Show Recovery Phrase screen. The explicit-uninstall result is kept as an asserted observation and stated in README.md: on Firefox, about:addons "Remove" is irreversible except from the recovery phrase.

Two harness facts this turned up, both now commented at the point they bite: uninstalling while the popup is the session's only window discards the browsing context and every later WebDriver command fails with "no such window"; and the moz-extension:// uuid is re-minted per install, with a navigation to a stale one hanging rather than failing, so the uuid is read live out of extensions.webextensions.uuids after every install. Driver.newSession() takes an optional profile directory so two browser runs can share one profile — that is what makes "survives a restart" askable at all.

Firefox is unsigned, and README.md says so

No AMO attempt: self-distribution needs credentials this unit does not have. README.md now states plainly that release Firefox and ESR refuse the artifact with no working override, that Developer Edition, Nightly or an Unbranded build with xpinstall.signatures.required=false is required, and that a temporary add-on does not survive a browser restart. If AMO signing is wanted for 1.0 that is a separate issue and your decision — I have not filed it.

Verification

  • make check: green on this branch after the rebase — 45 suites, 881 tests, test-verify-build 46 cases, check-censored 163 files, ESLint and prettier in the pinned container.
  • make test-e2e: green, exit 0.
  • make test-e2e-firefox: green, exit 0.
  • Rebased onto next at 669c443; conflicts in README.md and TODO.md (the make clean line and the Completed Steps head) resolved by hand, make check re-run green afterwards.
  • No stray containers: every docker run is --rm, docker ps -a empty afterwards. No prune of any kind.

Out of scope, deliberately

Tagging, CRX packing, AMO signing and any upload. Producing and verifying the artifact is here; publishing is yours.

Closes https://git.eeqj.de/sneak/AutistMask/issues/310. ## What changed **Chrome `key`.** `manifest/chrome.json` now carries a fixed `key` — the public half of a 2048-bit RSA keypair, DER SPKI, base64. Without it an unpacked extension's id, and therefore its `chrome.storage.local` partition, is derived from the absolute load path: moving or re-cloning the checkout presents an empty wallet with no error. The id is now pinned at `gipbhkogfopeahplcjhipkgpcimdpkip`, asserted as a literal in `tests/extensionId.test.js` (recomputing it from whatever `key` happens to be in the manifest would pass after someone replaced the key, which is the change that orphans every wallet). **Where a packer would get the private key.** It is NOT in this repo, no target generates one into the working tree, `*.pem`/`*.key` are gitignored, and `tests/extensionId.test.js` fails if such a file is ever committed. It is not needed to build, load or test anything here — it only signs a CRX. I generated the pair outside the repo and left the private half at `/srv/code/_clawbot_secrets/autistmask-chrome-crx-key.pem`, mode 0600, on this host. A packer would take it from there or from wherever you move it: `chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`. **Decision for you:** if you would rather own a key you generated yourself, regenerate NOW, before any wallet exists — changing `key` later changes the extension id and orphans the storage partition. **`make package` / `script/package`.** Runs `make build` first — deliberately, rather than re-implementing it: that target is the only audited path to a release build (receipt outside the repo, `AUTISTMASK_DEBUG` scrubbed from the verifier's environment, `--expect release` stated as an argument, `script/discard-dist-on-failure` on every step), and `script/test-verify-build` asserts that wiring by reading `make -n` back. A second path to `dist/` for the release artifacts specifically is the last place that should exist. Then it writes: ``` release/autistmask-chrome-0.1.0.zip release/autistmask-firefox-0.1.0.xpi release/SHA256SUMS ``` Deterministic: entries sorted, timestamps fixed at the format epoch, compression level fixed. Verified by running `make package` twice and diffing `SHA256SUMS` — identical. **The `dist/` root trap, handled explicitly.** `dist/styles.css` sits outside both browser directories. The packager resolves every path referenced by the manifest and by every HTML document, requires each to be inside the archive, and fails hard on a reference that climbs out of the extension root; the `dist/` root files are printed as deliberately not shipped rather than dropped by a glob: ``` Not shipped (dist/ root, outside every browser directory, and referenced by nothing inside one): styles.css autistmask-chrome-0.1.0.zip: 7 member(s), 1807533 bytes, sha256 90ee04bc... autistmask-firefox-0.1.0.xpi: 7 member(s), 1807169 bytes, sha256 ac629b65... ``` The archive is then read back off disk, every member's CRC checked, every member's sha256 compared against the directory, and the self-containment check re-run against the archive's own contents. An archive nobody opened is a claim, not an artifact. **No new dependency.** `script/lib/zip.js` is a ~200-line deterministic zip writer and reader on stdlib `zlib`. Output verified against Info-ZIP `unzip -t` and Python `zipfile` (both read all 7 members, no errors), and the XPI is loaded by a real Firefox in the suite below. **One version, enforced.** `script/lib/version.js` requires `package.json`, `manifest/chrome.json` and `manifest/firefox.json` to agree, and `build.js` calls it before anything is emitted. Demonstration — `package.json` set to `0.2.0`, manifests left at `0.1.0`: ``` $ make package Building AutistMask extension... Build failed: the declared versions disagree, so this tree has no version: package.json=0.2.0, manifest/chrome.json=0.1.0, manifest/firefox.json=0.1.0. Set all of them to the same value: the manifests are what the browser reports and package.json is what the About screen shows, and a build that picked one of them would ship the disagreement. make: *** [Makefile:45: package] Error 2 ``` `make check` goes red on the same tree (`tests/version.test.js`, 2 failures). Reverted afterwards; the branch is at `0.1.0` everywhere. **`BUILD_COMMIT` dirty.** Now `<short>-dirty` when `git status --porcelain` is non-empty, `<short>-unknown` when git answers the hash but not the status. Observed on a modified tree: `commitHash: 'cef6aaa-dirty'`. `BUILD_COMMIT_FULL` stays clean deliberately — it is the href of the About screen's commit link, and `abc123-dirty` is not a commit anyone can fetch. The displayed short hash carries the marker. ## Browser test results Both run on this branch after the rebase onto `next` at `669c443`. **`make test-e2e` — exit 0.** Existing suite 55/55, plus the new `tests/e2e/storagePartition.js` 5/5. Two unpacked loads from two different paths in one profile, the shipped manifest and then the same build with `key` stripped: ``` # with `key`: /tmp/autistmask-keyed-a-MGYu4G/chrome -> gipbhkogfopeahplcjhipkgpcimdpkip # with `key`: /tmp/autistmask-keyed-b-YMM2Mi/chrome -> gipbhkogfopeahplcjhipkgpcimdpkip # with `key`: sentinel read back: written-by-the-first-load # without `key`: /tmp/autistmask-bare-a-9MA6Pa/chrome -> goiibkladdekmnhflfmglfkkkmknjnil # without `key`: /tmp/autistmask-bare-b-h5p3zu/chrome -> kjpehecppkpndiimfalijhkcpopedfoj # without `key`: sentinel read back: null # both keyed copies loaded at once: 1 extension id(s): gipbhkogfopeahplcjhipkgpcimdpkip ``` So: **with `key`, storage IS shared across paths; without it, it is not** — the second load reads `null` where the first wrote. Loading both keyed copies simultaneously in one profile yields one id, not two: Chrome does not load a second copy of an id it already has. The assertions are annotated as observations, so a Chrome that ever changes this fails the run rather than passing it. **`make test-e2e-firefox` — exit 0.** Existing suite 8/8, plus the new `tests/e2e/firefox/reinstall.js` 7/7, which installs **the packaged XPI** (`/work/release/autistmask-firefox-0.1.0.xpi`) — the only place a real Firefox is asked to load the artifact that would actually be handed to someone. Two questions, two answers: ``` ok 1 - the artifact installs and reports the manifest's gecko id ok 2 - a wallet created through the UI is in extension storage ok 3 - the add-on installs again after a browser restart with the same id ok 4 - after a restart and re-add, the extension does not come up as a fresh install ok 5 - the vault, xpub and first address survive the restart unchanged ok 6 - the vault still decrypts with the original password to the original recovery phrase # after an explicit uninstall: {"present":false} ok 7 - an explicit uninstall DESTROYS the vault (observed Firefox behaviour: Remove is irreversible, unlike a restart) ``` **Finding, and a deviation from the issue's wording.** The DoD says "install, create a wallet, uninstall, re-install, assert the vault is still there". I wrote that first and it FAILED: Firefox destroys an add-on's storage on an explicit uninstall, so the popup came back on the welcome screen with no wallet. That is correct browser behaviour and not a defect here, but it means the literal DoD assertion is false and I have not asserted it. What I assert instead is the operation the issue is actually about for Firefox — a **browser restart** on the same profile with the temporary add-on added again, which is what daily use is, since a temporary add-on is unloaded on exit. That passes, including decrypting the vault back to the original recovery phrase through the real Show Recovery Phrase screen. The explicit-uninstall result is kept as an asserted observation and stated in `README.md`: **on Firefox, about:addons "Remove" is irreversible except from the recovery phrase.** Two harness facts this turned up, both now commented at the point they bite: uninstalling while the popup is the session's only window discards the browsing context and every later WebDriver command fails with "no such window"; and the `moz-extension://` uuid is re-minted per install, with a navigation to a stale one **hanging** rather than failing, so the uuid is read live out of `extensions.webextensions.uuids` after every install. `Driver.newSession()` takes an optional profile directory so two browser runs can share one profile — that is what makes "survives a restart" askable at all. ## Firefox is unsigned, and `README.md` says so No AMO attempt: self-distribution needs credentials this unit does not have. `README.md` now states plainly that release Firefox and ESR refuse the artifact with no working override, that Developer Edition, Nightly or an Unbranded build with `xpinstall.signatures.required=false` is required, and that a temporary add-on does not survive a browser restart. **If AMO signing is wanted for 1.0 that is a separate issue and your decision** — I have not filed it. ## Verification - `make check`: **green** on this branch after the rebase — 45 suites, 881 tests, `test-verify-build` 46 cases, `check-censored` 163 files, ESLint and prettier in the pinned container. - `make test-e2e`: **green**, exit 0. - `make test-e2e-firefox`: **green**, exit 0. - Rebased onto `next` at `669c443`; conflicts in `README.md` and `TODO.md` (the `make clean` line and the Completed Steps head) resolved by hand, `make check` re-run green afterwards. - No stray containers: every `docker run` is `--rm`, `docker ps -a` empty afterwards. No prune of any kind. ## Out of scope, deliberately Tagging, CRX packing, AMO signing and any upload. Producing and verifying the artifact is here; publishing is yours.
clawbot added 1 commit 2026-08-23 16:02:52 +02:00
release: package the extension, pin the Chrome extension id, and prove the wallet survives a reinstall (closes #310)
All checks were successful
check / check (push) Successful in 52s
e2e / e2e-chrome (push) Successful in 2m0s
e2e / e2e-firefox (push) Successful in 50s
a874299412
There was no packaging target anywhere, no artifact, and no `key` in
`manifest/chrome.json` — so an unpacked Chrome load derived its extension
id, and therefore its `chrome.storage.local` partition, from the absolute
checkout path. Moving or re-cloning the checkout presented an empty
wallet, with no error and nothing in the UI to say so.

`manifest/chrome.json` now carries a fixed `key`: the public half of an
RSA keypair, which pins the extension id to
`gipbhkogfopeahplcjhipkgpcimdpkip`. The private half is a credential and
is not in this repo; no target generates one into the working tree, and
`tests/extensionId.test.js` fails if a `.pem` is ever committed. Changing
`key` changes the id and orphans every wallet stored under the old one.

`make package` (script/package) runs `make build` — the only audited path
to a release build — and writes one self-contained, versioned archive per
browser into `release/`, plus `SHA256SUMS`. The archives are deterministic:
entries sorted, timestamps fixed, compression level fixed, so two builds
of one commit are byte-identical. Self-containment is checked rather than
assumed: every path the manifests and the popup HTML reference is resolved
and required to be inside the archive, a reference that climbs out of the
extension root is a hard failure, and files left at the `dist/` root —
`dist/styles.css`, which build.js copies into each browser directory — are
reported as deliberately not shipped rather than dropped by a glob. The
archive is then read back off disk and compared member by member against
the directory it was built from. The zip writer and reader are stdlib zlib
in `script/lib/zip.js`; no new dependency, and nothing unpinned.

One version, enforced rather than generated. `script/lib/version.js`
requires `package.json`, `manifest/chrome.json` and `manifest/firefox.json`
to agree and fails the build naming each file and what it said, instead of
reading from one of the three. `BUILD_COMMIT` now carries `-dirty` when the
working tree does not match `HEAD`, and `-unknown` when git cannot say;
the full hash behind the About screen's commit link stays clean so the link
still resolves.

Two real-browser observations, both run through the pinned harnesses:

- `tests/e2e/storagePartition.js` loads the build from two different paths
  in one Chrome profile. With `key`: same id, and the second load reads the
  first load's storage. Without `key`: different ids, and the second load
  sees an empty partition. Loading both keyed copies at once yields one id,
  not two.
- `tests/e2e/firefox/reinstall.js` installs the packaged XPI in a real
  Firefox, creates a wallet, quits the browser, restarts on the same
  profile, adds the add-on again, and decrypts the vault back to the
  original recovery phrase. It then observes that an explicit uninstall
  DESTROYS that storage — correct browser behaviour, but for a wallet it
  means Remove is irreversible except from the recovery phrase, so
  README.md says so.

Firefox ships an UNSIGNED XPI. README.md states plainly that release
Firefox and ESR will refuse it, that Developer Edition, Nightly or an
Unbranded build is required, and that a temporary add-on does not survive
a browser restart. AMO signing, CRX packing, tagging and any upload are
deliberately out of scope.
clawbot self-assigned this 2026-08-23 16:02:55 +02:00
clawbot added the needs-review label 2026-08-23 16:02:55 +02:00
Author
Collaborator

PASS — reviewed independently in a fresh clone at a874299: make check, make test-e2e and make test-e2e-firefox all green here; storage-partition behaviour, all three version-disagreement cases, the AUTISTMASK_DEBUG=1 failure-plus-dist/-removal boundary and the dirty BUILD_COMMIT stamp were each reproduced by hand, the artifacts were unpacked with unzip and verified self-contained, my SHA256SUMS matched the PR body byte-for-byte from a different absolute path, and no private key material exists in the tree or is reachable from any build target. The Firefox uninstall claim is true — I observed {"present":false} myself — and asserting the literal DoD item's real (negative) outcome while substituting the restart cycle is the honest answer, not a dodge.

Two disclosures, neither a defect of this PR and neither warranting rework:

  • The publicly committed test recovery phrase (DEBUG_MNEMONIC, src/shared/constants.js) survives as a dead string literal in both release artifacts' bundles. Pre-existing and unreachable — both bundles verify as autistmask-build-debug=off — but this PR is the first thing to ship an artifact at all, so it is now a 12-word BIP-39 string sitting in a distributed wallet build. Worth a separate issue.
  • The committed-private-key guard in tests/extensionId.test.js matches on filename extension only (.pem, .key, .p12, .pfx), so key material committed under any other name would pass it.
PASS — reviewed independently in a fresh clone at `a874299`: `make check`, `make test-e2e` and `make test-e2e-firefox` all green here; storage-partition behaviour, all three version-disagreement cases, the `AUTISTMASK_DEBUG=1` failure-plus-`dist/`-removal boundary and the dirty `BUILD_COMMIT` stamp were each reproduced by hand, the artifacts were unpacked with `unzip` and verified self-contained, my `SHA256SUMS` matched the PR body byte-for-byte from a different absolute path, and no private key material exists in the tree or is reachable from any build target. The Firefox uninstall claim is true — I observed `{"present":false}` myself — and asserting the literal DoD item's real (negative) outcome while substituting the restart cycle is the honest answer, not a dodge. Two disclosures, neither a defect of this PR and neither warranting rework: - The publicly committed test recovery phrase (`DEBUG_MNEMONIC`, `src/shared/constants.js`) survives as a dead string literal in both release artifacts' bundles. Pre-existing and unreachable — both bundles verify as `autistmask-build-debug=off` — but this PR is the first thing to ship an artifact at all, so it is now a 12-word BIP-39 string sitting in a distributed wallet build. Worth a separate issue. - The committed-private-key guard in `tests/extensionId.test.js` matches on filename extension only (`.pem`, `.key`, `.p12`, `.pfx`), so key material committed under any other name would pass it.
clawbot merged commit 769f6a5289 into next 2026-08-23 16:13:23 +02:00
clawbot deleted branch release/310-packaging-artifacts 2026-08-23 16:13:23 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#347