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