Working one-time message encryption and decryption

This commit is contained in:
2025-12-03 16:40:26 +03:00
Unverified
parent d4b1e261d7
commit fcb3dff2c9
18 changed files with 1392 additions and 336 deletions
+14 -1
View File
@@ -76,10 +76,23 @@ class SignalPreKeyBundle(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=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) 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): class DMEnvelope(Base):
__tablename__ = "dm_envelope" __tablename__ = "dm_envelope"
+144 -5
View File
@@ -488,7 +488,7 @@ def upload_prekey_bundle(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Upload Signal Protocol prekey bundle for the current user""" """Upload Signal Protocol prekey bundle for the current user"""
from models import SignalPreKeyBundle from models import SignalPreKeyBundle, SignalPreKey
import json import json
bundle = payload.get("bundle") bundle = payload.get("bundle")
@@ -508,11 +508,17 @@ def upload_prekey_bundle(
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]: if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
raise HTTPException(status_code=400, detail="Invalid signedPreKey format") raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
# Store as JSON string # Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey
bundle_json = json.dumps(bundle) 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 if len(bundle_json) > 50000: # 50KB limit
raise HTTPException(status_code=400, detail="Bundle too large") 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() row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
if row: if row:
row.bundle_json = bundle_json row.bundle_json = bundle_json
@@ -520,11 +526,121 @@ def upload_prekey_bundle(
else: else:
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json) row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
db.add(row) 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() db.commit()
return {"status": "ok"} 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") @router.get("/crypto/signal/prekey-bundle")
def get_prekey_bundle( def get_prekey_bundle(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -553,16 +669,39 @@ def get_prekey_bundle_of(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Get Signal Protocol prekey bundle for another user""" """Get Signal Protocol prekey bundle for another user with prekey rotation"""
from models import SignalPreKeyBundle from models import SignalPreKeyBundle, SignalPreKey
import json import json
# Get the base bundle (identity key, signed prekey, registration ID)
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first() row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
if not row: if not row:
raise HTTPException(status_code=404, detail="Prekey bundle not found") raise HTTPException(status_code=404, detail="Prekey bundle not found")
try: try:
bundle = json.loads(row.bundle_json) 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} return {"bundle": bundle}
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Invalid bundle data") raise HTTPException(status_code=500, detail="Invalid bundle data")
+271 -62
View File
@@ -1,28 +1,139 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth"; import api from "@/core/api";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf"; import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "../user/auth";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils"; import { b64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity"; import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { fetchUsers, searchUsers } from "../user/search"; import { useUserStore } from "@/state/user";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); if (!user?.id) {
throw new Error("User not authenticated");
}
// Obtain the key if (!envelope.ciphertext) {
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); throw new Error("DM envelope missing ciphertext");
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));
// Decrypt const signalService = new SignalProtocolService(user.id.toString());
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg); // 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 }> { 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}`; url += `&before_id=${beforeId}`;
} }
const response = await globalThis.fetch(url, { 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 }; if (!response.ok) return { messages: [], has_more: false };
const data = await response.json(); const data = await response.json();
return { messages: data.messages || [], has_more: data.has_more ?? false }; 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<void> { export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); if (!user?.id) {
throw new Error("User not authenticated");
}
// Encryption key const signalService = new SignalProtocolService(user.id.toString());
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 // Check if we have a session, if not, fetch prekey bundle and establish one
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); const hasSession = await signalService.hasSession(recipientId);
const wrap = await aesGcmEncrypt(wk, mk); 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 = { const payload: SendDMRequest = {
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), iv: "", // Not used for Signal Protocol
ciphertext: b64(encMsg.ciphertext), ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: b64(wkSalt), salt: "", // Not used for Signal Protocol
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext) wrappedMk: "" // Not used for Signal Protocol
}; };
if (replyToId) payload.replyToId = replyToId; 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<void> { export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); 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 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 form = new FormData();
const names: string[] = []; const names: string[] = [];
@@ -115,30 +299,54 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext), ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt), salt: "", // Not used for Signal Protocol
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext) wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope)); } satisfies BaseDmEnvelope));
await globalThis.fetch(`${API_BASE_URL}/dm/send`, { await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
method: "POST", method: "POST",
headers: getAuthHeaders(token, false), headers: api.user.auth.getAuthHeaders(token, false),
body: form body: form
}); });
} }
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> { export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); 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 mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); // Encrypt the master key using Signal Protocol
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const mkBase64 = b64(mk);
const wk = await importAesGcmKey(wkRaw); 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 encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({ await request({
type: "dmEdit", type: "dmEdit",
@@ -147,9 +355,9 @@ export async function edit(id: number, recipientPublicKeyB64: string, newPlainte
id, id,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext), ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext), wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: b64(wkSalt) salt: "" // Not used for Signal Protocol
} }
} as DMEditRequest); } as DMEditRequest);
} }
@@ -170,7 +378,7 @@ export interface ConversationResponse {
export async function conversations(token: string): Promise<ConversationResponse[]> { export async function conversations(token: string): Promise<ConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, { const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true) headers: api.user.auth.getAuthHeaders(token, true)
}); });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
@@ -189,6 +397,7 @@ export async function markRead(id: number, authToken: string): Promise<void> {
} }
// Re-export user functions for convenience // Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey }; export { fetchUsers, searchUsers } from "@/core/api/users";
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
+15 -8
View File
@@ -79,15 +79,22 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise
* Uploads Signal Protocol prekey bundle for the current user * Uploads Signal Protocol prekey bundle for the current user
*/ */
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> { export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
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`, { * Uploads all available prekeys to the server for rotation
method: "POST", */
headers, export async function uploadAllPreKeys(
body: JSON.stringify(payload) baseBundle: Omit<PreKeyBundleData, "preKey">,
}); prekeys: Array<{ keyId: number; publicKey: string }>,
if (!res.ok) throw new Error("Failed to upload prekey bundle"); token: string
): Promise<void> {
// Re-export from prekeys.ts
const { uploadAllPreKeys: upload } = await import("./crypto/prekeys");
return upload(baseBundle, prekeys, token);
} }
/** /**
+83 -8
View File
@@ -1,14 +1,89 @@
// Placeholder for Signal Protocol pre-key management import { API_BASE_URL } from "@/core/config";
// Will be implemented when Signal Protocol is added import { getAuthHeaders } from "../user/auth";
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
export async function upload(_bundle: unknown, _token: string): Promise<void> { /**
// TODO: Implement Signal Protocol pre-key upload * Uploads Signal Protocol prekey bundle for the current user
throw new Error("Not implemented yet"); * This uploads the base bundle (identity, signed prekey) and one prekey
*/
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
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<unknown> { /**
// TODO: Implement Signal Protocol pre-key fetch * Uploads all available prekeys to the server for rotation in a single request
throw new Error("Not implemented yet"); */
export async function uploadAllPreKeys(
baseBundle: Omit<PreKeyBundleData, "preKey">,
prekeys: Array<{ keyId: number; publicKey: string }>,
token: string
): Promise<void> {
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<PreKeyBundleData> {
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;
} }
+166 -42
View File
@@ -1,16 +1,13 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import api from "@/core/api";
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric"; import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf"; import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils"; import { b64 } from "@/utils/utils";
import { fetchUserPublicKey, fetchPreKeyBundle } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol"; import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user"; 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<string> { export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
const user = useUserStore.getState().user.currentUser; 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"); throw new Error("User not authenticated");
} }
const signalService = new SignalProtocolService(user.id.toString()); if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
// Parse Signal Protocol message
const signalCiphertext = JSON.parse(envelope.ciphertext);
if (!signalCiphertext.type || !signalCiphertext.body) {
throw new Error("Invalid Signal Protocol message format");
} }
const signalService = new SignalProtocolService(user.id.toString());
// 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;
}
// 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); const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext; 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<DmEnvelope[]> { export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { 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 []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
@@ -52,9 +138,9 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string,
const hasSession = await signalService.hasSession(recipientId); const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) { if (!hasSession) {
// Fetch prekey bundle from server // Fetch prekey bundle from server
const bundle = await fetchPreKeyBundle(recipientId, authToken); const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
if (!bundle) { 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); await signalService.processPreKeyBundle(recipientId, bundle);
} }
@@ -62,10 +148,13 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string,
// Encrypt with Signal Protocol // Encrypt with Signal Protocol
const ciphertext = await signalService.encryptMessage(recipientId, plaintext); const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
// Add padding to obfuscate message size (anti-censorship)
const paddedCiphertext = addPadding(JSON.stringify(ciphertext));
const payload: SendDMRequest = { const payload: SendDMRequest = {
recipientId: recipientId, recipientId: recipientId,
iv: "", // Not used for Signal Protocol 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 salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol iv2: "", // Not used for Signal Protocol
wrappedMk: "" // 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<void> { export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); 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 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 form = new FormData();
const names: string[] = []; const names: string[] = [];
@@ -124,30 +229,48 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext), ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt), salt: "", // Not used for Signal Protocol
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext) wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope)); } satisfies BaseDmEnvelope));
await fetch(`${API_BASE_URL}/dm/send`, { await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST", method: "POST",
headers: getAuthHeaders(token, false), headers: api.user.auth.getAuthHeaders(token, false),
body: form body: form
}); });
} }
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> { export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); 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 mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); // Encrypt the master key using Signal Protocol
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const mkBase64 = b64(mk);
const wk = await importAesGcmKey(wkRaw); 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 encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({ await request({
type: "dmEdit", type: "dmEdit",
@@ -156,9 +279,9 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
id, id,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext), ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext), wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: b64(wkSalt) salt: "" // Not used for Signal Protocol
} }
} as DMEditRequest); } as DMEditRequest);
} }
@@ -178,11 +301,12 @@ export interface DMConversationResponse {
} }
// Re-export for convenience // 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<DMConversationResponse[]> { export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, { const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true) headers: api.user.auth.getAuthHeaders(token, true)
}); });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
+9 -21
View File
@@ -5,7 +5,7 @@
* @version 1.0.0 * @version 1.0.0
*/ */
import { request } from "./websocket"; import { send } from "./websocket";
import type { import type {
TypingWebSocketMessage, TypingWebSocketMessage,
StopTypingWebSocketMessage, StopTypingWebSocketMessage,
@@ -39,7 +39,6 @@ export class TypingManager {
async sendTyping(): Promise<void> { async sendTyping(): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: TypingRequest = { const message: TypingRequest = {
type: "typing", type: "typing",
credentials: { credentials: {
@@ -49,11 +48,9 @@ export class TypingManager {
data: {} data: {}
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.scheduleStopTyping("public"); this.scheduleStopTyping("public");
} catch (error) {
console.error("Failed to send typing indicator:", error);
}
} }
/** /**
@@ -62,7 +59,6 @@ export class TypingManager {
async sendStopTyping(): Promise<void> { async sendStopTyping(): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: StopTypingRequest = { const message: StopTypingRequest = {
type: "stopTyping", type: "stopTyping",
credentials: { credentials: {
@@ -72,11 +68,9 @@ export class TypingManager {
data: {} data: {}
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout("public"); this.clearStopTypingTimeout("public");
} catch (error) {
console.error("Failed to send stop typing indicator:", error);
}
} }
/** /**
@@ -85,7 +79,6 @@ export class TypingManager {
async sendDmTyping(recipientId: number): Promise<void> { async sendDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: DmTypingRequest = { const message: DmTypingRequest = {
type: "dmTyping", type: "dmTyping",
credentials: { credentials: {
@@ -97,11 +90,9 @@ export class TypingManager {
} }
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.scheduleStopDmTyping(recipientId); this.scheduleStopDmTyping(recipientId);
} catch (error) {
console.error("Failed to send DM typing indicator:", error);
}
} }
/** /**
@@ -110,7 +101,6 @@ export class TypingManager {
async sendStopDmTyping(recipientId: number): Promise<void> { async sendStopDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: StopDmTypingRequest = { const message: StopDmTypingRequest = {
type: "stopDmTyping", type: "stopDmTyping",
credentials: { credentials: {
@@ -122,11 +112,9 @@ export class TypingManager {
} }
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout(`dm_${recipientId}`); this.clearStopTypingTimeout(`dm_${recipientId}`);
} catch (error) {
console.error("Failed to send stop DM typing indicator:", error);
}
} }
/** /**
+4 -35
View File
@@ -6,7 +6,6 @@
*/ */
import { openDB, type IDBPDatabase } from "idb"; import { openDB, type IDBPDatabase } from "idb";
import type { WebSocketCredentials, WebSocketMessage } from "./types";
interface UpdateMessage<T = any> { interface UpdateMessage<T = any> {
type: string; type: string;
@@ -72,28 +71,18 @@ export async function setLastSequence(seq: number): Promise<void> {
* Process a batched updates message * Process a batched updates message
* @param message - The batched updates message from the server * @param message - The batched updates message from the server
* @param handler - Function to handle individual updates * @param handler - Function to handle individual updates
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
*/ */
export async function processBatchedUpdates( export async function processBatchedUpdates(
message: BatchedUpdatesMessage, message: BatchedUpdatesMessage,
handler: (update: UpdateMessage) => void, handler: (update: UpdateMessage) => void
requestMissedFn?: (lastSeq: number) => Promise<void>
): Promise<void> { ): Promise<void> {
const { seq, updates } = message; const { seq, updates } = message;
const lastSeq = await getLastSequence(); 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) { if (seq !== lastSeq + 1 && lastSeq > 0) {
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`); const gapSize = seq - (lastSeq + 1);
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq} (gap size: ${gapSize}). Skipping ${gapSize} updates.`);
// Request missing updates if function provided
if (requestMissedFn) {
try {
await requestMissedFn(lastSeq);
} catch (error) {
console.error("Failed to request missed updates for gap:", error);
}
}
} }
// Process all updates in the batch // Process all updates in the batch
@@ -104,23 +93,3 @@ export async function processBatchedUpdates(
// Update last sequence number // Update last sequence number
await setLastSequence(seq); 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<void>,
credentials?: WebSocketCredentials
): Promise<void> {
if (lastSeq > 0) {
await requestFn({
type: "getUpdates",
data: { lastSeq },
credentials
});
}
}
+21 -27
View File
@@ -12,7 +12,7 @@ import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager"; import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager"; import { typingManager } from "./typingManager";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager"; import { processBatchedUpdates } from "./updateManager";
import { getAuthToken } from "@/core/api/user/auth"; import { getAuthToken } from "@/core/api/user/auth";
interface HttpError extends Error { interface HttpError extends Error {
@@ -161,21 +161,10 @@ function setupEventHandlers(): void {
// Handle batched updates // Handle batched updates
if (response.type === "updates" && "seq" in response && "updates" in response) { 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) => { await processBatchedUpdates(response as any, (update) => {
// Route individual updates to appropriate handlers // Route individual updates to appropriate handlers
handleUpdate(update); handleUpdate(update);
}, requestMissedFn); });
return; return;
} }
@@ -250,20 +239,9 @@ function setupEventHandlers(): void {
console.error("Failed to send ping on reconnect:", error); console.error("Failed to send ping on reconnect:", error);
} }
// Send last sequence number and request missed updates on reconnect // Note: We don't request missed updates on reconnect because getUpdates
// Wait a bit for ping to complete authentication // doesn't properly return updates (they're sent directly via WebSocket
await delay(100); // but the client can't handle them). Gaps will be logged but not recovered.
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);
}
} }
} catch (error) { } catch (error) {
console.error("Failed to authenticate on reconnect:", error); console.error("Failed to authenticate on reconnect:", error);
@@ -359,6 +337,22 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
}); });
} }
/**
* Send a WebSocket message without waiting for a response (fire-and-forget)
* Useful for typing indicators and other non-critical messages
*/
export function send<T = unknown>(payload: WebSocketMessage<T>): 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 // Initialization
// -------------- // --------------
+9 -2
View File
@@ -1,12 +1,13 @@
import { AuthContainer } from "./Auth"; import { AuthContainer } from "./Auth";
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react"; 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 { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { LoginForm } from "./LoginForm"; import { LoginForm } from "./LoginForm";
import { RegisterForm } from "./RegisterForm"; import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react"; import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss"; import styles from "./auth.module.scss";
import { useUserStore } from "@/state/user";
const slideVariants: Variants = { const slideVariants: Variants = {
enter: (direction: number) => ({ enter: (direction: number) => ({
@@ -37,8 +38,8 @@ const slideTransition: Transition = {
export default function AuthPage() { export default function AuthPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const { navigate: navigateDownloadApp } = useDownloadAppScreen();
if (navigateDownloadApp) return navigateDownloadApp;
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useUserStore();
const [direction, setDirection] = useState(0); const [direction, setDirection] = useState(0);
const prevMode = useRef(searchParams.get("mode") || "login"); const prevMode = useRef(searchParams.get("mode") || "login");
@@ -91,6 +92,12 @@ export default function AuthPage() {
}; };
}, [currentMode]); }, [currentMode]);
// Now we can do conditional returns after all hooks are called
if (navigateDownloadApp) return navigateDownloadApp;
if (user.authToken && user.currentUser) {
return <Navigate to="/chat" replace />;
}
function switchMode(newMode: "login" | "register") { function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true }); navigate(`/auth?mode=${newMode}`, { replace: true });
} }
+19 -3
View File
@@ -93,14 +93,30 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
try { try {
await api.user.auth.ensureKeysOnLogin(password, data.token); 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) { if (data.user?.id) {
// Run Signal Protocol initialization in background to avoid blocking navigation
(async () => {
try {
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol"); const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
const { uploadPreKeyBundle } = await import("@/core/api/crypto"); const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys");
const signalService = new SignalProtocolService(data.user.id.toString()); const signalService = new SignalProtocolService(data.user!.id.toString());
await signalService.initialize(); await signalService.initialize();
// Upload base bundle with one prekey (for backward compatibility)
const bundle = await signalService.getPreKeyBundle(); const bundle = await signalService.getPreKeyBundle();
await uploadPreKeyBundle(bundle, data.token); 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) { } catch (e) {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
+3 -1
View File
@@ -124,7 +124,9 @@ export function useDM() {
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
} }
} catch (error) { } 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);
} }
} }
+31 -14
View File
@@ -6,11 +6,12 @@ import { parse } from "marked";
import { escape as escapeHtml } from "he"; import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react"; import { useEffect, useState, useRef, useMemo } from "react";
import api from "@/core/api"; import api from "@/core/api";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile"; import { useProfileStore } from "@/state/profile";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { removePadding } from "@/utils/crypto/obfuscation";
import { ub64 } from "@/utils/utils"; import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -212,7 +213,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}, [message.files, isDm, decryptedFiles]); }, [message.files, isDm, decryptedFiles]);
async function decryptFile(file: Attachment): Promise<string | null> { async function decryptFile(file: Attachment): Promise<string | null> {
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 // Check if already decrypted
if (decryptedFiles.has(file.path)) { if (decryptedFiles.has(file.path)) {
@@ -220,7 +221,6 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
} }
try { try {
// no-op decrypt indicator removed from UI
// Fetch encrypted file // Fetch encrypted file
const response = await fetch(file.path, { const response = await fetch(file.path, {
headers: api.user.auth.getAuthHeaders(user.authToken!) headers: api.user.auth.getAuthHeaders(user.authToken!)
@@ -229,19 +229,36 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const encryptedData = await response.arrayBuffer(); const encryptedData = await response.arrayBuffer();
// Get current user's keys // Decrypt the master key using Signal Protocol
const keys = api.user.auth.getCurrentKeys(); const signalService = new SignalProtocolService(user.currentUser.id.toString());
if (!keys) throw new Error("Keys not initialized"); const senderId = dmEnvelope.senderId;
// Derive shared secret with the recipient's public key // Remove padding from wrappedMk (backward compatible)
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey)); let wrappedMkStr: string;
try {
wrappedMkStr = removePadding(dmEnvelope.wrappedMk);
} catch {
// If padding removal fails, assume it's an old message without padding
wrappedMkStr = dmEnvelope.wrappedMk;
}
// Derive wrapping key using the salt from the DM envelope // Parse wrappedMk - it's a JSON string containing Signal Protocol encrypted data
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1])); let mk: Uint8Array;
const wk = await importAesGcmKey(wkRaw); try {
const encryptedMk = JSON.parse(wrappedMkStr);
// Unwrap the message key if (encryptedMk.type && encryptedMk.body) {
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk)); // 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 // Decrypt the file using the message key
const iv = new Uint8Array(encryptedData, 0, 12); const iv = new Uint8Array(encryptedData, 0, 12);
@@ -53,6 +53,8 @@ export class DMPanel extends MessagePanel {
clearMessages(): void { clearMessages(): void {
super.clearMessages(); super.clearMessages();
this.messagesLoaded = false; this.messagesLoaded = false;
this.processedMessageIds.clear();
this.failedDecryptionIds.clear();
} }
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
@@ -111,6 +113,10 @@ export class DMPanel extends MessagePanel {
for (const env of messages) { for (const env of messages) {
try { try {
// Mark as processed to prevent duplicates
if (env.id) {
this.processedMessageIds.add(env.id);
}
const dmMsg = await this.parseTextPayload(env, decryptedMessages); const dmMsg = await this.parseTextPayload(env, decryptedMessages);
decryptedMessages.push(dmMsg); decryptedMessages.push(dmMsg);
@@ -118,7 +124,15 @@ export class DMPanel extends MessagePanel {
maxIncomingId = env.id; maxIncomingId = env.id;
} }
} catch (error) { } 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); const dmMsg = await this.parseTextPayload(env, decryptedMessages);
decryptedMessages.push(dmMsg); decryptedMessages.push(dmMsg);
} catch (error) { } 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 { } else {
await sendDmWithFiles( await sendDmWithFiles(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey,
json, json,
files, files,
this.currentUser.authToken this.currentUser.authToken
@@ -212,6 +225,16 @@ export class DMPanel extends MessagePanel {
} }
} catch (error) { } catch (error) {
console.error("Failed to send DM:", 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<number> = new Set();
private failedDecryptionIds: Set<number> = new Set(); // Track messages that failed decryption to avoid spam
// Handle incoming WebSocket DM messages // Handle incoming WebSocket DM messages
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> { async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
// Only process actual DM messages, not typing indicators or other events
if (response.type === "dmNew" && this.dmData) { if (response.type === "dmNew" && this.dmData) {
const envelope = response.data; 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 this is for the active DM conversation
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try { try {
// Mark as processed before attempting decryption
this.processedMessageIds.add(envelope.id);
const dmMsg = await this.parseTextPayload(envelope, this.getMessages()); const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
// Check if this is a confirmation of a message we sent // 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)); this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
} }
} catch (error) { } 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.dmData = null;
this.messagesLoaded = false; this.messagesLoaded = false;
this.clearMessages(); this.clearMessages();
this.failedDecryptionIds.clear(); // Clear failed decryption tracking
this.updateState({ this.updateState({
id: "dm", id: "dm",
title: "Select a user", title: "Select a user",
@@ -383,7 +434,7 @@ export class DMPanel extends MessagePanel {
reply_to_id: msg?.reply_to?.id ?? undefined 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); console.error("Failed to edit DM:", e);
}); });
} }
+76
View File
@@ -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;
}
}
+318 -36
View File
@@ -8,10 +8,12 @@ import {
SessionCipher, SessionCipher,
KeyHelper, KeyHelper,
SignalProtocolAddress, SignalProtocolAddress,
type DeviceType type DeviceType,
type KeyPairType
} from "@privacyresearch/libsignal-protocol-typescript"; } from "@privacyresearch/libsignal-protocol-typescript";
import { SignalProtocolStorage } from "./signalStorage"; import { SignalProtocolStorage } from "./signalStorage";
import { b64, ub64 } from "../utils"; import { b64, ub64 } from "../utils";
import api from "@/core/api";
// Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer) // Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer)
function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer { function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer {
@@ -40,6 +42,13 @@ export interface PreKeyBundleData {
export class SignalProtocolService { export class SignalProtocolService {
private storage: SignalProtocolStorage; 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) { constructor(userId: string) {
this.storage = new SignalProtocolStorage(userId); this.storage = new SignalProtocolStorage(userId);
} }
@@ -64,20 +73,110 @@ export class SignalProtocolService {
await this.storage.saveLocalRegistrationId(registrationId); await this.storage.saveLocalRegistrationId(registrationId);
// Generate signed prekey // Generate signed prekey
const signedPreKeyId = 1; const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID);
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, signedPreKeyId); // Store both the key pair and its signature
await this.storage.storeSignedPreKey(signedPreKeyId, signedPreKey.keyPair); await this.storage.storeSignedPreKey(
SignalProtocolService.SIGNED_PREKEY_ID,
signedPreKey.keyPair,
new Uint8Array(signedPreKey.signature)
);
// Store signature separately (we'll need it for the bundle) // Generate prekeys (one-time keys for establishing new sessions)
// For now, we'll regenerate it when needed since storage doesn't store signatures // 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
// Generate prekeys (typically 100 prekeys) for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
const preKeyCount = 100;
for (let i = 1; i <= preKeyCount; i++) {
const preKey = await KeyHelper.generatePreKey(i); const preKey = await KeyHelper.generatePreKey(i);
await this.storage.storePreKey(i, preKey.keyPair); 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 * Get prekey bundle for this user to share with others
@@ -93,37 +192,74 @@ export class SignalProtocolService {
throw new Error("Registration ID not found"); throw new Error("Registration ID not found");
} }
const signedPreKey = await this.storage.loadSignedPreKey(1); const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair);
if (!signedPreKey) { const { keyPair: preKey, keyId: preKeyId } = await this.findOrRegeneratePreKey();
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");
}
return { return {
registrationId: registrationId, registrationId: registrationId,
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)), identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
signedPreKey: { signedPreKey: {
keyId: 1, keyId: SignalProtocolService.SIGNED_PREKEY_ID,
publicKey: b64(new Uint8Array(signedPreKey.pubKey)), publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
signature: b64(new Uint8Array(signedPreKeyWithSig.signature)) signature: b64(signature)
}, },
preKey: { preKey: {
keyId: 1, keyId: preKeyId,
publicKey: b64(new Uint8Array(preKey.pubKey)) publicKey: b64(new Uint8Array(preKey.pubKey))
} }
}; };
} }
/**
* Get all available prekeys for uploading to the server
*/
async getAllPreKeys(): Promise<Array<{ keyId: number; publicKey: string }>> {
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<Omit<PreKeyBundleData, "preKey">> {
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 * Process a prekey bundle from another user and establish a session
*/ */
@@ -163,41 +299,187 @@ export class SignalProtocolService {
const sessionCipher = new SessionCipher(this.storage, address); const sessionCipher = new SessionCipher(this.storage, address);
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer); 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) { if (!body) {
throw new Error("Encryption failed: no body in ciphertext"); throw new Error("Encryption failed: no body in ciphertext");
} }
// ciphertext.body is a base64 string, but we need to convert it properly // The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string
// According to the library, body is a serialized protobuf message as base64 string // Always convert to Uint8Array first, then to base64, regardless of input type
return { type, body }; 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 * Decrypt a message from a sender
*/ */
async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise<string> { async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise<string> {
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 address = new SignalProtocolAddress(senderId.toString(), 1);
const sessionCipher = new SessionCipher(this.storage, address); const sessionCipher = new SessionCipher(this.storage, address);
// Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1) // Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1)
// ciphertext.body is a base64 string from the Signal Protocol library
let bodyBuffer: ArrayBuffer;
try {
const { buffer, byteOffset, byteLength } = ub64(ciphertext.body); const { buffer, byteOffset, byteLength } = ub64(ciphertext.body);
const bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength)); bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength));
let plaintextBytes: ArrayBuffer; } catch (error) {
throw new Error(`Failed to decode ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
let plaintextBytes: ArrayBuffer;
try {
if (ciphertext.type === 3) { if (ciphertext.type === 3) {
// PreKeyWhisperMessage // PreKeyWhisperMessage - this will consume a prekey
// Count available prekeys before decryption
const prekeysBefore = await this.countAvailablePrekeys();
plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer); 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 { } else {
// WhisperMessage // WhisperMessage - uses existing session, no prekey consumed
plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer); 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); return new TextDecoder().decode(plaintextBytes);
} }
/**
* Count available prekeys
*/
private async countAvailablePrekeys(): Promise<number> {
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<void> {
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 * Check if a session exists for a recipient
*/ */
+86 -9
View File
@@ -69,9 +69,44 @@ async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "r
} }
// Helper to convert Uint8Array to ArrayBuffer // 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; 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 // Helper to convert ArrayBuffer to Uint8Array
@@ -90,7 +125,7 @@ export class SignalProtocolStorage implements StorageType {
// Identity Key Management // Identity Key Management
async getIdentityKeyPair(): Promise<KeyPairType | undefined> { async getIdentityKeyPair(): Promise<KeyPairType | undefined> {
const store = await getStore("identityKeys"); const store = await getStore("identityKeys");
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => { const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
const request = store.get(this.userId); const request = store.get(this.userId);
request.onsuccess = () => { request.onsuccess = () => {
const data = request.result; const data = request.result;
@@ -165,7 +200,7 @@ export class SignalProtocolStorage implements StorageType {
async loadPreKey(encodedAddress: string | number): Promise<KeyPairType | undefined> { async loadPreKey(encodedAddress: string | number): Promise<KeyPairType | undefined> {
const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10); const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10);
const store = await getStore("preKeys"); const store = await getStore("preKeys");
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => { const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
const request = store.get([this.userId, preKeyId]); const request = store.get([this.userId, preKeyId]);
request.onsuccess = () => { request.onsuccess = () => {
const data = request.result; const data = request.result;
@@ -213,7 +248,7 @@ export class SignalProtocolStorage implements StorageType {
async loadSignedPreKey(keyId: number | string): Promise<KeyPairType | undefined> { async loadSignedPreKey(keyId: number | string): Promise<KeyPairType | undefined> {
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
const store = await getStore("signedPreKeys"); const store = await getStore("signedPreKeys");
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer; keyId: number } | undefined>((resolve, reject) => { const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
const request = store.get(this.userId); const request = store.get(this.userId);
request.onsuccess = () => { request.onsuccess = () => {
const data = request.result; const data = request.result;
@@ -232,21 +267,50 @@ export class SignalProtocolStorage implements StorageType {
return result; return result;
} }
async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType): Promise<void> { async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType, signature?: Uint8Array): Promise<void> {
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10); const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
const store = await getStore("signedPreKeys", "readwrite"); const store = await getStore("signedPreKeys", "readwrite");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const request = store.put({ interface SignedPreKeyData {
userId: string;
keyId: number;
publicKey: Uint8Array;
privateKey: Uint8Array;
signature?: Uint8Array;
}
const data: SignedPreKeyData = {
userId: this.userId, userId: this.userId,
keyId: signedPreKeyId, keyId: signedPreKeyId,
publicKey: toUint8Array(keyPair.pubKey), publicKey: toUint8Array(keyPair.pubKey),
privateKey: toUint8Array(keyPair.privKey) privateKey: toUint8Array(keyPair.privKey)
}); };
if (signature) {
data.signature = toUint8Array(signature);
}
const request = store.put(data);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} }
async loadSignedPreKeySignature(keyId: number | string): Promise<Uint8Array | undefined> {
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<void> { async removeSignedPreKey(keyId: number | string): Promise<void> {
const store = await getStore("signedPreKeys", "readwrite"); const store = await getStore("signedPreKeys", "readwrite");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -263,7 +327,7 @@ export class SignalProtocolStorage implements StorageType {
const deviceId = parts.length > 1 ? parts[1] : encodedAddress; const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
const store = await getStore("sessions"); const store = await getStore("sessions");
const result = await new Promise<{ record: string } | undefined>((resolve, reject) => { const result = await new Promise<string | undefined>((resolve, reject) => {
const request = store.get([this.userId, deviceId]); const request = store.get([this.userId, deviceId]);
request.onsuccess = () => { request.onsuccess = () => {
const data = request.result; const data = request.result;
@@ -291,5 +355,18 @@ export class SignalProtocolStorage implements StorageType {
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} }
async removeSession(encodedAddress: string): Promise<void> {
// 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<void>((resolve, reject) => {
const request = store.delete([this.userId, deviceId]);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
} }
+11 -1
View File
@@ -33,7 +33,17 @@ export function delay(ms: number): Promise<void> {
} }
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 { export function ub64(s: string): Uint8Array {
const bin = atob(s); const bin = atob(s);
const arr = new Uint8Array(bin.length); const arr = new Uint8Array(bin.length);