script/lint ran `prettier --check .`, byte for byte what script/fmt-check
runs, so make check checked formatting twice and did no static analysis on
a cryptocurrency wallet. Two used-but-not-imported crashes shipped past it.
ESLint is pinned in package.json with @eslint/js recommended as the base and
a flat config in eslint.config.js. no-undef and no-unused-vars are restated
error-level so a future recommended-set change cannot downgrade them.
Globals are declared per tree rather than globally, because a too-wide set
hides the next unimported identifier: browser for the popup and content
scripts, service worker for src/background/ and src/shared/, browser for the
one documented POPUP ONLY module in src/shared/, jest for tests/, node for
build.js, and both for the e2e harnesses, which carry the callbacks they
ship into the page inline.
Two rules new to the recommended set are narrowed, and both would have cost
something to satisfy. no-useless-assignment is off for approval.js and
confirmTx.js only: it flags the `password = null` and `decryptedSecret =
null` wipes at 9 sites there, which are dead by construction — that is what
a best-effort wipe of decrypted key material is — and the rule's fix is to
delete the wipe. It stays on for the rest of the tree, so an ordinary dead
store elsewhere is still an error. preserve-caught-error is off tree-wide:
it would change what the wallet's error paths throw at 3 sites
(src/shared/balances.js 207 and 215, tests/e2e/firefox/run.js 131), and
adopting `{ cause }` is a decision of its own rather than a side effect of
turning a linter on, so new code is not held to it either pending that
decision.
Every remaining violation is fixed: 41 unused bindings and 53 undefined
identifiers. Unused catch bindings became `catch {`, which the repo already
used; the shared init(ctx) view signature keeps its parameter as _ctx in the
three views that do not read it. src/shared/uniswap.js keeps its unused
V2_SWAP_EXACT_OUT decoder behind a scoped disable, because deleting it would
widen the gap it represents rather than close it (#283). driver.js's waitFor
had a plain dead store in its `last` initializer, which the newly scoped
no-useless-assignment catches; the initializer is dropped.
Linting is containerized. script/lint builds the Dockerfile's new lint stage
so the ESLint deciding whether this repo is green is the pinned one and not
whatever the host has; AUTISTMASK_LINT_NATIVE, set only in that image, is
what makes make check inside the CI build lint in place instead of recursing
into docker, and a value set to anything else is now an error rather than a
silent fall-through to the docker path. The check stage takes a COPY --from=
lint dependency so a lint failure fails the whole build early rather than
racing it.
The lint stage roughly doubles the image build, which exposed script/test's
30s cap as marginal rather than a bound: on the first CI run to rebuild the
base stage cold it killed a healthy suite at 30.6s with nothing asserting
false. The cap is a guard against a hung suite, not a wall-clock budget, and
one a healthy suite can trip teaches "just run it again". It stays at 30s on
a host, where the suite runs in about 8s and REPO_POLICIES' figure holds,
and the Dockerfile raises it to 180s through AUTISTMASK_TEST_TIMEOUT for the
in-image run, which also pays a cold jest cache and shares the runner with
the rest of the build. script/test now names a timeout kill as one instead
of reporting it as a test failure, and skips the verbose rerun in that case,
which would only spend the same wall clock to be killed again.
No --fix anywhere in the lint path: make check remains non-mutating.
The README claim that a used-but-not-imported identifier is invisible to
make check, and the same claim in script/test-e2e, are no longer true and
are corrected.
129 lines
5.3 KiB
JavaScript
129 lines
5.3 KiB
JavaScript
// Vault: password-based encryption of secrets using libsodium.
|
|
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
|
|
// All crypto operations are delegated to libsodium — no raw primitives.
|
|
//
|
|
// Backend: WebAssembly, deliberately (#182).
|
|
//
|
|
// libsodium ships one file containing both a WebAssembly build and a
|
|
// wasm2js ("asm.js") translation of it. It tries WASM first and, if
|
|
// instantiation throws, silently swaps in the translation. An extension
|
|
// CSP of plain script-src 'self' refuses WASM, so every popup load used
|
|
// to take that fallback — announced by nothing but an uncaught
|
|
// CompileError in the console.
|
|
//
|
|
// Measured here, same Argon2id parameters (OPSLIMIT_INTERACTIVE,
|
|
// MEMLIMIT_INTERACTIVE = 2 passes over 64MiB), node 22 on this machine:
|
|
// WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is
|
|
// identical either way — it is set by the ops/mem parameters, not by wall
|
|
// time — so the fallback bought no security, it only made every password
|
|
// operation take three and a half seconds, and the wallet asks for the
|
|
// password on every signature.
|
|
//
|
|
// So both manifests declare 'wasm-unsafe-eval' for extension pages. That
|
|
// keyword permits compiling WebAssembly and nothing else: not eval() of
|
|
// strings, not inline script, not remote script. Reaching it requires
|
|
// already executing script in the extension page, which is total
|
|
// compromise on its own. 'unsafe-eval' would be a different matter and is
|
|
// not granted. tests/manifest.test.js pins both policies to exactly
|
|
// "'self' 'wasm-unsafe-eval'" so neither the grant nor the surrounding
|
|
// strictness can drift unnoticed.
|
|
//
|
|
// The fallback still exists, and a wallet that refuses to decrypt is
|
|
// worse than a slow one, so it is not disabled — it is made loud:
|
|
// cryptoBackend() reports which backend this realm can run, ensureReady()
|
|
// logs an error if it is not WASM, tests/vaultBackend.test.js asserts the
|
|
// unit tests exercise the WASM backend, and the end-to-end suite asserts
|
|
// it in the real popup under the real manifest.
|
|
|
|
const sodium = require("libsodium-wrappers-sumo");
|
|
const { log } = require("./log");
|
|
|
|
// An empty WebAssembly module: the 8-byte magic number and version header,
|
|
// no sections. Compiling it asks the cheapest possible form of the only
|
|
// question that matters here — may this realm compile WebAssembly at all —
|
|
// which is exactly what a CSP without 'wasm-unsafe-eval' refuses, and
|
|
// exactly what decides which backend libsodium ends up on.
|
|
const EMPTY_WASM_MODULE = new Uint8Array([
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
|
]);
|
|
|
|
// "wasm" or "asmjs": whether this realm may compile WebAssembly, which is
|
|
// what decides libsodium's backend when the CSP is the reason it cannot —
|
|
// the case this codebase guards. It probes the realm, not libsodium, so a
|
|
// fallback taken for some other reason (allocation failure, corrupt module)
|
|
// would not be caught here; tests/vaultBackend.test.js checks libsodium's
|
|
// own marker directly.
|
|
async function cryptoBackend() {
|
|
try {
|
|
await WebAssembly.compile(EMPTY_WASM_MODULE);
|
|
return "wasm";
|
|
} catch {
|
|
return "asmjs";
|
|
}
|
|
}
|
|
|
|
let ready = false;
|
|
|
|
async function ensureReady() {
|
|
if (!ready) {
|
|
await sodium.ready;
|
|
if ((await cryptoBackend()) !== "wasm") {
|
|
log.errorf(
|
|
"libsodium is running on the wasm2js fallback: this realm " +
|
|
"refuses to compile WebAssembly, so every password " +
|
|
"derivation costs roughly 20x what it should. See the " +
|
|
"backend note in src/shared/vault.js.",
|
|
);
|
|
}
|
|
ready = true;
|
|
}
|
|
}
|
|
|
|
// Returns { salt, nonce, ciphertext } (all base64-encoded strings).
|
|
async function encryptWithPassword(plaintext, password) {
|
|
await ensureReady();
|
|
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
|
const key = sodium.crypto_pwhash(
|
|
sodium.crypto_secretbox_KEYBYTES,
|
|
password,
|
|
salt,
|
|
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
|
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
|
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
|
);
|
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
const ciphertext = sodium.crypto_secretbox_easy(
|
|
sodium.from_string(plaintext),
|
|
nonce,
|
|
key,
|
|
);
|
|
return {
|
|
salt: sodium.to_base64(salt),
|
|
nonce: sodium.to_base64(nonce),
|
|
ciphertext: sodium.to_base64(ciphertext),
|
|
};
|
|
}
|
|
|
|
// Returns the plaintext string, or throws on wrong password.
|
|
async function decryptWithPassword(encrypted, password) {
|
|
await ensureReady();
|
|
const salt = sodium.from_base64(encrypted.salt);
|
|
const nonce = sodium.from_base64(encrypted.nonce);
|
|
const ciphertext = sodium.from_base64(encrypted.ciphertext);
|
|
const key = sodium.crypto_pwhash(
|
|
sodium.crypto_secretbox_KEYBYTES,
|
|
password,
|
|
salt,
|
|
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
|
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
|
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
|
);
|
|
const plaintext = sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
|
if (!plaintext) {
|
|
throw new Error("Decryption failed — wrong password.");
|
|
}
|
|
return sodium.to_string(plaintext);
|
|
}
|
|
|
|
module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };
|