Working one-time message encryption and decryption

This commit is contained in:
2025-12-03 16:40:26 +03:00
Unverified
parent d4b1e261d7
commit fcb3dff2c9
18 changed files with 1392 additions and 336 deletions
+274 -65
View File
@@ -1,28 +1,139 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import api from "@/core/api";
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "../user/auth";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity";
import { fetchUsers, searchUsers } from "../user/search";
import { b64 } from "@/utils/utils";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
}
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
const signalService = new SignalProtocolService(user.id.toString());
// Remove padding (backward compatible with old messages)
// Check if ciphertext is base64 (padded) or already JSON (unpadded)
let ciphertextStr: string = envelope.ciphertext;
// Check if it's base64 (padded messages are base64)
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
if (isBase64) {
// Try to remove padding
try {
const unpadded = removePadding(envelope.ciphertext);
// Verify it's valid JSON before using it
JSON.parse(unpadded);
ciphertextStr = unpadded;
} catch {
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
try {
JSON.parse(envelope.ciphertext);
ciphertextStr = envelope.ciphertext;
} catch {
// If both fail, throw an error
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
}
}
} else {
// Not base64, assume it's already JSON (unpadded message)
ciphertextStr = envelope.ciphertext;
}
// Parse Signal Protocol message
let signalCiphertext: { type: number; body: string };
try {
signalCiphertext = JSON.parse(ciphertextStr);
} catch (error) {
throw new Error(`Failed to parse ciphertext as JSON: ${error instanceof Error ? error.message : String(error)}. Ciphertext length: ${ciphertextStr.length}, first 100 chars: ${ciphertextStr.substring(0, 100)}`);
}
if (!signalCiphertext || typeof signalCiphertext !== "object") {
throw new Error("Invalid Signal Protocol message format: not an object");
}
if (typeof signalCiphertext.type !== "number") {
throw new Error("Invalid Signal Protocol message format: type is not a number");
}
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
}
// Check if body contains non-printable characters (corrupted binary data from old encryption)
// This must be checked first, before any base64 validation
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
if (hasNonPrintable) {
// This is a corrupted message from before the base64 conversion fix
// It cannot be decrypted - the body contains raw binary data instead of base64
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
return "_This message is corrupted and cannot be displayed._";
}
// Check if body contains Unicode escape sequences (from JSON.stringify escaping)
// If so, we need to unescape them to get the actual base64 string
let bodyToDecode = signalCiphertext.body;
// Check for literal backslash-u sequences (before JSON parsing, these would be "\\u")
// After JSON parsing, Unicode escapes are converted to actual characters, so we check for
// the pattern that indicates it might have been escaped
if (bodyToDecode.includes("\\u") || bodyToDecode.match(/\\u[0-9a-fA-F]{4}/)) {
// Try to unescape Unicode sequences by wrapping in JSON quotes
try {
bodyToDecode = JSON.parse(`"${bodyToDecode.replace(/\\/g, "\\\\")}"`);
} catch {
// If unescaping fails, use the original
bodyToDecode = signalCiphertext.body;
}
}
// Validate that body is valid base64 before attempting decryption
// Check if it's a valid base64 string (only contains base64 characters and padding)
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(bodyToDecode)) {
// Log for debugging - this should help identify the issue
console.error("Invalid base64 in body:", {
bodyType: typeof signalCiphertext.body,
bodyLength: signalCiphertext.body.length,
unescapedLength: bodyToDecode.length,
first50: signalCiphertext.body.substring(0, 50),
unescapedFirst50: bodyToDecode.substring(0, 50),
envelopeId: envelope.id
});
throw new Error(`Invalid base64 format in ciphertext body`);
}
// Use the unescaped body for decryption
signalCiphertext.body = bodyToDecode;
try {
// Try to decode a small portion to validate base64
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
} catch (error) {
// Log for debugging
console.error("Base64 decode failed:", {
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id,
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext;
}
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
@@ -31,35 +142,86 @@ export async function fetchMessages(userId: number, token: string, limit: number
url += `&before_id=${beforeId}`;
}
const response = await globalThis.fetch(url, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!response.ok) return { messages: [], has_more: false };
const data = await response.json();
return { messages: data.messages || [], has_more: data.has_more ?? false };
}
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Re-throw other errors
throw error;
}
}
// Encrypt with Signal Protocol
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
// Verify the body is valid base64 before stringifying
if (ciphertext.body && typeof ciphertext.body === "string") {
try {
// Test that body is valid base64
atob(ciphertext.body.substring(0, Math.min(4, ciphertext.body.length)));
// Verify the entire body is valid base64
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(ciphertext.body)) {
console.error("Invalid base64 characters in encrypted body:", {
bodyLength: ciphertext.body.length,
first100: ciphertext.body.substring(0, 100),
last100: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 100))
});
throw new Error("Encrypted body contains invalid base64 characters");
}
} catch (error) {
throw new Error(`Encrypted body is not valid base64: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Stringify the ciphertext - JSON.stringify should not escape base64 strings
const ciphertextJson = JSON.stringify(ciphertext);
// Verify the stringified JSON doesn't have escaped characters in the body field
const parsed = JSON.parse(ciphertextJson);
if (parsed.body !== ciphertext.body) {
console.error("Body was modified during JSON stringification:", {
original: ciphertext.body.substring(0, 50),
stringified: parsed.body.substring(0, 50),
originalLength: ciphertext.body.length,
stringifiedLength: parsed.body.length
});
throw new Error("Body was incorrectly escaped during JSON stringification");
}
// Add padding to obfuscate message size (anti-censorship)
const paddedCiphertext = addPadding(ciphertextJson);
const payload: SendDMRequest = {
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
iv: "", // Not used for Signal Protocol
ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: "" // Not used for Signal Protocol
};
if (replyToId) payload.replyToId = replyToId;
@@ -73,17 +235,39 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p
});
}
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Re-throw other errors
throw error;
}
}
// Generate master key for file encryption
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
const form = new FormData();
const names: string[] = [];
@@ -115,30 +299,54 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope));
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
headers: api.user.auth.getAuthHeaders(token, false),
body: form
});
}
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Re-throw other errors
throw error;
}
}
// Generate fresh master key for the edited message
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
// Encrypt the message content with the master key
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({
type: "dmEdit",
@@ -147,9 +355,9 @@ export async function edit(id: number, recipientPublicKeyB64: string, newPlainte
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: "" // Not used for Signal Protocol
}
} as DMEditRequest);
}
@@ -170,7 +378,7 @@ export interface ConversationResponse {
export async function conversations(token: string): Promise<ConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
@@ -189,6 +397,7 @@ export async function markRead(id: number, authToken: string): Promise<void> {
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export { fetchUsers, searchUsers } from "@/core/api/users";
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
+15 -8
View File
@@ -79,15 +79,22 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise
* Uploads Signal Protocol prekey bundle for the current user
*/
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
const payload = { bundle };
// Re-export from prekeys.ts
const { uploadPreKeyBundle: upload } = await import("./crypto/prekeys");
return upload(bundle, token);
}
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to upload prekey bundle");
/**
* Uploads all available prekeys to the server for rotation
*/
export async function uploadAllPreKeys(
baseBundle: Omit<PreKeyBundleData, "preKey">,
prekeys: Array<{ keyId: number; publicKey: string }>,
token: string
): Promise<void> {
// Re-export from prekeys.ts
const { uploadAllPreKeys: upload } = await import("./crypto/prekeys");
return upload(baseBundle, prekeys, token);
}
/**
+83 -8
View File
@@ -1,14 +1,89 @@
// Placeholder for Signal Protocol pre-key management
// Will be implemented when Signal Protocol is added
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
export async function upload(_bundle: unknown, _token: string): Promise<void> {
// TODO: Implement Signal Protocol pre-key upload
throw new Error("Not implemented yet");
/**
* Uploads Signal Protocol prekey bundle for the current user
* This uploads the base bundle (identity, signed prekey) and one prekey
*/
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
const payload = { bundle };
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to upload prekey bundle");
}
export async function fetch(_userId: number, _token: string): Promise<unknown> {
// TODO: Implement Signal Protocol pre-key fetch
throw new Error("Not implemented yet");
/**
* Uploads all available prekeys to the server for rotation in a single request
*/
export async function uploadAllPreKeys(
baseBundle: Omit<PreKeyBundleData, "preKey">,
prekeys: Array<{ keyId: number; publicKey: string }>,
token: string
): Promise<void> {
const headers = getAuthHeaders(token, true);
const payload = {
baseBundle,
prekeys
};
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekeys/bulk`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) {
throw new Error(`Failed to upload prekeys: ${res.statusText}`);
}
}
/**
* Custom error for prekey exhaustion
*/
export class PrekeyExhaustedError extends Error {
constructor(public readonly recipientId: number) {
super("Recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys.");
this.name = "PrekeyExhaustedError";
}
}
/**
* Fetches Signal Protocol prekey bundle for another user
* @throws {PrekeyExhaustedError} If the recipient has no unused prekeys available
*/
export async function fetchPreKeyBundle(userId: number, token: string): Promise<PreKeyBundleData> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
method: "GET",
headers
});
if (!res.ok) {
if (res.status === 404) {
throw new Error("Recipient has not set up encryption. They need to log in to initialize their encryption keys.");
}
throw new Error("Failed to fetch prekey bundle");
}
const data = await res.json();
const bundle = data.bundle;
// Check if bundle exists but has no prekey (all prekeys exhausted)
if (!bundle) {
throw new PrekeyExhaustedError(userId);
}
// If bundle exists but has no preKey field, it means all prekeys are exhausted
// The backend returns bundle without preKey when no unused prekeys are available
if (!bundle.preKey) {
throw new PrekeyExhaustedError(userId);
}
return bundle;
}
+167 -43
View File
@@ -1,16 +1,13 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import api from "@/core/api";
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey, fetchPreKeyBundle } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
import { b64 } from "@/utils/utils";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
const user = useUserStore.getState().user.currentUser;
@@ -18,21 +15,110 @@ export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise
throw new Error("User not authenticated");
}
if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
}
const signalService = new SignalProtocolService(user.id.toString());
// Parse Signal Protocol message
const signalCiphertext = JSON.parse(envelope.ciphertext);
if (!signalCiphertext.type || !signalCiphertext.body) {
throw new Error("Invalid Signal Protocol message format");
// Remove padding (backward compatible with old messages)
// Check if ciphertext is base64 (padded messages are base64)
let ciphertextStr: string = envelope.ciphertext;
// Check if it's base64 (padded messages are base64)
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
if (isBase64) {
// Try to remove padding
try {
const unpadded = removePadding(envelope.ciphertext);
// Verify it's valid JSON before using it
JSON.parse(unpadded);
ciphertextStr = unpadded;
} catch {
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
try {
JSON.parse(envelope.ciphertext);
ciphertextStr = envelope.ciphertext;
} catch {
// If both fail, throw an error
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
}
}
} else {
// Not base64, assume it's already JSON (unpadded message)
ciphertextStr = envelope.ciphertext;
}
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext;
// Parse Signal Protocol message
let signalCiphertext: { type: number; body: string };
try {
signalCiphertext = JSON.parse(ciphertextStr);
} catch (error) {
throw new Error(`Failed to parse Signal Protocol message: ${error instanceof Error ? error.message : String(error)}`);
}
if (!signalCiphertext || typeof signalCiphertext !== "object") {
throw new Error("Invalid Signal Protocol message format: not an object");
}
if (typeof signalCiphertext.type !== "number") {
throw new Error("Invalid Signal Protocol message format: type is not a number");
}
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
}
// Validate that body is valid base64 before attempting decryption
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(signalCiphertext.body)) {
// Check if body contains non-printable characters (corrupted binary data)
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
if (hasNonPrintable) {
// This is a corrupted message from before the base64 conversion fix
// It cannot be decrypted - the body contains raw binary data instead of base64
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
return "_This message is corrupted and cannot be displayed._";
}
console.error("Invalid base64 in body:", {
bodyType: typeof signalCiphertext.body,
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id
});
throw new Error(`Invalid base64 format in ciphertext body`);
}
try {
// Try to decode a small portion to validate base64
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
} catch (error) {
console.error("Base64 decode failed:", {
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id,
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext;
} catch (error) {
throw new Error(`Failed to decrypt DM: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
@@ -52,9 +138,9 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string,
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
// Fetch prekey bundle from server
const bundle = await fetchPreKeyBundle(recipientId, authToken);
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
if (!bundle) {
throw new Error("No Signal Protocol prekey bundle available for recipient");
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
@@ -62,10 +148,13 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string,
// Encrypt with Signal Protocol
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
// Add padding to obfuscate message size (anti-censorship)
const paddedCiphertext = addPadding(JSON.stringify(ciphertext));
const payload: SendDMRequest = {
recipientId: recipientId,
iv: "", // Not used for Signal Protocol
ciphertext: JSON.stringify(ciphertext), // Store Signal Protocol message as JSON
ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: "" // Not used for Signal Protocol
@@ -82,17 +171,33 @@ export async function sendDMViaWebSocket(recipientId: number, plaintext: string,
});
}
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
if (!bundle) {
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Generate master key for file encryption
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
const form = new FormData();
const names: string[] = [];
@@ -124,30 +229,48 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope));
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
headers: api.user.auth.getAuthHeaders(token, false),
body: form
});
}
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
if (!bundle) {
throw new Error("No Signal Protocol prekey bundle available for recipient");
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Generate fresh master key for the edited message
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
// Encrypt the message content with the master key
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({
type: "dmEdit",
@@ -156,9 +279,9 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: "" // Not used for Signal Protocol
}
} as DMEditRequest);
}
@@ -178,11 +301,12 @@ export interface DMConversationResponse {
}
// Re-export for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export { fetchUsers, searchUsers } from "./users";
export { fetchUserPublicKey } from "./crypto/identity";
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
+49 -61
View File
@@ -5,7 +5,7 @@
* @version 1.0.0
*/
import { request } from "./websocket";
import { send } from "./websocket";
import type {
TypingWebSocketMessage,
StopTypingWebSocketMessage,
@@ -39,21 +39,18 @@ export class TypingManager {
async sendTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: TypingRequest = {
type: "typing",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
const message: TypingRequest = {
type: "typing",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.scheduleStopTyping("public");
} catch (error) {
console.error("Failed to send typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.scheduleStopTyping("public");
}
/**
@@ -62,21 +59,18 @@ export class TypingManager {
async sendStopTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: StopTypingRequest = {
type: "stopTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
const message: StopTypingRequest = {
type: "stopTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.clearStopTypingTimeout("public");
} catch (error) {
console.error("Failed to send stop typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout("public");
}
/**
@@ -85,23 +79,20 @@ export class TypingManager {
async sendDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: DmTypingRequest = {
type: "dmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
const message: DmTypingRequest = {
type: "dmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.scheduleStopDmTyping(recipientId);
} catch (error) {
console.error("Failed to send DM typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.scheduleStopDmTyping(recipientId);
}
/**
@@ -110,23 +101,20 @@ export class TypingManager {
async sendStopDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: StopDmTypingRequest = {
type: "stopDmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
const message: StopDmTypingRequest = {
type: "stopDmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.clearStopTypingTimeout(`dm_${recipientId}`);
} catch (error) {
console.error("Failed to send stop DM typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout(`dm_${recipientId}`);
}
/**
+4 -35
View File
@@ -6,7 +6,6 @@
*/
import { openDB, type IDBPDatabase } from "idb";
import type { WebSocketCredentials, WebSocketMessage } from "./types";
interface UpdateMessage<T = any> {
type: string;
@@ -72,28 +71,18 @@ export async function setLastSequence(seq: number): Promise<void> {
* Process a batched updates message
* @param message - The batched updates message from the server
* @param handler - Function to handle individual updates
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
*/
export async function processBatchedUpdates(
message: BatchedUpdatesMessage,
handler: (update: UpdateMessage) => void,
requestMissedFn?: (lastSeq: number) => Promise<void>
handler: (update: UpdateMessage) => void
): Promise<void> {
const { seq, updates } = message;
const lastSeq = await getLastSequence();
// Check for gap
// Log gap for debugging, but don't try to recover (getUpdates doesn't work properly)
if (seq !== lastSeq + 1 && lastSeq > 0) {
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
// Request missing updates if function provided
if (requestMissedFn) {
try {
await requestMissedFn(lastSeq);
} catch (error) {
console.error("Failed to request missed updates for gap:", error);
}
}
const gapSize = seq - (lastSeq + 1);
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq} (gap size: ${gapSize}). Skipping ${gapSize} updates.`);
}
// Process all updates in the batch
@@ -104,23 +93,3 @@ export async function processBatchedUpdates(
// Update last sequence number
await setLastSequence(seq);
}
/**
* Request missed updates from the server
* @param lastSeq - The last sequence number we received
* @param requestFn - Function to send the request to the server
* @param credentials - Optional WebSocket credentials for authentication
*/
export async function requestMissedUpdates(
lastSeq: number,
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
credentials?: WebSocketCredentials
): Promise<void> {
if (lastSeq > 0) {
await requestFn({
type: "getUpdates",
data: { lastSeq },
credentials
});
}
}
+21 -27
View File
@@ -12,7 +12,7 @@ import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager";
import { useUserStore } from "@/state/user";
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
import { processBatchedUpdates } from "./updateManager";
import { getAuthToken } from "@/core/api/user/auth";
interface HttpError extends Error {
@@ -161,21 +161,10 @@ function setupEventHandlers(): void {
// Handle batched updates
if (response.type === "updates" && "seq" in response && "updates" in response) {
// Create function to request missed updates with credentials
const token = getAuthToken();
const requestMissedFn = token ? async (lastSeq: number) => {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, {
scheme: "Bearer",
credentials: token
});
} : undefined;
await processBatchedUpdates(response as any, (update) => {
// Route individual updates to appropriate handlers
handleUpdate(update);
}, requestMissedFn);
});
return;
}
@@ -250,20 +239,9 @@ function setupEventHandlers(): void {
console.error("Failed to send ping on reconnect:", error);
}
// Send last sequence number and request missed updates on reconnect
// Wait a bit for ping to complete authentication
await delay(100);
try {
const lastSeq = await getLastSequence();
if (lastSeq > 0) {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, credentials);
}
} catch (error) {
console.error("Failed to request missed updates:", error);
}
// Note: We don't request missed updates on reconnect because getUpdates
// doesn't properly return updates (they're sent directly via WebSocket
// but the client can't handle them). Gaps will be logged but not recovered.
}
} catch (error) {
console.error("Failed to authenticate on reconnect:", error);
@@ -359,6 +337,22 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
});
}
/**
* Send a WebSocket message without waiting for a response (fire-and-forget)
* Useful for typing indicators and other non-critical messages
*/
export function send<T = unknown>(payload: WebSocketMessage<T>): void {
if (websocket.readyState !== WebSocket.OPEN) {
console.warn("WebSocket is not open, cannot send message");
return;
}
try {
websocket.send(JSON.stringify(payload));
} catch (error) {
console.error("Failed to send WebSocket message:", error);
}
}
// --------------
// Initialization
// --------------