Some checks failed
check / check (push) Has been cancelled
libsodium ships a WASM build and a wasm2js translation in one file, tries WASM first, and silently falls back if instantiation throws. Under a plain script-src 'self' the fallback was taken on every popup load, announced by nothing but an uncaught CompileError. Measured on the vault's own Argon2id parameters (OPSLIMIT_INTERACTIVE, MEMLIMIT_INTERACTIVE), node 22: WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is identical either way — it is set by the ops and memory parameters, not by wall time — so the fallback bought no security and cost about 3.5s on every operation that asks for the password, which is every signature. Both manifests now declare script-src 'self' 'wasm-unsafe-eval'; object-src 'self' for extension pages: an object under content_security_policy.extension_pages for Chrome MV3, a bare string for Firefox MV2. The keyword permits compiling WebAssembly and nothing else — not eval() of strings, not inline script, not remote script — and reaching it requires already executing script in an extension page. 'unsafe-eval' is not granted. The silence is what made this dangerous, so the fallback is now loud at three levels: tests/manifest.test.js pins both policies to exactly that token set, failing make check if the grant is dropped or if anything is added beside it; tests/vaultBackend.test.js asserts the unit tests exercise the WASM backend, with a self-validating check that libsodium never swapped its fallback in; and the e2e suite compiles a WebAssembly module inside the real popup under the real manifest, with the harness allowlist entry that used to excuse the CompileError now deleted. The runtime fallback itself is kept — a wallet that refuses to decrypt is worse than a slow one — but vault.js now reports the backend and logs an error when it is not WASM.
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 };
|