From 3d223238b5ad78982300557e10bb43cdb0b64396 Mon Sep 17 00:00:00 2001 From: Pushp Date: Tue, 25 Aug 2026 04:29:51 +0530 Subject: [PATCH] feat(client): add multi-device E2EE and refresh matches Add wire-v2 multi-device chat encryption, device management UI and tests, update the generated API client dependency, and include the current app-shell and matches state/UI changes. --- found.txt | 0 package-lock.json | 7 +- package.json | 2 +- served.js | 0 src/api/chat.ts | 127 ++++ src/app/AppContent.tsx | 60 +- .../profile/DeviceManagementCard.tsx | 171 +++++ src/features/matches/MatchesView.tsx | 628 +++++++++++++----- src/features/profile/ProfileView.tsx | 2 + src/lib/chat-encryption.ts | 453 +++++++++++-- src/lib/chat-wire-v2.ts | 305 +++++++++ src/tests/chat-wire-v2.test.ts | 193 ++++++ src/tests/matches-state.test.ts | 20 +- 13 files changed, 1700 insertions(+), 268 deletions(-) delete mode 100644 found.txt delete mode 100644 served.js create mode 100644 src/components/profile/DeviceManagementCard.tsx create mode 100644 src/lib/chat-wire-v2.ts create mode 100644 src/tests/chat-wire-v2.test.ts diff --git a/found.txt b/found.txt deleted file mode 100644 index e69de29..0000000 diff --git a/package-lock.json b/package-lock.json index 5d6da54..703ca7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "constructs": "^10.6.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", - "onehook-api-client": "file:../OneHookBackend/packages/api-models/build/smithy/source/typescript-codegen", + "onehook-api-client": "^1.0.6", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", @@ -8491,8 +8491,9 @@ } }, "node_modules/onehook-api-client": { - "version": "1.0.5", - "resolved": "file:../OneHookBackend/packages/api-models/build/smithy/source/typescript-codegen", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.6.tgz", + "integrity": "sha512-o95ZqBy3I0v9iijCxPJYlukOvOWM6D+4GPMwKb04wmUnQS+M05YqR9t8q/pwq4exWM681uzQcwXwVyiomBw/dQ==", "license": "UNLICENSED", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", diff --git a/package.json b/package.json index 91de690..216e005 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "dependencies": { "@aws-amplify/ui-react": "^6.15.5", "@aws-amplify/ui-react-liveness": "^3.6.8", - "onehook-api-client": "file:../OneHookBackend/packages/api-models/build/smithy/source/typescript-codegen", "@rolldown/pluginutils": "^1.0.1", "@smithy/fetch-http-handler": "^5.6.13", "@tailwindcss/vite": "^4.1.14", @@ -44,6 +43,7 @@ "constructs": "^10.6.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "onehook-api-client": "^1.0.6", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", diff --git a/served.js b/served.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/api/chat.ts b/src/api/chat.ts index 14eb9a7..6dd1945 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -1,5 +1,6 @@ import { generateClient } from 'aws-amplify/api'; import { sdkClient } from './sdk-client'; +import { apiBaseUrl } from '../utils/env.config'; import type { Message as ChatMessage, MessageReceipt, @@ -251,6 +252,105 @@ export const ChatMessagingApi = { }, }; +// ───────────────────────────────────────────────────────────────────────────── +// Multi-device registry (wire v2) +// +// The generated Smithy SDK does not (yet) model the `/chat/devices` routes, so these are issued as +// raw authenticated REST calls against the same API domain and Cognito authorizer the SDK uses. The +// request shape mirrors the SDK's `sdk-client.ts` middleware exactly: a Cognito JWT Bearer token and +// an `X-User-Id` header derived from its `sub`. Kept deliberately thin — no plaintext ever crosses +// this boundary; only public device/history keys and ciphertext envelopes do. +// ───────────────────────────────────────────────────────────────────────────── + +export interface DeviceRecord { + deviceId: string; + /** Base64 SPKI (P-256 ECDH) public key of the device. */ + publicKey: string; + platform: string; + displayName?: string; + maxEnvelopeVersion: number; + createdAt?: number; + lastSeenAt?: number; + revoked?: boolean; +} + +export interface AccountHistoryKey { + keyId: string; + /** Base64 SPKI (P-256 ECDH) public key of the account-history key. */ + publicKey: string; +} + +export interface DeviceRegistry { + devices: DeviceRecord[]; + accountHistoryKey?: AccountHistoryKey; +} + +export interface RegisterDeviceRequest { + deviceId: string; + publicKey: string; + platform: string; + displayName?: string; + maxEnvelopeVersion: number; + accountHistoryKey?: AccountHistoryKey; +} + +export interface RegisterDeviceResponse { + device: DeviceRecord; + accountHistoryKey?: AccountHistoryKey; + ownsAccountHistoryKey: boolean; +} + +async function chatRestBaseUrl(): Promise { + if (apiBaseUrl && apiBaseUrl.startsWith('http')) return apiBaseUrl.replace(/\/+$/, ''); + if (typeof window !== 'undefined' && window.location?.origin) { + const origin = window.location.origin; + return apiBaseUrl ? `${origin}/${apiBaseUrl.replace(/^\/+|\/+$/g, '')}` : origin; + } + return apiBaseUrl || ''; +} + +async function chatAuthHeaders(): Promise> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (typeof window === 'undefined') return headers; + try { + const { fetchAuthSession } = await import('aws-amplify/auth'); + const session = await fetchAuthSession(); + const token = + session.tokens?.idToken?.toString() || session.tokens?.accessToken?.toString() || null; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + const sub = + (session.tokens?.idToken?.payload?.sub as string) || + (session.tokens?.accessToken?.payload?.sub as string) || + ''; + if (sub) headers['X-User-Id'] = sub; + } + } catch { + // Unauthenticated callers get no Authorization header; the API rejects them, which surfaces as + // a thrown error below rather than a silent success. + } + return headers; +} + +async function chatRest(method: string, path: string, body?: unknown): Promise { + const base = await chatRestBaseUrl(); + const headers = await chatAuthHeaders(); + const response = await fetch(`${base}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `chat device request failed (${response.status} ${response.statusText})${text ? `: ${text}` : ''}` + ); + } + if (response.status === 204) return undefined as T; + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + /** * E2EE key-server + match management, served over REST via the generated SDK. */ @@ -274,4 +374,31 @@ export const ChatApi = { /** Hard-delete all messages for a match (PREMIUM, e.g. on unmatch). */ deleteMatchMessages: (matchId: string) => sdkClient.deleteMatchMessages({ matchId }), + + // --- Multi-device registry (wire v2) --- + + /** + * Register (or refresh) this device in the caller's device registry and, on first registration, + * propose an account-history key. Returns the caller's full device list plus the canonical + * account-history key and whether the proposed key was accepted. + */ + registerDevice: (request: RegisterDeviceRequest): Promise => + chatRest('POST', '/chat/devices', request), + + /** Fetch the caller's own device registry. */ + getSelfDevices: (): Promise => chatRest('GET', '/chat/devices'), + + /** + * Fetch a peer's device registry for a shared match. The server authorizes the read against a + * State-verified mutual match, so `matchId` is required. + */ + getPeerDevices: (userId: string, matchId: string): Promise => + chatRest( + 'GET', + `/chat/registry/${encodeURIComponent(userId)}?matchId=${encodeURIComponent(matchId)}` + ), + + /** Revoke (remove) a device from the caller's registry. */ + revokeDevice: (deviceId: string): Promise => + chatRest('DELETE', `/chat/devices/${encodeURIComponent(deviceId)}`), }; diff --git a/src/app/AppContent.tsx b/src/app/AppContent.tsx index 58e1560..b08dc64 100644 --- a/src/app/AppContent.tsx +++ b/src/app/AppContent.tsx @@ -15,12 +15,11 @@ import { useToast } from '../../src/components/common/Toast'; import { MediaImage } from '../components/common/MediaImage'; import { DiscoveryView } from '../features/discovery/DiscoveryView'; import { MatchesView } from '../features/matches/MatchesView'; -import { ChatView } from '../features/chat/ChatView'; import { ProfileView } from '../features/profile/ProfileView'; export function AppContent() { const navigate = useNavigate(); - const [appState, setAppState] = useState<'DISCOVERY' | 'MATCHES' | 'CHAT' | 'PROFILE'>('DISCOVERY'); + const [appState, setAppState] = useState<'DISCOVERY' | 'MATCHES' | 'PROFILE'>('DISCOVERY'); const { currentUser, setCurrentUser, logout, userState } = useAppStore(); const { profile, @@ -96,7 +95,7 @@ export function AppContent() { if (result.matched) { showToast("It's a match! 🎉", 'success'); setActiveMatchId(result.matchId || null); - setAppState('CHAT'); + setAppState('MATCHES'); } else if (direction === 'RIGHT') { showToast( 'Your interest is on its way — we’ll let you know if they feel the same.', @@ -175,9 +174,9 @@ export function AppContent() { } return ( -
+
{(profileError || candidatesError) && ( -
+
{profileError?.message || candidatesError?.message || 'We’re having trouble loading the latest updates. Here’s what we have for now.'} @@ -185,7 +184,7 @@ export function AppContent() { )} {/* Top Navigation Bar */} -
-
diff --git a/src/components/profile/DeviceManagementCard.tsx b/src/components/profile/DeviceManagementCard.tsx new file mode 100644 index 0000000..e73c883 --- /dev/null +++ b/src/components/profile/DeviceManagementCard.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Loader, MonitorSmartphone, ShieldCheck, History, Trash2 } from 'lucide-react'; +import { ChatEncryptionManager, type DeviceOverview } from '../../lib/chat-encryption'; +import { useAppStore } from '../../store/app-store'; +import { useToast } from '../common/Toast'; + +/** + * Shows the devices registered for end-to-end encrypted chat (wire v2), the account-history key + * status, and lets the member revoke a device. Revoking removes the device's key from the registry + * so future messages are no longer wrapped to it. + */ +export function DeviceManagementCard() { + const { showToast } = useToast(); + const { currentUser } = useAppStore(); + const userId = currentUser?.id; + + const manager = useMemo( + () => (userId ? new ChatEncryptionManager(userId) : null), + [userId] + ); + + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [revoking, setRevoking] = useState(null); + + const load = useCallback(async () => { + if (!manager) return; + try { + setLoading(true); + setError(null); + // Ensure this device is registered before reading the registry, so a brand-new device shows + // up in its own list. + await manager.initialize().catch(() => { + /* registration is best-effort; still show whatever the registry returns */ + }); + const data = await manager.getDeviceOverview(); + setOverview(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not load your devices.'); + } finally { + setLoading(false); + } + }, [manager]); + + useEffect(() => { + void load(); + }, [load]); + + const handleRevoke = useCallback( + async (deviceId: string) => { + if (!manager) return; + try { + setRevoking(deviceId); + await manager.revokeDevice(deviceId); + showToast('Device revoked.', 'success'); + await load(); + } catch (err) { + showToast(err instanceof Error ? err.message : 'Could not revoke device.', 'error'); + } finally { + setRevoking(null); + } + }, + [manager, showToast, load] + ); + + const historyLabel = useMemo(() => { + switch (overview?.historyStatus) { + case 'holding': + return { + text: 'This device holds your history key — it can restore past chats.', + tone: 'text-green-600', + }; + case 'future-only': + return { + text: 'History key established on another device. New messages sync; older ones stay on the original device.', + tone: 'text-amber-600', + }; + default: + return { text: 'No account history key established yet.', tone: 'opacity-50' }; + } + }, [overview?.historyStatus]); + + return ( +
+
+ +
+
+ Chat Devices +
+

+ Devices that can read your end-to-end encrypted chats. Revoke any you no longer use. +

+
+
+ + {/* History key status */} +
+ +
+ + History Recovery Key + +

{historyLabel.text}

+
+
+ + {/* Device list */} +
+ + Active Devices + + + {loading ? ( +
+ Loading devices… +
+ ) : error ? ( +

{error}

+ ) : !overview?.devices.length ? ( +

No devices registered yet.

+ ) : ( +
    + {overview.devices.map((device) => ( +
  • +
    +
    + + {device.displayName || `${device.platform} device`} + + {device.isCurrentDevice && ( + + This device + + )} + {device.revoked && ( + + Revoked + + )} +
    +
    + {device.platform} · v{device.maxEnvelopeVersion} · {device.deviceId.slice(0, 8)} +
    +
    + {!device.isCurrentDevice && !device.revoked && ( + + )} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/features/matches/MatchesView.tsx b/src/features/matches/MatchesView.tsx index 2b801a2..56a8448 100644 --- a/src/features/matches/MatchesView.tsx +++ b/src/features/matches/MatchesView.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'motion/react'; +import { MessageStatus } from 'onehook-api-client/graphql'; import { HeartHandshake, MessageCircle, @@ -10,11 +11,16 @@ import { MapPin, Briefcase, AlertTriangle, + ArrowLeft, + Info, + Send, } from 'lucide-react'; -import type { UserProfile, UserStateSnapshot, Match } from '../../types'; +import type { UserProfile, UserStateSnapshot, ChatMessageDTO } from '../../types'; import { UserState, SubscriptionTier } from '../../types'; import { StateApi } from '../../api/state'; import { ProfileApi } from '../../api/profile'; +import { useChatMessages } from '../../hooks/use-api'; +import { ApiError } from '../../lib/api-client'; import { LoadingSpinner } from '../../components/common/LoadingSpinner'; import { MediaImage } from '../../components/common/MediaImage'; import { FALLBACK_PROFILE_IMAGE } from '../../utils/profile-image'; @@ -32,20 +38,30 @@ export interface HydratedMatch { export function MatchesView({ currentUser, userState, - onOpenChat, + activeMatchId: initialActiveMatchId, + onSelectMatch, onRefetchState, onNavigateToDiscovery, }: { key?: string; currentUser: UserProfile; userState: UserStateSnapshot | null; - onOpenChat: (matchId: string) => void; + activeMatchId?: string | null; + onSelectMatch?: (matchId: string | null) => void; onRefetchState?: () => Promise; onNavigateToDiscovery: () => void; }) { const { showToast } = useToast(); const [matches, setMatches] = useState([]); const [loading, setLoading] = useState(true); + const [selectedMatchId, setSelectedMatchId] = useState( + initialActiveMatchId || null + ); + const [showMobileChat, setShowMobileChat] = useState( + Boolean(initialActiveMatchId) + ); + const [showProfileDetails, setShowProfileDetails] = useState(false); + const [unhookingMatchId, setUnhookingMatchId] = useState(null); const [unhookReason, setUnhookReason] = useState('NOT_A_FIT'); const [customReason, setCustomReason] = useState(''); @@ -132,6 +148,32 @@ export function MatchesView({ void loadMatches(); }, [loadMatches]); + // Sync selected match with prop changes or list updates + useEffect(() => { + if (initialActiveMatchId) { + setSelectedMatchId(initialActiveMatchId); + setShowMobileChat(true); + } else if (matches.length > 0 && !selectedMatchId) { + const firstId = matches[0].matchId; + setSelectedMatchId(firstId); + onSelectMatch?.(firstId); + } else if (matches.length === 0) { + setSelectedMatchId(null); + onSelectMatch?.(null); + setShowMobileChat(false); + } + }, [initialActiveMatchId, matches, selectedMatchId, onSelectMatch]); + + const activeMatch = matches.find((m) => m.matchId === selectedMatchId) || null; + const peerProfile = activeMatch?.peerProfile; + const recipientId = activeMatch?.peerId; + + const handleSelectMatch = (matchId: string) => { + setSelectedMatchId(matchId); + setShowMobileChat(true); + onSelectMatch?.(matchId); + }; + const handleUnhook = async () => { if (!unhookingMatchId) return; @@ -140,6 +182,15 @@ export function MatchesView({ const reason = unhookReason === 'OTHER' ? customReason.trim() : unhookReason; await StateApi.releaseHook(unhookingMatchId, reason); showToast('Connection released. You are now available for new connections.', 'info'); + + if (selectedMatchId === unhookingMatchId) { + const remaining = matches.filter((m) => m.matchId !== unhookingMatchId); + const nextId = remaining.length > 0 ? remaining[0].matchId : null; + setSelectedMatchId(nextId); + onSelectMatch?.(nextId); + if (!nextId) setShowMobileChat(false); + } + setUnhookingMatchId(null); setCustomReason(''); if (onRefetchState) { @@ -169,185 +220,246 @@ export function MatchesView({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="flex-1 flex flex-col bg-[#F9F9F9] overflow-y-auto" + className="flex-1 flex w-full h-full overflow-hidden bg-bg min-h-0" > - {/* Connection Status Banner */} -
-
-
-
+ {/* Master Column: Connections / Matches Sidebar */} + + + {/* Detail Column: Conversation & Profile */} +
+ {selectedMatchId && activeMatch ? ( +
+ {/* Conversation Area */} + setShowMobileChat(false)} + onToggleProfile={() => setShowProfileDetails((prev) => !prev)} + onUnhookClick={() => setUnhookingMatchId(selectedMatchId)} + /> + + {/* Peer Profile Inspector Panel (Desktop Side or Drawer) */} + + {(showProfileDetails || window.innerWidth >= 1280) && ( + +
+
+
-
- Hooked -
+
+
+ Hooked +
+
-
-
-
-

{displayName}

- {profile?.age && ( - , {profile.age} - )} - {profile?.verified && ( - - )} -
- {profile?.currentLocation && ( -
- - {profile.currentLocation} -
- )} - {profile?.work && ( -
- - {profile.work} -
- )} -
- - {profile?.bio ? ( -

- {profile.bio} -

- ) : ( -

- Connected on {formatMatchDate(m.createdAt)} -

+
+
+

+ {peerProfile?.displayName || peerProfile?.name || 'Your Match'} +

+ {peerProfile?.age && ( + + , {peerProfile.age} + + )} + {peerProfile?.verified && ( + )} +
-
- Match ID: {m.matchId.substring(0, 8)} + {peerProfile?.currentLocation && ( +
+ + {peerProfile.currentLocation}
-
+ )} + + {peerProfile?.work && ( +
+ + {peerProfile.work} +
+ )} + + {peerProfile?.bio && ( +

+ “{peerProfile.bio}” +

+ )}
-
- - - +
+
+ Focus Mode +
+

+ Discovery is paused while connected so you can engage with full intent. +

- ); - })} + +
+ +
+ + )} + +
+ ) : ( +
+
+
+ +
+
+

Select a Connection

+

+ Choose a match from the list on the left to start messaging. +

+
)} -
+
{/* Unhook Confirmation Modal */} @@ -423,3 +535,215 @@ export function MatchesView({ ); } + +/** + * Inner conversation component managing chat messages and input for the selected match + */ +function ChatConversationPanel({ + matchId, + peerId, + peerProfile, + onBack, + onToggleProfile, + onUnhookClick, +}: { + matchId: string; + peerId?: string; + peerProfile?: UserProfile | null; + onBack: () => void; + onToggleProfile: () => void; + onUnhookClick: () => void; +}) { + const { messages, loading, error, sendMessage, markAsDelivered, markAsRead } = useChatMessages( + matchId, + peerId + ); + const [input, setInput] = useState(''); + const { showToast } = useToast(); + const messagesEndRef = useRef(null); + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Mark messages as delivered + useEffect(() => { + messages + .filter((m) => m.senderId !== 'me' && m.status === 'SENT') + .forEach((m) => markAsDelivered(m.messageId)); + }, [messages, markAsDelivered]); + + // Mark messages as read when viewing + useEffect(() => { + const timer = setTimeout(() => { + messages + .filter((m) => m.senderId !== 'me' && m.status === 'DELIVERED') + .forEach((m) => markAsRead(m.messageId)); + }, 1000); + + return () => clearTimeout(timer); + }, [messages, markAsRead]); + + const handleSend = async () => { + if (!input.trim() || !matchId) return; + + try { + await sendMessage(input); + setInput(''); + } catch (error) { + if (error instanceof ApiError) { + showToast(error.message, 'error'); + } + } + }; + + const getMessageStatusIcon = (message: ChatMessageDTO) => { + if (message.senderId !== 'me') return null; + + switch (message.status) { + case MessageStatus.Sending: + return ⏱; + case MessageStatus.Sent: + return ✓; + case MessageStatus.Delivered: + return ✓✓; + case MessageStatus.Read: + return ✓✓; + case MessageStatus.Failed: + return ✗; + default: + return null; + } + }; + + const peerName = peerProfile?.displayName || peerProfile?.name || 'Your Match'; + + return ( +
+ {/* Conversation Header */} +
+
+ + +
+
+

+ {peerName} +

+ {peerProfile?.verified && ( + + )} +
+
+ End-to-End Encrypted +
+
+
+ +
+ + +
+
+ + {/* Message History */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+

{error.message}

+
+ ) : messages.length === 0 ? ( +
+

+ No messages yet. Say hello to {peerName} whenever you’re ready. +

+
+ ) : ( + messages.map((m) => ( +
+
+ {m.senderId === 'me' ? 'You' : peerName} + {getMessageStatusIcon(m)} +
+
+ {m.ciphertext} + {m.status === 'FAILED' && ( +
+ Didn’t send + +
+ )} +
+
+ )) + )} +
+
+ + {/* Input Composer */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + type="text" + placeholder="Write a message…" + className="w-full py-3 pr-20 pl-0 border-b border-accent focus:border-b-2 transition-all outline-none text-sm bg-transparent placeholder:opacity-30 italic font-serif" + /> + +
+
+ Connected + One Connection at a Time +
+
+
+ ); +} diff --git a/src/features/profile/ProfileView.tsx b/src/features/profile/ProfileView.tsx index 8adf19a..7602416 100644 --- a/src/features/profile/ProfileView.tsx +++ b/src/features/profile/ProfileView.tsx @@ -21,6 +21,7 @@ import type { UserProfile } from '../../types'; import { ProfileApi } from '../../api/profile'; import { SetPasswordCard } from '../../components/profile/SetPasswordCard'; import { AccountSettingsCard } from '../../components/profile/AccountSettingsCard'; +import { DeviceManagementCard } from '../../components/profile/DeviceManagementCard'; import { usePreferences } from '../../hooks/use-api'; import { isPremium, useAppStore } from '../../store/app-store'; import { useToast } from '../../components/common/Toast'; @@ -1489,6 +1490,7 @@ export function ProfileView({ )} + diff --git a/src/lib/chat-encryption.ts b/src/lib/chat-encryption.ts index 2d49c66..650c269 100644 --- a/src/lib/chat-encryption.ts +++ b/src/lib/chat-encryption.ts @@ -1,46 +1,105 @@ -import { ChatApi } from '../api/chat'; +import { + ChatApi, + type AccountHistoryKey, + type DeviceRecord, + type DeviceRegistry, +} from '../api/chat'; +import { + decryptV2, + deviceTargetId, + encryptV2, + historyTargetId, + isV2Envelope, + type WireRecipient, + type WireTarget, +} from './chat-wire-v2'; /** * End-to-end message encryption for a match conversation. * - * Scheme (wire version 1): + * Wire version 1 (legacy, retained for interop): * - Each member owns a long-lived ECDH P-256 identity key pair. The private key is generated * non-extractable by WebCrypto and persisted as a `CryptoKey` in IndexedDB, so it is never * serialisable to JavaScript, never written to `localStorage`, and never leaves the device. * - The public key is published to the chat key server (`POST /chat/prekeys`). * - A conversation key is derived with ECDH against the peer's published identity key, then - * HKDF-SHA256 (salt = matchId) into a 256-bit AES-GCM key. - * - Every message uses a fresh 96-bit random IV and carries `matchId` as additional authenticated - * data, so ciphertext cannot be replayed into a different conversation. AES-GCM is - * authenticated: tampering fails decryption rather than yielding altered plaintext. + * HKDF-SHA256 (salt = matchId) into a 256-bit AES-GCM key. This binds a conversation to a single + * device, so a member could only read a match from the device that generated that key. * - * Security properties, stated precisely: - * - The server stores only ciphertext and cannot read message content. - * - Integrity and conversation-binding are cryptographically enforced. - * - There is NO forward secrecy: the conversation key is static for a match, so compromise of an - * identity private key exposes that conversation's history. A Double Ratchet upgrade is the - * intended next step; the key server already accepts the signed and one-time pre-keys uploaded - * below, which that upgrade requires. - * - Peer public keys are obtained from the key server, so a malicious server could substitute its - * own key. Detecting that requires out-of-band key verification (safety numbers), which this - * client does not yet offer. + * Wire version 2 (multi-device, see chat-wire-v2.ts): + * - Each device owns its own non-extractable P-256 ECDH "device key", registered with the server + * (`POST /chat/devices`) under a random `deviceId`. A message is encrypted once with a random + * AES-256 content key, and that content key is wrapped separately for every registered device of + * BOTH members plus each member's account-history key. Every device registered at send time can + * read the message immediately. A later device is future-only until it securely obtains the + * account-history private key; this module never derives that private key from public account + * identifiers or backend-known material. + * - The first device to register may propose an account-history key. The server returns the + * canonical account-history key and whether the proposal was accepted. If accepted, this device + * keeps the private key and can decrypt history wraps. If a different device already owns it, this + * device keeps only the public key ("future-only") — it can wrap NEW messages to history but + * cannot decrypt history-wrapped messages it was not a recipient of. + * + * Security properties: + * - The server stores only ciphertext and public keys; it never sees plaintext. + * - AES-GCM is authenticated and the message AAD binds ciphertext to its matchId, so ciphertext + * cannot be replayed into a different conversation. + * - There is NO forward secrecy: the account-history key is long-lived, which is the deliberate + * tradeoff that enables multi-device history recovery. + * - Peer keys come from the server, so a malicious server could substitute keys. Detecting that + * requires out-of-band verification (safety numbers), which this client does not yet offer. * * Every failure path throws. Nothing here degrades to plaintext, and no branch returns an * unencrypted message as if it had been protected. */ const DB_NAME = 'onehook-e2ee'; -const DB_VERSION = 1; +// v2 adds the device identity + account-history fields to the identity record. The store keyPath is +// unchanged (`userId`), so existing v1 records are migrated in place on read rather than dropped. +const DB_VERSION = 2; const STORE_NAME = 'identity-keys'; -const WIRE_VERSION = 1; +const WIRE_VERSION_V1 = 1; const IV_BYTES = 12; const HKDF_INFO = 'onehook-chat-v1'; const ONE_TIME_PREKEY_COUNT = 5; +const MAX_ENVELOPE_VERSION = 2; +const PLATFORM = 'WEB'; + +export type HistoryKeyStatus = 'none' | 'holding' | 'future-only'; interface StoredIdentity { userId: string; + // v1 identity key (retained for v1 decrypt / fallback send). privateKey: CryptoKey; publicKey: CryptoKey; + // v2 device identity. + deviceId?: string; + devicePrivateKey?: CryptoKey; + devicePublicKey?: CryptoKey; + // Account-history key. `historyPrivateKey` is present only when this device holds the canonical + // history private key; otherwise the key is "future-only" (public wrap target without decrypt). + historyKeyId?: string; + historyPrivateKey?: CryptoKey; + historyPublicKey?: CryptoKey; + historyFutureOnly?: boolean; +} + +export interface DeviceSummary { + deviceId: string; + platform: string; + displayName?: string; + maxEnvelopeVersion: number; + createdAt?: number; + lastSeenAt?: number; + revoked?: boolean; + isCurrentDevice: boolean; +} + +export interface DeviceOverview { + devices: DeviceSummary[]; + currentDeviceId?: string; + historyStatus: HistoryKeyStatus; + historyKeyId?: string; } function subtle(): SubtleCrypto { @@ -53,6 +112,12 @@ function subtle(): SubtleCrypto { return api; } +function randomId(): string { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + function toBase64(bytes: ArrayBuffer): string { const view = new Uint8Array(bytes); let binary = ''; @@ -113,10 +178,27 @@ async function exportPublicKey(key: CryptoKey): Promise { return toBase64(await subtle().exportKey('spki', key)); } +async function importEcdhPublicKey(spkiBase64: string): Promise { + return subtle().importKey( + 'spki', + fromBase64(spkiBase64) as unknown as ArrayBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); +} + +function isUsableV2Device(device: DeviceRecord): boolean { + return !device.revoked && device.maxEnvelopeVersion >= 2 && Boolean(device.publicKey); +} + export class ChatEncryptionManager { private readonly userId: string; private identity: Promise | null = null; + private registration: Promise | null = null; private readonly conversationKeys = new Map>(); + private readonly peerRegistries = new Map>(); + private selfRegistry: Promise | null = null; constructor(userId: string) { if (!userId) throw new Error('A userId is required to encrypt messages.'); @@ -124,9 +206,9 @@ export class ChatEncryptionManager { } /** - * Loads (or creates) this device's identity key pair and publishes the public key material the - * key server needs. Safe to call repeatedly: the identity key is reused, while the signed and - * one-time pre-keys are replenished. + * Loads (or creates and migrates) this device's identity, publishes v1 pre-key material, and + * registers the device for wire v2 (including proposing an account-history key on first run). + * Safe to call repeatedly. */ async initialize(): Promise { const identity = await this.loadIdentity(); @@ -141,26 +223,300 @@ export class ChatEncryptionManager { await exportPublicKey(signedPreKey.publicKey), await Promise.all(oneTimePreKeys.map((pair) => exportPublicKey(pair.publicKey))) ); + + // Register the device for v2. Non-fatal: a registry failure must not break v1 messaging. + await this.ensureDeviceRegistered().catch((error) => { + console.warn('Chat device registration failed; continuing with v1 only:', error); + }); } /** - * Ensures the E2E session for a match exists, claiming the peer's pre-key bundle if needed. - * - *

This must be called when a conversation is OPENED, not only when a message is - * encrypted/decrypted. Claiming the bundle is what initializes the chat server-side (it writes the - * ChatTable TAIL marker), and the AppSync `getMessages` resolver refuses to read a conversation - * whose TAIL is absent (`verifyMatch` → unauthorized). Because the lazy claim previously happened - * only inside encrypt/decrypt, a brand-new match deadlocked: `getMessages` failed, the UI rendered - * the error state instead of a composer, so no message could ever be sent to trigger the claim. - * Establishing the session up front breaks that cycle. Safe to call repeatedly — the derived key is - * cached per match+peer. + * Ensures the E2E session for a match exists. For v1 this claims the peer's pre-key bundle (which + * initializes the conversation server-side). For v2 this pre-warms the peer device registry. + * Safe to call repeatedly. */ async ensureSession(peerId: string, matchId: string): Promise { - await this.conversationKey(peerId, matchId); + // Claiming the v1 bundle writes the ChatTable TAIL marker the read path requires, so keep doing + // it even when the peer supports v2. + await this.conversationKey(peerId, matchId).catch((error) => { + console.warn('Could not establish v1 chat session:', error); + }); + await this.peerRegistry(peerId, matchId).catch(() => { + /* peer may not support v2 yet; v1 path remains available */ + }); } - /** Encrypts one message for a peer inside a match. */ + /** Encrypts one message for a peer inside a match, using v2 when the peer supports it. */ async encryptMessage(peerId: string, matchId: string, plaintext: string): Promise { + if (!peerId) throw new Error('A peer id is required to derive the conversation key.'); + if (!matchId) throw new Error('A matchId is required to derive the conversation key.'); + + const targets = await this.collectV2Targets(peerId, matchId); + if (targets.length) { + return encryptV2(plaintext, matchId, targets); + } + // A successful registry response confirmed that the peer has no usable v2 device. Network, + // authentication, and server failures propagate above rather than silently creating a v1 + // message that a known multi-device peer may be unable to open. + return this.encryptV1(peerId, matchId, plaintext); + } + + /** Decrypts one message from a peer inside a match. Throws when authentication fails. */ + async decryptMessage(peerId: string, matchId: string, ciphertext: string): Promise { + if (isV2Envelope(ciphertext)) { + const recipients = await this.v2Recipients(); + return decryptV2(ciphertext, matchId, recipients); + } + return this.decryptV1(peerId, matchId, ciphertext); + } + + // ── Device management (for the account settings UI) ──────────────────────────────────────────── + + /** Returns the caller's registered devices and account-history status. */ + async getDeviceOverview(): Promise { + const identity = await this.loadIdentity(); + const registry = await this.selfRegistryFresh(); + const historyStatus: HistoryKeyStatus = identity.historyPrivateKey + ? 'holding' + : identity.historyKeyId + ? 'future-only' + : 'none'; + return { + currentDeviceId: identity.deviceId, + historyStatus, + historyKeyId: identity.historyKeyId, + devices: (registry.devices ?? []).map((device) => ({ + deviceId: device.deviceId, + platform: device.platform, + displayName: device.displayName, + maxEnvelopeVersion: device.maxEnvelopeVersion, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + revoked: device.revoked, + isCurrentDevice: device.deviceId === identity.deviceId, + })), + }; + } + + /** Revokes a device from the caller's registry and refreshes the cached self registry. */ + async revokeDevice(deviceId: string): Promise { + if (!deviceId) throw new Error('A deviceId is required to revoke a device.'); + await ChatApi.revokeDevice(deviceId); + this.selfRegistry = null; + } + + // ── v2 internals ─────────────────────────────────────────────────────────────────────────────── + + private async collectV2Targets(peerId: string, matchId: string): Promise { + const peerRegistry = await this.peerRegistry(peerId, matchId); + const peerDevices = (peerRegistry.devices ?? []).filter(isUsableV2Device); + if (!peerDevices.length) return []; + + const selfRegistry = await this.selfRegistryFresh(); + const selfDevices = (selfRegistry.devices ?? []).filter(isUsableV2Device); + const identity = await this.loadIdentity(); + + const targets: WireTarget[] = []; + if (identity.deviceId && identity.devicePublicKey) { + targets.push({ + id: deviceTargetId(identity.deviceId), + publicKey: await exportPublicKey(identity.devicePublicKey), + }); + } + for (const device of selfDevices) { + targets.push({ id: deviceTargetId(device.deviceId), publicKey: device.publicKey }); + } + for (const device of peerDevices) { + targets.push({ id: deviceTargetId(device.deviceId), publicKey: device.publicKey }); + } + // Both members' account-history keys, so either member can recover this message on a new device. + if (selfRegistry.accountHistoryKey?.publicKey) { + targets.push({ + id: historyTargetId(this.userId, selfRegistry.accountHistoryKey.keyId), + publicKey: selfRegistry.accountHistoryKey.publicKey, + }); + } + if (peerRegistry.accountHistoryKey?.publicKey) { + targets.push({ + id: historyTargetId(peerId, peerRegistry.accountHistoryKey.keyId), + publicKey: peerRegistry.accountHistoryKey.publicKey, + }); + } + return targets; + } + + private async v2Recipients(): Promise { + const identity = await this.loadIdentity(); + const recipients: WireRecipient[] = []; + // Own device key first, account-history key second (device-preferred decryption). + if (identity.deviceId && identity.devicePrivateKey) { + recipients.push({ + id: deviceTargetId(identity.deviceId), + privateKey: identity.devicePrivateKey, + }); + } + if (identity.historyKeyId && identity.historyPrivateKey) { + recipients.push({ + id: historyTargetId(this.userId, identity.historyKeyId), + privateKey: identity.historyPrivateKey, + }); + } + return recipients; + } + + private peerRegistry(peerId: string, matchId: string): Promise { + const cacheKey = `${matchId}:${peerId}`; + const cached = this.peerRegistries.get(cacheKey); + if (cached) return cached; + const pending = ChatApi.getPeerDevices(peerId, matchId).catch((error) => { + this.peerRegistries.delete(cacheKey); + throw error; + }); + this.peerRegistries.set(cacheKey, pending); + return pending; + } + + private selfRegistryFresh(): Promise { + if (this.selfRegistry) return this.selfRegistry; + const pending = ChatApi.getSelfDevices().catch((error) => { + this.selfRegistry = null; + throw error; + }); + this.selfRegistry = pending; + return pending; + } + + private ensureDeviceRegistered(): Promise { + if (this.registration) return this.registration; + this.registration = (async () => { + const db = await openDatabase(); + const identity = await this.loadIdentity(); + if (!identity.deviceId || !identity.devicePublicKey) { + throw new Error('Device identity is missing after migration.'); + } + + // Propose a fresh account-history key only if this account has none yet on this device. + let proposal: AccountHistoryKey | undefined; + let proposedPair: CryptoKeyPair | undefined; + if (!identity.historyKeyId) { + proposedPair = await generateKeyPair(); + proposal = { + keyId: randomId(), + publicKey: await exportPublicKey(proposedPair.publicKey), + }; + } + + const response = await ChatApi.registerDevice({ + deviceId: identity.deviceId, + publicKey: await exportPublicKey(identity.devicePublicKey), + platform: PLATFORM, + displayName: this.defaultDisplayName(), + maxEnvelopeVersion: MAX_ENVELOPE_VERSION, + accountHistoryKey: proposal, + }); + + // Reconcile the account-history key against the server's canonical answer. + if (proposal && proposedPair) { + if (response.ownsAccountHistoryKey && response.accountHistoryKey?.keyId === proposal.keyId) { + // Accepted: this device owns the canonical history key and can decrypt history wraps. + identity.historyKeyId = proposal.keyId; + identity.historyPrivateKey = proposedPair.privateKey; + identity.historyPublicKey = proposedPair.publicKey; + identity.historyFutureOnly = false; + await writeIdentity(db, identity); + } else if (response.accountHistoryKey?.publicKey) { + // Rejected: a different device owns history. Discard our private key and retain only the + // canonical public key so we can still wrap NEW messages to it ("future-only"). + identity.historyKeyId = response.accountHistoryKey.keyId; + identity.historyPrivateKey = undefined; + identity.historyPublicKey = await importEcdhPublicKey( + response.accountHistoryKey.publicKey + ); + identity.historyFutureOnly = true; + await writeIdentity(db, identity); + } + } + + // POST /chat/devices intentionally returns only the registered device, not the full list. + // Read the authoritative registry after persisting any accepted key so subsequent sends fan + // out to all sender devices and the management UI is complete. + this.selfRegistry = Promise.resolve(await ChatApi.getSelfDevices()); + })().catch((error) => { + this.registration = null; + throw error; + }); + return this.registration; + } + + private defaultDisplayName(): string { + if (typeof navigator !== 'undefined' && navigator.userAgent) { + const ua = navigator.userAgent; + const platform = + /iPhone|iPad|iPod/.test(ua) + ? 'iOS' + : /Android/.test(ua) + ? 'Android' + : /Macintosh/.test(ua) + ? 'Mac' + : /Windows/.test(ua) + ? 'Windows' + : /Linux/.test(ua) + ? 'Linux' + : 'Web'; + const browser = /Firefox\//.test(ua) + ? 'Firefox' + : /Edg\//.test(ua) + ? 'Edge' + : /Chrome\//.test(ua) + ? 'Chrome' + : /Safari\//.test(ua) + ? 'Safari' + : 'Browser'; + return `${browser} on ${platform}`; + } + return 'Web'; + } + + // ── Identity storage + v1 internals ───────────────────────────────────────────────────────────── + + private async loadIdentity(): Promise { + if (!this.identity) { + this.identity = (async () => { + const db = await openDatabase(); + const existing = await readIdentity(db, this.userId); + if (existing && existing.deviceId && existing.devicePrivateKey) { + return existing; + } + + // Either no record yet, or a v1 record predating multi-device support. In both cases we + // migrate IN PLACE: keep any existing v1 identity key, and add a random deviceId plus a + // fresh non-extractable device key. The account-history key is proposed later, during + // registration, once the server can tell us whether the account already has one. + const v1Pair = existing + ? { privateKey: existing.privateKey, publicKey: existing.publicKey } + : await generateKeyPair(); + const devicePair = await generateKeyPair(); + const identity: StoredIdentity = { + ...(existing ?? {}), + userId: this.userId, + privateKey: v1Pair.privateKey, + publicKey: v1Pair.publicKey, + deviceId: existing?.deviceId ?? randomId(), + devicePrivateKey: devicePair.privateKey, + devicePublicKey: devicePair.publicKey, + }; + await writeIdentity(db, identity); + return identity; + })().catch((error) => { + this.identity = null; + throw error; + }); + } + return this.identity; + } + + /** v1 single-device encryption, retained for peers that have not registered v2 devices. */ + private async encryptV1(peerId: string, matchId: string, plaintext: string): Promise { const key = await this.conversationKey(peerId, matchId); const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES)); const ciphertext = await subtle().encrypt( @@ -170,19 +526,19 @@ export class ChatEncryptionManager { ); const envelope = new Uint8Array(1 + IV_BYTES + ciphertext.byteLength); - envelope[0] = WIRE_VERSION; + envelope[0] = WIRE_VERSION_V1; envelope.set(iv, 1); envelope.set(new Uint8Array(ciphertext), 1 + IV_BYTES); return toBase64(envelope.buffer); } - /** Decrypts one message from a peer inside a match. Throws when authentication fails. */ - async decryptMessage(peerId: string, matchId: string, ciphertext: string): Promise { + /** v1 single-device decryption. */ + private async decryptV1(peerId: string, matchId: string, ciphertext: string): Promise { const envelope = fromBase64(ciphertext); if (envelope.length <= 1 + IV_BYTES) { throw new Error('Message is malformed.'); } - if (envelope[0] !== WIRE_VERSION) { + if (envelope[0] !== WIRE_VERSION_V1) { throw new Error(`Unsupported message encryption version ${envelope[0]}.`); } @@ -199,29 +555,6 @@ export class ChatEncryptionManager { return new TextDecoder().decode(plaintext); } - private async loadIdentity(): Promise { - if (!this.identity) { - this.identity = (async () => { - const db = await openDatabase(); - const existing = await readIdentity(db, this.userId); - if (existing) return existing; - - const pair = await generateKeyPair(); - const identity: StoredIdentity = { - userId: this.userId, - privateKey: pair.privateKey, - publicKey: pair.publicKey, - }; - await writeIdentity(db, identity); - return identity; - })().catch((error) => { - this.identity = null; - throw error; - }); - } - return this.identity; - } - private conversationKey(peerId: string, matchId: string): Promise { if (!peerId) throw new Error('A peer id is required to derive the conversation key.'); if (!matchId) throw new Error('A matchId is required to derive the conversation key.'); diff --git a/src/lib/chat-wire-v2.ts b/src/lib/chat-wire-v2.ts new file mode 100644 index 0000000..808843e --- /dev/null +++ b/src/lib/chat-wire-v2.ts @@ -0,0 +1,305 @@ +/** + * OneHook chat wire format, version 2 (multi-device envelope). + * + * Version 1 (see chat-encryption.ts) bound a conversation to a single ECDH identity key per user, + * so a member could only read a match from the one device that generated that key. Version 2 fans + * a message out to EVERY registered device of both members (plus each member's account-history + * key), so a person can read their conversations from any of their devices and recover history on + * a new device — without the server ever seeing plaintext. + * + * Exact wire contract (do not change without bumping the version byte — peers parse this literally): + * + * envelope = base64( 0x02 || UTF8( canonicalJson(payload) ) ) + * + * payload = { "e": , + * "n": , + * "c": , + * "w": [ wrap, ... ] } + * + * wrap = { "i": , + * "n": , + * "k": } + * + * canonicalJson serialises compactly (no whitespace) with object keys sorted lexicographically, + * recursively — so the byte layout is deterministic and reproducible on every platform. The + * canonical top-level key order is therefore c, e, n, w and every wrap is i, k, n. + * + * Cryptography: + * - One fresh random AES-256 content key encrypts the message body. + * message: AES-GCM, random 12-byte nonce, AAD = "onehook-chat-v2-message\0" || matchId. + * - One fresh ephemeral P-256 key pair is generated per message; its public key (SPKI) is "e". + * - The content key is wrapped once per target. For a target with public key P and id T: + * shared = ECDH(ephemeralPrivate, P) + * wrapKey = HKDF-SHA256(shared, salt = matchId, info = "onehook-chat-v2-wrap\0" || T) -> AES-256 + * "k" = AES-GCM(wrapKey, nonce, contentKeyBytes, AAD = "onehook-chat-v2-wrap\0" || T) + * - Targets are device keys (id "d:") and account-history keys ("h::"). + * + * Every failure path throws. Nothing here degrades to plaintext. + */ + +export const WIRE_VERSION_V2 = 0x02; +const IV_BYTES = 12; +const CONTENT_KEY_BYTES = 32; +const MESSAGE_AAD_PREFIX = 'onehook-chat-v2-message\0'; +const WRAP_INFO_PREFIX = 'onehook-chat-v2-wrap\0'; + +export interface WireTarget { + /** Canonical target id: "d:" for a device, "h::" for a history key. */ + id: string; + /** Target public key, base64-encoded SPKI (P-256 ECDH). */ + publicKey: string; +} + +export interface WireRecipient { + /** Canonical target id this private key can unwrap. */ + id: string; + /** Non-extractable P-256 ECDH private key held by this device. */ + privateKey: CryptoKey; +} + +interface WrapEntry { + i: string; + n: string; + k: string; +} + +interface WirePayload { + e: string; + n: string; + c: string; + w: WrapEntry[]; +} + +/** Canonical target id for a device key. */ +export function deviceTargetId(deviceId: string): string { + return `d:${deviceId}`; +} + +/** Canonical target id for an account-history key. */ +export function historyTargetId(userId: string, keyId: string): string { + return `h:${userId}:${keyId}`; +} + +function subtle(): SubtleCrypto { + const api = globalThis.crypto?.subtle; + if (!api) { + throw new Error( + 'Message encryption is unavailable: this environment has no Web Crypto API (a secure HTTPS context is required).' + ); + } + return api; +} + +export function toBase64(bytes: ArrayBuffer | Uint8Array): string { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ''; + for (const byte of view) binary += String.fromCharCode(byte); + return btoa(binary); +} + +export function fromBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** + * Serialises a JSON value compactly with object keys sorted lexicographically, recursively. This + * is the byte-for-byte canonical form both peers agree on; it must never depend on insertion order. + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(',')}]`; + } + const record = value as Record; + const keys = Object.keys(record).sort(); + const members = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`); + return `{${members.join(',')}}`; +} + +async function importEcdhPublicKey(spkiBase64: string): Promise { + return subtle().importKey( + 'spki', + fromBase64(spkiBase64) as unknown as ArrayBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); +} + +async function deriveWrapKey( + privateKey: CryptoKey, + publicKey: CryptoKey, + matchId: string, + targetId: string +): Promise { + const shared = await subtle().deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256); + const hkdfKey = await subtle().importKey('raw', shared, 'HKDF', false, ['deriveKey']); + return subtle().deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new TextEncoder().encode(matchId), + info: new TextEncoder().encode(WRAP_INFO_PREFIX + targetId), + }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypts a plaintext message for every target and produces the base64 v2 envelope. + * + * @throws if no targets are supplied — a message with no recipients would be silently unreadable. + */ +export async function encryptV2( + plaintext: string, + matchId: string, + targets: WireTarget[] +): Promise { + if (!matchId) throw new Error('A matchId is required to encrypt a message.'); + if (!targets.length) throw new Error('At least one target device is required to encrypt.'); + + // Deduplicate targets by id so a device that appears in both the self and peer registries (or a + // history key listed twice) is wrapped exactly once. + const uniqueTargets = new Map(); + for (const target of targets) { + if (!target.id || !target.publicKey) continue; + if (!uniqueTargets.has(target.id)) uniqueTargets.set(target.id, target); + } + if (!uniqueTargets.size) throw new Error('No valid targets to encrypt to.'); + + const contentKeyBytes = globalThis.crypto.getRandomValues(new Uint8Array(CONTENT_KEY_BYTES)); + const contentKey = await subtle().importKey('raw', contentKeyBytes, 'AES-GCM', false, [ + 'encrypt', + ]); + + const messageIv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const ciphertext = await subtle().encrypt( + { + name: 'AES-GCM', + iv: messageIv, + additionalData: new TextEncoder().encode(MESSAGE_AAD_PREFIX + matchId), + }, + contentKey, + new TextEncoder().encode(plaintext) + ); + + const ephemeral = (await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, [ + 'deriveBits', + ])) as CryptoKeyPair; + const ephemeralSpki = await subtle().exportKey('spki', ephemeral.publicKey); + + const wraps: WrapEntry[] = await Promise.all( + [...uniqueTargets.values()].map(async (target) => { + const targetPublic = await importEcdhPublicKey(target.publicKey); + const wrapKey = await deriveWrapKey(ephemeral.privateKey, targetPublic, matchId, target.id); + const wrapIv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const wrapped = await subtle().encrypt( + { + name: 'AES-GCM', + iv: wrapIv, + additionalData: new TextEncoder().encode(WRAP_INFO_PREFIX + target.id), + }, + wrapKey, + contentKeyBytes + ); + return { i: target.id, n: toBase64(wrapIv), k: toBase64(wrapped) }; + }) + ); + + const payload: WirePayload = { + e: toBase64(ephemeralSpki), + n: toBase64(messageIv), + c: toBase64(ciphertext), + w: wraps, + }; + + const json = new TextEncoder().encode(canonicalJson(payload)); + const envelope = new Uint8Array(1 + json.length); + envelope[0] = WIRE_VERSION_V2; + envelope.set(json, 1); + return toBase64(envelope); +} + +/** True when the base64 envelope carries the v2 version byte. */ +export function isV2Envelope(envelope: string): boolean { + try { + return fromBase64(envelope)[0] === WIRE_VERSION_V2; + } catch { + return false; + } +} + +/** + * Decrypts a v2 envelope. Recipients are tried in the order supplied — callers pass the device key + * first and the account-history key second, so a device decrypts with its own key when possible and + * only falls back to shared history material otherwise. + * + * @throws if the envelope is malformed, the version byte is wrong, or no recipient can unwrap it. + */ +export async function decryptV2( + envelope: string, + matchId: string, + recipients: WireRecipient[] +): Promise { + const raw = fromBase64(envelope); + if (raw.length <= 1) throw new Error('Message is malformed.'); + if (raw[0] !== WIRE_VERSION_V2) { + throw new Error(`Unsupported message encryption version ${raw[0]}.`); + } + + let payload: WirePayload; + try { + payload = JSON.parse(new TextDecoder().decode(raw.subarray(1))) as WirePayload; + } catch { + throw new Error('Message is malformed.'); + } + if (!payload?.e || !payload?.n || !payload?.c || !Array.isArray(payload?.w)) { + throw new Error('Message is malformed.'); + } + + const ephemeralPublic = await importEcdhPublicKey(payload.e); + + for (const recipient of recipients) { + if (!recipient?.id || !recipient?.privateKey) continue; + const wrap = payload.w.find((entry) => entry.i === recipient.id); + if (!wrap) continue; + + try { + const wrapKey = await deriveWrapKey(recipient.privateKey, ephemeralPublic, matchId, wrap.i); + const contentKeyBytes = await subtle().decrypt( + { + name: 'AES-GCM', + iv: fromBase64(wrap.n), + additionalData: new TextEncoder().encode(WRAP_INFO_PREFIX + wrap.i), + }, + wrapKey, + fromBase64(wrap.k) + ); + const contentKey = await subtle().importKey('raw', contentKeyBytes, 'AES-GCM', false, [ + 'decrypt', + ]); + const plaintext = await subtle().decrypt( + { + name: 'AES-GCM', + iv: fromBase64(payload.n), + additionalData: new TextEncoder().encode(MESSAGE_AAD_PREFIX + matchId), + }, + contentKey, + fromBase64(payload.c) + ); + return new TextDecoder().decode(plaintext); + } catch { + // Try the next recipient key (e.g. device key failed, fall back to history key). + } + } + + throw new Error('This message was not encrypted for any of this device’s keys.'); +} diff --git a/src/tests/chat-wire-v2.test.ts b/src/tests/chat-wire-v2.test.ts new file mode 100644 index 0000000..bb1ec45 --- /dev/null +++ b/src/tests/chat-wire-v2.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { + WIRE_VERSION_V2, + canonicalJson, + decryptV2, + deviceTargetId, + encryptV2, + fromBase64, + historyTargetId, + isV2Envelope, + toBase64, + type WireRecipient, + type WireTarget, +} from '../lib/chat-wire-v2'; + +/** + * Wire v2 crypto guard rails. Node ships Web Crypto (globalThis.crypto.subtle) with P-256 ECDH, + * AES-GCM and HKDF, so these exercise the real primitives — no mocks. IndexedDB is not required + * here because the wire module is pure: it takes CryptoKeys as arguments. + */ + +async function newDeviceKeyPair(): Promise { + return globalThis.crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, [ + 'deriveKey', + 'deriveBits', + ]) as Promise; +} + +async function exportSpki(key: CryptoKey): Promise { + return toBase64(await globalThis.crypto.subtle.exportKey('spki', key)); +} + +async function makeDevice(id: string): Promise<{ target: WireTarget; recipient: WireRecipient }> { + const pair = await newDeviceKeyPair(); + return { + target: { id, publicKey: await exportSpki(pair.publicKey) }, + recipient: { id, privateKey: pair.privateKey }, + }; +} + +describe('canonicalJson', () => { + it('sorts object keys lexicographically and recursively, with no whitespace', () => { + const value = { w: [{ n: '2', i: '1', k: '3' }], e: 'E', n: 'N', c: 'C' }; + expect(canonicalJson(value)).toBe('{"c":"C","e":"E","n":"N","w":[{"i":"1","k":"3","n":"2"}]}'); + }); + + it('is independent of insertion order', () => { + const a = canonicalJson({ b: 1, a: 2, c: { z: 1, y: 2 } }); + const b = canonicalJson({ c: { y: 2, z: 1 }, a: 2, b: 1 }); + expect(a).toBe(b); + }); +}); + +describe('target ids', () => { + it('formats device and history ids exactly', () => { + expect(deviceTargetId('abc')).toBe('d:abc'); + expect(historyTargetId('user-1', 'key-9')).toBe('h:user-1:key-9'); + }); +}); + +describe('encryptV2 / decryptV2', () => { + it('produces a v2 envelope: base64 with a 0x02 first byte and canonical JSON payload', async () => { + const { target } = await makeDevice(deviceTargetId('dev-1')); + const envelope = await encryptV2('hello', 'match-1', [target]); + + expect(isV2Envelope(envelope)).toBe(true); + const raw = fromBase64(envelope); + expect(raw[0]).toBe(WIRE_VERSION_V2); + + const payload = JSON.parse(new TextDecoder().decode(raw.subarray(1))); + expect(Object.keys(payload).sort()).toEqual(['c', 'e', 'n', 'w']); + expect(payload.w).toHaveLength(1); + expect(Object.keys(payload.w[0]).sort()).toEqual(['i', 'k', 'n']); + expect(payload.w[0].i).toBe('d:dev-1'); + + // Byte layout is exactly the canonical JSON of the parsed payload. + expect(new TextDecoder().decode(raw.subarray(1))).toBe(canonicalJson(payload)); + }); + + it('round-trips for every registered device and the history key', async () => { + const selfDevice = await makeDevice(deviceTargetId('self-dev')); + const peerDevice = await makeDevice(deviceTargetId('peer-dev')); + const selfHistory = await makeDevice(historyTargetId('me', 'hk1')); + const peerHistory = await makeDevice(historyTargetId('peer', 'hk2')); + + const envelope = await encryptV2('the quick brown fox', 'match-42', [ + selfDevice.target, + peerDevice.target, + selfHistory.target, + peerHistory.target, + ]); + + // Each recipient independently recovers the same plaintext. + for (const recipient of [ + selfDevice.recipient, + peerDevice.recipient, + selfHistory.recipient, + peerHistory.recipient, + ]) { + await expect(decryptV2(envelope, 'match-42', [recipient])).resolves.toBe( + 'the quick brown fox' + ); + } + }); + + it('prefers the device key but falls back to the history key (device-preferred order)', async () => { + const device = await makeDevice(deviceTargetId('dev-x')); + const history = await makeDevice(historyTargetId('me', 'hk')); + const envelope = await encryptV2('secret', 'm1', [device.target, history.target]); + + // A device holding only the history key still decrypts. + await expect(decryptV2(envelope, 'm1', [history.recipient])).resolves.toBe('secret'); + // Ordering device-first-then-history works too. + await expect( + decryptV2(envelope, 'm1', [device.recipient, history.recipient]) + ).resolves.toBe('secret'); + }); + + it('rejects a recipient that was not a target', async () => { + const target = await makeDevice(deviceTargetId('dev-1')); + const stranger = await makeDevice(deviceTargetId('dev-stranger')); + const envelope = await encryptV2('hi', 'm1', [target.target]); + await expect(decryptV2(envelope, 'm1', [stranger.recipient])).rejects.toThrow(); + }); + + it('binds ciphertext to its matchId via AAD — a wrong matchId fails to decrypt', async () => { + const device = await makeDevice(deviceTargetId('dev-1')); + const envelope = await encryptV2('hi', 'match-A', [device.target]); + await expect(decryptV2(envelope, 'match-B', [device.recipient])).rejects.toThrow(); + }); + + it('fails authentication when the ciphertext is tampered with', async () => { + const device = await makeDevice(deviceTargetId('dev-1')); + const envelope = await encryptV2('hi', 'm1', [device.target]); + + const raw = fromBase64(envelope); + const payload = JSON.parse(new TextDecoder().decode(raw.subarray(1))); + const c = fromBase64(payload.c); + c[0] ^= 0xff; // flip a ciphertext byte + payload.c = toBase64(c); + const tampered = new Uint8Array(1 + new TextEncoder().encode(canonicalJson(payload)).length); + tampered[0] = WIRE_VERSION_V2; + tampered.set(new TextEncoder().encode(canonicalJson(payload)), 1); + + await expect(decryptV2(toBase64(tampered), 'm1', [device.recipient])).rejects.toThrow(); + }); + + it('requires at least one target', async () => { + await expect(encryptV2('hi', 'm1', [])).rejects.toThrow(/at least one target/i); + }); + + it('requires a matchId', async () => { + const device = await makeDevice(deviceTargetId('dev-1')); + await expect(encryptV2('hi', '', [device.target])).rejects.toThrow(/matchId/i); + }); + + it('deduplicates targets that appear more than once', async () => { + const device = await makeDevice(deviceTargetId('dev-dup')); + const envelope = await encryptV2('hi', 'm1', [device.target, device.target]); + const raw = fromBase64(envelope); + const payload = JSON.parse(new TextDecoder().decode(raw.subarray(1))); + expect(payload.w).toHaveLength(1); + }); + + it('rejects a non-v2 envelope in decryptV2', async () => { + const notV2 = toBase64(new Uint8Array([0x01, 0x02, 0x03])); + await expect(decryptV2(notV2, 'm1', [])).rejects.toThrow(/version|malformed/i); + expect(isV2Envelope(notV2)).toBe(false); + }); +}); + + +describe('cross-platform wire-v2 vector', () => { + it('decrypts onehook-wire-v2-interop-1 shared with Swift and Kotlin', async () => { + const recipientPrivatePkcs8 = + 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh+hRANCAAR6WTGAhgxAN8g8EnSYRcjuFCTdKX+ty4leNYJV0sfSsqjKJVgPJib+V5Bi/xuZ/5HCSg2gb7MrW+IBSMkkn1ZQ'; + const envelope = + 'AnsiYyI6IkNHeXpVNnFLcVR2NktPWHVuSjlLVGVxNDgxR0NGQzljeVBoeEZSL3pxL3ZsREkwZ2dtZ1UxS1NzM1h3PSIsImUiOiJNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVtRUlsV0YwaWhjRTRBejFoUU9QTytMa1lXWEJPVThNVCtMWTJ1aytXZGttWE5CUlBSdjBacDJlbFJTaDhRNWE1ZTJuZE9QcXVxSmdhM0JwUDdadEFIZz09IiwibiI6IkFBRUNBd1FGQmdjSUNRb0wiLCJ3IjpbeyJpIjoiZDppbnRlcm9wLWRldmljZSIsImsiOiJycjZBb083WmJqVUNhUGR5UFBsakcvMDhTSGRMSWlYOWExVnY1b2pHM1FsNGgrbDU0TzVsNHFMeXlFemVqbG1MIiwibiI6IkRBME9EeEFSRWhNVUZSWVgifV19'; + const privateKey = await globalThis.crypto.subtle.importKey( + 'pkcs8', + fromBase64(recipientPrivatePkcs8) as unknown as ArrayBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveBits'] + ); + + await expect( + decryptV2(envelope, 'interop-match-2026', [ + { id: 'd:interop-device', privateKey }, + ]) + ).resolves.toBe('OneHook wire-v2 interop 🔐'); + }); +}); diff --git a/src/tests/matches-state.test.ts b/src/tests/matches-state.test.ts index f939ed5..70a793a 100644 --- a/src/tests/matches-state.test.ts +++ b/src/tests/matches-state.test.ts @@ -88,27 +88,25 @@ describe('Matches: State Service Extension', () => { expect(state.matchIds).toHaveLength(0); }); - it('app state accommodates 4 distinct sections: DISCOVERY, MATCHES, CHAT, PROFILE', () => { - type AppSection = 'DISCOVERY' | 'MATCHES' | 'CHAT' | 'PROFILE'; - const sections: AppSection[] = ['DISCOVERY', 'MATCHES', 'CHAT', 'PROFILE']; + it('app state accommodates unified sections: DISCOVERY, MATCHES (with integrated chat), PROFILE', () => { + type AppSection = 'DISCOVERY' | 'MATCHES' | 'PROFILE'; + const sections: AppSection[] = ['DISCOVERY', 'MATCHES', 'PROFILE']; expect(sections).toContain('MATCHES'); - expect(sections).toContain('CHAT'); - expect(sections).toHaveLength(4); + expect(sections).toContain('DISCOVERY'); + expect(sections).toContain('PROFILE'); + expect(sections).toHaveLength(3); }); - it('selecting a match from Matches navigates to Chat with the selected matchId', () => { - let currentSection: 'DISCOVERY' | 'MATCHES' | 'CHAT' | 'PROFILE' = 'MATCHES'; + it('selecting a match within the unified MatchesView updates active match state directly', () => { let activeMatchId: string | null = null; - const onOpenChat = (matchId: string) => { + const onSelectMatch = (matchId: string | null) => { activeMatchId = matchId; - currentSection = 'CHAT'; }; - onOpenChat('match-abc-456'); + onSelectMatch('match-abc-456'); - expect(currentSection).toBe('CHAT'); expect(activeMatchId).toBe('match-abc-456'); }); });