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.
104 lines
3.6 KiB
JavaScript
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 };
|
|
}
|