diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx index 97ec6fc..0c05ab6 100644 --- a/apps/desktop/src/components/AppShell.tsx +++ b/apps/desktop/src/components/AppShell.tsx @@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { startConversationKeySync } from '../lib/conversationKeySync'; import { startDeviceApprovalListener } from '../lib/deviceApproval'; +import { ensureInstallId } from '../lib/installId'; import { ensureNotificationPermission } from '../lib/osNotify'; import { cachedUserKey } from '../lib/userIdentity'; import { BackupPromptBanner } from './BackupPromptBanner'; @@ -12,7 +13,7 @@ import { DeviceApprovalBanner } from './DeviceApprovalBanner'; import { Sidebar } from './Sidebar'; export function AppShell() { - const { session, device } = useAuth(); + const { session } = useAuth(); useEffect(() => { // Prompt once per authenticated shell mount. Module-level guard prevents // re-asking if the user already responded this session. @@ -20,20 +21,25 @@ export function AppShell() { }, []); useEffect(() => { - if (!session?.user.id || !device?.id) return; + if (!session?.user.id) return; const userId = session.user.id; - const deviceId = device.id; - const stopKeySync = startConversationKeySync(userId, deviceId); + // Per-install id replaces the per-device record now that crypto is + // user-keyed. The legacy conv-key-sync and device-approval subsystems + // still take a "deviceId" argument for telemetry / row identity; passing + // the install id keeps those wires intact until they are themselves + // reworked in later tasks (Task 16/17/19 territory). + const installId = ensureInstallId(); + const stopKeySync = startConversationKeySync(userId, installId); const stopApproval = startDeviceApprovalListener({ ownUserId: userId, - ownDeviceId: deviceId, + ownDeviceId: installId, getPriv: () => cachedUserKey(userId), }); return () => { stopKeySync(); stopApproval(); }; - }, [session?.user.id, device?.id]); + }, [session?.user.id]); return (
diff --git a/apps/desktop/src/components/ForwardDialog.tsx b/apps/desktop/src/components/ForwardDialog.tsx index 082ab3c..74156ae 100644 --- a/apps/desktop/src/components/ForwardDialog.tsx +++ b/apps/desktop/src/components/ForwardDialog.tsx @@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useConversationsContext } from '../context/ConversationsContext'; +import { ensureInstallId } from '../lib/installId'; import { supabase } from '../lib/supabase'; import { cachedUserKey } from '../lib/userIdentity'; import { Avatar } from './Avatar'; @@ -32,7 +33,7 @@ interface Props { // preview hints at the dropped attachment. export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) { const { t } = useTranslation(['app', 'errors']); - const { session, device } = useAuth(); + const { session } = useAuth(); const { conversations } = useConversationsContext(); const [selected, setSelected] = useState>(new Set()); @@ -83,7 +84,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }: } async function handleSend() { - if (!session?.user.id || !device?.id || !message) return; + if (!session?.user.id || !message) return; if (selected.size === 0) return; setBusy(true); setError(null); @@ -137,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }: conversationId: convId, plaintext: text, senderUserId: session.user.id, - senderDeviceId: device.id, + senderDeviceId: ensureInstallId(), senderPrivateKey: priv, ...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}), }); diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index c48aaa1..5f2a234 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -10,6 +10,7 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; +import { ensureInstallId } from '../lib/installId'; import { supabase } from '../lib/supabase'; import { cachedUserKey } from '../lib/userIdentity'; import type { AggregatedReaction } from '../lib/useMessageReactions'; @@ -97,7 +98,7 @@ export function MessageBubble({ highlighted = false, }: Props) { const { t } = useTranslation(['app']); - const { session, device } = useAuth(); + const { session } = useAuth(); const parsed = parseMessagePayload(message.plaintext); const initialText = parsed.kind === 'text' ? parsed.text : ''; @@ -190,7 +191,7 @@ export function MessageBubble({ }, [pickerOpen]); const handleEditSave = useCallback(async () => { - if (!session || !device) return; + if (!session) return; const trimmed = editText.trim(); if (!trimmed || trimmed === bodyText) { setEditing(false); @@ -208,7 +209,7 @@ export function MessageBubble({ conversationId, newPlaintext: trimmed, senderUserId: session.user.id, - senderDeviceId: device.id, + senderDeviceId: ensureInstallId(), senderPrivateKey: priv, }); setEditing(false); @@ -224,7 +225,7 @@ export function MessageBubble({ } finally { setBusy(false); } - }, [editText, message.id, message.plaintext, conversationId, session, device, t]); + }, [editText, message.id, message.plaintext, conversationId, session, t]); const handleDelete = useCallback(async () => { if (busy) return; diff --git a/apps/desktop/src/components/guards.tsx b/apps/desktop/src/components/guards.tsx index c6d386b..f0a61ce 100644 --- a/apps/desktop/src/components/guards.tsx +++ b/apps/desktop/src/components/guards.tsx @@ -23,11 +23,14 @@ export function RequireAuth() { return ; } -// Forces a registered device on this install. Sends to /device otherwise. +// Forces a usable per-user encrypted key blob on this install. Sends to +// /device (the setup/unlock page) when the blob is missing or locked. export function RequireDevice() { - const { device, deviceLookupDone } = useAuth(); - if (!deviceLookupDone) return ; - if (!device) return ; + const { userKeyState } = useAuth(); + if (userKeyState.status === 'loading') return ; + if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') { + return ; + } return ; } diff --git a/apps/desktop/src/context/AuthContext.tsx b/apps/desktop/src/context/AuthContext.tsx index 9a3ae79..0d00a4a 100644 --- a/apps/desktop/src/context/AuthContext.tsx +++ b/apps/desktop/src/context/AuthContext.tsx @@ -1,9 +1,8 @@ import { - type DeviceRecord, + fetchUserKeyBlob, getOwnProfile, signOut as supabaseSignOut, type Profile, - touchDeviceLastSeen, updateOwnProfile, } from '@chat-app/shared/auth'; import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n'; @@ -20,23 +19,34 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; -import { findExistingDevice } from '../lib/device'; -import { PRESENCE_HEARTBEAT_MS } from '../lib/presence'; +import { ensureInstallId } from '../lib/installId'; import { setSecretStoreUser } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; +import { cachedUserKey } from '../lib/userIdentity'; import { registerWebPush } from '../lib/webPush'; +// Discriminated union describing the per-user encrypted key blob lifecycle: +// +// loading — initial state, or refresh in flight +// needs-setup — no row exists on Supabase; user must pick a PIN +// needs-unlock — row exists but local cache empty; PIN (or recovery code) +// required. `lockedUntil` non-null means the server-side +// rate limiter is currently rejecting attempts. +// unlocked — private key is in the local secret store and ready to use +export type UserKeyState = + | { status: 'loading' } + | { status: 'needs-setup' } + | { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean } + | { status: 'unlocked' }; + interface AuthContextValue { session: Session | null; profile: Profile | null; - device: DeviceRecord | null; + userKeyState: UserKeyState; // null while we're still resolving the very first auth state. ready: boolean; - // null until a device lookup has finished for the current session. - deviceLookupDone: boolean; refreshProfile: () => Promise; - refreshDevice: () => Promise; - setDevice: (device: DeviceRecord | null) => void; + refreshUserKeyState: () => Promise; signOut: () => Promise; } @@ -47,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); const [ready, setReady] = useState(false); const [profile, setProfile] = useState(null); - const [device, setDevice] = useState(null); - const [deviceLookupDone, setDeviceLookupDone] = useState(false); + const [userKeyState, setUserKeyState] = useState({ status: 'loading' }); const autoOnlineUserRef = useRef(null); // Initial session + auth subscription. We verify the cached JWT against the @@ -112,41 +121,72 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [session, i18n.resolvedLanguage]); - const refreshDevice = useCallback(async () => { + // Resolves the current state of the per-user encrypted key blob: + // 1. local cache hit → 'unlocked' + // 2. no remote row → 'needs-setup' + // 3. server rate-limit → 'needs-unlock' with lockedUntil set + // 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a + // recovery-code blob is present so the UI can + // conditionally offer the recovery affordance + const refreshUserKeyState = useCallback(async () => { if (!session) { - setDevice(null); - setDeviceLookupDone(false); + setUserKeyState({ status: 'loading' }); return; } - setDeviceLookupDone(false); - const found = await findExistingDevice(session.user.id); - setDevice(found); - setDeviceLookupDone(true); + setUserKeyState({ status: 'loading' }); + const cached = await cachedUserKey(session.user.id); + if (cached) { + setUserKeyState({ status: 'unlocked' }); + return; + } + const blob = await fetchUserKeyBlob(supabase, session.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, + }); }, [session]); - // Re-pull profile + device whenever session flips. + // Re-pull profile + user-key state whenever session flips. useEffect(() => { if (!session) { setProfile(null); - setDevice(null); - setDeviceLookupDone(false); + setUserKeyState({ status: 'loading' }); return; } void refreshProfile().catch((err: unknown) => { console.error('refreshProfile failed', err); }); - void refreshDevice().catch((err: unknown) => { - console.error('refreshDevice failed', err); - setDeviceLookupDone(true); + void refreshUserKeyState().catch((err: unknown) => { + console.error('refreshUserKeyState failed', err); + // Treat an unrecoverable lookup error as "needs-setup" so the UI at + // least drives the user toward the setup/unlock page rather than + // hanging forever on the spinner. + setUserKeyState({ status: 'needs-setup' }); }); - }, [session, refreshProfile, refreshDevice]); + }, [session, refreshProfile, refreshUserKeyState]); - // Best-effort web-push registration once we know the device id. No-op on - // Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset. + // Best-effort web-push registration once we have a session. Keyed by an + // install-id (localStorage UUID) since there's no longer a per-device + // crypto record to key by. No-op on Tauri (uses native notifications) or + // when VITE_VAPID_PUBLIC_KEY is unset. useEffect(() => { - if (!device?.id) return; - void registerWebPush(device.id); - }, [device?.id]); + if (!session) return; + const installId = ensureInstallId(); + void registerWebPush(installId); + }, [session]); // Auto online/offline transition. // @@ -188,32 +228,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; }, [session, profile, refreshProfile]); - useEffect(() => { - if (!session || !device?.id) return; - - const touch = () => { - void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => { - console.warn('presence heartbeat failed', err); - }); - }; - const touchWhenVisible = () => { - if (document.visibilityState === 'visible') touch(); - }; - - touch(); - const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS); - window.addEventListener('focus', touch); - window.addEventListener('online', touch); - document.addEventListener('visibilitychange', touchWhenVisible); - - return () => { - window.clearInterval(heartbeat); - window.removeEventListener('focus', touch); - window.removeEventListener('online', touch); - document.removeEventListener('visibilitychange', touchWhenVisible); - }; - }, [session, device?.id]); - const signOut = useCallback(async () => { await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => { console.warn('offline update before sign-out failed', err); @@ -225,15 +239,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { () => ({ session, profile, - device, + userKeyState, ready, - deviceLookupDone, refreshProfile, - refreshDevice, - setDevice, + refreshUserKeyState, signOut, }), - [session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut], + [session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut], ); return {children}; diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx index 80bea6c..39a3ef9 100644 --- a/apps/desktop/src/context/CallContext.tsx +++ b/apps/desktop/src/context/CallContext.tsx @@ -113,6 +113,7 @@ import { type ScreenSharePreset, updateScreenShareSettings, } from '../lib/screenShareSettings'; +import { ensureInstallId } from '../lib/installId'; import { supabase } from '../lib/supabase'; import { cachedUserKey } from '../lib/userIdentity'; import { setWindowFullscreen } from '../lib/windowFullscreen'; @@ -324,7 +325,7 @@ function newCallId(): string { } export function CallProvider({ children }: { children: ReactNode }) { - const { session, device, profile } = useAuth(); + const { session, profile } = useAuth(); const { conversations } = useConversationsContext(); const myId = session?.user.id; @@ -445,7 +446,7 @@ export function CallProvider({ children }: { children: ReactNode }) { mediaKind: CallKind, durationSec: number, ) => { - if (!session?.user.id || !device?.id) return; + if (!session?.user.id) return; try { const priv = await cachedUserKey(session.user.id); if (!priv) return; @@ -461,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) { conversationId, plaintext: payload, senderUserId: session.user.id, - senderDeviceId: device.id, + senderDeviceId: ensureInstallId(), senderPrivateKey: priv, }); } catch (err: unknown) { console.error('emitCallEvent failed', err); } }, - [session?.user.id, device?.id], + [session?.user.id], ); const clearRingTimer = useCallback(() => { diff --git a/apps/desktop/src/lib/installId.ts b/apps/desktop/src/lib/installId.ts new file mode 100644 index 0000000..83e12d9 --- /dev/null +++ b/apps/desktop/src/lib/installId.ts @@ -0,0 +1,19 @@ +// Per-install browser identifier. Replaces the legacy per-device crypto +// identity for non-crypto bookkeeping (push tokens, message sender_device_id +// metadata) where we just need a stable, opaque id for this browser/install. +// +// The user-key model has no device-bound crypto; the `devices` table and +// `messages.sender_device_id` column still exist but they're now plain +// telemetry. Using a localStorage UUID keeps the column populated without +// any of the old key-management overhead. + +const KEY = 'chatapp.installId'; + +export function ensureInstallId(): string { + let id = window.localStorage.getItem(KEY); + if (!id) { + id = crypto.randomUUID(); + window.localStorage.setItem(KEY, id); + } + return id; +} diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 02e2d52..ca47a37 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -37,6 +37,7 @@ import { useCall } from '../context/CallContext'; import { useConversationsContext } from '../context/ConversationsContext'; import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures'; import { compressImages } from '../lib/imageCompress'; +import { ensureInstallId } from '../lib/installId'; import { searchCachedMessages } from '../lib/messageCache'; import type { OutboxItem } from '../lib/messageOutbox'; import { useConversationMessages } from '../lib/useConversationMessages'; @@ -61,7 +62,7 @@ const scrollPositions = new Map(); - const { session, device } = useAuth(); + const { session } = useAuth(); const { conversations, setActiveConversation, markRead, unread } = useConversationsContext(); const conversation = useMemo( @@ -75,7 +76,7 @@ export function ConversationPage() { useConversationMessages({ conversationId: id, userId: session?.user.id, - deviceId: device?.id, + deviceId: ensureInstallId(), }); const messageIds = useMemo(() => messages.map((m) => m.id), [messages]); const {