lumbda/wasm/app/crypto.js
russell@unturf.com 3d6d5d1010
playground: free-form code option + encrypted vault, single-scroll layout
Free-form radio adds a 5th demo slot. When selected, a vault bar appears
under the controls: enter a password, "unlock" derives a per-device
vault and decrypts (or creates fresh). Edits in the editor auto-save
350ms after typing stops. Reload + same password restores the code.

Same Web Crypto stack as /repl/ (PBKDF2 + AES-GCM, vault id =
SHA-256(password || device-salt)).

Layout: one shared vertical scroller — code pane and output pane both
grow with content, the body scrolls. No more independent in-pane
scrollers fighting the page.

Home page split into "Demo" and "REPL" sections with their own CTAs.
2026-06-14 12:56:25 -04:00

104 lines
3.6 KiB
JavaScript

// 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:<id> — { 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 };
}