From adc96860351e8e90d90fb6f7e68afa8a2c53b41c Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 16:47:55 +0200 Subject: [PATCH] refactor(mobile): AuthProvider exposes userKeyState instead of device record --- apps/mobile/lib/authContext.tsx | 139 +++++++++++++++++--------------- 1 file changed, 73 insertions(+), 66 deletions(-) diff --git a/apps/mobile/lib/authContext.tsx b/apps/mobile/lib/authContext.tsx index fc618ff..8c85cb7 100644 --- a/apps/mobile/lib/authContext.tsx +++ b/apps/mobile/lib/authContext.tsx @@ -1,23 +1,31 @@ import type { Session, User } from '@supabase/supabase-js'; -import { auth, crypto } from '@chat-app/shared'; -import type { DeviceRecord } from '@chat-app/shared/auth'; -import { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { Platform } from 'react-native'; +import { fetchUserKeyBlob } from '@chat-app/shared/auth'; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; -import { secretStore } from './secretStore'; import { supabase } from './supabase'; +import { cachedUserKey, ensureLegacyMigrated } from './userIdentity'; -// Locally-stored secrets keyed by stable names. Mirrors the desktop -// convention so the migration tests (later) can compare snapshots. -const KEY_DEVICE_ID = 'device.id'; -const KEY_DEVICE_PRIVKEY = 'device.privateKey'; +export type UserKeyState = + | { status: 'loading' } + | { status: 'needs-setup' } + | { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean } + | { status: 'unlocked' }; interface AuthContextValue { session: Session | null; user: User | null; - device: DeviceRecord | null; + userId: string | null; ownPrivateKey: Uint8Array | null; - loading: boolean; + userKeyState: UserKeyState; + ready: boolean; + refreshUserKeyState: () => Promise; signOut: () => Promise; } @@ -29,44 +37,48 @@ export function useAuth(): AuthContextValue { return v; } -export function AuthProvider({ children }: { children: React.ReactNode }) { +export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); - const [device, setDevice] = useState(null); const [ownPrivateKey, setOwnPrivateKey] = useState(null); - const [loading, setLoading] = useState(true); + const [userKeyState, setUserKeyState] = useState({ status: 'loading' }); + const [ready, setReady] = useState(false); - // Resolve or create the device record for this install given an active - // session. Stores the private key in expo-secure-store on first run. - const ensureDevice = useCallback(async (_currentSession: Session): Promise => { - const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID); - const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY); - - if (savedDeviceId && savedPrivKey) { - const devices = await auth.listOwnDevices(supabase); - const deviceIdStr = new TextDecoder().decode(savedDeviceId); - const match = devices.find((d) => d.id === deviceIdStr); - if (match) { - setDevice(match); - setOwnPrivateKey(savedPrivKey); - return; - } - // Stored id no longer matches any device on the server (revoked, - // wiped). Fall through to register a fresh one. + const refreshUserKeyState = useCallback(async () => { + const s = session; + if (!s) { + setUserKeyState({ status: 'loading' }); + setOwnPrivateKey(null); + return; } - - const backend = crypto.getCryptoBackend(); - const kp = backend.generateKeyPair(); - const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux'; - const record = await auth.registerDevice(supabase, { - name: `Netralax Mobile (${Platform.OS})`, - platform, - publicKey: kp.publicKey, + setUserKeyState({ status: 'loading' }); + const cached = await cachedUserKey(s.user.id); + if (cached) { + setOwnPrivateKey(cached); + setUserKeyState({ status: 'unlocked' }); + void ensureLegacyMigrated(s.user.id).catch((err) => { + console.warn('legacy migration on auth-resume failed', err); + }); + return; + } + const blob = await fetchUserKeyBlob(supabase, s.user.id); + if (!blob || !blob.exists) { + setUserKeyState({ status: 'needs-setup' }); + return; + } + if (blob.locked) { + setUserKeyState({ + status: 'needs-unlock', + lockedUntil: blob.lockedUntil, + hasRecovery: false, + }); + return; + } + setUserKeyState({ + status: 'needs-unlock', + lockedUntil: null, + hasRecovery: blob.recoverySealedPrivateKey !== null, }); - await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id)); - await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey); - setDevice(record); - setOwnPrivateKey(kp.privateKey); - }, []); + }, [session]); useEffect(() => { let cancelled = false; @@ -74,48 +86,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { data } = await supabase.auth.getSession(); if (cancelled) return; setSession(data.session); - if (data.session) { - try { - await ensureDevice(data.session); - } catch (err) { - console.warn('[auth] ensureDevice failed', err); - } - } - setLoading(false); + setReady(true); })(); - const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => { setSession(nextSession); + setReady(true); if (!nextSession) { - setDevice(null); setOwnPrivateKey(null); - } else { - void ensureDevice(nextSession).catch((err) => - console.warn('[auth] ensureDevice (state change) failed', err), - ); + setUserKeyState({ status: 'loading' }); } }); - return () => { cancelled = true; sub.subscription.unsubscribe(); }; - }, [ensureDevice]); + }, []); - const signOut = useCallback(async (): Promise => { + useEffect(() => { + void refreshUserKeyState().catch((err) => { + console.warn('refreshUserKeyState failed', err); + setUserKeyState({ status: 'needs-setup' }); + }); + }, [session, refreshUserKeyState]); + + const signOut = useCallback(async () => { await supabase.auth.signOut(); - await secretStore.removeSecret(KEY_DEVICE_ID); - await secretStore.removeSecret(KEY_DEVICE_PRIVKEY); - setDevice(null); setOwnPrivateKey(null); + setUserKeyState({ status: 'loading' }); }, []); const value: AuthContextValue = { session, user: session?.user ?? null, - device, + userId: session?.user.id ?? null, ownPrivateKey, - loading, + userKeyState, + ready, + refreshUserKeyState, signOut, }; return {children};