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