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.
120 lines
5.2 KiB
JavaScript
120 lines
5.2 KiB
JavaScript
// The extension identity on both browsers, pinned.
|
|
//
|
|
// This is the anti-regression check for
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/310. An unpacked Chrome
|
|
// extension with no `key` in its manifest gets an id derived from the
|
|
// ABSOLUTE PATH it was loaded from, and chrome.storage.local is partitioned by
|
|
// that id. Move the checkout, re-clone it, or load it from a second directory,
|
|
// and the wallet is silently gone: the extension comes up on a fresh, empty
|
|
// storage partition with no error anywhere. `key` pins the id to the public
|
|
// key instead of to the path, which is what makes the storage survive.
|
|
//
|
|
// So the id is asserted as a literal. A test that merely recomputed the id
|
|
// from whatever `key` happened to be in the manifest would pass after someone
|
|
// replaced the key — and replacing the key is exactly the change that orphans
|
|
// every existing wallet. The value below is the promise; changing it is a
|
|
// migration, not an edit.
|
|
//
|
|
// Firefox needs no key: browser_specific_settings.gecko.id declares the id
|
|
// directly, and it is pinned here for the same reason. The Firefox e2e suite
|
|
// depends on it too (tests/e2e/firefox/driver.js maps it to a fixed uuid), and
|
|
// tests/e2e/firefox/reinstall.js is the empirical half — it removes the add-on
|
|
// and installs it again and reads the vault back out.
|
|
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
|
|
|
// The public half of an RSA keypair, DER-encoded SubjectPublicKeyInfo, base64.
|
|
// The PRIVATE half is not in this repo and is not needed to build, load or
|
|
// test anything here: it is only ever used to sign a CRX, which this repo does
|
|
// not do.
|
|
const CHROME_KEY =
|
|
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy/G9gT4Z3Ci0HCmthUPEiCjENg+" +
|
|
"5meZpjdogyT7SiMfxENtHdrpDL6wGhAg1Dk0f1C67Ft8OYpMrMH3kiP2Wnt0UpHo45PY0YUU" +
|
|
"YzdJgbsp8u0kaykd5FFiY6FycIIFaTniMuh7wRKuNNdJWly+H3aG7qZ6nGu5PIMdb1GXUk35" +
|
|
"hY+yl7dz5dqFFYUCyxvWCT9XGBSYiI+XRBB/rVZjMWfWpaTmRPdOZ4+GO/Lx0OdMxKlPA/kL" +
|
|
"WoPot5vMlLn2FDPu6sASphiu7dKZnrINW+h/27jlHMJQS0jncB1EgqOHW0vbXrZnTveFX6UW" +
|
|
"+Qp86FfSkikhKtQgTW2A4mtWawIDAQAB";
|
|
|
|
// chrome.storage.local for this extension lives under this id, and nowhere
|
|
// else.
|
|
const CHROME_EXTENSION_ID = "gipbhkogfopeahplcjhipkgpcimdpkip";
|
|
|
|
const FIREFOX_EXTENSION_ID = "autistmask@sneak.berlin";
|
|
|
|
// Chrome's id derivation: sha256 of the DER public key, first 16 bytes, each
|
|
// hex digit mapped 0-f onto a-p. Written out here rather than taken on trust,
|
|
// because the whole claim of this file is that the committed key produces that
|
|
// id.
|
|
function chromeExtensionId(keyBase64) {
|
|
const der = Buffer.from(keyBase64, "base64");
|
|
const digest = crypto.createHash("sha256").update(der).digest("hex");
|
|
return [...digest.slice(0, 32)]
|
|
.map((c) => String.fromCharCode(97 + parseInt(c, 16)))
|
|
.join("");
|
|
}
|
|
|
|
function readManifest(name) {
|
|
return JSON.parse(
|
|
fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"),
|
|
);
|
|
}
|
|
|
|
describe("chrome extension identity", () => {
|
|
test("the manifest carries the pinned key", () => {
|
|
expect(readManifest("chrome").key).toBe(CHROME_KEY);
|
|
});
|
|
|
|
test("the key is a well-formed RSA public key", () => {
|
|
const der = Buffer.from(CHROME_KEY, "base64");
|
|
// Round-trips: a truncated or re-wrapped base64 blob would still
|
|
// decode to bytes, and Chrome would then derive an id from garbage.
|
|
expect(der.toString("base64")).toBe(CHROME_KEY);
|
|
const key = crypto.createPublicKey({
|
|
key: der,
|
|
format: "der",
|
|
type: "spki",
|
|
});
|
|
expect(key.asymmetricKeyType).toBe("rsa");
|
|
expect(key.asymmetricKeyDetails.modulusLength).toBe(2048);
|
|
});
|
|
|
|
test("the key derives the pinned extension id", () => {
|
|
expect(chromeExtensionId(CHROME_KEY)).toBe(CHROME_EXTENSION_ID);
|
|
expect(CHROME_EXTENSION_ID).toMatch(/^[a-p]{32}$/);
|
|
});
|
|
|
|
// The private half is a credential. It has never been in this repo and no
|
|
// target generates one into the working tree; this fails loudly if that
|
|
// ever changes, because a committed .pem is a key anyone can sign a CRX
|
|
// with under this extension's id.
|
|
test("no private key is committed anywhere in the tree", () => {
|
|
const tracked = require("child_process")
|
|
.execSync("git ls-files", {
|
|
cwd: path.join(__dirname, ".."),
|
|
encoding: "utf8",
|
|
})
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
expect(tracked.filter((f) => /\.(pem|key|p12|pfx)$/i.test(f))).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("firefox extension identity", () => {
|
|
test("the manifest declares the pinned gecko id", () => {
|
|
const gecko = readManifest("firefox").browser_specific_settings.gecko;
|
|
expect(gecko.id).toBe(FIREFOX_EXTENSION_ID);
|
|
});
|
|
|
|
// Firefox derives nothing from the path, so no key field belongs here; one
|
|
// would be ignored and would only suggest the id came from somewhere else.
|
|
test("the firefox manifest carries no chrome key field", () => {
|
|
expect(readManifest("firefox").key).toBeUndefined();
|
|
});
|
|
});
|