lumbda/wasm/dist-repl/crypto.js
russell@unturf.com d8ffab5ea6
repl: /repl/ page with encrypted multi-tab sessions
Interactive REPL at lumbda.com/repl with:
  - multi-tab sessions (click + to add, × to close, double-click to rename)
  - per-tab tier selector (python/c/asm/all-three race)
  - persistent transcripts encrypted in localStorage via Web Crypto
    (PBKDF2 + AES-GCM, vault id = SHA-256(password || device-salt) —
    same pattern as unsandbox's vault-encryption-design.md, native
    crypto.subtle API instead of CryptoJS)
  - ephemeral mode (skip vault, transcripts vanish on reload)
  - one worker per (tab × tier) — state persists across evals in a tab
  - reboot tier button (terminate this tab's worker, fresh state next eval)
  - cancel button (kills the running worker in active tab)

Home page now links to both /playground/ and /repl/.

Tier state itself does NOT persist across reloads — the transcript does,
but defines/set!/hash-tables vanish with the worker. Portal save/resume
in WAT (deferred) will let a tier session survive close+reopen.
2026-06-14 12:50:35 -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 };
}