+// ============================================ +// v-encrypt-chats-locally-v0.1 BEGIN: FLChatVault +// Chats sealed at rest with AES-GCM 256 (WebCrypto). +// Honest limits: the key lives in this same browser (a non-extractable +// CryptoKey in the IndexedDB database "FreeLatticeVault"). That stops +// casual reading of the browser's storage files and copies of the chat +// database. It does not stop someone who can use this browser profile, +// harmful code running on this page, or a compromised device. +// The secret is 256 random bits from crypto.getRandomValues. The phi +// labels below are domain separation and a key ID (see kidOf), never +// the secret and never extra strength. +// Layer, never lose: plaintext records keep working, sealed records are +// only written after a verified round trip, and a record whose key is +// missing is shown as locked and never overwritten or deleted. +// ============================================ +var FLChatVault = (function () { + 'use strict'; + var V = 1; + var VAULT_DB = 'FreeLatticeVault'; + var VAULT_STORE = 'keys'; + var INFO_SEAL = 'freelattice.chats.v1|phi=1.6180339887'; + var INFO_CHECK = 'freelattice.chats.keycheck.v1|phi=1.6180339887'; + var FIELDS = { + 'FreeLatticeDB/conversations': ['name', 'title', 'lastMessage', 'contextNote'], + 'FreeLatticeDB/messages': ['content'], + 'FreeLatticeMemory/conversations': ['title', 'messages', 'tags', 'summary', 'keywords'] + }; + var LOCKED_TEXT = 'Locked on this device. This was sealed with a key this browser no longer has. It has not been deleted.'; + var mode = 'starting'; // starting | ready | unavailable + var cur = null; // { kid, aes } + var keys = {}; // kid -> AES-GCM CryptoKey + var readyP = null; + var keyStore = null; // { getAll(), put(rec) } + var stores = []; // { kind, getDb, storeName } + var info = { lastError: '', lastRun: null, sealedNow: 0, plainNow: 0, lockedNow: 0 }; + + function enc(s) { return new TextEncoder().encode(s); } + function dec(b) { return new TextDecoder().decode(b); } + function hex(buf) { return Array.from(new Uint8Array(buf)).map(function (b) { return b.toString(16).padStart(2, '0'); }).join(''); } + function b64(u8) { var s = ''; for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } + function unb64(s) { var bin = atob(s); var u8 = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); return u8; } + function subtleOk() { + try { + if (typeof crypto === 'undefined' || !crypto.subtle || !crypto.getRandomValues) return false; + if (typeof isSecureContext !== 'undefined' && !isSecureContext) return false; + return true; + } catch (e) { return false; } + } + function withTimeout(p, ms) { + return new Promise(function (resolve, reject) { + var t = setTimeout(function () { reject(new Error('timed out')); }, ms); + p.then(function (v) { clearTimeout(t); resolve(v); }, function (e) { clearTimeout(t); reject(e); }); + }); + } + + function idbKeyStore() { + function open() { + return new Promise(function (resolve, reject) { + var req = indexedDB.open(VAULT_DB, 1); + req.onupgradeneeded = function (e) { + var db = e.target.result; + if (!db.objectStoreNames.contains(VAULT_STORE)) db.createObjectStore(VAULT_STORE, { keyPath: 'id' }); + }; + req.onsuccess = function (e) { resolve(e.target.result); }; + req.onerror = function () { reject(req.error); }; + }); + } + return { + getAll: function () { + return open().then(function (db) { + return new Promise(function (resolve, reject) { + var r = db.transaction(VAULT_STORE, 'readonly').objectStore(VAULT_STORE).getAll(); + r.onsuccess = function () { db.close(); resolve(r.result || []); }; + r.onerror = function () { db.close(); reject(r.error); }; + }); + }); + }, + put: function (rec) { + return open().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(VAULT_STORE, 'readwrite'); + tx.objectStore(VAULT_STORE).put(rec); // a CryptoKey is stored by structured clone, never as raw bytes + tx.oncomplete = function () { db.close(); resolve(); }; + tx.onerror = function () { db.close(); reject(tx.error); }; + }); + }); + } + }; + } + + function hkdf(salt, label) { return { name: 'HKDF', hash: 'SHA-256', salt: salt, info: enc(label) }; } + function deriveAes(rec) { + return crypto.subtle.deriveKey(hkdf(rec.salt, INFO_SEAL), rec.root, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + } + // Key ID: a one-way check value from the same root (different HKDF label), + // named with the phi-hash. It lets the vault tell the right key from a + // wrong or missing one before trying to open anything. It reveals + // nothing usable about the key. + async function kidOf(root, salt) { + var bits = await crypto.subtle.deriveBits(hkdf(salt, INFO_CHECK), root, 128); + var h = (typeof phiHashData === 'function') ? await phiHashData(hex(bits)) : hex(await crypto.subtle.digest('SHA-256', bits)); + return 'phi-' + h.slice(0, 16); + } + + function init() { + if (readyP) return readyP; + readyP = (async function () { + if (!subtleOk()) { mode = 'unavailable'; info.lastError = 'WebCrypto is not available here (needs https or localhost).'; return; } + try { + keyStore = keyStore || idbKeyStore(); + var recs = await withTimeout(keyStore.getAll(), 5000); + var pointer = null; + for (var i = 0; i < recs.length; i++) { + var r = recs[i]; + if (r.id === 'current') { pointer = r; continue; } + if (!r.root || !r.salt) continue; + try { + if (await kidOf(r.root, r.salt) !== r.id) continue; // wrong key under this name: skip it + keys[r.id] = await deriveAes(r); + } catch (e) {} + } + if (pointer && keys[pointer.kid]) { + cur = { kid: pointer.kid, aes: keys[pointer.kid] }; + } else { + var raw = crypto.getRandomValues(new Uint8Array(32)); + var root = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']); + raw.fill(0); + var salt = crypto.getRandomValues(new Uint8Array(32)); + var kid = await kidOf(root, salt); + await keyStore.put({ id: kid, v: V, root: root, salt: salt, created: new Date().toISOString() }); + // Read it back before trusting it with a single chat. + var back = (await keyStore.getAll()).filter(function (x) { return x.id === kid; })[0]; + if (!back || !back.root || await kidOf(back.root, back.salt) !== kid) throw new Error('the key did not persist'); + keys[kid] = await deriveAes(back); + await keyStore.put({ id: 'current', kid: kid }); + cur = { kid: kid, aes: keys[kid] }; + } + mode = 'ready'; + } catch (e) { + mode = 'unavailable'; + cur = null; + info.lastError = (e && e.message) ? e.message : String(e); + } + })(); + return readyP; + } + + function aad(kind, r) { + if (kind === 'FreeLatticeDB/messages') return enc([kind, r.conversationId, r.role, r.timestamp].join('|')); + return enc([kind, r.id].join('|')); + } + + async function sealFor(kind, value) { + var f = FIELDS[kind]; + if (!f || !value || typeof value !== 'object') return value; + await init(); + var out = Object.assign({}, value); + if (out._flLocked) { + // Never seal a locked placeholder. Put the original sealed blob back. + out.sealed = out._flLocked; + delete out._flLocked; + f.forEach(function (k) { delete out[k]; }); + return out; + } + if (out.sealed) return out; + if (mode !== 'ready' || !cur) return value; // honest passthrough: stays plaintext + var body = {}, any = false; + f.forEach(function (k) { if (out[k] !== undefined) { body[k] = out[k]; any = true; } }); + if (!any) return value; +