mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Working one-time message encryption and decryption
This commit is contained in:
+14
-1
@@ -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
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
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");
|
||||
}
|
||||
|
||||
// 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);
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
// 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";
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+168
-44
@@ -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");
|
||||
}
|
||||
|
||||
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");
|
||||
if (!envelope.ciphertext) {
|
||||
throw new Error("DM envelope missing ciphertext");
|
||||
}
|
||||
|
||||
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||
return plaintext;
|
||||
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);
|
||||
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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// --------------
|
||||
|
||||
@@ -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,8 +38,8 @@ 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");
|
||||
@@ -91,6 +92,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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
// Decrypt the master key using Signal Protocol
|
||||
const signalService = new SignalProtocolService(user.currentUser.id.toString());
|
||||
const senderId = dmEnvelope.senderId;
|
||||
|
||||
// Derive shared secret with the recipient's public key
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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));
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -40,6 +42,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);
|
||||
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)
|
||||
);
|
||||
|
||||
// Store signature separately (we'll need it for the bundle)
|
||||
// For now, we'll regenerate it when needed since storage doesn't store signatures
|
||||
|
||||
// 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,37 +192,74 @@ 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,41 +299,187 @@ 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
|
||||
*/
|
||||
|
||||
@@ -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,21 +267,50 @@ 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");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user