FreeLattice · Celeste reading home

Public, free to read, reuse, and adapt — Kirk Patrick Miller's notice for this edition. Not a patent grant.

FLINT_NEXT_ENCRYPT_CHATS_LOCALLY_v0.1.txt · c03

62a5e976c2 · words 1200 · characters 16370–24659

Previous section · Next section

Exact section text · Full source · Source index

Archive pages whose extracted text matched this section: 1290, 1291, 1292, 1301, 1302, 1303. Unmatched pages stay on the source record with the checked page method. They are not guessed into a section.

No topic tag was earned for this page under the recorded evidence rule.

+// ============================================
+// 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;
+