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:
@@ -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 {
|
||||
@@ -39,6 +41,13 @@ export interface PreKeyBundleData {
|
||||
|
||||
export class SignalProtocolService {
|
||||
private storage: SignalProtocolStorage;
|
||||
|
||||
// Prekey configuration constants
|
||||
private static readonly PREKEY_COUNT = 20;
|
||||
private static readonly PREKEY_REGEN_THRESHOLD = 5; // Regenerate when fewer than this many prekeys are left
|
||||
private static readonly PREKEY_REGEN_COUNT = 10; // Number of prekeys to regenerate
|
||||
private static readonly SIGNED_PREKEY_ID = 1;
|
||||
private static readonly BATCH_SIZE = 10;
|
||||
|
||||
constructor(userId: string) {
|
||||
this.storage = new SignalProtocolStorage(userId);
|
||||
@@ -64,21 +73,111 @@ export class SignalProtocolService {
|
||||
await this.storage.saveLocalRegistrationId(registrationId);
|
||||
|
||||
// Generate signed prekey
|
||||
const signedPreKeyId = 1;
|
||||
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, signedPreKeyId);
|
||||
await this.storage.storeSignedPreKey(signedPreKeyId, signedPreKey.keyPair);
|
||||
|
||||
// Store signature separately (we'll need it for the bundle)
|
||||
// For now, we'll regenerate it when needed since storage doesn't store signatures
|
||||
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID);
|
||||
// Store both the key pair and its signature
|
||||
await this.storage.storeSignedPreKey(
|
||||
SignalProtocolService.SIGNED_PREKEY_ID,
|
||||
signedPreKey.keyPair,
|
||||
new Uint8Array(signedPreKey.signature)
|
||||
);
|
||||
|
||||
// Generate prekeys (typically 100 prekeys)
|
||||
const preKeyCount = 100;
|
||||
for (let i = 1; i <= preKeyCount; i++) {
|
||||
// Generate prekeys (one-time keys for establishing new sessions)
|
||||
// Each new conversation consumes one prekey when the first message is sent
|
||||
// Generation is non-blocking (yields to event loop), so this doesn't freeze the UI
|
||||
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
|
||||
const preKey = await KeyHelper.generatePreKey(i);
|
||||
await this.storage.storePreKey(i, preKey.keyPair);
|
||||
|
||||
// Yield to event loop every batchSize keys to prevent UI freezing
|
||||
if (i % SignalProtocolService.BATCH_SIZE === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure signed prekey exists and is valid, regenerating if necessary
|
||||
*/
|
||||
private async ensureSignedPreKey(identityKeyPair: KeyPairType): Promise<{ keyPair: KeyPairType; signature: Uint8Array }> {
|
||||
let signature = await this.storage.loadSignedPreKeySignature(SignalProtocolService.SIGNED_PREKEY_ID);
|
||||
let signedPreKey = await this.storage.loadSignedPreKey(SignalProtocolService.SIGNED_PREKEY_ID);
|
||||
|
||||
if (!signedPreKey || !signature) {
|
||||
// Signed prekey or signature missing - regenerate both to ensure consistency
|
||||
const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID);
|
||||
await this.storage.storeSignedPreKey(
|
||||
SignalProtocolService.SIGNED_PREKEY_ID,
|
||||
signedPreKeyWithSig.keyPair,
|
||||
new Uint8Array(signedPreKeyWithSig.signature)
|
||||
);
|
||||
signedPreKey = signedPreKeyWithSig.keyPair;
|
||||
signature = new Uint8Array(signedPreKeyWithSig.signature);
|
||||
}
|
||||
|
||||
return { keyPair: signedPreKey, signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available prekey, regenerating if necessary
|
||||
*/
|
||||
private async findOrRegeneratePreKey(): Promise<{ keyPair: KeyPairType; keyId: number }> {
|
||||
// Find the first available prekey
|
||||
let preKey: KeyPairType | undefined;
|
||||
let preKeyId = 0;
|
||||
let availableCount = 0;
|
||||
|
||||
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
|
||||
const candidate = await this.storage.loadPreKey(i);
|
||||
if (candidate) {
|
||||
availableCount++;
|
||||
if (!preKey) {
|
||||
preKey = candidate;
|
||||
preKeyId = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we're running low on prekeys, regenerate more proactively
|
||||
if (availableCount < SignalProtocolService.PREKEY_REGEN_THRESHOLD) {
|
||||
console.warn(`Low on prekeys (${availableCount} remaining), regenerating...`);
|
||||
|
||||
// Find the next available ID to regenerate from
|
||||
let nextId = SignalProtocolService.PREKEY_COUNT + 1;
|
||||
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
|
||||
const existing = await this.storage.loadPreKey(i);
|
||||
if (!existing) {
|
||||
nextId = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate prekeys starting from nextId
|
||||
for (let i = 0; i < SignalProtocolService.PREKEY_REGEN_COUNT; i++) {
|
||||
const keyId = nextId + i;
|
||||
const existing = await this.storage.loadPreKey(keyId);
|
||||
if (!existing) {
|
||||
const newPreKey = await KeyHelper.generatePreKey(keyId);
|
||||
await this.storage.storePreKey(keyId, newPreKey.keyPair);
|
||||
if (!preKey) {
|
||||
preKey = newPreKey.keyPair;
|
||||
preKeyId = keyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emergency fallback if still no prekey
|
||||
if (!preKey) {
|
||||
console.error("No prekeys available, emergency regeneration...");
|
||||
const newPreKey = await KeyHelper.generatePreKey(1);
|
||||
await this.storage.storePreKey(1, newPreKey.keyPair);
|
||||
preKey = newPreKey.keyPair;
|
||||
preKeyId = 1;
|
||||
}
|
||||
|
||||
return { keyPair: preKey, keyId: preKeyId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prekey bundle for this user to share with others
|
||||
*/
|
||||
@@ -93,36 +192,73 @@ export class SignalProtocolService {
|
||||
throw new Error("Registration ID not found");
|
||||
}
|
||||
|
||||
const signedPreKey = await this.storage.loadSignedPreKey(1);
|
||||
if (!signedPreKey) {
|
||||
throw new Error("Signed prekey not found");
|
||||
}
|
||||
|
||||
// Regenerate signed prekey to get signature (since storage doesn't store it)
|
||||
// In production, you'd store the signature separately
|
||||
const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, 1);
|
||||
await this.storage.storeSignedPreKey(1, signedPreKeyWithSig.keyPair);
|
||||
|
||||
// Get a prekey to include
|
||||
const preKey = await this.storage.loadPreKey(1);
|
||||
if (!preKey) {
|
||||
throw new Error("No prekeys available");
|
||||
}
|
||||
const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair);
|
||||
const { keyPair: preKey, keyId: preKeyId } = await this.findOrRegeneratePreKey();
|
||||
|
||||
return {
|
||||
registrationId: registrationId,
|
||||
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
|
||||
signedPreKey: {
|
||||
keyId: 1,
|
||||
keyId: SignalProtocolService.SIGNED_PREKEY_ID,
|
||||
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
|
||||
signature: b64(new Uint8Array(signedPreKeyWithSig.signature))
|
||||
signature: b64(signature)
|
||||
},
|
||||
preKey: {
|
||||
keyId: 1,
|
||||
keyId: preKeyId,
|
||||
publicKey: b64(new Uint8Array(preKey.pubKey))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available prekeys for uploading to the server
|
||||
*/
|
||||
async getAllPreKeys(): Promise<Array<{ keyId: number; publicKey: string }>> {
|
||||
const prekeys: Array<{ keyId: number; publicKey: string }> = [];
|
||||
|
||||
// Check all possible prekey IDs (including regenerated ones beyond initial count)
|
||||
// We check up to PREKEY_COUNT + PREKEY_REGEN_COUNT to include regenerated prekeys
|
||||
const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT;
|
||||
|
||||
for (let i = 1; i <= maxPreKeyId; i++) {
|
||||
const prekey = await this.storage.loadPreKey(i);
|
||||
if (prekey) {
|
||||
prekeys.push({
|
||||
keyId: i,
|
||||
publicKey: b64(new Uint8Array(prekey.pubKey))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return prekeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base bundle (without prekey) for uploading all prekeys
|
||||
*/
|
||||
async getBaseBundle(): Promise<Omit<PreKeyBundleData, "preKey">> {
|
||||
const identityKeyPair = await this.storage.getIdentityKeyPair();
|
||||
if (!identityKeyPair) {
|
||||
throw new Error("Signal Protocol not initialized");
|
||||
}
|
||||
|
||||
const registrationId = await this.storage.getLocalRegistrationId();
|
||||
if (!registrationId) {
|
||||
throw new Error("Registration ID not found");
|
||||
}
|
||||
|
||||
const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair);
|
||||
|
||||
return {
|
||||
registrationId: registrationId,
|
||||
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
|
||||
signedPreKey: {
|
||||
keyId: SignalProtocolService.SIGNED_PREKEY_ID,
|
||||
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
|
||||
signature: b64(signature)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a prekey bundle from another user and establish a session
|
||||
@@ -163,40 +299,186 @@ export class SignalProtocolService {
|
||||
|
||||
const sessionCipher = new SessionCipher(this.storage, address);
|
||||
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer);
|
||||
const { type, body } = await sessionCipher.encrypt(plaintextBuffer);
|
||||
const encryptResult = await sessionCipher.encrypt(plaintextBuffer);
|
||||
const { type, body } = encryptResult;
|
||||
|
||||
if (!body) {
|
||||
throw new Error("Encryption failed: no body in ciphertext");
|
||||
}
|
||||
|
||||
// ciphertext.body is a base64 string, but we need to convert it properly
|
||||
// According to the library, body is a serialized protobuf message as base64 string
|
||||
return { type, body };
|
||||
// The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string
|
||||
// Always convert to Uint8Array first, then to base64, regardless of input type
|
||||
let bodyArray: Uint8Array;
|
||||
const bodyAny = body as any;
|
||||
|
||||
if (typeof body === "string") {
|
||||
// String input - check if it's already base64
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (base64Regex.test(body)) {
|
||||
// Already base64, use as-is
|
||||
bodyArray = ub64(body);
|
||||
} else {
|
||||
// String contains binary data, convert to Uint8Array
|
||||
bodyArray = new Uint8Array([...body].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
} else if (bodyAny instanceof Uint8Array) {
|
||||
bodyArray = bodyAny;
|
||||
} else if (bodyAny instanceof ArrayBuffer) {
|
||||
bodyArray = new Uint8Array(bodyAny);
|
||||
} else {
|
||||
// Try to convert unknown type
|
||||
if (bodyAny.buffer && bodyAny.buffer instanceof ArrayBuffer) {
|
||||
bodyArray = new Uint8Array(bodyAny.buffer, bodyAny.byteOffset || 0, bodyAny.byteLength || bodyAny.buffer.byteLength);
|
||||
} else {
|
||||
bodyArray = new Uint8Array(bodyAny as ArrayBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const bodyBase64 = b64(bodyArray);
|
||||
|
||||
// Final validation - ensure the result is valid base64
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (!base64Regex.test(bodyBase64)) {
|
||||
throw new Error(`Failed to convert body to base64: result contains invalid characters. Length: ${bodyBase64.length}`);
|
||||
}
|
||||
|
||||
// Test that it can be decoded
|
||||
try {
|
||||
atob(bodyBase64.substring(0, Math.min(4, bodyBase64.length)));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to convert body to base64: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
return { type, body: bodyBase64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message from a sender
|
||||
*/
|
||||
async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise<string> {
|
||||
if (!ciphertext.body || typeof ciphertext.body !== "string") {
|
||||
throw new Error("Invalid ciphertext: body is missing or not a string");
|
||||
}
|
||||
|
||||
const address = new SignalProtocolAddress(senderId.toString(), 1);
|
||||
|
||||
const sessionCipher = new SessionCipher(this.storage, address);
|
||||
|
||||
// Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1)
|
||||
const { buffer, byteOffset, byteLength } = ub64(ciphertext.body);
|
||||
const bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength));
|
||||
let plaintextBytes: ArrayBuffer;
|
||||
// ciphertext.body is a base64 string from the Signal Protocol library
|
||||
let bodyBuffer: ArrayBuffer;
|
||||
try {
|
||||
const { buffer, byteOffset, byteLength } = ub64(ciphertext.body);
|
||||
bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decode ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (ciphertext.type === 3) {
|
||||
// PreKeyWhisperMessage
|
||||
plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer);
|
||||
} else {
|
||||
// WhisperMessage
|
||||
plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer);
|
||||
let plaintextBytes: ArrayBuffer;
|
||||
try {
|
||||
if (ciphertext.type === 3) {
|
||||
// PreKeyWhisperMessage - this will consume a prekey
|
||||
// Count available prekeys before decryption
|
||||
const prekeysBefore = await this.countAvailablePrekeys();
|
||||
|
||||
plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer);
|
||||
|
||||
// Check if a prekey was consumed (removed by the library)
|
||||
const prekeysAfter = await this.countAvailablePrekeys();
|
||||
if (prekeysBefore > prekeysAfter) {
|
||||
// A prekey was consumed - refresh the bundle in the background
|
||||
// This ensures new users can still message you while you're offline
|
||||
this.refreshPreKeyBundle().catch(err =>
|
||||
console.warn("Failed to refresh prekey bundle after consumption:", err)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// WhisperMessage - uses existing session, no prekey consumed
|
||||
plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log detailed error information for debugging
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Handle different types of decryption errors
|
||||
if (errorMessage.includes("Bad MAC")) {
|
||||
console.warn(`Bad MAC error detected for sender ${senderId} (type ${ciphertext.type}). Session may be out of sync.`);
|
||||
|
||||
// For both types, remove the session so next message can re-establish it
|
||||
try {
|
||||
await this.storage.removeSession(address.toString());
|
||||
console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`);
|
||||
} catch (resetError) {
|
||||
console.error("Failed to remove session:", resetError);
|
||||
}
|
||||
} else if (errorMessage.includes("Tried to decrypt on a sending chain") || errorMessage.includes("No record for device")) {
|
||||
// These errors indicate the session state is corrupted or missing
|
||||
// Remove the session so it can be re-established
|
||||
console.warn(`Session state error for sender ${senderId}: ${errorMessage}. Removing session.`);
|
||||
try {
|
||||
await this.storage.removeSession(address.toString());
|
||||
console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`);
|
||||
} catch (resetError) {
|
||||
console.error("Failed to remove session:", resetError);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Signal Protocol decryption failed:", {
|
||||
senderId,
|
||||
type: ciphertext.type,
|
||||
bodyLength: ciphertext.body.length,
|
||||
bodyFirst50: ciphertext.body.substring(0, 50),
|
||||
bodyLast50: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 50)),
|
||||
bodyIsBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(ciphertext.body),
|
||||
error: errorMessage
|
||||
});
|
||||
throw new Error(`Failed to decrypt message: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(plaintextBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count available prekeys
|
||||
*/
|
||||
private async countAvailablePrekeys(): Promise<number> {
|
||||
let count = 0;
|
||||
const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT;
|
||||
|
||||
for (let i = 1; i <= maxPreKeyId; i++) {
|
||||
const prekey = await this.storage.loadPreKey(i);
|
||||
if (prekey) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh prekey bundle after a prekey was consumed
|
||||
* This ensures new users can still message you while you're offline
|
||||
* Uploads all available prekeys to the server for rotation
|
||||
*/
|
||||
private async refreshPreKeyBundle(): Promise<void> {
|
||||
try {
|
||||
const token = api.user.auth.getAuthToken();
|
||||
if (!token) {
|
||||
console.warn("No auth token, cannot refresh prekey bundle");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseBundle = await this.getBaseBundle();
|
||||
const prekeys = await this.getAllPreKeys();
|
||||
|
||||
// Upload all prekeys in the background
|
||||
api.crypto.prekeys.uploadAllPreKeys(baseBundle, prekeys, token).catch(err =>
|
||||
console.warn("Failed to upload all prekeys:", err)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh prekey bundle:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists for a recipient
|
||||
|
||||
@@ -69,9 +69,44 @@ async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "r
|
||||
}
|
||||
|
||||
// Helper to convert Uint8Array to ArrayBuffer
|
||||
function toArrayBuffer(u8: Uint8Array | ArrayBuffer): ArrayBuffer {
|
||||
function toArrayBuffer(u8: Uint8Array | ArrayBuffer | ArrayBufferLike): ArrayBuffer {
|
||||
if (u8 instanceof ArrayBuffer) return u8;
|
||||
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
|
||||
// Check if SharedArrayBuffer is available (requires COOP/COEP headers)
|
||||
const SharedArrayBufferConstructor = typeof SharedArrayBuffer !== "undefined" ? SharedArrayBuffer : null;
|
||||
|
||||
if (SharedArrayBufferConstructor && u8 instanceof SharedArrayBufferConstructor) {
|
||||
// Convert SharedArrayBuffer to ArrayBuffer by copying
|
||||
const view = new Uint8Array(u8);
|
||||
const copy = new Uint8Array(view.length);
|
||||
copy.set(view);
|
||||
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
|
||||
return copy.buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
// Uint8Array case - buffer might be SharedArrayBuffer, so copy it
|
||||
if (u8 instanceof Uint8Array) {
|
||||
const buffer = u8.buffer;
|
||||
if (SharedArrayBufferConstructor && buffer instanceof SharedArrayBufferConstructor) {
|
||||
const copy = new Uint8Array(u8.length);
|
||||
copy.set(u8);
|
||||
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
|
||||
return copy.buffer as ArrayBuffer;
|
||||
}
|
||||
const sliced = buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
// Ensure we return ArrayBuffer, not SharedArrayBuffer
|
||||
if (SharedArrayBufferConstructor && sliced instanceof SharedArrayBufferConstructor) {
|
||||
const copy = new Uint8Array(sliced);
|
||||
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
|
||||
return copy.buffer as unknown as ArrayBuffer;
|
||||
}
|
||||
// TypeScript doesn't know that slice() returns ArrayBuffer when buffer is ArrayBuffer
|
||||
// But we've already checked it's not SharedArrayBuffer, so it must be ArrayBuffer
|
||||
return sliced as unknown as ArrayBuffer;
|
||||
}
|
||||
|
||||
// Fallback: treat as ArrayBuffer
|
||||
return u8 as unknown as ArrayBuffer;
|
||||
}
|
||||
|
||||
// Helper to convert ArrayBuffer to Uint8Array
|
||||
@@ -90,7 +125,7 @@ export class SignalProtocolStorage implements StorageType {
|
||||
// Identity Key Management
|
||||
async getIdentityKeyPair(): Promise<KeyPairType | undefined> {
|
||||
const store = await getStore("identityKeys");
|
||||
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => {
|
||||
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
|
||||
const request = store.get(this.userId);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
@@ -165,7 +200,7 @@ export class SignalProtocolStorage implements StorageType {
|
||||
async loadPreKey(encodedAddress: string | number): Promise<KeyPairType | undefined> {
|
||||
const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10);
|
||||
const store = await getStore("preKeys");
|
||||
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer } | undefined>((resolve, reject) => {
|
||||
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
|
||||
const request = store.get([this.userId, preKeyId]);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
@@ -213,7 +248,7 @@ export class SignalProtocolStorage implements StorageType {
|
||||
async loadSignedPreKey(keyId: number | string): Promise<KeyPairType | undefined> {
|
||||
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||
const store = await getStore("signedPreKeys");
|
||||
const result = await new Promise<{ publicKey: ArrayBuffer; privateKey: ArrayBuffer; keyId: number } | undefined>((resolve, reject) => {
|
||||
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
|
||||
const request = store.get(this.userId);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
@@ -232,20 +267,49 @@ export class SignalProtocolStorage implements StorageType {
|
||||
return result;
|
||||
}
|
||||
|
||||
async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType): Promise<void> {
|
||||
async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType, signature?: Uint8Array): Promise<void> {
|
||||
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||
const store = await getStore("signedPreKeys", "readwrite");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put({
|
||||
interface SignedPreKeyData {
|
||||
userId: string;
|
||||
keyId: number;
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
signature?: Uint8Array;
|
||||
}
|
||||
const data: SignedPreKeyData = {
|
||||
userId: this.userId,
|
||||
keyId: signedPreKeyId,
|
||||
publicKey: toUint8Array(keyPair.pubKey),
|
||||
privateKey: toUint8Array(keyPair.privKey)
|
||||
});
|
||||
};
|
||||
if (signature) {
|
||||
data.signature = toUint8Array(signature);
|
||||
}
|
||||
const request = store.put(data);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async loadSignedPreKeySignature(keyId: number | string): Promise<Uint8Array | undefined> {
|
||||
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||
const store = await getStore("signedPreKeys");
|
||||
const result = await new Promise<{ signature?: Uint8Array } | undefined>((resolve, reject) => {
|
||||
const request = store.get(this.userId);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
if (!data || data.keyId !== signedPreKeyId) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve(data.signature ? { signature: toUint8Array(data.signature) } : undefined);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return result?.signature;
|
||||
}
|
||||
|
||||
async removeSignedPreKey(keyId: number | string): Promise<void> {
|
||||
const store = await getStore("signedPreKeys", "readwrite");
|
||||
@@ -263,7 +327,7 @@ export class SignalProtocolStorage implements StorageType {
|
||||
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
||||
|
||||
const store = await getStore("sessions");
|
||||
const result = await new Promise<{ record: string } | undefined>((resolve, reject) => {
|
||||
const result = await new Promise<string | undefined>((resolve, reject) => {
|
||||
const request = store.get([this.userId, deviceId]);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
@@ -291,5 +355,18 @@ export class SignalProtocolStorage implements StorageType {
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async removeSession(encodedAddress: string): Promise<void> {
|
||||
// encodedAddress format: "userId.deviceId"
|
||||
const parts = encodedAddress.split(".");
|
||||
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
||||
|
||||
const store = await getStore("sessions", "readwrite");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete([this.userId, deviceId]);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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