// wasm/repl/crypto.js // Encrypted-at-rest localStorage. Modeled after unsandbox crypto-utils.js // (priv/static/js/crypto-utils.js) but uses the native Web Crypto API // — no CryptoJS dep — so the playground and the REPL share one tiny // vault primitive. // // Vault layout in localStorage: // lumbda_salt — random 32 bytes, generated once per device // lumbda_vault: — { iv: base64, data: base64 } AES-GCM encrypted JSON // // vaultId = SHA-256(password || salt), hex. Same password yields the same // vault id on this device; different password yields a different (and // independent) vault. const SALT_KEY = "lumbda_salt"; function buf2b64(buf) { let binary = ""; const bytes = new Uint8Array(buf); for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } function b642buf(b64) { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function hex(buf) { return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } export function getDeviceSalt() { let salt = localStorage.getItem(SALT_KEY); if (!salt) { const random = crypto.getRandomValues(new Uint8Array(32)); salt = buf2b64(random); localStorage.setItem(SALT_KEY, salt); } return new Uint8Array(b642buf(salt)); } async function deriveKey(password, salt) { const enc = new TextEncoder(); const baseKey = await crypto.subtle.importKey( "raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } export async function getVaultId(password) { const salt = getDeviceSalt(); const enc = new TextEncoder(); const data = new Uint8Array(password.length + salt.length); data.set(enc.encode(password), 0); data.set(salt, password.length); const digest = await crypto.subtle.digest("SHA-256", data); return hex(digest); } // Open a vault under the given password. Returns { read, write, vaultId }. export async function openVault(password) { const salt = getDeviceSalt(); const key = await deriveKey(password, salt); const vaultId = await getVaultId(password); const lsKey = "lumbda_vault:" + vaultId; async function read() { const raw = localStorage.getItem(lsKey); if (!raw) return null; let envelope; try { envelope = JSON.parse(raw); } catch { return null; } if (!envelope || !envelope.iv || !envelope.data) return null; try { const plain = await crypto.subtle.decrypt( { name: "AES-GCM", iv: b642buf(envelope.iv) }, key, b642buf(envelope.data)); return JSON.parse(new TextDecoder().decode(plain)); } catch { return { __decryptionFailed: true }; } } async function write(value) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = new TextEncoder(); const cipher = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(value))); localStorage.setItem(lsKey, JSON.stringify({ iv: buf2b64(iv), data: buf2b64(cipher), })); } function destroy() { localStorage.removeItem(lsKey); } return { read, write, destroy, vaultId }; }