The 12-word BIP-39 test phrase survived in every release bundle as dead text: module.exports keeps DEBUG_MNEMONIC live even though wallet.js's only use of it folds away in a release build, so it could not be tree-shaken. Putting the value itself behind the __BUILD_DEBUG__ define makes esbuild fold it to null, so no distributed bundle carries it. script/verify-build now fails a release build if the phrase appears in any emitted file, so the fold cannot silently regress; test-verify-build covers both the release failure and the debug allowance. tests/extensionId.test.js now scans the content of every tracked file for a PEM private-key header instead of matching filename extensions alone, so a key committed under an unexpected name is caught. Model: opus-4-8
129 lines
5.8 KiB
JavaScript
129 lines
5.8 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 private key is one anyone can sign a
|
|
// CRX with under this extension's id.
|
|
//
|
|
// Matched by CONTENT, not by filename: a key committed as notes.txt or with
|
|
// no extension carries the same risk as one named key.pem, and a
|
|
// filename-only check waves it through. The PEM header a private key opens
|
|
// with is the signature searched for. The pattern does not trip on its own
|
|
// source: the bracket-expression characters between the two anchors are not
|
|
// in the character class, so this file is not a match for it.
|
|
const PRIVATE_KEY_HEADER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
|
test("no private key is committed anywhere in the tree", () => {
|
|
const root = path.join(__dirname, "..");
|
|
const tracked = require("child_process")
|
|
.execSync("git ls-files", { cwd: root, encoding: "utf8" })
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
const offenders = tracked.filter((f) =>
|
|
PRIVATE_KEY_HEADER.test(
|
|
fs.readFileSync(path.join(root, f), "latin1"),
|
|
),
|
|
);
|
|
expect(offenders).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();
|
|
});
|
|
});
|