Public, free to read, reuse, and adapt — Kirk Patrick Miller's notice for this edition. Not a patent grant.
GROK_NEXT_ENCRYPT_CHATS_LOCALLY_2026-10-02.txt · c03
f688437464 · words 1200 · characters 20289–28556
Previous section · Next section
Exact section text · Full source · Source index
+// ============================================
+// 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. Phi is a
+// label here and nothing more: it is written into the two HKDF label
+// strings and it names the key ID ("phi-" plus 16 hex, see kidOf). The
+// two labels differ by purpose (chats, keycheck), and that difference is
+// what keeps the two derived keys apart; any distinct strings would do
+// the same. Phi is never the secret and adds no 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 (a name, not a strength). 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;