fuzzyworld

north-star / commit

encryption is working

5d1e23809612a2cf17b5aefe0267afd9611e0eed · Pavel Safronov · 2026-03-16 21:16:22 -0700 · parents 5fd9434045b7

encryption is working
 .vscode/settings.json                      |   4 +
 mobile/App.tsx                             | 100 +++++++-
 mobile/package-lock.json                   |  37 ++-
 mobile/package.json                        |   6 +-
 mobile/src/core/crypto.ts                  |  92 ++++++++
 mobile/src/features/friends/sync.ts        |  82 ++++++-
 mobile/src/screens/onboarding_screen.tsx   | 257 ++++++++++++++++++++
 mobile/src/screens/room_screen.tsx         |  30 ++-
 mobile/src/screens/room_session_screen.tsx | 365 +++++++++++++++++++++++++++++
 9 files changed, 948 insertions(+), 25 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2a60ba5..e9d5b25 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -17,6 +17,10 @@
         "/^npx tsc --noEmit && npm run build 2>&1 \\| tail -20$/": {
             "approve": true,
             "matchCommandLine": true
+        },
+        "/^cd /Users/pavel/code/north-star && npm run mobile:typecheck 2>&1$/": {
+            "approve": true,
+            "matchCommandLine": true
         }
     },
     "cSpell.words": [
diff --git a/mobile/App.tsx b/mobile/App.tsx
index 8c4602a..72e6795 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -1,12 +1,68 @@
+import { useEffect, useState } from 'react';
 import { StatusBar } from 'expo-status-bar';
-import { SafeAreaView, StyleSheet } from 'react-native';
-import { RoomScreen } from './src/screens/room_screen';
+import { SafeAreaView, StyleSheet, Text, View } from 'react-native';
+import type { SymmetricKey } from './src/core/crypto';
+import { loadProfile, saveProfile } from './src/platform/profile';
+import { OnboardingScreen } from './src/screens/onboarding_screen';
+import { RoomSessionScreen } from './src/screens/room_session_screen';
+
+const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`;
 
 export default function App() {
+  const [displayName, setDisplayName] = useState(createDefaultDisplayName());
+  const [displayColor, setDisplayColor] = useState('#3366cc');
+  const [activeRoom, setActiveRoom] = useState<{ roomHash: string; roomKey: SymmetricKey | null } | null>(null);
+
+  useEffect(() => {
+    loadProfile().then((saved) => {
+      if (saved) {
+        setDisplayName(saved.name);
+        setDisplayColor(saved.color);
+      }
+    }).catch(() => { /* ignore storage errors on load */ });
+  }, []);
+
+  useEffect(() => {
+    const id = setTimeout(() => {
+      void saveProfile(displayName, displayColor);
+    }, 1000);
+    return () => clearTimeout(id);
+  }, [displayName, displayColor]);
+
+  const isInSession = !!activeRoom;
+
   return (
     <SafeAreaView style={styles.root}>
       <StatusBar style="dark" />
-      <RoomScreen />
+      <View style={styles.progressWrap}>
+        <View style={styles.progressHeaderRow}>
+          <View style={[styles.stepPill, !isInSession && styles.stepPillActive]}>
+            <Text style={[styles.stepPillText, !isInSession && styles.stepPillTextActive]}>1. Setup</Text>
+          </View>
+          <View style={[styles.stepConnector, isInSession && styles.stepConnectorActive]} />
+          <View style={[styles.stepPill, isInSession && styles.stepPillActive]}>
+            <Text style={[styles.stepPillText, isInSession && styles.stepPillTextActive]}>2. Live Room</Text>
+          </View>
+        </View>
+      </View>
+      {!activeRoom && (
+        <OnboardingScreen
+          displayName={displayName}
+          displayColor={displayColor}
+          onChangeDisplayName={setDisplayName}
+          onChangeDisplayColor={setDisplayColor}
+          onStartRoom={setActiveRoom}
+        />
+      )}
+      {!!activeRoom && (
+        <RoomSessionScreen
+          roomHash={activeRoom.roomHash}
+          roomKey={activeRoom.roomKey}
+          displayName={displayName}
+          displayColor={displayColor}
+          onLeaveRoom={() => setActiveRoom(null)}
+        />
+      )}
     </SafeAreaView>
   );
 }
@@ -15,5 +71,43 @@ const styles = StyleSheet.create({
   root: {
     flex: 1,
     backgroundColor: '#f5f1ea'
+  },
+  progressWrap: {
+    paddingHorizontal: 20,
+    paddingTop: 12,
+    paddingBottom: 8,
+    backgroundColor: '#efe8d9',
+    borderBottomWidth: 1,
+    borderBottomColor: '#ddd2bc'
+  },
+  progressHeaderRow: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8
+  },
+  stepPill: {
+    borderRadius: 999,
+    backgroundColor: '#d9cfbb',
+    paddingHorizontal: 12,
+    paddingVertical: 6
+  },
+  stepPillActive: {
+    backgroundColor: '#7a4f2a'
+  },
+  stepPillText: {
+    color: '#5f5648',
+    fontSize: 12,
+    fontWeight: '700'
+  },
+  stepPillTextActive: {
+    color: '#f6ead9'
+  },
+  stepConnector: {
+    height: 2,
+    flex: 1,
+    backgroundColor: '#cdbfa6'
+  },
+  stepConnectorActive: {
+    backgroundColor: '#7a4f2a'
   }
 });
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index e652472..63711b7 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -8,16 +8,20 @@
       "name": "mobile",
       "version": "1.0.0",
       "dependencies": {
+        "@noble/ciphers": "^2.1.1",
         "@react-native-async-storage/async-storage": "2.2.0",
+        "base-64": "^1.0.0",
         "expo": "~54.0.0",
         "expo-location": "~19.0.8",
         "expo-sensors": "~15.0.8",
         "expo-status-bar": "~3.0.9",
         "firebase": "^12.0.0",
         "react": "19.1.0",
-        "react-native": "0.81.5"
+        "react-native": "0.81.5",
+        "tweetnacl": "^1.0.3"
       },
       "devDependencies": {
+        "@types/base-64": "^1.0.2",
         "@types/react": "~19.1.10",
         "typescript": "~5.9.2"
       }
@@ -2700,6 +2704,18 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@noble/ciphers": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
+      "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/@protobufjs/aspromise": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -3095,6 +3111,13 @@
         "@babel/types": "^7.28.2"
       }
     },
+    "node_modules/@types/base-64": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.2.tgz",
+      "integrity": "sha512-uPgKMmM9fmn7I+Zi6YBqctOye4SlJsHKcisjHIMWpb2YKZRc36GpKyNuQ03JcT+oNXg1m7Uv4wU94EVltn8/cw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/graceful-fs": {
       "version": "4.1.9",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -3551,6 +3574,12 @@
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "license": "MIT"
     },
+    "node_modules/base-64": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
+      "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
+      "license": "MIT"
+    },
     "node_modules/base64-js": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -8090,6 +8119,12 @@
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
       "license": "0BSD"
     },
+    "node_modules/tweetnacl": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+      "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
+      "license": "Unlicense"
+    },
     "node_modules/type-detect": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index 9fc990f..e4e3cb7 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -10,16 +10,20 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
+    "@noble/ciphers": "^2.1.1",
     "@react-native-async-storage/async-storage": "2.2.0",
+    "base-64": "^1.0.0",
     "expo": "~54.0.0",
     "expo-location": "~19.0.8",
     "expo-sensors": "~15.0.8",
     "expo-status-bar": "~3.0.9",
     "firebase": "^12.0.0",
     "react": "19.1.0",
-    "react-native": "0.81.5"
+    "react-native": "0.81.5",
+    "tweetnacl": "^1.0.3"
   },
   "devDependencies": {
+    "@types/base-64": "^1.0.2",
     "@types/react": "~19.1.10",
     "typescript": "~5.9.2"
   },
diff --git a/mobile/src/core/crypto.ts b/mobile/src/core/crypto.ts
new file mode 100644
index 0000000..3792a0d
--- /dev/null
+++ b/mobile/src/core/crypto.ts
@@ -0,0 +1,92 @@
+import { gcm } from '@noble/ciphers/aes.js';
+import { utf8ToBytes, bytesToUtf8 } from '@noble/ciphers/utils.js';
+import { encode as base64Encode, decode as base64Decode } from 'base-64';
+
+const KEY_BYTES = 32;
+const IV_BYTES = 12;
+
+const bytesToBase64 = (bytes: Uint8Array): string => {
+    let binary = '';
+    for (let i = 0; i < bytes.length; i += 1) {
+        binary += String.fromCharCode(bytes[i]);
+    }
+    return base64Encode(binary);
+};
+
+const base64ToBytes = (base64: string): Uint8Array => {
+    const binary = base64Decode(base64);
+    const out = new Uint8Array(binary.length);
+    for (let i = 0; i < binary.length; i += 1) {
+        out[i] = binary.charCodeAt(i);
+    }
+    return out;
+};
+
+const randomBytes = (length: number): Uint8Array => {
+    const out = new Uint8Array(length);
+    if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
+        crypto.getRandomValues(out);
+        return out;
+    }
+    for (let i = 0; i < out.length; i += 1) {
+        out[i] = Math.floor(Math.random() * 256);
+    }
+    return out;
+};
+
+/**
+ * Represents a symmetric encryption key.
+ * Stores raw AES-256 key bytes shared between web and mobile.
+ */
+export interface SymmetricKey {
+    _type: 'SymmetricKey';
+    keyBytes: Uint8Array;
+}
+
+/**
+ * Generate a new 256-bit symmetric key.
+ */
+export const generateRoomKey = (): SymmetricKey => {
+    return { _type: 'SymmetricKey', keyBytes: randomBytes(KEY_BYTES) };
+};
+
+/**
+ * Export a key to base64url format for room hashes.
+ */
+export const exportRoomKey = async (key: SymmetricKey): Promise<string> => {
+    const b64 = bytesToBase64(key.keyBytes);
+    return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+};
+
+/**
+ * Import a base64url-encoded key from a room hash.
+ */
+export const importRoomKey = async (base64url: string): Promise<SymmetricKey> => {
+    const b64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+    const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
+    const keyBytes = base64ToBytes(padded);
+    return { _type: 'SymmetricKey', keyBytes };
+};
+
+/**
+ * Encrypts JSON using AES-256-GCM and returns `ivBase64:ciphertextBase64`.
+ * This matches the web app format for cross-platform interoperability.
+ */
+export const encryptJson = async (key: SymmetricKey, value: unknown): Promise<string> => {
+    const iv = randomBytes(IV_BYTES);
+    const plaintext = utf8ToBytes(JSON.stringify(value));
+    const ciphertext = gcm(key.keyBytes, iv).encrypt(plaintext);
+    return `${bytesToBase64(iv)}:${bytesToBase64(ciphertext)}`;
+};
+
+/**
+ * Decrypts JSON from `ivBase64:ciphertextBase64` produced by `encryptJson`.
+ */
+export const decryptJson = async (key: SymmetricKey, encryptedValue: string): Promise<unknown> => {
+    const [ivB64, ctB64] = encryptedValue.split(':');
+    if (!ivB64 || !ctB64) throw new Error('Invalid encrypted payload format');
+    const iv = base64ToBytes(ivB64);
+    const ciphertext = base64ToBytes(ctB64);
+    const plaintext = gcm(key.keyBytes, iv).decrypt(ciphertext);
+    return JSON.parse(bytesToUtf8(plaintext));
+};
diff --git a/mobile/src/features/friends/sync.ts b/mobile/src/features/friends/sync.ts
index 84ed18d..d92459a 100644
--- a/mobile/src/features/friends/sync.ts
+++ b/mobile/src/features/friends/sync.ts
@@ -1,4 +1,5 @@
 import { onValue, ref, serverTimestamp, set, type Database } from 'firebase/database';
+import { decryptJson, encryptJson, type SymmetricKey } from '../../core/crypto';
 
 export type MobileFriendRecord = {
     id: string;
@@ -11,13 +12,19 @@ export type MobileFriendRecord = {
 
 export type LatLon = { lat: number; lon: number };
 
+type MobileFriendDbRecord = MobileFriendRecord & {
+    encryptedState?: string;
+    encryptedPosition?: string;
+};
+
 export const upsertSelfFriend = async ({
     db,
     roomId,
     uid,
     name,
     color,
-    position
+    position,
+    roomKey
 }: {
     db: Database;
     roomId: string;
@@ -25,14 +32,27 @@ export const upsertSelfFriend = async ({
     name: string;
     color: string;
     position: LatLon | null;
+    roomKey: SymmetricKey | null;
 }) => {
     const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`);
