From fcb3dff2c9f34205752e492394c1980f4290f3fc Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 3 Dec 2025 16:40:26 +0300 Subject: [PATCH] Working one-time message encryption and decryption --- backend/models.py | 15 +- backend/routes/account.py | 149 ++++++- frontend/src/core/api/chats/dm.ts | 339 ++++++++++++---- frontend/src/core/api/crypto.ts | 23 +- frontend/src/core/api/crypto/prekeys.ts | 91 ++++- frontend/src/core/api/dmApi.ts | 210 +++++++--- frontend/src/core/typingManager.ts | 110 +++--- frontend/src/core/updateManager.ts | 39 +- frontend/src/core/websocket.ts | 48 +-- frontend/src/pages/auth/AuthPage.tsx | 13 +- frontend/src/pages/auth/LoginForm.tsx | 30 +- frontend/src/pages/chat/hooks/useDM.ts | 4 +- frontend/src/pages/chat/ui/right/Message.tsx | 49 ++- .../src/pages/chat/ui/right/panels/DMPanel.ts | 61 ++- frontend/src/utils/crypto/obfuscation.ts | 76 ++++ frontend/src/utils/crypto/signalProtocol.ts | 364 ++++++++++++++++-- frontend/src/utils/crypto/signalStorage.ts | 95 ++++- frontend/src/utils/utils.ts | 12 +- 18 files changed, 1392 insertions(+), 336 deletions(-) create mode 100644 frontend/src/utils/crypto/obfuscation.ts diff --git a/backend/models.py b/backend/models.py index 065915c..7d72ac9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -76,10 +76,23 @@ class SignalPreKeyBundle(Base): id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) - bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData + bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData (identity, signed prekey, registration ID) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) +class SignalPreKey(Base): + __tablename__ = "signal_prekey" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) + prekey_id = Column(Integer, nullable=False) # The prekey ID from the client + public_key = Column(Text, nullable=False) # Base64 encoded public key + used = Column(Boolean, default=False, nullable=False, index=True) # Whether this prekey has been used + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),) + + class DMEnvelope(Base): __tablename__ = "dm_envelope" diff --git a/backend/routes/account.py b/backend/routes/account.py index b592b88..ee2905e 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -488,7 +488,7 @@ def upload_prekey_bundle( db: Session = Depends(get_db) ): """Upload Signal Protocol prekey bundle for the current user""" - from models import SignalPreKeyBundle + from models import SignalPreKeyBundle, SignalPreKey import json bundle = payload.get("bundle") @@ -508,11 +508,17 @@ def upload_prekey_bundle( if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]: raise HTTPException(status_code=400, detail="Invalid signedPreKey format") - # Store as JSON string - bundle_json = json.dumps(bundle) + # Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey + bundle_without_prekey = { + "registrationId": bundle["registrationId"], + "identityKey": bundle["identityKey"], + "signedPreKey": bundle["signedPreKey"] + } + bundle_json = json.dumps(bundle_without_prekey) if len(bundle_json) > 50000: # 50KB limit raise HTTPException(status_code=400, detail="Bundle too large") + # Store or update the bundle row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first() if row: row.bundle_json = bundle_json @@ -520,11 +526,121 @@ def upload_prekey_bundle( else: row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json) db.add(row) + + # Store the one-time prekey if provided + if "preKey" in bundle and bundle["preKey"]: + prekey = bundle["preKey"] + if isinstance(prekey, dict) and "keyId" in prekey and "publicKey" in prekey: + # Check if this prekey already exists + existing = db.query(SignalPreKey).filter( + SignalPreKey.user_id == current_user.id, + SignalPreKey.prekey_id == prekey["keyId"] + ).first() + + if existing: + # Update existing prekey (mark as unused if it was used) + existing.public_key = prekey["publicKey"] + existing.used = False + existing.created_at = datetime.now() + else: + # Add new prekey + new_prekey = SignalPreKey( + user_id=current_user.id, + prekey_id=prekey["keyId"], + public_key=prekey["publicKey"], + used=False + ) + db.add(new_prekey) + db.commit() return {"status": "ok"} +@router.post("/crypto/signal/prekeys/bulk") +@rate_limit_per_ip("10/minute") +def upload_prekeys_bulk( + request: Request, + payload: dict, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Upload multiple Signal Protocol prekeys in one request""" + from models import SignalPreKeyBundle, SignalPreKey + import json + + base_bundle = payload.get("baseBundle") + prekeys = payload.get("prekeys", []) + + if not base_bundle: + raise HTTPException(status_code=400, detail="baseBundle required") + + if not isinstance(prekeys, list): + raise HTTPException(status_code=400, detail="prekeys must be an array") + + # Validate base bundle structure + if not isinstance(base_bundle, dict): + raise HTTPException(status_code=400, detail="baseBundle must be a JSON object") + + # Validate required fields + required_fields = ["registrationId", "identityKey", "signedPreKey"] + for field in required_fields: + if field not in base_bundle: + raise HTTPException(status_code=400, detail=f"Missing required field in baseBundle: {field}") + + if not isinstance(base_bundle["signedPreKey"], dict) or "keyId" not in base_bundle["signedPreKey"]: + raise HTTPException(status_code=400, detail="Invalid signedPreKey format") + + # Store or update the base bundle (identity key, signed prekey, registration ID) + bundle_without_prekey = { + "registrationId": base_bundle["registrationId"], + "identityKey": base_bundle["identityKey"], + "signedPreKey": base_bundle["signedPreKey"] + } + bundle_json = json.dumps(bundle_without_prekey) + if len(bundle_json) > 50000: # 50KB limit + raise HTTPException(status_code=400, detail="Bundle too large") + + # Store or update the bundle + row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first() + if row: + row.bundle_json = bundle_json + row.updated_at = datetime.now() + else: + row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json) + db.add(row) + + # Store all prekeys + for prekey in prekeys: + if not isinstance(prekey, dict) or "keyId" not in prekey or "publicKey" not in prekey: + continue # Skip invalid prekeys + + # Check if this prekey already exists + existing = db.query(SignalPreKey).filter( + SignalPreKey.user_id == current_user.id, + SignalPreKey.prekey_id == prekey["keyId"] + ).first() + + if existing: + # Update existing prekey (mark as unused if it was used) + existing.public_key = prekey["publicKey"] + existing.used = False + existing.created_at = datetime.now() + else: + # Add new prekey + new_prekey = SignalPreKey( + user_id=current_user.id, + prekey_id=prekey["keyId"], + public_key=prekey["publicKey"], + used=False + ) + db.add(new_prekey) + + db.commit() + + return {"status": "ok", "uploaded": len(prekeys)} + + @router.get("/crypto/signal/prekey-bundle") def get_prekey_bundle( current_user: User = Depends(get_current_user), @@ -553,16 +669,39 @@ def get_prekey_bundle_of( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - """Get Signal Protocol prekey bundle for another user""" - from models import SignalPreKeyBundle + """Get Signal Protocol prekey bundle for another user with prekey rotation""" + from models import SignalPreKeyBundle, SignalPreKey import json + # Get the base bundle (identity key, signed prekey, registration ID) row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first() if not row: raise HTTPException(status_code=404, detail="Prekey bundle not found") try: bundle = json.loads(row.bundle_json) + + # Find an unused prekey for this user + unused_prekey = db.query(SignalPreKey).filter( + SignalPreKey.user_id == user_id, + SignalPreKey.used == False + ).order_by(SignalPreKey.created_at.asc()).first() + + if unused_prekey: + # Mark this prekey as used (atomic operation) + unused_prekey.used = True + db.commit() + + # Add the prekey to the bundle + bundle["preKey"] = { + "keyId": unused_prekey.prekey_id, + "publicKey": unused_prekey.public_key + } + else: + # No unused prekeys available - return bundle without prekey + # The client will need to establish a session using the signed prekey only + pass + return {"bundle": bundle} except json.JSONDecodeError: raise HTTPException(status_code=500, detail="Invalid bundle data") diff --git a/frontend/src/core/api/chats/dm.ts b/frontend/src/core/api/chats/dm.ts index 98dc1c5..d74cacb 100644 --- a/frontend/src/core/api/chats/dm.ts +++ b/frontend/src/core/api/chats/dm.ts @@ -1,28 +1,139 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "../user/auth"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import api from "@/core/api"; +import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric"; import { randomBytes } from "@/utils/crypto/kdf"; -import { getCurrentKeys } from "../user/auth"; import { request } from "@/core/websocket"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; -import { b64, ub64 } from "@/utils/utils"; -import { fetchUserPublicKey } from "../crypto/identity"; -import { fetchUsers, searchUsers } from "../user/search"; +import { b64 } from "@/utils/utils"; +import { SignalProtocolService } from "@/utils/crypto/signalProtocol"; +import { useUserStore } from "@/state/user"; +import { addPadding, removePadding } from "@/utils/crypto/obfuscation"; -export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function decrypt(envelope: DmEnvelope, senderId: number): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } - // Obtain the key - const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); + if (!envelope.ciphertext) { + throw new Error("DM envelope missing ciphertext"); + } - // Decrypt - const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); - return new TextDecoder().decode(msg); + const signalService = new SignalProtocolService(user.id.toString()); + + // Remove padding (backward compatible with old messages) + // Check if ciphertext is base64 (padded) or already JSON (unpadded) + let ciphertextStr: string = envelope.ciphertext; + + // Check if it's base64 (padded messages are base64) + const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/; + const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0; + + if (isBase64) { + // Try to remove padding + try { + const unpadded = removePadding(envelope.ciphertext); + // Verify it's valid JSON before using it + JSON.parse(unpadded); + ciphertextStr = unpadded; + } catch { + // If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully) + try { + JSON.parse(envelope.ciphertext); + ciphertextStr = envelope.ciphertext; + } catch { + // If both fail, throw an error + throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`); + } + } + } else { + // Not base64, assume it's already JSON (unpadded message) + ciphertextStr = envelope.ciphertext; + } + + // Parse Signal Protocol message + let signalCiphertext: { type: number; body: string }; + try { + signalCiphertext = JSON.parse(ciphertextStr); + } catch (error) { + throw new Error(`Failed to parse ciphertext as JSON: ${error instanceof Error ? error.message : String(error)}. Ciphertext length: ${ciphertextStr.length}, first 100 chars: ${ciphertextStr.substring(0, 100)}`); + } + + if (!signalCiphertext || typeof signalCiphertext !== "object") { + throw new Error("Invalid Signal Protocol message format: not an object"); + } + + if (typeof signalCiphertext.type !== "number") { + throw new Error("Invalid Signal Protocol message format: type is not a number"); + } + + if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") { + throw new Error("Invalid Signal Protocol message format: body is missing or not a string"); + } + + // Check if body contains non-printable characters (corrupted binary data from old encryption) + // This must be checked first, before any base64 validation + const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body); + if (hasNonPrintable) { + // This is a corrupted message from before the base64 conversion fix + // It cannot be decrypted - the body contains raw binary data instead of base64 + console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`); + return "_This message is corrupted and cannot be displayed._"; + } + + // Check if body contains Unicode escape sequences (from JSON.stringify escaping) + // If so, we need to unescape them to get the actual base64 string + let bodyToDecode = signalCiphertext.body; + + // Check for literal backslash-u sequences (before JSON parsing, these would be "\\u") + // After JSON parsing, Unicode escapes are converted to actual characters, so we check for + // the pattern that indicates it might have been escaped + if (bodyToDecode.includes("\\u") || bodyToDecode.match(/\\u[0-9a-fA-F]{4}/)) { + // Try to unescape Unicode sequences by wrapping in JSON quotes + try { + bodyToDecode = JSON.parse(`"${bodyToDecode.replace(/\\/g, "\\\\")}"`); + } catch { + // If unescaping fails, use the original + bodyToDecode = signalCiphertext.body; + } + } + + // Validate that body is valid base64 before attempting decryption + // Check if it's a valid base64 string (only contains base64 characters and padding) + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + if (!base64Regex.test(bodyToDecode)) { + // Log for debugging - this should help identify the issue + console.error("Invalid base64 in body:", { + bodyType: typeof signalCiphertext.body, + bodyLength: signalCiphertext.body.length, + unescapedLength: bodyToDecode.length, + first50: signalCiphertext.body.substring(0, 50), + unescapedFirst50: bodyToDecode.substring(0, 50), + envelopeId: envelope.id + }); + throw new Error(`Invalid base64 format in ciphertext body`); + } + + // Use the unescaped body for decryption + signalCiphertext.body = bodyToDecode; + + try { + // Try to decode a small portion to validate base64 + atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length))); + } catch (error) { + // Log for debugging + console.error("Base64 decode failed:", { + bodyLength: signalCiphertext.body.length, + first50: signalCiphertext.body.substring(0, 50), + last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)), + envelopeId: envelope.id, + error: error instanceof Error ? error.message : String(error) + }); + throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`); + } + + const plaintext = await signalService.decryptMessage(senderId, signalCiphertext); + return plaintext; } export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> { @@ -31,35 +142,86 @@ export async function fetchMessages(userId: number, token: string, limit: number url += `&before_id=${beforeId}`; } const response = await globalThis.fetch(url, { - headers: getAuthHeaders(token, true) + headers: api.user.auth.getAuthHeaders(token, true) }); if (!response.ok) return { messages: [], has_more: false }; const data = await response.json(); return { messages: data.messages || [], has_more: data.has_more ?? false }; } -export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // Encryption key - const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - // Encrypt the message - const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); - const wrap = await aesGcmEncrypt(wk, mk); +export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } + const signalService = new SignalProtocolService(user.id.toString()); + + // Check if we have a session, if not, fetch prekey bundle and establish one + const hasSession = await signalService.hasSession(recipientId); + if (!hasSession) { + try { + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken); + await signalService.processPreKeyBundle(recipientId, bundle); + } catch (error) { + // Re-throw PrekeyExhaustedError as-is for proper handling + if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) { + throw error; + } + // Re-throw other errors + throw error; + } + } + + // Encrypt with Signal Protocol + const ciphertext = await signalService.encryptMessage(recipientId, plaintext); + + // Verify the body is valid base64 before stringifying + if (ciphertext.body && typeof ciphertext.body === "string") { + try { + // Test that body is valid base64 + atob(ciphertext.body.substring(0, Math.min(4, ciphertext.body.length))); + + // Verify the entire body is valid base64 + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + if (!base64Regex.test(ciphertext.body)) { + console.error("Invalid base64 characters in encrypted body:", { + bodyLength: ciphertext.body.length, + first100: ciphertext.body.substring(0, 100), + last100: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 100)) + }); + throw new Error("Encrypted body contains invalid base64 characters"); + } + } catch (error) { + throw new Error(`Encrypted body is not valid base64: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Stringify the ciphertext - JSON.stringify should not escape base64 strings + const ciphertextJson = JSON.stringify(ciphertext); + + // Verify the stringified JSON doesn't have escaped characters in the body field + const parsed = JSON.parse(ciphertextJson); + if (parsed.body !== ciphertext.body) { + console.error("Body was modified during JSON stringification:", { + original: ciphertext.body.substring(0, 50), + stringified: parsed.body.substring(0, 50), + originalLength: ciphertext.body.length, + stringifiedLength: parsed.body.length + }); + throw new Error("Body was incorrectly escaped during JSON stringification"); + } + + // Add padding to obfuscate message size (anti-censorship) + const paddedCiphertext = addPadding(ciphertextJson); + const payload: SendDMRequest = { recipientId: recipientId, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) + iv: "", // Not used for Signal Protocol + ciphertext: paddedCiphertext, // Padded Signal Protocol message + salt: "", // Not used for Signal Protocol + iv2: "", // Not used for Signal Protocol + wrappedMk: "" // Not used for Signal Protocol }; if (replyToId) payload.replyToId = replyToId; @@ -73,17 +235,39 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p }); } -export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } + const signalService = new SignalProtocolService(user.id.toString()); + + // Check if we have a session, if not, fetch prekey bundle and establish one + const hasSession = await signalService.hasSession(recipientId); + if (!hasSession) { + try { + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token); + await signalService.processPreKeyBundle(recipientId, bundle); + } catch (error) { + // Re-throw PrekeyExhaustedError as-is for proper handling + if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) { + throw error; + } + // Re-throw other errors + throw error; + } + } + + // Generate master key for file encryption const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - const wrap = await aesGcmEncrypt(wk, mk); + + // Encrypt the master key using Signal Protocol + const mkBase64 = b64(mk); + const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64); + + // Add padding to obfuscate master key size + const paddedMk = addPadding(JSON.stringify(encryptedMk)); const form = new FormData(); const names: string[] = []; @@ -115,30 +299,54 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: recipientId: recipientId, iv: b64(encMsg.iv), ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) + salt: "", // Not used for Signal Protocol + iv2: "", // Not used for Signal Protocol + wrappedMk: paddedMk // Padded Signal Protocol encrypted master key } satisfies BaseDmEnvelope)); await globalThis.fetch(`${API_BASE_URL}/dm/send`, { method: "POST", - headers: getAuthHeaders(token, false), + headers: api.user.auth.getAuthHeaders(token, false), body: form }); } -export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } - // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap + const signalService = new SignalProtocolService(user.id.toString()); + + // Check if we have a session, if not, fetch prekey bundle and establish one + const hasSession = await signalService.hasSession(recipientId); + if (!hasSession) { + try { + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken); + await signalService.processPreKeyBundle(recipientId, bundle); + } catch (error) { + // Re-throw PrekeyExhaustedError as-is for proper handling + if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) { + throw error; + } + // Re-throw other errors + throw error; + } + } + + // Generate fresh master key for the edited message const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); + + // Encrypt the master key using Signal Protocol + const mkBase64 = b64(mk); + const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64); + + // Add padding to obfuscate master key size + const paddedMk = addPadding(JSON.stringify(encryptedMk)); + + // Encrypt the message content with the master key const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); - const wrap = await aesGcmEncrypt(wk, mk); await request({ type: "dmEdit", @@ -147,9 +355,9 @@ export async function edit(id: number, recipientPublicKeyB64: string, newPlainte id, iv: b64(encMsg.iv), ciphertext: b64(encMsg.ciphertext), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext), - salt: b64(wkSalt) + iv2: "", // Not used for Signal Protocol + wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key + salt: "" // Not used for Signal Protocol } } as DMEditRequest); } @@ -170,7 +378,7 @@ export interface ConversationResponse { export async function conversations(token: string): Promise { const res = await fetch(`${API_BASE_URL}/dm/conversations`, { - headers: getAuthHeaders(token, true) + headers: api.user.auth.getAuthHeaders(token, true) }); if (!res.ok) return []; const data = await res.json(); @@ -189,6 +397,7 @@ export async function markRead(id: number, authToken: string): Promise { } // Re-export user functions for convenience -export { fetchUsers, searchUsers, fetchUserPublicKey }; +export { fetchUsers, searchUsers } from "@/core/api/users"; +export { fetchUserPublicKey } from "@/core/api/crypto/identity"; diff --git a/frontend/src/core/api/crypto.ts b/frontend/src/core/api/crypto.ts index e64c7e7..3e8229c 100644 --- a/frontend/src/core/api/crypto.ts +++ b/frontend/src/core/api/crypto.ts @@ -79,15 +79,22 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise * Uploads Signal Protocol prekey bundle for the current user */ export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise { - const payload = { bundle }; + // Re-export from prekeys.ts + const { uploadPreKeyBundle: upload } = await import("./crypto/prekeys"); + return upload(bundle, token); +} - const headers = getAuthHeaders(token, true); - const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, { - method: "POST", - headers, - body: JSON.stringify(payload) - }); - if (!res.ok) throw new Error("Failed to upload prekey bundle"); +/** + * Uploads all available prekeys to the server for rotation + */ +export async function uploadAllPreKeys( + baseBundle: Omit, + prekeys: Array<{ keyId: number; publicKey: string }>, + token: string +): Promise { + // Re-export from prekeys.ts + const { uploadAllPreKeys: upload } = await import("./crypto/prekeys"); + return upload(baseBundle, prekeys, token); } /** diff --git a/frontend/src/core/api/crypto/prekeys.ts b/frontend/src/core/api/crypto/prekeys.ts index 3a32ce6..184d202 100644 --- a/frontend/src/core/api/crypto/prekeys.ts +++ b/frontend/src/core/api/crypto/prekeys.ts @@ -1,14 +1,89 @@ -// Placeholder for Signal Protocol pre-key management -// Will be implemented when Signal Protocol is added +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol"; -export async function upload(_bundle: unknown, _token: string): Promise { - // TODO: Implement Signal Protocol pre-key upload - throw new Error("Not implemented yet"); +/** + * Uploads Signal Protocol prekey bundle for the current user + * This uploads the base bundle (identity, signed prekey) and one prekey + */ +export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise { + const payload = { bundle }; + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload prekey bundle"); } -export async function fetch(_userId: number, _token: string): Promise { - // TODO: Implement Signal Protocol pre-key fetch - throw new Error("Not implemented yet"); +/** + * Uploads all available prekeys to the server for rotation in a single request + */ +export async function uploadAllPreKeys( + baseBundle: Omit, + prekeys: Array<{ keyId: number; publicKey: string }>, + token: string +): Promise { + const headers = getAuthHeaders(token, true); + const payload = { + baseBundle, + prekeys + }; + + const res = await fetch(`${API_BASE_URL}/crypto/signal/prekeys/bulk`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + throw new Error(`Failed to upload prekeys: ${res.statusText}`); + } +} + +/** + * Custom error for prekey exhaustion + */ +export class PrekeyExhaustedError extends Error { + constructor(public readonly recipientId: number) { + super("Recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys."); + this.name = "PrekeyExhaustedError"; + } +} + +/** + * Fetches Signal Protocol prekey bundle for another user + * @throws {PrekeyExhaustedError} If the recipient has no unused prekeys available + */ +export async function fetchPreKeyBundle(userId: number, token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, { + method: "GET", + headers + }); + if (!res.ok) { + if (res.status === 404) { + throw new Error("Recipient has not set up encryption. They need to log in to initialize their encryption keys."); + } + throw new Error("Failed to fetch prekey bundle"); + } + const data = await res.json(); + const bundle = data.bundle; + + // Check if bundle exists but has no prekey (all prekeys exhausted) + if (!bundle) { + throw new PrekeyExhaustedError(userId); + } + + // If bundle exists but has no preKey field, it means all prekeys are exhausted + // The backend returns bundle without preKey when no unused prekeys are available + if (!bundle.preKey) { + throw new PrekeyExhaustedError(userId); + } + + return bundle; } diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index b66e55c..96bbf6e 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,16 +1,13 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./account"; +import api from "@/core/api"; import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric"; import { randomBytes } from "@/utils/crypto/kdf"; -import { getCurrentKeys } from "./account"; import { request } from "@/core/websocket"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; -import { b64, ub64 } from "@/utils/utils"; -import { fetchUserPublicKey, fetchPreKeyBundle } from "./crypto"; -import { fetchUsers, searchUsers } from "./users"; +import { b64 } from "@/utils/utils"; import { SignalProtocolService } from "@/utils/crypto/signalProtocol"; import { useUserStore } from "@/state/user"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { addPadding, removePadding } from "@/utils/crypto/obfuscation"; export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise { const user = useUserStore.getState().user.currentUser; @@ -18,21 +15,110 @@ export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise throw new Error("User not authenticated"); } + if (!envelope.ciphertext) { + throw new Error("DM envelope missing ciphertext"); + } + const signalService = new SignalProtocolService(user.id.toString()); - // Parse Signal Protocol message - const signalCiphertext = JSON.parse(envelope.ciphertext); - if (!signalCiphertext.type || !signalCiphertext.body) { - throw new Error("Invalid Signal Protocol message format"); + // Remove padding (backward compatible with old messages) + // Check if ciphertext is base64 (padded messages are base64) + let ciphertextStr: string = envelope.ciphertext; + + // Check if it's base64 (padded messages are base64) + const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/; + const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0; + + if (isBase64) { + // Try to remove padding + try { + const unpadded = removePadding(envelope.ciphertext); + // Verify it's valid JSON before using it + JSON.parse(unpadded); + ciphertextStr = unpadded; + } catch { + // If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully) + try { + JSON.parse(envelope.ciphertext); + ciphertextStr = envelope.ciphertext; + } catch { + // If both fail, throw an error + throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`); + } + } + } else { + // Not base64, assume it's already JSON (unpadded message) + ciphertextStr = envelope.ciphertext; } - const plaintext = await signalService.decryptMessage(senderId, signalCiphertext); - return plaintext; + // Parse Signal Protocol message + let signalCiphertext: { type: number; body: string }; + try { + signalCiphertext = JSON.parse(ciphertextStr); + } catch (error) { + throw new Error(`Failed to parse Signal Protocol message: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!signalCiphertext || typeof signalCiphertext !== "object") { + throw new Error("Invalid Signal Protocol message format: not an object"); + } + + if (typeof signalCiphertext.type !== "number") { + throw new Error("Invalid Signal Protocol message format: type is not a number"); + } + + if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") { + throw new Error("Invalid Signal Protocol message format: body is missing or not a string"); + } + + // Validate that body is valid base64 before attempting decryption + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + if (!base64Regex.test(signalCiphertext.body)) { + // Check if body contains non-printable characters (corrupted binary data) + const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body); + if (hasNonPrintable) { + // This is a corrupted message from before the base64 conversion fix + // It cannot be decrypted - the body contains raw binary data instead of base64 + console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`); + + return "_This message is corrupted and cannot be displayed._"; + } + + console.error("Invalid base64 in body:", { + bodyType: typeof signalCiphertext.body, + bodyLength: signalCiphertext.body.length, + first50: signalCiphertext.body.substring(0, 50), + last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)), + envelopeId: envelope.id + }); + throw new Error(`Invalid base64 format in ciphertext body`); + } + + try { + // Try to decode a small portion to validate base64 + atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length))); + } catch (error) { + console.error("Base64 decode failed:", { + bodyLength: signalCiphertext.body.length, + first50: signalCiphertext.body.substring(0, 50), + last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)), + envelopeId: envelope.id, + error: error instanceof Error ? error.message : String(error) + }); + throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`); + } + + try { + const plaintext = await signalService.decryptMessage(senderId, signalCiphertext); + return plaintext; + } catch (error) { + throw new Error(`Failed to decrypt DM: ${error instanceof Error ? error.message : String(error)}`); + } } export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { - headers: getAuthHeaders(token, true) + headers: api.user.auth.getAuthHeaders(token, true) }); if (!response.ok) return []; const data = await response.json(); @@ -52,9 +138,9 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string, const hasSession = await signalService.hasSession(recipientId); if (!hasSession) { // Fetch prekey bundle from server - const bundle = await fetchPreKeyBundle(recipientId, authToken); + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken); if (!bundle) { - throw new Error("No Signal Protocol prekey bundle available for recipient"); + throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`); } await signalService.processPreKeyBundle(recipientId, bundle); } @@ -62,10 +148,13 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string, // Encrypt with Signal Protocol const ciphertext = await signalService.encryptMessage(recipientId, plaintext); + // Add padding to obfuscate message size (anti-censorship) + const paddedCiphertext = addPadding(JSON.stringify(ciphertext)); + const payload: SendDMRequest = { recipientId: recipientId, iv: "", // Not used for Signal Protocol - ciphertext: JSON.stringify(ciphertext), // Store Signal Protocol message as JSON + ciphertext: paddedCiphertext, // Padded Signal Protocol message salt: "", // Not used for Signal Protocol iv2: "", // Not used for Signal Protocol wrappedMk: "" // Not used for Signal Protocol @@ -82,17 +171,33 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string, }); } -export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } + const signalService = new SignalProtocolService(user.id.toString()); + + // Check if we have a session, if not, fetch prekey bundle and establish one + const hasSession = await signalService.hasSession(recipientId); + if (!hasSession) { + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token); + if (!bundle) { + throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`); + } + await signalService.processPreKeyBundle(recipientId, bundle); + } + + // Generate master key for file encryption const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - const wrap = await aesGcmEncrypt(wk, mk); + + // Encrypt the master key using Signal Protocol + const mkBase64 = b64(mk); + const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64); + + // Add padding to obfuscate master key size + const paddedMk = addPadding(JSON.stringify(encryptedMk)); const form = new FormData(); const names: string[] = []; @@ -124,30 +229,48 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64 recipientId: recipientId, iv: b64(encMsg.iv), ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) + salt: "", // Not used for Signal Protocol + iv2: "", // Not used for Signal Protocol + wrappedMk: paddedMk // Padded Signal Protocol encrypted master key } satisfies BaseDmEnvelope)); await fetch(`${API_BASE_URL}/dm/send`, { method: "POST", - headers: getAuthHeaders(token, false), + headers: api.user.auth.getAuthHeaders(token, false), body: form }); } -export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { - const keys = getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); +export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise { + const user = useUserStore.getState().user.currentUser; + if (!user?.id) { + throw new Error("User not authenticated"); + } - // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap + const signalService = new SignalProtocolService(user.id.toString()); + + // Check if we have a session, if not, fetch prekey bundle and establish one + const hasSession = await signalService.hasSession(recipientId); + if (!hasSession) { + const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken); + if (!bundle) { + throw new Error("No Signal Protocol prekey bundle available for recipient"); + } + await signalService.processPreKeyBundle(recipientId, bundle); + } + + // Generate fresh master key for the edited message const mk = randomBytes(32); - const wkSalt = randomBytes(16); - const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); - const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); + + // Encrypt the master key using Signal Protocol + const mkBase64 = b64(mk); + const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64); + + // Add padding to obfuscate master key size + const paddedMk = addPadding(JSON.stringify(encryptedMk)); + + // Encrypt the message content with the master key const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); - const wrap = await aesGcmEncrypt(wk, mk); await request({ type: "dmEdit", @@ -156,9 +279,9 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, id, iv: b64(encMsg.iv), ciphertext: b64(encMsg.ciphertext), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext), - salt: b64(wkSalt) + iv2: "", // Not used for Signal Protocol + wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key + salt: "" // Not used for Signal Protocol } } as DMEditRequest); } @@ -178,11 +301,12 @@ export interface DMConversationResponse { } // Re-export for convenience -export { fetchUsers, searchUsers, fetchUserPublicKey }; +export { fetchUsers, searchUsers } from "./users"; +export { fetchUserPublicKey } from "./crypto/identity"; export async function fetchDMConversations(token: string): Promise { const res = await fetch(`${API_BASE_URL}/dm/conversations`, { - headers: getAuthHeaders(token, true) + headers: api.user.auth.getAuthHeaders(token, true) }); if (!res.ok) return []; const data = await res.json(); diff --git a/frontend/src/core/typingManager.ts b/frontend/src/core/typingManager.ts index 740c800..61d176d 100644 --- a/frontend/src/core/typingManager.ts +++ b/frontend/src/core/typingManager.ts @@ -5,7 +5,7 @@ * @version 1.0.0 */ -import { request } from "./websocket"; +import { send } from "./websocket"; import type { TypingWebSocketMessage, StopTypingWebSocketMessage, @@ -39,21 +39,18 @@ export class TypingManager { async sendTyping(): Promise { if (!this.authToken) return; - try { - const message: TypingRequest = { - type: "typing", - credentials: { - scheme: "Bearer", - credentials: this.authToken - }, - data: {} - }; + const message: TypingRequest = { + type: "typing", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: {} + }; - await request(message); - this.scheduleStopTyping("public"); - } catch (error) { - console.error("Failed to send typing indicator:", error); - } + // Fire-and-forget - don't wait for response + send(message); + this.scheduleStopTyping("public"); } /** @@ -62,21 +59,18 @@ export class TypingManager { async sendStopTyping(): Promise { if (!this.authToken) return; - try { - const message: StopTypingRequest = { - type: "stopTyping", - credentials: { - scheme: "Bearer", - credentials: this.authToken - }, - data: {} - }; + const message: StopTypingRequest = { + type: "stopTyping", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: {} + }; - await request(message); - this.clearStopTypingTimeout("public"); - } catch (error) { - console.error("Failed to send stop typing indicator:", error); - } + // Fire-and-forget - don't wait for response + send(message); + this.clearStopTypingTimeout("public"); } /** @@ -85,23 +79,20 @@ export class TypingManager { async sendDmTyping(recipientId: number): Promise { if (!this.authToken) return; - try { - const message: DmTypingRequest = { - type: "dmTyping", - credentials: { - scheme: "Bearer", - credentials: this.authToken - }, - data: { - recipientId - } - }; + const message: DmTypingRequest = { + type: "dmTyping", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: { + recipientId + } + }; - await request(message); - this.scheduleStopDmTyping(recipientId); - } catch (error) { - console.error("Failed to send DM typing indicator:", error); - } + // Fire-and-forget - don't wait for response + send(message); + this.scheduleStopDmTyping(recipientId); } /** @@ -110,23 +101,20 @@ export class TypingManager { async sendStopDmTyping(recipientId: number): Promise { if (!this.authToken) return; - try { - const message: StopDmTypingRequest = { - type: "stopDmTyping", - credentials: { - scheme: "Bearer", - credentials: this.authToken - }, - data: { - recipientId - } - }; + const message: StopDmTypingRequest = { + type: "stopDmTyping", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: { + recipientId + } + }; - await request(message); - this.clearStopTypingTimeout(`dm_${recipientId}`); - } catch (error) { - console.error("Failed to send stop DM typing indicator:", error); - } + // Fire-and-forget - don't wait for response + send(message); + this.clearStopTypingTimeout(`dm_${recipientId}`); } /** diff --git a/frontend/src/core/updateManager.ts b/frontend/src/core/updateManager.ts index ae89c32..23af0f6 100644 --- a/frontend/src/core/updateManager.ts +++ b/frontend/src/core/updateManager.ts @@ -6,7 +6,6 @@ */ import { openDB, type IDBPDatabase } from "idb"; -import type { WebSocketCredentials, WebSocketMessage } from "./types"; interface UpdateMessage { type: string; @@ -72,28 +71,18 @@ export async function setLastSequence(seq: number): Promise { * Process a batched updates message * @param message - The batched updates message from the server * @param handler - Function to handle individual updates - * @param requestMissedFn - Optional function to request missed updates (for gap detection) */ export async function processBatchedUpdates( message: BatchedUpdatesMessage, - handler: (update: UpdateMessage) => void, - requestMissedFn?: (lastSeq: number) => Promise + handler: (update: UpdateMessage) => void ): Promise { const { seq, updates } = message; const lastSeq = await getLastSequence(); - // Check for gap + // Log gap for debugging, but don't try to recover (getUpdates doesn't work properly) if (seq !== lastSeq + 1 && lastSeq > 0) { - console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`); - - // Request missing updates if function provided - if (requestMissedFn) { - try { - await requestMissedFn(lastSeq); - } catch (error) { - console.error("Failed to request missed updates for gap:", error); - } - } + const gapSize = seq - (lastSeq + 1); + console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq} (gap size: ${gapSize}). Skipping ${gapSize} updates.`); } // Process all updates in the batch @@ -104,23 +93,3 @@ export async function processBatchedUpdates( // Update last sequence number await setLastSequence(seq); } - -/** - * Request missed updates from the server - * @param lastSeq - The last sequence number we received - * @param requestFn - Function to send the request to the server - * @param credentials - Optional WebSocket credentials for authentication - */ -export async function requestMissedUpdates( - lastSeq: number, - requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise, - credentials?: WebSocketCredentials -): Promise { - if (lastSeq > 0) { - await requestFn({ - type: "getUpdates", - data: { lastSeq }, - credentials - }); - } -} \ No newline at end of file diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 1ea1689..172cd73 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -12,7 +12,7 @@ import { CallSignalingHandler } from "./calls/signaling"; import { onlineStatusManager } from "./onlineStatusManager"; import { typingManager } from "./typingManager"; import { useUserStore } from "@/state/user"; -import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager"; +import { processBatchedUpdates } from "./updateManager"; import { getAuthToken } from "@/core/api/user/auth"; interface HttpError extends Error { @@ -161,21 +161,10 @@ function setupEventHandlers(): void { // Handle batched updates if (response.type === "updates" && "seq" in response && "updates" in response) { - // Create function to request missed updates with credentials - const token = getAuthToken(); - const requestMissedFn = token ? async (lastSeq: number) => { - await requestMissedUpdates(lastSeq, async (req) => { - await request(req); - }, { - scheme: "Bearer", - credentials: token - }); - } : undefined; - await processBatchedUpdates(response as any, (update) => { // Route individual updates to appropriate handlers handleUpdate(update); - }, requestMissedFn); + }); return; } @@ -250,20 +239,9 @@ function setupEventHandlers(): void { console.error("Failed to send ping on reconnect:", error); } - // Send last sequence number and request missed updates on reconnect - // Wait a bit for ping to complete authentication - await delay(100); - - try { - const lastSeq = await getLastSequence(); - if (lastSeq > 0) { - await requestMissedUpdates(lastSeq, async (req) => { - await request(req); - }, credentials); - } - } catch (error) { - console.error("Failed to request missed updates:", error); - } + // Note: We don't request missed updates on reconnect because getUpdates + // doesn't properly return updates (they're sent directly via WebSocket + // but the client can't handle them). Gaps will be logged but not recovered. } } catch (error) { console.error("Failed to authenticate on reconnect:", error); @@ -359,6 +337,22 @@ export function request(payload: WebSocketMessage(payload: WebSocketMessage): void { + if (websocket.readyState !== WebSocket.OPEN) { + console.warn("WebSocket is not open, cannot send message"); + return; + } + try { + websocket.send(JSON.stringify(payload)); + } catch (error) { + console.error("Failed to send WebSocket message:", error); + } +} + // -------------- // Initialization // -------------- diff --git a/frontend/src/pages/auth/AuthPage.tsx b/frontend/src/pages/auth/AuthPage.tsx index 5011317..6273e19 100644 --- a/frontend/src/pages/auth/AuthPage.tsx +++ b/frontend/src/pages/auth/AuthPage.tsx @@ -1,12 +1,13 @@ import { AuthContainer } from "./Auth"; import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams, Navigate } from "react-router-dom"; import { motion, AnimatePresence } from "motion/react"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { LoginForm } from "./LoginForm"; import { RegisterForm } from "./RegisterForm"; import type { Variants, Transition } from "motion/react"; import styles from "./auth.module.scss"; +import { useUserStore } from "@/state/user"; const slideVariants: Variants = { enter: (direction: number) => ({ @@ -37,9 +38,9 @@ const slideTransition: Transition = { export default function AuthPage() { const [searchParams] = useSearchParams(); const { navigate: navigateDownloadApp } = useDownloadAppScreen(); - if (navigateDownloadApp) return navigateDownloadApp; const navigate = useNavigate(); - + const { user } = useUserStore(); + const [direction, setDirection] = useState(0); const prevMode = useRef(searchParams.get("mode") || "login"); const containerRef = useRef(null); @@ -90,6 +91,12 @@ export default function AuthPage() { } }; }, [currentMode]); + + // Now we can do conditional returns after all hooks are called + if (navigateDownloadApp) return navigateDownloadApp; + if (user.authToken && user.currentUser) { + return ; + } function switchMode(newMode: "login" | "register") { navigate(`/auth?mode=${newMode}`, { replace: true }); diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 64cf407..d72e4fc 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -93,14 +93,30 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { try { await api.user.auth.ensureKeysOnLogin(password, data.token); - // Initialize Signal Protocol after keys are set up + // Initialize Signal Protocol after keys are set up (non-blocking) if (data.user?.id) { - const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); - const { uploadPreKeyBundle } = await import("@/core/api/crypto"); - const signalService = new SignalProtocolService(data.user.id.toString()); - await signalService.initialize(); - const bundle = await signalService.getPreKeyBundle(); - await uploadPreKeyBundle(bundle, data.token); + // Run Signal Protocol initialization in background to avoid blocking navigation + (async () => { + try { + const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); + const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys"); + const signalService = new SignalProtocolService(data.user!.id.toString()); + await signalService.initialize(); + + // Upload base bundle with one prekey (for backward compatibility) + const bundle = await signalService.getPreKeyBundle(); + await uploadPreKeyBundle(bundle, data.token); + + // Upload all prekeys for server-side rotation + const baseBundle = await signalService.getBaseBundle(); + const prekeys = await signalService.getAllPreKeys(); + await uploadAllPreKeys(baseBundle, prekeys, data.token); + + console.log(`Uploaded ${prekeys.length} prekeys to server`); + } catch (e) { + console.error("Key setup failed:", e); + } + })(); } } catch (e) { console.error("Key setup failed:", e); diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index c8a1690..9b3e62b 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -124,7 +124,9 @@ export function useDM() { lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); } } catch (error) { - console.error("Failed to decrypt last message for user", conv.user.id, error); + // Silently fail for last message decryption - it's not critical + // The message will just show "No messages" instead + console.debug("Failed to decrypt last message for user", conv.user.id, error); } } diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 012629c..68a6aa0 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -6,11 +6,12 @@ import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; import api from "@/core/api"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; +import { SignalProtocolService } from "@/utils/crypto/signalProtocol"; +import { removePadding } from "@/utils/crypto/obfuscation"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; import { createPortal } from "react-dom"; @@ -212,7 +213,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD }, [message.files, isDm, decryptedFiles]); async function decryptFile(file: Attachment): Promise { - if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null; + if (!file.encrypted || !isDm || !user.authToken || !dmEnvelope || !user.currentUser?.id) return null; // Check if already decrypted if (decryptedFiles.has(file.path)) { @@ -220,7 +221,6 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD } try { - // no-op decrypt indicator removed from UI // Fetch encrypted file const response = await fetch(file.path, { headers: api.user.auth.getAuthHeaders(user.authToken!) @@ -229,19 +229,36 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD const encryptedData = await response.arrayBuffer(); - // Get current user's keys - const keys = api.user.auth.getCurrentKeys(); - if (!keys) throw new Error("Keys not initialized"); - - // Derive shared secret with the recipient's public key - const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey)); - - // Derive wrapping key using the salt from the DM envelope - const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1])); - const wk = await importAesGcmKey(wkRaw); - - // Unwrap the message key - const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk)); + // Decrypt the master key using Signal Protocol + const signalService = new SignalProtocolService(user.currentUser.id.toString()); + const senderId = dmEnvelope.senderId; + + // Remove padding from wrappedMk (backward compatible) + let wrappedMkStr: string; + try { + wrappedMkStr = removePadding(dmEnvelope.wrappedMk); + } catch { + // If padding removal fails, assume it's an old message without padding + wrappedMkStr = dmEnvelope.wrappedMk; + } + + // Parse wrappedMk - it's a JSON string containing Signal Protocol encrypted data + let mk: Uint8Array; + try { + const encryptedMk = JSON.parse(wrappedMkStr); + if (encryptedMk.type && encryptedMk.body) { + // Signal Protocol encrypted + const mkBase64 = await signalService.decryptMessage(senderId, encryptedMk); + mk = new Uint8Array( + atob(mkBase64).split("").map(c => c.charCodeAt(0)) + ); + } else { + throw new Error("Invalid Signal Protocol message format"); + } + } catch (error) { + console.error("Failed to decrypt master key with Signal Protocol:", error); + throw error; + } // Decrypt the file using the message key const iv = new Uint8Array(encryptedData, 0, 12); diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 5319a5c..f2c5f15 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -53,6 +53,8 @@ export class DMPanel extends MessagePanel { clearMessages(): void { super.clearMessages(); this.messagesLoaded = false; + this.processedMessageIds.clear(); + this.failedDecryptionIds.clear(); } private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { @@ -111,6 +113,10 @@ export class DMPanel extends MessagePanel { for (const env of messages) { try { + // Mark as processed to prevent duplicates + if (env.id) { + this.processedMessageIds.add(env.id); + } const dmMsg = await this.parseTextPayload(env, decryptedMessages); decryptedMessages.push(dmMsg); @@ -118,7 +124,15 @@ export class DMPanel extends MessagePanel { maxIncomingId = env.id; } } catch (error) { - console.error("Error decrypting message:", error); + // Log warning with deduplication to avoid console spam + if (env.id && !this.failedDecryptionIds.has(env.id)) { + this.failedDecryptionIds.add(env.id); + console.warn(`Failed to decrypt DM ${env.id}:`, error instanceof Error ? error.message : String(error)); + } + // Remove from processed set if decryption failed + if (env.id) { + this.processedMessageIds.delete(env.id); + } } } @@ -165,7 +179,7 @@ export class DMPanel extends MessagePanel { const dmMsg = await this.parseTextPayload(env, decryptedMessages); decryptedMessages.push(dmMsg); } catch (error) { - console.error("Error decrypting message:", error); + // Silently skip messages that can't be decrypted } } @@ -204,7 +218,6 @@ export class DMPanel extends MessagePanel { } else { await sendDmWithFiles( this.dmData.userId, - this.dmData.publicKey, json, files, this.currentUser.authToken @@ -212,6 +225,16 @@ export class DMPanel extends MessagePanel { } } catch (error) { console.error("Failed to send DM:", error); + + // Check if it's a prekey exhaustion error + const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys"); + if (error instanceof PrekeyExhaustedError) { + const { alert } = await import("@/core/components/AlertDialog"); + await alert({ + headline: "Cannot Send Message", + description: "The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security." + }); + } } } @@ -228,14 +251,33 @@ export class DMPanel extends MessagePanel { } + // Track processed message IDs to prevent duplicates + private processedMessageIds: Set = new Set(); + private failedDecryptionIds: Set = new Set(); // Track messages that failed decryption to avoid spam + // Handle incoming WebSocket DM messages async handleWebSocketMessage(response: DMWebSocketMessage): Promise { + // Only process actual DM messages, not typing indicators or other events if (response.type === "dmNew" && this.dmData) { const envelope = response.data; + // Validate envelope has required fields + if (!envelope || !envelope.ciphertext || !envelope.senderId || !envelope.id) { + console.warn("Invalid DM envelope received, skipping"); + return; + } + + // Skip if we've already processed this message + if (this.processedMessageIds.has(envelope.id)) { + return; + } + // If this is for the active DM conversation if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { try { + // Mark as processed before attempting decryption + this.processedMessageIds.add(envelope.id); + const dmMsg = await this.parseTextPayload(envelope, this.getMessages()); // Check if this is a confirmation of a message we sent @@ -258,7 +300,15 @@ export class DMPanel extends MessagePanel { this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id)); } } catch (error) { - console.error("Failed to decrypt incoming DM:", error); + // Only log each failed message once to avoid console spam + if (envelope.id && !this.failedDecryptionIds.has(envelope.id)) { + this.failedDecryptionIds.add(envelope.id); + console.warn(`Failed to decrypt DM ${envelope.id}:`, error instanceof Error ? error.message : String(error)); + } + // Remove from processed set so we can retry if needed + if (envelope.id) { + this.processedMessageIds.delete(envelope.id); + } } } } @@ -315,6 +365,7 @@ export class DMPanel extends MessagePanel { this.dmData = null; this.messagesLoaded = false; this.clearMessages(); + this.failedDecryptionIds.clear(); // Clear failed decryption tracking this.updateState({ id: "dm", title: "Select a user", @@ -383,7 +434,7 @@ export class DMPanel extends MessagePanel { reply_to_id: msg?.reply_to?.id ?? undefined } }; - api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { + api.chats.dm.edit(messageId, this.dmData.userId, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { console.error("Failed to edit DM:", e); }); } diff --git a/frontend/src/utils/crypto/obfuscation.ts b/frontend/src/utils/crypto/obfuscation.ts new file mode 100644 index 0000000..95e6c28 --- /dev/null +++ b/frontend/src/utils/crypto/obfuscation.ts @@ -0,0 +1,76 @@ +import { randomBytes } from "./kdf"; + +/** + * Padding sizes that look like normal HTTP/WebSocket traffic + * These sizes are common in real web traffic to avoid fingerprinting + */ +const PADDING_BUCKETS = [64, 128, 256, 512, 1024, 2048, 4096]; + +/** + * Adds padding to a message to make it resistant to size-based fingerprinting + * Pads to the nearest bucket size to make all messages look similar + * @param data - The data to pad + * @returns Padded data with padding length prefix + */ +export function addPadding(data: string): string { + const dataBytes = new TextEncoder().encode(data); + const dataSize = dataBytes.length; + + // Find the smallest bucket that fits the data + let targetSize = PADDING_BUCKETS[PADDING_BUCKETS.length - 1]; + for (const bucket of PADDING_BUCKETS) { + if (bucket >= dataSize + 4) { // +4 for padding length header + targetSize = bucket; + break; + } + } + + // Calculate padding needed (subtract data size and 4-byte length header) + const paddingSize = targetSize - dataSize - 4; + const padding = randomBytes(Math.max(0, paddingSize)); + + // Create padded message: [4-byte length][data][random padding] + const lengthBytes = new Uint8Array(4); + const view = new DataView(lengthBytes.buffer); + view.setUint32(0, dataSize, true); // Little-endian + + const padded = new Uint8Array(4 + dataSize + padding.length); + padded.set(lengthBytes, 0); + padded.set(dataBytes, 4); + padded.set(padding, 4 + dataSize); + + // Return as base64 for easy transmission + // Use chunked approach to avoid "Maximum call stack size exceeded" for large arrays + // Convert Uint8Array to base64 in chunks + const chunkSize = 8192; + let binary = ''; + for (let i = 0; i < padded.length; i += chunkSize) { + const chunk = padded.slice(i, i + chunkSize); + binary += String.fromCharCode.apply(null, Array.from(chunk)); + } + return btoa(binary); +} + +/** + * Removes padding from a message + * @param paddedData - The padded data (base64) + * @returns Original unpadded data + */ +export function removePadding(paddedData: string): string { + try { + const padded = Uint8Array.from(atob(paddedData), c => c.charCodeAt(0)); + + // Read length from first 4 bytes + const view = new DataView(padded.buffer); + const dataSize = view.getUint32(0, true); // Little-endian + + // Extract original data + const data = padded.slice(4, 4 + dataSize); + return new TextDecoder().decode(data); + } catch (error) { + // If padding removal fails, assume it's an old message without padding + return paddedData; + } +} + + diff --git a/frontend/src/utils/crypto/signalProtocol.ts b/frontend/src/utils/crypto/signalProtocol.ts index 7ef3376..ff727b7 100644 --- a/frontend/src/utils/crypto/signalProtocol.ts +++ b/frontend/src/utils/crypto/signalProtocol.ts @@ -8,10 +8,12 @@ import { SessionCipher, KeyHelper, SignalProtocolAddress, - type DeviceType + type DeviceType, + type KeyPairType } from "@privacyresearch/libsignal-protocol-typescript"; import { SignalProtocolStorage } from "./signalStorage"; import { b64, ub64 } from "../utils"; +import api from "@/core/api"; // Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer) function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer { @@ -39,6 +41,13 @@ export interface PreKeyBundleData { export class SignalProtocolService { private storage: SignalProtocolStorage; + + // Prekey configuration constants + private static readonly PREKEY_COUNT = 20; + private static readonly PREKEY_REGEN_THRESHOLD = 5; // Regenerate when fewer than this many prekeys are left + private static readonly PREKEY_REGEN_COUNT = 10; // Number of prekeys to regenerate + private static readonly SIGNED_PREKEY_ID = 1; + private static readonly BATCH_SIZE = 10; constructor(userId: string) { this.storage = new SignalProtocolStorage(userId); @@ -64,21 +73,111 @@ export class SignalProtocolService { await this.storage.saveLocalRegistrationId(registrationId); // Generate signed prekey - const signedPreKeyId = 1; - const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, signedPreKeyId); - await this.storage.storeSignedPreKey(signedPreKeyId, signedPreKey.keyPair); - - // Store signature separately (we'll need it for the bundle) - // For now, we'll regenerate it when needed since storage doesn't store signatures + const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID); + // Store both the key pair and its signature + await this.storage.storeSignedPreKey( + SignalProtocolService.SIGNED_PREKEY_ID, + signedPreKey.keyPair, + new Uint8Array(signedPreKey.signature) + ); - // Generate prekeys (typically 100 prekeys) - const preKeyCount = 100; - for (let i = 1; i <= preKeyCount; i++) { + // Generate prekeys (one-time keys for establishing new sessions) + // Each new conversation consumes one prekey when the first message is sent + // Generation is non-blocking (yields to event loop), so this doesn't freeze the UI + for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) { const preKey = await KeyHelper.generatePreKey(i); await this.storage.storePreKey(i, preKey.keyPair); + + // Yield to event loop every batchSize keys to prevent UI freezing + if (i % SignalProtocolService.BATCH_SIZE === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + } } } + /** + * Ensure signed prekey exists and is valid, regenerating if necessary + */ + private async ensureSignedPreKey(identityKeyPair: KeyPairType): Promise<{ keyPair: KeyPairType; signature: Uint8Array }> { + let signature = await this.storage.loadSignedPreKeySignature(SignalProtocolService.SIGNED_PREKEY_ID); + let signedPreKey = await this.storage.loadSignedPreKey(SignalProtocolService.SIGNED_PREKEY_ID); + + if (!signedPreKey || !signature) { + // Signed prekey or signature missing - regenerate both to ensure consistency + const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID); + await this.storage.storeSignedPreKey( + SignalProtocolService.SIGNED_PREKEY_ID, + signedPreKeyWithSig.keyPair, + new Uint8Array(signedPreKeyWithSig.signature) + ); + signedPreKey = signedPreKeyWithSig.keyPair; + signature = new Uint8Array(signedPreKeyWithSig.signature); + } + + return { keyPair: signedPreKey, signature }; + } + + /** + * Find an available prekey, regenerating if necessary + */ + private async findOrRegeneratePreKey(): Promise<{ keyPair: KeyPairType; keyId: number }> { + // Find the first available prekey + let preKey: KeyPairType | undefined; + let preKeyId = 0; + let availableCount = 0; + + for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) { + const candidate = await this.storage.loadPreKey(i); + if (candidate) { + availableCount++; + if (!preKey) { + preKey = candidate; + preKeyId = i; + } + } + } + + // If we're running low on prekeys, regenerate more proactively + if (availableCount < SignalProtocolService.PREKEY_REGEN_THRESHOLD) { + console.warn(`Low on prekeys (${availableCount} remaining), regenerating...`); + + // Find the next available ID to regenerate from + let nextId = SignalProtocolService.PREKEY_COUNT + 1; + for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) { + const existing = await this.storage.loadPreKey(i); + if (!existing) { + nextId = i; + break; + } + } + + // Regenerate prekeys starting from nextId + for (let i = 0; i < SignalProtocolService.PREKEY_REGEN_COUNT; i++) { + const keyId = nextId + i; + const existing = await this.storage.loadPreKey(keyId); + if (!existing) { + const newPreKey = await KeyHelper.generatePreKey(keyId); + await this.storage.storePreKey(keyId, newPreKey.keyPair); + if (!preKey) { + preKey = newPreKey.keyPair; + preKeyId = keyId; + } + } + } + } + + // Emergency fallback if still no prekey + if (!preKey) { + console.error("No prekeys available, emergency regeneration..."); + const newPreKey = await KeyHelper.generatePreKey(1); + await this.storage.storePreKey(1, newPreKey.keyPair); + preKey = newPreKey.keyPair; + preKeyId = 1; + } + + return { keyPair: preKey, keyId: preKeyId }; + } + /** * Get prekey bundle for this user to share with others */ @@ -93,36 +192,73 @@ export class SignalProtocolService { throw new Error("Registration ID not found"); } - const signedPreKey = await this.storage.loadSignedPreKey(1); - if (!signedPreKey) { - throw new Error("Signed prekey not found"); - } - - // Regenerate signed prekey to get signature (since storage doesn't store it) - // In production, you'd store the signature separately - const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, 1); - await this.storage.storeSignedPreKey(1, signedPreKeyWithSig.keyPair); - - // Get a prekey to include - const preKey = await this.storage.loadPreKey(1); - if (!preKey) { - throw new Error("No prekeys available"); - } + const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair); + const { keyPair: preKey, keyId: preKeyId } = await this.findOrRegeneratePreKey(); return { registrationId: registrationId, identityKey: b64(new Uint8Array(identityKeyPair.pubKey)), signedPreKey: { - keyId: 1, + keyId: SignalProtocolService.SIGNED_PREKEY_ID, publicKey: b64(new Uint8Array(signedPreKey.pubKey)), - signature: b64(new Uint8Array(signedPreKeyWithSig.signature)) + signature: b64(signature) }, preKey: { - keyId: 1, + keyId: preKeyId, publicKey: b64(new Uint8Array(preKey.pubKey)) } }; } + + /** + * Get all available prekeys for uploading to the server + */ + async getAllPreKeys(): Promise> { + const prekeys: Array<{ keyId: number; publicKey: string }> = []; + + // Check all possible prekey IDs (including regenerated ones beyond initial count) + // We check up to PREKEY_COUNT + PREKEY_REGEN_COUNT to include regenerated prekeys + const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT; + + for (let i = 1; i <= maxPreKeyId; i++) { + const prekey = await this.storage.loadPreKey(i); + if (prekey) { + prekeys.push({ + keyId: i, + publicKey: b64(new Uint8Array(prekey.pubKey)) + }); + } + } + + return prekeys; + } + + /** + * Get the base bundle (without prekey) for uploading all prekeys + */ + async getBaseBundle(): Promise> { + const identityKeyPair = await this.storage.getIdentityKeyPair(); + if (!identityKeyPair) { + throw new Error("Signal Protocol not initialized"); + } + + const registrationId = await this.storage.getLocalRegistrationId(); + if (!registrationId) { + throw new Error("Registration ID not found"); + } + + const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair); + + return { + registrationId: registrationId, + identityKey: b64(new Uint8Array(identityKeyPair.pubKey)), + signedPreKey: { + keyId: SignalProtocolService.SIGNED_PREKEY_ID, + publicKey: b64(new Uint8Array(signedPreKey.pubKey)), + signature: b64(signature) + } + }; + } /** * Process a prekey bundle from another user and establish a session @@ -163,40 +299,186 @@ export class SignalProtocolService { const sessionCipher = new SessionCipher(this.storage, address); const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer); - const { type, body } = await sessionCipher.encrypt(plaintextBuffer); + const encryptResult = await sessionCipher.encrypt(plaintextBuffer); + const { type, body } = encryptResult; if (!body) { throw new Error("Encryption failed: no body in ciphertext"); } - // ciphertext.body is a base64 string, but we need to convert it properly - // According to the library, body is a serialized protobuf message as base64 string - return { type, body }; + // The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string + // Always convert to Uint8Array first, then to base64, regardless of input type + let bodyArray: Uint8Array; + const bodyAny = body as any; + + if (typeof body === "string") { + // String input - check if it's already base64 + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + if (base64Regex.test(body)) { + // Already base64, use as-is + bodyArray = ub64(body); + } else { + // String contains binary data, convert to Uint8Array + bodyArray = new Uint8Array([...body].map(c => c.charCodeAt(0))); + } + } else if (bodyAny instanceof Uint8Array) { + bodyArray = bodyAny; + } else if (bodyAny instanceof ArrayBuffer) { + bodyArray = new Uint8Array(bodyAny); + } else { + // Try to convert unknown type + if (bodyAny.buffer && bodyAny.buffer instanceof ArrayBuffer) { + bodyArray = new Uint8Array(bodyAny.buffer, bodyAny.byteOffset || 0, bodyAny.byteLength || bodyAny.buffer.byteLength); + } else { + bodyArray = new Uint8Array(bodyAny as ArrayBuffer); + } + } + + // Convert to base64 + const bodyBase64 = b64(bodyArray); + + // Final validation - ensure the result is valid base64 + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + if (!base64Regex.test(bodyBase64)) { + throw new Error(`Failed to convert body to base64: result contains invalid characters. Length: ${bodyBase64.length}`); + } + + // Test that it can be decoded + try { + atob(bodyBase64.substring(0, Math.min(4, bodyBase64.length))); + } catch (error) { + throw new Error(`Failed to convert body to base64: ${error instanceof Error ? error.message : String(error)}`); + } + + return { type, body: bodyBase64 }; } /** * Decrypt a message from a sender */ async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise { + if (!ciphertext.body || typeof ciphertext.body !== "string") { + throw new Error("Invalid ciphertext: body is missing or not a string"); + } + const address = new SignalProtocolAddress(senderId.toString(), 1); const sessionCipher = new SessionCipher(this.storage, address); // Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1) - const { buffer, byteOffset, byteLength } = ub64(ciphertext.body); - const bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength)); - let plaintextBytes: ArrayBuffer; + // ciphertext.body is a base64 string from the Signal Protocol library + let bodyBuffer: ArrayBuffer; + try { + const { buffer, byteOffset, byteLength } = ub64(ciphertext.body); + bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength)); + } catch (error) { + throw new Error(`Failed to decode ciphertext body: ${error instanceof Error ? error.message : String(error)}`); + } - if (ciphertext.type === 3) { - // PreKeyWhisperMessage - plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer); - } else { - // WhisperMessage - plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer); + let plaintextBytes: ArrayBuffer; + try { + if (ciphertext.type === 3) { + // PreKeyWhisperMessage - this will consume a prekey + // Count available prekeys before decryption + const prekeysBefore = await this.countAvailablePrekeys(); + + plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer); + + // Check if a prekey was consumed (removed by the library) + const prekeysAfter = await this.countAvailablePrekeys(); + if (prekeysBefore > prekeysAfter) { + // A prekey was consumed - refresh the bundle in the background + // This ensures new users can still message you while you're offline + this.refreshPreKeyBundle().catch(err => + console.warn("Failed to refresh prekey bundle after consumption:", err) + ); + } + } else { + // WhisperMessage - uses existing session, no prekey consumed + plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer); + } + } catch (error) { + // Log detailed error information for debugging + const errorMessage = error instanceof Error ? error.message : String(error); + + // Handle different types of decryption errors + if (errorMessage.includes("Bad MAC")) { + console.warn(`Bad MAC error detected for sender ${senderId} (type ${ciphertext.type}). Session may be out of sync.`); + + // For both types, remove the session so next message can re-establish it + try { + await this.storage.removeSession(address.toString()); + console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`); + } catch (resetError) { + console.error("Failed to remove session:", resetError); + } + } else if (errorMessage.includes("Tried to decrypt on a sending chain") || errorMessage.includes("No record for device")) { + // These errors indicate the session state is corrupted or missing + // Remove the session so it can be re-established + console.warn(`Session state error for sender ${senderId}: ${errorMessage}. Removing session.`); + try { + await this.storage.removeSession(address.toString()); + console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`); + } catch (resetError) { + console.error("Failed to remove session:", resetError); + } + } + + console.error("Signal Protocol decryption failed:", { + senderId, + type: ciphertext.type, + bodyLength: ciphertext.body.length, + bodyFirst50: ciphertext.body.substring(0, 50), + bodyLast50: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 50)), + bodyIsBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(ciphertext.body), + error: errorMessage + }); + throw new Error(`Failed to decrypt message: ${errorMessage}`); } return new TextDecoder().decode(plaintextBytes); } + + /** + * Count available prekeys + */ + private async countAvailablePrekeys(): Promise { + let count = 0; + const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT; + + for (let i = 1; i <= maxPreKeyId; i++) { + const prekey = await this.storage.loadPreKey(i); + if (prekey) { + count++; + } + } + return count; + } + + /** + * Refresh prekey bundle after a prekey was consumed + * This ensures new users can still message you while you're offline + * Uploads all available prekeys to the server for rotation + */ + private async refreshPreKeyBundle(): Promise { + try { + const token = api.user.auth.getAuthToken(); + if (!token) { + console.warn("No auth token, cannot refresh prekey bundle"); + return; + } + + const baseBundle = await this.getBaseBundle(); + const prekeys = await this.getAllPreKeys(); + + // Upload all prekeys in the background + api.crypto.prekeys.uploadAllPreKeys(baseBundle, prekeys, token).catch(err => + console.warn("Failed to upload all prekeys:", err) + ); + } catch (error) { + console.error("Failed to refresh prekey bundle:", error); + } + } /** * Check if a session exists for a recipient diff --git a/frontend/src/utils/crypto/signalStorage.ts b/frontend/src/utils/crypto/signalStorage.ts index de81051..208a7b8 100644 --- a/frontend/src/utils/crypto/signalStorage.ts +++ b/frontend/src/utils/crypto/signalStorage.ts @@ -69,9 +69,44 @@ async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "r } // Helper to convert Uint8Array to ArrayBuffer -function toArrayBuffer(u8: Uint8Array | ArrayBuffer): ArrayBuffer { +function toArrayBuffer(u8: Uint8Array | ArrayBuffer | ArrayBufferLike): ArrayBuffer { if (u8 instanceof ArrayBuffer) return u8; - return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + + // Check if SharedArrayBuffer is available (requires COOP/COEP headers) + const SharedArrayBufferConstructor = typeof SharedArrayBuffer !== "undefined" ? SharedArrayBuffer : null; + + if (SharedArrayBufferConstructor && u8 instanceof SharedArrayBufferConstructor) { + // Convert SharedArrayBuffer to ArrayBuffer by copying + const view = new Uint8Array(u8); + const copy = new Uint8Array(view.length); + copy.set(view); + // copy.buffer is always ArrayBuffer for a newly created Uint8Array + return copy.buffer as ArrayBuffer; + } + + // Uint8Array case - buffer might be SharedArrayBuffer, so copy it + if (u8 instanceof Uint8Array) { + const buffer = u8.buffer; + if (SharedArrayBufferConstructor && buffer instanceof SharedArrayBufferConstructor) { + const copy = new Uint8Array(u8.length); + copy.set(u8); + // copy.buffer is always ArrayBuffer for a newly created Uint8Array + return copy.buffer as ArrayBuffer; + } + const sliced = buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + // Ensure we return ArrayBuffer, not SharedArrayBuffer + if (SharedArrayBufferConstructor && sliced instanceof SharedArrayBufferConstructor) { + const copy = new Uint8Array(sliced); + // copy.buffer is always ArrayBuffer for a newly created Uint8Array + return copy.buffer as unknown as ArrayBuffer; + } + // TypeScript doesn't know that slice() returns ArrayBuffer when buffer is ArrayBuffer + // But we've already checked it's not SharedArrayBuffer, so it must be ArrayBuffer + return sliced as unknown as ArrayBuffer; + } + + // Fallback: treat as ArrayBuffer + return u8 as unknown as ArrayBuffer; } // Helper to convert ArrayBuffer to Uint8Array @@ -90,7 +125,7 @@ export class SignalProtocolStorage implements StorageType { // Identity Key Management async getIdentityKeyPair(): Promise { const store = await getStore("identityKeys"); - const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => { + const result = await new Promise((resolve, reject) => { const request = store.get(this.userId); request.onsuccess = () => { const data = request.result; @@ -165,7 +200,7 @@ export class SignalProtocolStorage implements StorageType { async loadPreKey(encodedAddress: string | number): Promise { const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10); const store = await getStore("preKeys"); - const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => { + const result = await new Promise((resolve, reject) => { const request = store.get([this.userId, preKeyId]); request.onsuccess = () => { const data = request.result; @@ -213,7 +248,7 @@ export class SignalProtocolStorage implements StorageType { async loadSignedPreKey(keyId: number | string): Promise { const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); const store = await getStore("signedPreKeys"); - const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer; keyId: number } | undefined>((resolve, reject) => { + const result = await new Promise((resolve, reject) => { const request = store.get(this.userId); request.onsuccess = () => { const data = request.result; @@ -232,20 +267,49 @@ export class SignalProtocolStorage implements StorageType { return result; } - async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType): Promise { + async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType, signature?: Uint8Array): Promise { const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); const store = await getStore("signedPreKeys", "readwrite"); await new Promise((resolve, reject) => { - const request = store.put({ + interface SignedPreKeyData { + userId: string; + keyId: number; + publicKey: Uint8Array; + privateKey: Uint8Array; + signature?: Uint8Array; + } + const data: SignedPreKeyData = { userId: this.userId, keyId: signedPreKeyId, publicKey: toUint8Array(keyPair.pubKey), privateKey: toUint8Array(keyPair.privKey) - }); + }; + if (signature) { + data.signature = toUint8Array(signature); + } + const request = store.put(data); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } + + async loadSignedPreKeySignature(keyId: number | string): Promise { + const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); + const store = await getStore("signedPreKeys"); + const result = await new Promise<{ signature?: Uint8Array } | undefined>((resolve, reject) => { + const request = store.get(this.userId); + request.onsuccess = () => { + const data = request.result; + if (!data || data.keyId !== signedPreKeyId) { + resolve(undefined); + return; + } + resolve(data.signature ? { signature: toUint8Array(data.signature) } : undefined); + }; + request.onerror = () => reject(request.error); + }); + return result?.signature; + } async removeSignedPreKey(keyId: number | string): Promise { const store = await getStore("signedPreKeys", "readwrite"); @@ -263,7 +327,7 @@ export class SignalProtocolStorage implements StorageType { const deviceId = parts.length > 1 ? parts[1] : encodedAddress; const store = await getStore("sessions"); - const result = await new Promise<{ record: string } | undefined>((resolve, reject) => { + const result = await new Promise((resolve, reject) => { const request = store.get([this.userId, deviceId]); request.onsuccess = () => { const data = request.result; @@ -291,5 +355,18 @@ export class SignalProtocolStorage implements StorageType { request.onerror = () => reject(request.error); }); } + + async removeSession(encodedAddress: string): Promise { + // encodedAddress format: "userId.deviceId" + const parts = encodedAddress.split("."); + const deviceId = parts.length > 1 ? parts[1] : encodedAddress; + + const store = await getStore("sessions", "readwrite"); + await new Promise((resolve, reject) => { + const request = store.delete([this.userId, deviceId]); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } } diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 874b6d6..2ba741d 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -33,7 +33,17 @@ export function delay(ms: number): Promise { } -export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); } +export function b64(a: Uint8Array): string { + // Use chunked approach to avoid "Maximum call stack size exceeded" for large arrays + // Process in chunks and use apply to avoid spreading large arrays + const chunkSize = 8192; + let binary = ''; + for (let i = 0; i < a.length; i += chunkSize) { + const chunk = a.slice(i, i + chunkSize); + binary += String.fromCharCode.apply(null, Array.from(chunk)); + } + return btoa(binary); +} export function ub64(s: string): Uint8Array { const bin = atob(s); const arr = new Uint8Array(bin.length);