fuzzyworld

north-star / commit

encrypt all the things

3c70855fdb19f99410bf081fef440e2ac5d70ccc · Pavel Safronov · 2026-03-14 23:41:19 -0700 · parents dc2392486a6e

encrypt all the things
 src/app.js | 34 +++++++++++++++++++---------------
 1 file changed, 19 insertions(+), 15 deletions(-)

diff --git a/src/app.js b/src/app.js
index d73b915..51ae878 100644
--- a/src/app.js
+++ b/src/app.js
@@ -89,7 +89,7 @@ const selfPosition = {
 let selfHeading = 0;
 let lastRawHeading = null;
 
-// ── End-to-end position encryption ──────────────────────────────────────────
+// ── End-to-end participant-state encryption ────────────────────────────────
 // The room key lives only in the URL hash (#key=…), which browsers never send
 // to any server. Firebase therefore stores only opaque ciphertext.
 const _AES_PARAMS = { name: 'AES-GCM', length: 256 };
@@ -111,17 +111,17 @@ const importRoomKey = async (base64url) => {
     return crypto.subtle.importKey('raw', raw, _AES_PARAMS, false, ['encrypt', 'decrypt']);
 };
 
-const encryptPosition = async (key, position) => {
+const encryptJson = async (key, value) => {
     const iv = crypto.getRandomValues(new Uint8Array(_IV_BYTES));
-    const plaintext = new TextEncoder().encode(JSON.stringify(position));
+    const plaintext = new TextEncoder().encode(JSON.stringify(value));
     const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
     const ivB64 = btoa(String.fromCharCode(...iv));
     const ctB64 = btoa(String.fromCharCode(...new Uint8Array(ciphertext)));
     return `${ivB64}:${ctB64}`;
 };
 
-const decryptPosition = async (key, encryptedPosition) => {
-    const [ivB64, ctB64] = encryptedPosition.split(':');
+const decryptJson = async (key, encryptedValue) => {
+    const [ivB64, ctB64] = encryptedValue.split(':');
     const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
     const ciphertext = Uint8Array.from(atob(ctB64), c => c.charCodeAt(0));
     const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
@@ -162,10 +162,6 @@ const parseRoomFromHash = (hashValue) => {
     const keyValue = (hashParams.get('key') || '').trim() || null;
     return { roomId: roomValue, roomKeyBase64: keyValue };
 };
-const parseRoomIdFromHash = (hashValue) => {
-    const parsed = parseRoomFromHash(hashValue);
-    return parsed ? parsed.roomId : null;
-};
 
 let roomKey = null;       // CryptoKey | null — null means plaintext fallback
 let roomKeyBase64 = null; // string | null
@@ -651,9 +647,9 @@ async function syncSelfParticipant() {
     selfParticipant.timestamp = Date.now();
     let dbRecord;
     if (roomKey) {
-        const encryptedPosition = await encryptPosition(roomKey, selfParticipant.position);
-        const { position: _stripped, ...rest } = selfParticipant;
-        dbRecord = { ...rest, encryptedPosition };
+        dbRecord = {
+            encryptedState: await encryptJson(roomKey, selfParticipant)
+        };
     } else {
         dbRecord = selfParticipant;
     }
@@ -847,14 +843,22 @@ onValue(participantsRef, async (snapshot) => {
     if (participantsData) {
         const rawParticipants = Object.values(participantsData);
         const decrypted = await Promise.all(rawParticipants.map(async (p) => {
+            if (p.encryptedState && roomKey) {
+                try {
+                    return await decryptJson(roomKey, p.encryptedState);
+                } catch (err) {
+                    console.error('Failed to decrypt participant state', p.id, err);
+                    return null; // exclude participant whose data can't be decrypted
+                }
+            }
             if (p.encryptedPosition && roomKey) {
                 try {
-                    const position = await decryptPosition(roomKey, p.encryptedPosition);
+                    const position = await decryptJson(roomKey, p.encryptedPosition);
                     const { encryptedPosition: _stripped, ...rest } = p;
                     return { ...rest, position };
                 } catch (err) {
-                    console.error('Failed to decrypt position for participant', p.id, err);
-                    return null; // exclude participant whose data can't be decrypted
+                    console.error('Failed to decrypt legacy participant position', p.id, err);
+                    return null;
                 }
             }
             return p; // plaintext fallback for rooms without a key