-    await set(friendRef, {
+    const friendData: MobileFriendRecord = {
         id: uid,
         name,
         color,
-        position,
-        timestamp: Date.now(),
+        position: position ?? undefined,
+        timestamp: Date.now()
+    };
+
+    if (roomKey) {
+        await set(friendRef, {
+            encryptedState: await encryptJson(roomKey, friendData),
+            lastSeen: serverTimestamp()
+        });
+        return;
+    }
+
+    await set(friendRef, {
+        ...friendData,
         lastSeen: serverTimestamp()
     });
 };
@@ -40,11 +60,13 @@ export const upsertSelfFriend = async ({
 export const subscribeToFriends = ({
     db,
     roomId,
+    roomKey,
     onUpdate,
     onError
 }: {
     db: Database;
     roomId: string;
+    roomKey: SymmetricKey | null;
     onUpdate: (friends: MobileFriendRecord[]) => void;
     onError: (error: Error) => void;
 }) => {
@@ -52,15 +74,49 @@ export const subscribeToFriends = ({
 
     return onValue(
         friendsRef,
-        (snapshot) => {
-            const value = snapshot.val() as Record<string, MobileFriendRecord> | null;
-            const nextFriends = value ? Object.values(value) : [];
-            nextFriends.sort((a, b) => {
-                const aName = (a.name || '').toLowerCase();
-                const bName = (b.name || '').toLowerCase();
-                return aName.localeCompare(bName);
-            });
-            onUpdate(nextFriends);
+        async (snapshot) => {
+            try {
+                const value = snapshot.val() as Record<string, MobileFriendDbRecord> | null;
+                const raw = value ? Object.values(value) : [];
+
+                const resolved = await Promise.all(raw.map(async (friend): Promise<MobileFriendRecord | null> => {
+                    if (friend.encryptedState) {
+                        if (!roomKey) return null;
+                        try {
+                            const decrypted = await decryptJson(roomKey, friend.encryptedState);
+                            return {
+                                ...(decrypted as MobileFriendRecord),
+                                lastSeen: friend.lastSeen
+                            };
+                        } catch {
+                            return null;
+                        }
+                    }
+
+                    if (friend.encryptedPosition) {
+                        if (!roomKey) return null;
+                        try {
+                            const position = await decryptJson(roomKey, friend.encryptedPosition) as { lat: number; lon: number };
+                            const { encryptedPosition: _encryptedPosition, ...rest } = friend;
+                            return { ...rest, position };
+                        } catch {
+                            return null;
+                        }
+                    }
+
+                    return friend;
+                }));
+
+                const nextFriends = resolved.filter((item): item is MobileFriendRecord => item !== null);
+                nextFriends.sort((a, b) => {
+                    const aName = (a.name || '').toLowerCase();
+                    const bName = (b.name || '').toLowerCase();
+                    return aName.localeCompare(bName);
+                });
+                onUpdate(nextFriends);
+            } catch (err) {
+                onError(err instanceof Error ? err : new Error('Unknown error reading friends'));
+            }
         },
         (error) => {
             onError(error as Error);
diff --git a/mobile/src/screens/onboarding_screen.tsx b/mobile/src/screens/onboarding_screen.tsx
new file mode 100644
index 0000000..2087e99
--- /dev/null
+++ b/mobile/src/screens/onboarding_screen.tsx
@@ -0,0 +1,257 @@
+import { useState } from 'react';
+import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
+import { generateRoomKey, exportRoomKey, importRoomKey, type SymmetricKey } from '../core/crypto';
+import { buildRoomHash, parseRoomFromHash } from '../core/room';
+import { PUBLIC_SETTINGS } from '../public_settings';
+
+type OnboardingScreenProps = {
+    displayName: string;
+    displayColor: string;
+    onChangeDisplayName: (name: string) => void;
+    onChangeDisplayColor: (color: string) => void;
+    onStartRoom: (params: { roomHash: string; roomKey: SymmetricKey | null }) => void;
+};
+
+const randomRoomId = () => {
+    const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
+    let out = '';
+    for (let i = 0; i < PUBLIC_SETTINGS.room.idLength; i += 1) {
+        out += alphabet[Math.floor(Math.random() * alphabet.length)];
+    }
+    return out;
+};
+
+export function OnboardingScreen({
+    displayName,
+    displayColor,
+    onChangeDisplayName,
+    onChangeDisplayColor,
+    onStartRoom
+}: OnboardingScreenProps) {
+    const [roomHashInput, setRoomHashInput] = useState('');
+    const [joinError, setJoinError] = useState<string | null>(null);
+
+    const createRoom = async () => {
+        setJoinError(null);
+        const roomId = randomRoomId();
+        const key = generateRoomKey();
+        const keyB64 = await exportRoomKey(key);
+        const hash = buildRoomHash(roomId, keyB64);
+
+        const webAppUrl = `http://localhost:5173/#${hash}`;
+        console.log(`Room created. Join from web: ${webAppUrl}`);
+
+        onStartRoom({ roomHash: hash, roomKey: key });
+    };
+
+    const joinRoom = async () => {
+        setJoinError(null);
+        const normalized = roomHashInput.trim().replace(/^#/, '');
+        const parsed = parseRoomFromHash(normalized);
+        if (!parsed) {
+            setJoinError('Invalid room hash.');
+            return;
+        }
+
+        const hash = buildRoomHash(parsed.roomId, parsed.roomKeyBase64);
+        const roomKey = parsed.roomKeyBase64 ? await importRoomKey(parsed.roomKeyBase64) : null;
+        onStartRoom({ roomHash: hash, roomKey });
+    };
+
+    return (
+        <ScrollView contentContainerStyle={styles.container}>
+            <View style={styles.heroCard}>
+                <View style={styles.heroOrbA} />
+                <View style={styles.heroOrbB} />
+                <Text style={styles.heroEyebrow}>NORTH STAR</Text>
+                <Text style={styles.title}>Friend Compass</Text>
+                <Text style={styles.subtitle}>Set your profile, then create or join a room to start live direction sync.</Text>
+            </View>
+
+            <View style={styles.card}>
+                <View style={styles.profileHeaderRow}>
+                    <Text style={styles.label}>Step 1: Your profile</Text>
+                    <View style={[styles.colorPreview, { backgroundColor: displayColor || '#3366cc' }]} />
+                </View>
+                <TextInput
+                    value={displayName}
+                    onChangeText={onChangeDisplayName}
+                    autoCapitalize="none"
+                    autoCorrect={false}
+                    placeholder="display name"
+                    placeholderTextColor="#7a7469"
+                    style={styles.input}
+                />
+                <TextInput
+                    value={displayColor}
+                    onChangeText={onChangeDisplayColor}
+                    autoCapitalize="none"
+                    autoCorrect={false}
+                    placeholder="#3366cc"
+                    placeholderTextColor="#7a7469"
+                    style={styles.input}
+                />
+                <Text style={styles.helperText}>Pick a name and color that others will see in the room.</Text>
+            </View>
+
+            <View style={styles.createCard}>
+                <Text style={styles.label}>Step 2A: Create room</Text>
+                <Text style={styles.helperText}>Creates a fresh encrypted room hash for sharing.</Text>
+                <Pressable onPress={() => void createRoom()} style={({ pressed }) => [styles.buttonPrimary, pressed && styles.buttonPressed]}>
+                    <Text style={styles.buttonText}>Create encrypted room</Text>
+                </Pressable>
+            </View>
+
+            <View style={styles.joinCard}>
+                <Text style={styles.label}>Step 2B: Join existing room</Text>
+                <Text style={styles.helperText}>Paste an invite hash from web or another phone.</Text>
+                <TextInput
+                    value={roomHashInput}
+                    onChangeText={setRoomHashInput}
+                    autoCapitalize="none"
+                    autoCorrect={false}
+                    placeholder="example: ab12cd34"
+                    placeholderTextColor="#7a7469"
+                    style={styles.input}
+                />
+                <Pressable onPress={() => void joinRoom()} style={({ pressed }) => [styles.buttonSecondary, pressed && styles.buttonPressed]}>
+                    <Text style={styles.buttonText}>Join room</Text>
+                </Pressable>
+                {!!joinError && <Text style={styles.errorText}>{joinError}</Text>}
+            </View>
+
+            <Text style={styles.footerNote}>Tip: after creating a room, the app logs a web URL in the console for quick laptop join.</Text>
+        </ScrollView>
+    );
+}
+
+const styles = StyleSheet.create({
+    container: {
+        paddingHorizontal: 20,
+        paddingVertical: 28,
+        gap: 14
+    },
+    heroCard: {
+        backgroundColor: '#ddd0b9',
+        borderRadius: 18,
+        padding: 18,
+        gap: 8,
+        overflow: 'hidden',
+        borderWidth: 1,
+        borderColor: '#c9b89d'
+    },
+    heroOrbA: {
+        position: 'absolute',
+        width: 130,
+        height: 130,
+        borderRadius: 65,
+        backgroundColor: '#c8b08a',
+        right: -30,
+        top: -35,
+        opacity: 0.55
+    },
+    heroOrbB: {
+        position: 'absolute',
+        width: 100,
+        height: 100,
+        borderRadius: 50,
+        backgroundColor: '#b38a56',
+        right: 20,
+        bottom: -45,
+        opacity: 0.22
+    },
+    heroEyebrow: {
+        letterSpacing: 1.6,
+        fontSize: 11,
+        fontWeight: '800',
+        color: '#6d5330'
+    },
+    title: {
+        fontSize: 30,
+        fontWeight: '800',
+        color: '#2e2a23'
+    },
+    subtitle: {
+        fontSize: 15,
+        color: '#5b554c'
+    },
+    card: {
+        backgroundColor: '#ece6da',
+        borderRadius: 14,
+        padding: 16,
+        gap: 10
+    },
+    createCard: {
+        backgroundColor: '#e4dfd1',
+        borderRadius: 14,
+        padding: 16,
+        gap: 10,
+        borderWidth: 1,
+        borderColor: '#cac0ac'
+    },
+    joinCard: {
+        backgroundColor: '#e5d8c4',
+        borderRadius: 14,
+        padding: 16,
+        gap: 10,
+        borderWidth: 1,
+        borderColor: '#ccb798'
+    },
+    profileHeaderRow: {
+        flexDirection: 'row',
+        justifyContent: 'space-between',
+        alignItems: 'center'
+    },
+    colorPreview: {
+        width: 18,
+        height: 18,
+        borderRadius: 999,
+        borderWidth: 1,
+        borderColor: '#8f8778'
+    },
+    label: {
+        fontSize: 14,
+        fontWeight: '600',
+        color: '#2e2a23'
+    },
+    helperText: {
+        fontSize: 12,
+        color: '#6a6359'
+    },
+    input: {
+        backgroundColor: '#f6f1e7',
+        borderRadius: 10,
+        paddingHorizontal: 12,
+        paddingVertical: 10,
+        color: '#2e2a23'
+    },
+    buttonPrimary: {
+        backgroundColor: '#7a4f2a',
+        borderRadius: 10,
+        alignItems: 'center',
+        paddingVertical: 12
+    },
+    buttonSecondary: {
+        backgroundColor: '#5a6f3d',
+        borderRadius: 10,
+        alignItems: 'center',
+        paddingVertical: 12
+    },
+    buttonPressed: {
+        opacity: 0.85
+    },
+    buttonText: {
+        color: '#f7eee2',
+        fontSize: 15,
+        fontWeight: '700'
+    },
+    errorText: {
+        fontSize: 14,
+        color: '#8b2f2f'
+    },
+    footerNote: {
+        fontSize: 12,
+        color: '#6f695f',
+        paddingHorizontal: 4
+    }
+});
diff --git a/mobile/src/screens/room_screen.tsx b/mobile/src/screens/room_screen.tsx
index b960ace..a2b9021 100644
--- a/mobile/src/screens/room_screen.tsx
+++ b/mobile/src/screens/room_screen.tsx
@@ -10,6 +10,7 @@ import {
 } from 'react-native';
 import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing';
 import { buildRoomHash, parseRoomFromHash } from '../core/room';
+import { generateRoomKey, exportRoomKey, importRoomKey, type SymmetricKey } from '../core/crypto';
 import { watchHeading } from '../platform/heading';
 import { loadProfile, saveProfile } from '../platform/profile';
 import { subscribeToFriends, upsertSelfFriend, type MobileFriendRecord } from '../features/friends/sync';
@@ -51,6 +52,7 @@ export function RoomScreen() {
     const [position, setPosition] = useState<LatLon | null>(null);
     const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle');
     const [headingDeg, setHeadingDeg] = useState(0);
+    const [roomKey, setRoomKey] = useState<SymmetricKey | null>(null);
 
     const parsedRoom = useMemo(() => {
         if (!activeRoomHash) return null;
@@ -75,24 +77,36 @@ export function RoomScreen() {
         return () => clearTimeout(id);
     }, [displayName, displayColor]);
 
-    const createRoom = () => {
+    const createRoom = async () => {
         const roomId = randomRoomId();
-        const hash = buildRoomHash(roomId);
+        const key = generateRoomKey();
+        setRoomKey(key);
+
+        const keyB64 = await exportRoomKey(key);
+        const hash = buildRoomHash(roomId, keyB64);
         setActiveRoomHash(hash);
         setRoomHashInput('');
 
         // Log the URL for the web app to join this room
         const webAppUrl = `http://localhost:5173/#${hash}`;
-        console.log(`📱 Room created! Join on web app:\n${webAppUrl}`);
+        console.log(`📱 Encrypted room created! Join on web app:\n${webAppUrl}`);
         console.log(`Replace localhost:5173 with your web app URL if needed.`);
     };
 
-    const joinFromInput = () => {
+    const joinFromInput = async () => {
         const normalized = roomHashInput.trim().replace(/^#/, '');
         const parsed = parseRoomFromHash(normalized);
         if (!parsed) return;
         setActiveRoomHash(buildRoomHash(parsed.roomId, parsed.roomKeyBase64));
         setRoomHashInput('');
+
+        // Import encryption key if present
+        if (parsed.roomKeyBase64) {
+            const key = await importRoomKey(parsed.roomKeyBase64);
+            setRoomKey(key);
+        } else {
+            setRoomKey(null);
+        }
     };
 
     const connectFirebase = async () => {
@@ -161,7 +175,8 @@ export function RoomScreen() {
                     uid: firebaseSession.uid,
                     name: displayName.trim() || createDefaultDisplayName(),
                     color: normalizeColor(displayColor),
-                    position: currentPosition
+                    position: currentPosition,
+                    roomKey
                 });
             } catch (error) {
                 const nextError = error instanceof Error ? error.message : 'Unknown room sync write error';
@@ -179,6 +194,7 @@ export function RoomScreen() {
         const unsubscribe = subscribeToFriends({
             db: firebaseSession.db,
             roomId,
+            roomKey,
             onUpdate: (nextFriends) => {
                 if (!isDisposed) setFriends(nextFriends);
             },
@@ -192,7 +208,7 @@ export function RoomScreen() {
             clearInterval(intervalId);
             unsubscribe();
         };
-    }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position]);
+    }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position, roomKey]);
 
     return (
         <ScrollView contentContainerStyle={styles.container}>
@@ -229,7 +245,7 @@ export function RoomScreen() {
                     <>
                         <Text style={styles.value}>Hash: {activeRoomHash}</Text>
                         <Text style={styles.value}>Room id: {parsedRoom?.roomId ?? 'invalid'}</Text>
-                        <Text style={styles.value}>Encrypted key: {parsedRoom?.roomKeyBase64 ? 'present' : 'none'}</Text>
+                        <Text style={styles.value}>Encryption: {roomKey ? '🔐 enabled' : 'none'}</Text>
                     </>
                 )}
             </View>
diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx
new file mode 100644
index 0000000..be5e840
--- /dev/null
+++ b/mobile/src/screens/room_session_screen.tsx
@@ -0,0 +1,365 @@
+import { useEffect, useMemo, useState } from 'react';
+import {
+    ActivityIndicator,
+    Pressable,
+    ScrollView,
+    StyleSheet,
+    Text,
+    View
+} from 'react-native';
+import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing';
+import { parseRoomFromHash } from '../core/room';
+import type { SymmetricKey } from '../core/crypto';
+import { watchHeading } from '../platform/heading';
+import { subscribeToFriends, upsertSelfFriend, type MobileFriendRecord } from '../features/friends/sync';
+import { initFirebaseSession, type FirebaseSession } from '../platform/firebase';
+import { requestLocationPermission, watchPosition, type LatLon } from '../platform/geolocation';
+
+const MOBILE_SYNC_INTERVAL_MS = 10000;
+
+type RoomSessionScreenProps = {
+    roomHash: string;
+    roomKey: SymmetricKey | null;
+    displayName: string;
+    displayColor: string;
+    onLeaveRoom: () => void;
+};
+
+const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`;
+
+const normalizeColor = (value: string) => {
+    const candidate = value.trim().toLowerCase();
+    if (!/^#[0-9a-f]{6}$/.test(candidate)) return '#3366cc';
+    return candidate;
+};
+
+export function RoomSessionScreen({
+    roomHash,
+    roomKey,
+    displayName,
+    displayColor,
+    onLeaveRoom
+}: RoomSessionScreenProps) {
+    const [firebaseSession, setFirebaseSession] = useState<FirebaseSession | null>(null);
+    const [isConnectingFirebase, setIsConnectingFirebase] = useState(false);
+    const [firebaseError, setFirebaseError] = useState<string | null>(null);
+
+    const [isRoomSyncEnabled, setIsRoomSyncEnabled] = useState(false);
+    const [friends, setFriends] = useState<MobileFriendRecord[]>([]);
+    const [roomSyncError, setRoomSyncError] = useState<string | null>(null);
+    const [position, setPosition] = useState<LatLon | null>(null);
+    const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle');
+    const [headingDeg, setHeadingDeg] = useState(0);
+
+    const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]);
+
+    const connectFirebase = async () => {
+        setIsConnectingFirebase(true);
+        setFirebaseError(null);
+        try {
+            const session = await initFirebaseSession();
+            setFirebaseSession(session);
+        } catch (error) {
+            const nextError = error instanceof Error ? error.message : 'Unknown Firebase error';
+            setFirebaseError(nextError);
+        } finally {
+            setIsConnectingFirebase(false);
+        }
+    };
+
+    useEffect(() => {
+        if (!isRoomSyncEnabled) return;
+
+        let stopWatching: (() => void) | null = null;
+
+        const start = async () => {
+            const granted = await requestLocationPermission();
+            if (!granted) {
+                setLocationStatus('denied');
+                return;
+            }
+            setLocationStatus('watching');
+            stopWatching = watchPosition(
+                (nextPosition) => setPosition(nextPosition),
+                (message) => setRoomSyncError(message)
+            );
+        };
+
+        void start();
+
+        return () => {
+            stopWatching?.();
+            setLocationStatus('idle');
+        };
+    }, [isRoomSyncEnabled]);
+
+    useEffect(() => {
+        if (!isRoomSyncEnabled) return;
+        const stop = watchHeading(
+            (deg) => setHeadingDeg(deg),
+            (msg) => setRoomSyncError(msg)
+        );
+        return stop;
+    }, [isRoomSyncEnabled]);
+
+    useEffect(() => {
+        if (!isRoomSyncEnabled || !firebaseSession || !parsedRoom) {
+            setFriends([]);
+            return;
+        }
+
+        let isDisposed = false;
+        const roomId = parsedRoom.roomId;
+
+        const writeSelf = async (currentPosition: LatLon) => {
+            try {
+                await upsertSelfFriend({
+                    db: firebaseSession.db,
+                    roomId,
+                    uid: firebaseSession.uid,
+                    name: displayName.trim() || createDefaultDisplayName(),
+                    color: normalizeColor(displayColor),
+                    position: currentPosition,
+                    roomKey
+                });
+            } catch (error) {
+                const nextError = error instanceof Error ? error.message : 'Unknown room sync write error';
+                if (!isDisposed) setRoomSyncError(nextError);
+            }
+        };
+
+        setRoomSyncError(null);
+        if (position) void writeSelf(position);
+
+        const intervalId = setInterval(() => {
+            if (position) void writeSelf(position);
+        }, MOBILE_SYNC_INTERVAL_MS);
+
+        const unsubscribe = subscribeToFriends({
+            db: firebaseSession.db,
+            roomId,
+            roomKey,
+            onUpdate: (nextFriends) => {
+                if (!isDisposed) setFriends(nextFriends);
+            },
+            onError: (error) => {
+                if (!isDisposed) setRoomSyncError(error.message);
+            }
+        });
+
+        return () => {
+            isDisposed = true;
+            clearInterval(intervalId);
+            unsubscribe();
+        };
+    }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position, roomKey]);
+
+    return (
+        <ScrollView contentContainerStyle={styles.container}>
+            <View style={styles.heroCard}>
+                <View style={styles.heroOrb} />
+                <View style={styles.headerRow}>
+                    <View>
+                        <Text style={styles.title}>Room Session</Text>
+                        <Text style={styles.subtitle}>Room id: {parsedRoom?.roomId ?? 'invalid'}</Text>
+                    </View>
+                    <Pressable onPress={onLeaveRoom} style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}>
+                        <Text style={styles.secondaryButtonText}>Leave</Text>
+                    </Pressable>
+                </View>
+            </View>
+
+            <View style={styles.resultCard}>
+                <Text style={styles.label}>Live room details</Text>
+                <View>
+                    <Text style={styles.value}>Hash: {roomHash}</Text>
+                    <Text style={styles.value}>Encryption: {roomKey ? 'enabled' : 'none'}</Text>
+                </View>
+            </View>
+
+            <View style={styles.card}>
+                <Text style={styles.label}>Firebase session</Text>
+                <Pressable
+                    onPress={connectFirebase}
+                    disabled={isConnectingFirebase}
+                    style={({ pressed }) => [styles.button, (pressed || isConnectingFirebase) && styles.buttonPressed]}
+                >
+                    <Text style={styles.buttonText}>
+                        {isConnectingFirebase ? 'Connecting...' : firebaseSession ? 'Reconnect Firebase' : 'Connect Firebase'}
+                    </Text>
+                </Pressable>
+                {isConnectingFirebase && <ActivityIndicator color="#7a4f2a" />}
+                {!!firebaseSession && <Text style={styles.value}>Signed in uid: {firebaseSession.uid}</Text>}
+                {!!firebaseError && <Text style={styles.errorText}>Firebase error: {firebaseError}</Text>}
+            </View>
+
+            <View style={styles.card}>
+                <Text style={styles.label}>Live sync</Text>
+                <Pressable
+                    onPress={() => setIsRoomSyncEnabled((prev) => !prev)}
+                    disabled={!firebaseSession || !parsedRoom}
+                    style={({ pressed }) => [
+                        styles.button,
+                        (!firebaseSession || !parsedRoom || pressed) && styles.buttonPressed
+                    ]}
+                >
+                    <Text style={styles.buttonText}>
+                        {isRoomSyncEnabled ? 'Stop room sync' : 'Start room sync'}
+                    </Text>
+                </Pressable>
+                {!firebaseSession && <Text style={styles.valueMuted}>Connect Firebase first.</Text>}
+                {!!firebaseSession && !parsedRoom && <Text style={styles.valueMuted}>Invalid room hash.</Text>}
+                {locationStatus === 'denied' && <Text style={styles.errorText}>Location permission denied.</Text>}
+                {locationStatus === 'watching' && (
+                    position
+                        ? <Text style={styles.value}>Location: {position.lat.toFixed(5)}, {position.lon.toFixed(5)}</Text>
+                        : <Text style={styles.valueMuted}>Waiting for GPS fix...</Text>
+                )}
+                <Text style={styles.value}>Participants in room: {friends.length}</Text>
+                {friends.map((friend) => {
+                    const bearingDeg = position && friend.position
+                        ? getBearingDeg(position, friend.position)
+                        : null;
+                    const arrowDeg = bearingDeg !== null
+                        ? getRelativeBearingDeg(bearingDeg, headingDeg)
+                        : null;
+                    const dist = position && friend.position
+                        ? formatDistance(getDistanceMeters(position, friend.position))
+                        : null;
+                    return (
+                        <View key={friend.id} style={styles.friendRow}>
+                            <View style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888' }]} />
+                            <Text style={[styles.value, styles.friendName]}>{friend.name || friend.id}</Text>
+                            {dist !== null && <Text style={styles.friendDist}>{dist}</Text>}
+                            {arrowDeg !== null && (
+                                <Text style={[styles.friendArrow, { transform: [{ rotate: `${arrowDeg}deg` }] }]}>↑</Text>
+                            )}
+                        </View>
+                    );
+                })}
+                {!!roomSyncError && <Text style={styles.errorText}>Room sync error: {roomSyncError}</Text>}
+            </View>
+        </ScrollView>
+    );
+}
+
+const styles = StyleSheet.create({
+    container: {
+        paddingHorizontal: 20,
+        paddingVertical: 28,
+        gap: 14
+    },
+    heroCard: {
+        backgroundColor: '#d6ccb7',
+        borderRadius: 16,
+        padding: 14,
+        overflow: 'hidden',
+        borderWidth: 1,
+        borderColor: '#c7b79d'
+    },
+    heroOrb: {
+        position: 'absolute',
+        width: 120,
+        height: 120,
+        borderRadius: 60,
+        backgroundColor: '#b38a56',
+        right: -28,
+        top: -34,
+        opacity: 0.25
+    },
+    headerRow: {
+        flexDirection: 'row',
+        justifyContent: 'space-between',
+        alignItems: 'center'
+    },
+    title: {
+        fontSize: 28,
+        fontWeight: '700',
+        color: '#2e2a23'
+    },
+    subtitle: {
+        fontSize: 15,
+        color: '#5b554c'
+    },
+    card: {
+        backgroundColor: '#ece6da',
+        borderRadius: 14,
+        padding: 16,
+        gap: 10,
+        borderWidth: 1,
+        borderColor: '#d7ccb9'
+    },
+    resultCard: {
+        backgroundColor: '#dbe3df',
+        borderRadius: 14,
+        padding: 16,
+        gap: 6,
+        borderWidth: 1,
+        borderColor: '#c2d1cb'
+    },
+    label: {
+        fontSize: 14,
+        fontWeight: '600',
+        color: '#2e2a23'
+    },
+    button: {
+        backgroundColor: '#7a4f2a',
+        borderRadius: 10,
+        alignItems: 'center',
+        paddingVertical: 12
+    },
+    secondaryButton: {
+        borderWidth: 1,
+        borderColor: '#7a4f2a',
+        borderRadius: 10,
+        alignItems: 'center',
+        paddingVertical: 8,
+        paddingHorizontal: 12
+    },
+    secondaryButtonText: {
+        color: '#7a4f2a',
+        fontSize: 14,
+        fontWeight: '700'
+    },
+    buttonPressed: {
+        opacity: 0.85
+    },
+    buttonText: {
+        color: '#f7eee2',
+        fontSize: 15,
+        fontWeight: '700'
+    },
+    value: {
+        fontSize: 14,
+        color: '#2e2a23'
+    },
+    valueMuted: {
+        fontSize: 14,
+        color: '#5b554c'
+    },
+    errorText: {
+        fontSize: 14,
+        color: '#8b2f2f'
+    },
+    friendRow: {
+        flexDirection: 'row',
+        alignItems: 'center',
+        gap: 8
+    },
+    friendDot: {
+        width: 12,
+        height: 12,
+        borderRadius: 6
+    },
+    friendName: {
+        flex: 1
+    },
+    friendDist: {
+        fontSize: 13,
+        color: '#7a7469'
+    },
+    friendArrow: {
+        fontSize: 24,
+        color: '#2e2a23'
+    }
+});