north-star / commit
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