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.
275 lines
10 KiB
JavaScript
275 lines
10 KiB
JavaScript
// A minimal, deterministic ZIP writer and reader.
|
|
//
|
|
// Used by script/lib/package.js to build the distributable archives: a Chrome
|
|
// zip and a Firefox XPI are both ordinary zip files with manifest.json at the
|
|
// root, so one implementation covers both.
|
|
//
|
|
// Why this rather than a package or the zip(1) binary. A dependency would have
|
|
// to be hash-pinned like everything else in REPO_POLICIES.md, and this is
|
|
// about a hundred lines of stdlib zlib for a format the archives use two
|
|
// features of. The binary is worse: the release artifact would then depend on
|
|
// whichever Info-ZIP the machine happens to have, which is the same objection
|
|
// that keeps linting inside a container.
|
|
//
|
|
// Deterministic on purpose. Entries are sorted by name, every timestamp is the
|
|
// same fixed 1980-01-01 the format's epoch starts at, and the compression
|
|
// level is fixed, so building the same dist/ twice produces byte-identical
|
|
// archives and the sha256 in SHA256SUMS is a property of the input rather than
|
|
// of the clock. Two builds of the same commit that disagree are then visible
|
|
// instead of expected.
|
|
//
|
|
// Deliberately NOT implemented: zip64, encryption, data descriptors,
|
|
// directory entries (browsers infer directories from member paths), and
|
|
// anything to do with symlinks. writeZip refuses input it cannot represent
|
|
// rather than emitting an archive that is quietly wrong.
|
|
|
|
"use strict";
|
|
|
|
const zlib = require("zlib");
|
|
|
|
const LOCAL_SIG = 0x04034b50;
|
|
const CENTRAL_SIG = 0x02014b50;
|
|
const EOCD_SIG = 0x06054b50;
|
|
|
|
const METHOD_STORE = 0;
|
|
const METHOD_DEFLATE = 8;
|
|
|
|
// 1980-01-01 00:00:00, the earliest the MS-DOS timestamp fields can express.
|
|
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
|
|
const DOS_TIME = 0;
|
|
|
|
// Unix regular file, mode 0644, in the high 16 bits, which is where the "made
|
|
// by unix" convention puts it.
|
|
// >>> 0 because JS shifts are signed 32-bit and this one sets the top bit.
|
|
const EXTERNAL_ATTRS = (0o100644 << 16) >>> 0;
|
|
const VERSION_MADE_BY = (3 << 8) | 20; // unix, needs zip 2.0
|
|
const VERSION_NEEDED = 20;
|
|
|
|
// Without zip64 every size and offset is a u32.
|
|
const MAX_U32 = 0xffffffff;
|
|
|
|
const CRC_TABLE = (() => {
|
|
const table = new Int32Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
let c = i;
|
|
for (let k = 0; k < 8; k++) {
|
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
}
|
|
table[i] = c;
|
|
}
|
|
return table;
|
|
})();
|
|
|
|
// Written out rather than taken from zlib.crc32, which only exists from node
|
|
// 22.2: this runs from script/ on whatever node the host has as well as inside
|
|
// the pinned image, and a checksum that silently is not there is worse than
|
|
// twelve lines.
|
|
function crc32(buf) {
|
|
let c = -1;
|
|
for (let i = 0; i < buf.length; i++) {
|
|
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
}
|
|
return (c ^ -1) >>> 0;
|
|
}
|
|
|
|
// Member names are stored as raw bytes. Anything outside ASCII would need the
|
|
// UTF-8 flag and interoperability care that nothing this repo emits requires,
|
|
// so it is refused instead of guessed at.
|
|
function encodeName(name) {
|
|
if (typeof name !== "string" || name === "") {
|
|
throw new Error(`zip: unusable member name ${JSON.stringify(name)}`);
|
|
}
|
|
const segments = name.split("/");
|
|
if (
|
|
name.startsWith("/") ||
|
|
name.includes("\\") ||
|
|
segments.some((s) => s === "" || s === "." || s === "..")
|
|
) {
|
|
throw new Error(
|
|
`zip: refusing member name ${JSON.stringify(name)}: archive ` +
|
|
`members must be relative paths under the archive root`,
|
|
);
|
|
}
|
|
if (/[^\x20-\x7e]/.test(name)) {
|
|
throw new Error(
|
|
`zip: refusing non-ASCII member name ${JSON.stringify(name)}`,
|
|
);
|
|
}
|
|
return Buffer.from(name, "ascii");
|
|
}
|
|
|
|
function compress(data) {
|
|
if (data.length === 0) {
|
|
return { method: METHOD_STORE, body: data };
|
|
}
|
|
const deflated = zlib.deflateRawSync(data, { level: 9 });
|
|
if (deflated.length >= data.length) {
|
|
return { method: METHOD_STORE, body: data };
|
|
}
|
|
return { method: METHOD_DEFLATE, body: deflated };
|
|
}
|
|
|
|
// entries: [{ name, data }]. Returns the archive as a Buffer.
|
|
function writeZip(entries) {
|
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
throw new Error("zip: refusing to write an archive with no members");
|
|
}
|
|
|
|
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
const seen = new Set();
|
|
const locals = [];
|
|
const centrals = [];
|
|
let offset = 0;
|
|
|
|
for (const entry of sorted) {
|
|
const name = encodeName(entry.name);
|
|
if (seen.has(entry.name)) {
|
|
throw new Error(`zip: duplicate member ${entry.name}`);
|
|
}
|
|
seen.add(entry.name);
|
|
|
|
const data = Buffer.from(entry.data);
|
|
const { method, body } = compress(data);
|
|
if (data.length > MAX_U32 || body.length > MAX_U32) {
|
|
throw new Error(
|
|
`zip: ${entry.name} is too large for a non-zip64 archive`,
|
|
);
|
|
}
|
|
const crc = crc32(data);
|
|
|
|
const local = Buffer.alloc(30 + name.length);
|
|
local.writeUInt32LE(LOCAL_SIG, 0);
|
|
local.writeUInt16LE(VERSION_NEEDED, 4);
|
|
local.writeUInt16LE(0, 6);
|
|
local.writeUInt16LE(method, 8);
|
|
local.writeUInt16LE(DOS_TIME, 10);
|
|
local.writeUInt16LE(DOS_DATE, 12);
|
|
local.writeUInt32LE(crc, 14);
|
|
local.writeUInt32LE(body.length, 18);
|
|
local.writeUInt32LE(data.length, 22);
|
|
local.writeUInt16LE(name.length, 26);
|
|
local.writeUInt16LE(0, 28);
|
|
name.copy(local, 30);
|
|
|
|
const central = Buffer.alloc(46 + name.length);
|
|
central.writeUInt32LE(CENTRAL_SIG, 0);
|
|
central.writeUInt16LE(VERSION_MADE_BY, 4);
|
|
central.writeUInt16LE(VERSION_NEEDED, 6);
|
|
central.writeUInt16LE(0, 8);
|
|
central.writeUInt16LE(method, 10);
|
|
central.writeUInt16LE(DOS_TIME, 12);
|
|
central.writeUInt16LE(DOS_DATE, 14);
|
|
central.writeUInt32LE(crc, 16);
|
|
central.writeUInt32LE(body.length, 20);
|
|
central.writeUInt32LE(data.length, 24);
|
|
central.writeUInt16LE(name.length, 28);
|
|
central.writeUInt16LE(0, 30);
|
|
central.writeUInt16LE(0, 32);
|
|
central.writeUInt16LE(0, 34);
|
|
central.writeUInt16LE(0, 36);
|
|
central.writeUInt32LE(EXTERNAL_ATTRS, 38);
|
|
if (offset > MAX_U32) {
|
|
throw new Error("zip: archive too large for a non-zip64 archive");
|
|
}
|
|
central.writeUInt32LE(offset, 42);
|
|
name.copy(central, 46);
|
|
|
|
locals.push(local, body);
|
|
centrals.push(central);
|
|
offset += local.length + body.length;
|
|
}
|
|
|
|
const centralBuf = Buffer.concat(centrals);
|
|
const eocd = Buffer.alloc(22);
|
|
eocd.writeUInt32LE(EOCD_SIG, 0);
|
|
eocd.writeUInt16LE(0, 4);
|
|
eocd.writeUInt16LE(0, 6);
|
|
eocd.writeUInt16LE(sorted.length, 8);
|
|
eocd.writeUInt16LE(sorted.length, 10);
|
|
eocd.writeUInt32LE(centralBuf.length, 12);
|
|
eocd.writeUInt32LE(offset, 16);
|
|
eocd.writeUInt16LE(0, 20);
|
|
|
|
return Buffer.concat([...locals, centralBuf, eocd]);
|
|
}
|
|
|
|
// Read an archive back into [{ name, data }], from the central directory
|
|
// rather than by scanning for local headers: the central directory is the
|
|
// authoritative index, and a member reachable only by scanning is one a real
|
|
// unzipper would not extract.
|
|
//
|
|
// Every member's CRC is checked. The point of reading an archive back is to
|
|
// establish that it holds what it was meant to hold, so a member that does not
|
|
// decompress to its recorded checksum is a failure and never a warning.
|
|
function readZip(buf) {
|
|
if (buf.length < 22) {
|
|
throw new Error("zip: too short to be an archive");
|
|
}
|
|
// No archive this writes has a trailing comment, so the EOCD is the last
|
|
// 22 bytes. Anything else is not an archive this produced.
|
|
const eocdAt = buf.length - 22;
|
|
if (buf.readUInt32LE(eocdAt) !== EOCD_SIG) {
|
|
throw new Error(
|
|
"zip: no end-of-central-directory record at the end of the " +
|
|
"archive (a trailing comment, or not a zip at all)",
|
|
);
|
|
}
|
|
const count = buf.readUInt16LE(eocdAt + 10);
|
|
const centralSize = buf.readUInt32LE(eocdAt + 12);
|
|
let at = buf.readUInt32LE(eocdAt + 16);
|
|
if (at + centralSize > eocdAt) {
|
|
throw new Error("zip: central directory runs past the archive");
|
|
}
|
|
|
|
const out = [];
|
|
for (let i = 0; i < count; i++) {
|
|
if (buf.readUInt32LE(at) !== CENTRAL_SIG) {
|
|
throw new Error(`zip: bad central directory entry ${i}`);
|
|
}
|
|
const method = buf.readUInt16LE(at + 10);
|
|
const crc = buf.readUInt32LE(at + 16);
|
|
const compSize = buf.readUInt32LE(at + 20);
|
|
const rawSize = buf.readUInt32LE(at + 24);
|
|
const nameLen = buf.readUInt16LE(at + 28);
|
|
const extraLen = buf.readUInt16LE(at + 30);
|
|
const commentLen = buf.readUInt16LE(at + 32);
|
|
const localAt = buf.readUInt32LE(at + 42);
|
|
const name = buf.toString("ascii", at + 46, at + 46 + nameLen);
|
|
at += 46 + nameLen + extraLen + commentLen;
|
|
|
|
if (buf.readUInt32LE(localAt) !== LOCAL_SIG) {
|
|
throw new Error(`zip: ${name} has no local header`);
|
|
}
|
|
// The local header's own name and extra lengths, not the central
|
|
// directory's: the two are allowed to differ and the data starts after
|
|
// the local ones.
|
|
const localNameLen = buf.readUInt16LE(localAt + 26);
|
|
const localExtraLen = buf.readUInt16LE(localAt + 28);
|
|
const dataAt = localAt + 30 + localNameLen + localExtraLen;
|
|
const body = buf.subarray(dataAt, dataAt + compSize);
|
|
|
|
let data;
|
|
if (method === METHOD_STORE) {
|
|
data = Buffer.from(body);
|
|
} else if (method === METHOD_DEFLATE) {
|
|
data = zlib.inflateRawSync(body);
|
|
} else {
|
|
throw new Error(`zip: ${name} uses compression method ${method}`);
|
|
}
|
|
|
|
if (data.length !== rawSize) {
|
|
throw new Error(
|
|
`zip: ${name} decompressed to ${data.length} bytes, not the ` +
|
|
`recorded ${rawSize}`,
|
|
);
|
|
}
|
|
if (crc32(data) !== crc) {
|
|
throw new Error(`zip: ${name} fails its recorded CRC32`);
|
|
}
|
|
out.push({ name, data });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
module.exports = { crc32, readZip, writeZip };
|