fuzzyworld

north-star / commit

added encryption by default and info about this

05c8dab2f964bf8f5a7aad0aa8f31be8a08560c6 · Pavel Safronov · 2026-03-14 23:17:29 -0700 · parents 2b712221710f

added encryption by default and info about this
 readme.md      |   4 +-
 src/app.js     | 154 +++++++++++++++++++++++++++++++++++++--------------------
 src/index.html |   2 -
 3 files changed, 103 insertions(+), 57 deletions(-)

diff --git a/readme.md b/readme.md
index 84fd146..097819e 100644
--- a/readme.md
+++ b/readme.md
@@ -19,7 +19,9 @@ The app uses Firebase Realtime Database to store and sync the location data of a
 
 ## Is it secure?
 
-The app does not require any authentication, so anyone with the room link can join and see the location of all participants. However, the app does not store any personal information and only uses the location data for real-time sharing. The data is stored in Firebase Realtime Database, which has security rules to prevent unauthorized access, so only people in a given room can access the data about that room.
+The app does not require any authentication, so anyone with the room link can join and see the location of all participants.
+
+The room link contains a unique room id (so it's hard to guess), but also an encryption key that is used to encrypt the location data before storing it in the database. This means that even if someone were to gain access to the database (like myself, or a bad actor), they would not be able to read the location data without the encryption key.
 
 It's important to share the room link only with trusted individuals to ensure your privacy.
 
diff --git a/src/app.js b/src/app.js
index 89813ee..d73b915 100644
--- a/src/app.js
+++ b/src/app.js
@@ -12,7 +12,6 @@ const selfNameInput = document.getElementById('self-name-input');
 const selfColorInput = document.getElementById('self-color-input');
 const roomCurrent = document.getElementById('room-current');
 const inviteRoomLink = document.getElementById('invite-room-link');
-const joinRoomLink = document.getElementById('join-room-link');
 const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
 const inviteRoomStatus = document.getElementById('invite-room-status');
 const createRoomLink = document.getElementById('create-room-link');
@@ -90,6 +89,46 @@ const selfPosition = {
 let selfHeading = 0;
 let lastRawHeading = null;
 
+// ── End-to-end position 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 };
+const _IV_BYTES = 12;
+
+const generateRoomKey = () =>
+    crypto.subtle.generateKey(_AES_PARAMS, true, ['encrypt', 'decrypt']);
+
+const exportRoomKey = async (key) => {
+    const raw = await crypto.subtle.exportKey('raw', key);
+    return btoa(String.fromCharCode(...new Uint8Array(raw)))
+        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+};
+
+const importRoomKey = async (base64url) => {
+    const b64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+    const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
+    const raw = Uint8Array.from(atob(padded), c => c.charCodeAt(0));
+    return crypto.subtle.importKey('raw', raw, _AES_PARAMS, false, ['encrypt', 'decrypt']);
+};
+
+const encryptPosition = async (key, position) => {
+    const iv = crypto.getRandomValues(new Uint8Array(_IV_BYTES));
+    const plaintext = new TextEncoder().encode(JSON.stringify(position));
+    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 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);
+    return JSON.parse(new TextDecoder().decode(plaintext));
+};
+// ─────────────────────────────────────────────────────────────────────────────
+
 const firebaseConfig = {
     apiKey: "AIzaSyA4G7e13Gt1XAIds1Y63X_RFw-2O48iCLM",
     authDomain: "north-star-e5dee.firebaseapp.com",
@@ -113,26 +152,52 @@ console.debug('User ID:', uid);
 const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`;
 const ROOM_ID_LENGTH = 8;
 const ROOM_ID_PATTERN = new RegExp(`^[a-z0-9]{${ROOM_ID_LENGTH}}$`);
-const parseRoomIdFromHash = (hashValue) => {
+
+// Parse both room ID and optional encryption key from the URL hash.
+const parseRoomFromHash = (hashValue) => {
     const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || '');
     const hashParams = new URLSearchParams(normalizedHash);
     const roomValue = (hashParams.get('room') || '').trim();
-    return ROOM_ID_PATTERN.test(roomValue) ? roomValue : null;
+    if (!ROOM_ID_PATTERN.test(roomValue)) return null;
+    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
+let roomId;
 
-let roomId = parseRoomIdFromHash(location.hash);
-if (!roomId) {
+const parsedRoom = parseRoomFromHash(location.hash);
+if (parsedRoom) {
+    roomId = parsedRoom.roomId;
+    if (parsedRoom.roomKeyBase64) {
+        try {
+            roomKey = await importRoomKey(parsedRoom.roomKeyBase64);
+            roomKeyBase64 = parsedRoom.roomKeyBase64;
+        } catch (err) {
+            console.error('Failed to import room key from URL — running without encryption', err);
+        }
+    }
+} else {
+    // New room: generate a fresh AES-GCM key and embed it in the hash.
     roomId = crypto.randomUUID().replace(/-/g, '').slice(0, ROOM_ID_LENGTH);
-    location.hash = 'room=' + roomId;
+    roomKey = await generateRoomKey();
+    roomKeyBase64 = await exportRoomKey(roomKey);
+    location.hash = `room=${roomId}&key=${roomKeyBase64}`;
 }
-console.debug('Room ID:', roomId);
+console.debug('Room ID:', roomId, '| Encrypted:', roomKey !== null);
 
 if (roomCurrent) {
     roomCurrent.textContent = `You are currently in room ${roomId}`;
 }
 
 if (inviteRoomLink) {
-    inviteRoomLink.href = `${BASE_ROOM_URL}#room=${encodeURIComponent(roomId)}`;
+    const keyFragment = roomKeyBase64 ? `&key=${encodeURIComponent(roomKeyBase64)}` : '';
+    inviteRoomLink.href = `${BASE_ROOM_URL}#room=${encodeURIComponent(roomId)}${keyFragment}`;
 }
 if (createRoomLink) {
     createRoomLink.href = BASE_ROOM_URL;
@@ -344,22 +409,6 @@ const copyTextToClipboard = async (text) => {
     }
 };
 
-const parseRoomIdFromInput = (value) => {
-    const input = (value || '').trim();
-    if (!input) return null;
-
-    if (ROOM_ID_PATTERN.test(input)) {
-        return input;
-    }
-
-    try {
-        const parsedUrl = new URL(input, window.location.href);
-        return parseRoomIdFromHash(parsedUrl.hash);
-    } catch (err) {
-        return null;
-    }
-};
-
 if (inviteRoomLink) {
     inviteRoomLink.addEventListener('click', async (event) => {
         event.preventDefault();
@@ -374,30 +423,6 @@ if (inviteRoomLink) {
     });
 }
 
-if (joinRoomLink) {
-    joinRoomLink.addEventListener('click', (event) => {
-        event.preventDefault();
-
-        const roomInput = window.prompt('Enter room ID or room URL');
-        if (roomInput === null) {
-            return;
-        }
-
-        const parsedRoomId = parseRoomIdFromInput(roomInput);
-        if (!parsedRoomId) {
-            alert(`Invalid room ID or URL. Please enter a ${ROOM_ID_LENGTH}-character lowercase alphanumeric room ID or a URL containing it.`);
-            return;
-        }
-
-        if (parsedRoomId === roomId) {
-            alert('You are already in this room!');
-            return;
-        }
-
-        window.location.assign(`${BASE_ROOM_URL}#room=${encodeURIComponent(parsedRoomId)}`);
-    });
-}
-
 if (calibrateHeadingLink) {
     calibrateHeadingLink.addEventListener('click', async (event) => {
         event.preventDefault();
@@ -624,9 +649,17 @@ onDisconnect(participantRef).remove();
 
 async function syncSelfParticipant() {
     selfParticipant.timestamp = Date.now();
-    console.debug('Syncing self participant:', selfParticipant);
+    let dbRecord;
+    if (roomKey) {
+        const encryptedPosition = await encryptPosition(roomKey, selfParticipant.position);
+        const { position: _stripped, ...rest } = selfParticipant;
+        dbRecord = { ...rest, encryptedPosition };
+    } else {
+        dbRecord = selfParticipant;
+    }
+    console.debug('Syncing self participant:', dbRecord);
     try {
-        await set(participantRef, selfParticipant);
+        await set(participantRef, dbRecord);
     } catch (err) {
         console.error('Error syncing participant in database', err);
     }
@@ -807,16 +840,29 @@ const participantsRef = ref(db, `rooms/${roomId}/participants`);
 //     // update arrows in your UI here
 // });
 const participants = [];
-onValue(participantsRef, (snapshot) => {
+onValue(participantsRef, async (snapshot) => {
     console.debug('Participants data updated:', snapshot.val());
 
     const participantsData = snapshot.val(); // object of uid → participant
     if (participantsData) {
-        participants.length = 0; // clear the array while keeping the reference
-        participants.push(...Object.values(participantsData)); // add new participants
+        const rawParticipants = Object.values(participantsData);
+        const decrypted = await Promise.all(rawParticipants.map(async (p) => {
+            if (p.encryptedPosition && roomKey) {
+                try {
+                    const position = await decryptPosition(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
+                }
+            }
+            return p; // plaintext fallback for rooms without a key
+        }));
+        participants.length = 0;
+        participants.push(...decrypted.filter(Boolean));
     } else {
         console.warn('No participants data');
-        // return;
     }
 
     console.debug('Updated participants array:', participants);
diff --git a/src/index.html b/src/index.html
index ddf2aab..f60a72e 100644
--- a/src/index.html
+++ b/src/index.html
@@ -15,8 +15,6 @@
     <p class="room-links">
         <a id="invite-room-link" href="#">Invite to Room</a>
         <span aria-hidden="true">·</span>
-        <a id="join-room-link" href="#">Join Room</a>
-        <span aria-hidden="true">·</span>
         <a id="create-room-link" href="https://pavelsafronov.gitlab.io/north-star/">Create New Room</a>
     </p>
     <form id="name-form" class="name-form" autocomplete="off">