Files
AutistMask/src/shared/vault.js
clawbot bf1dbec87c
All checks were successful
check / check (push) Successful in 26s
fix: run libsodium on WebAssembly under the extension CSP (closes #182)
2026-08-12 10:30:15 +02:00

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 };