mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement Signal Protocol
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Signal Protocol service wrapper
|
||||
* Provides high-level API for encrypting/decrypting messages using Signal Protocol
|
||||
*/
|
||||
|
||||
import {
|
||||
SessionBuilder,
|
||||
SessionCipher,
|
||||
KeyHelper,
|
||||
SignalProtocolAddress,
|
||||
type DeviceType
|
||||
} from "@privacyresearch/libsignal-protocol-typescript";
|
||||
import { SignalProtocolStorage } from "./signalStorage";
|
||||
import { b64, ub64 } from "../utils";
|
||||
|
||||
// Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer)
|
||||
function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer {
|
||||
if (buffer instanceof ArrayBuffer) return buffer;
|
||||
// Convert SharedArrayBuffer to ArrayBuffer by copying
|
||||
const view = new Uint8Array(buffer);
|
||||
const copy = new Uint8Array(view.length);
|
||||
copy.set(view);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
export interface PreKeyBundleData {
|
||||
registrationId: number;
|
||||
identityKey: string; // base64
|
||||
signedPreKey: {
|
||||
keyId: number;
|
||||
publicKey: string; // base64
|
||||
signature: string; // base64
|
||||
};
|
||||
preKey?: {
|
||||
keyId: number;
|
||||
publicKey: string; // base64
|
||||
};
|
||||
}
|
||||
|
||||
export class SignalProtocolService {
|
||||
private storage: SignalProtocolStorage;
|
||||
|
||||
constructor(userId: string) {
|
||||
this.storage = new SignalProtocolStorage(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Signal Protocol for this user
|
||||
* Generates identity keys, registration ID, and prekeys if they don't exist
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Check if already initialized
|
||||
const existingIdentity = await this.storage.getIdentityKeyPair();
|
||||
if (existingIdentity) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
// Generate identity key pair
|
||||
const identityKeyPair = await KeyHelper.generateIdentityKeyPair();
|
||||
await this.storage.saveIdentityKeyPair(identityKeyPair);
|
||||
|
||||
// Generate registration ID
|
||||
const registrationId = KeyHelper.generateRegistrationId();
|
||||
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
|
||||
|
||||
// Generate prekeys (typically 100 prekeys)
|
||||
const preKeyCount = 100;
|
||||
for (let i = 1; i <= preKeyCount; i++) {
|
||||
const preKey = await KeyHelper.generatePreKey(i);
|
||||
await this.storage.storePreKey(i, preKey.keyPair);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prekey bundle for this user to share with others
|
||||
*/
|
||||
async getPreKeyBundle(): Promise<PreKeyBundleData> {
|
||||
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 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");
|
||||
}
|
||||
|
||||
return {
|
||||
registrationId: registrationId,
|
||||
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
|
||||
signedPreKey: {
|
||||
keyId: 1,
|
||||
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
|
||||
signature: b64(new Uint8Array(signedPreKeyWithSig.signature))
|
||||
},
|
||||
preKey: {
|
||||
keyId: 1,
|
||||
publicKey: b64(new Uint8Array(preKey.pubKey))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a prekey bundle from another user and establish a session
|
||||
*/
|
||||
async processPreKeyBundle(recipientId: number, bundle: PreKeyBundleData): Promise<void> {
|
||||
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||
|
||||
const identityKeyBuf = ub64(bundle.identityKey);
|
||||
const signedPreKeyPubBuf = ub64(bundle.signedPreKey.publicKey);
|
||||
const signedPreKeySigBuf = ub64(bundle.signedPreKey.signature);
|
||||
|
||||
const deviceBundle: DeviceType = {
|
||||
identityKey: toArrayBuffer(identityKeyBuf.buffer.slice(identityKeyBuf.byteOffset, identityKeyBuf.byteOffset + identityKeyBuf.byteLength)),
|
||||
signedPreKey: {
|
||||
keyId: bundle.signedPreKey.keyId,
|
||||
publicKey: toArrayBuffer(signedPreKeyPubBuf.buffer.slice(signedPreKeyPubBuf.byteOffset, signedPreKeyPubBuf.byteOffset + signedPreKeyPubBuf.byteLength)),
|
||||
signature: toArrayBuffer(signedPreKeySigBuf.buffer.slice(signedPreKeySigBuf.byteOffset, signedPreKeySigBuf.byteOffset + signedPreKeySigBuf.byteLength))
|
||||
},
|
||||
preKey: bundle.preKey ? {
|
||||
keyId: bundle.preKey.keyId,
|
||||
publicKey: (() => {
|
||||
const preKeyBuf = ub64(bundle.preKey!.publicKey);
|
||||
return toArrayBuffer(preKeyBuf.buffer.slice(preKeyBuf.byteOffset, preKeyBuf.byteOffset + preKeyBuf.byteLength));
|
||||
})()
|
||||
} : undefined,
|
||||
registrationId: bundle.registrationId
|
||||
};
|
||||
|
||||
const sessionBuilder = new SessionBuilder(this.storage, address);
|
||||
await sessionBuilder.processPreKey(deviceBundle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message for a recipient
|
||||
*/
|
||||
async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> {
|
||||
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||
|
||||
const sessionCipher = new SessionCipher(this.storage, address);
|
||||
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer);
|
||||
const { type, body } = await sessionCipher.encrypt(plaintextBuffer);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message from a sender
|
||||
*/
|
||||
async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise<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;
|
||||
|
||||
if (ciphertext.type === 3) {
|
||||
// PreKeyWhisperMessage
|
||||
plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer);
|
||||
} else {
|
||||
// WhisperMessage
|
||||
plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(plaintextBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists for a recipient
|
||||
*/
|
||||
async hasSession(recipientId: number): Promise<boolean> {
|
||||
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||
const sessionCipher = new SessionCipher(this.storage, address);
|
||||
return await sessionCipher.hasOpenSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* IndexedDB storage implementation for Signal Protocol
|
||||
* Stores identity keys, prekeys, signed prekeys, and session states
|
||||
*/
|
||||
|
||||
import type { StorageType, KeyPairType, Direction } from "@privacyresearch/libsignal-protocol-typescript";
|
||||
|
||||
const DB_NAME = "signal_protocol_db";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface SignalDB {
|
||||
identityKeys: IDBObjectStore;
|
||||
preKeys: IDBObjectStore;
|
||||
signedPreKeys: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
registrationId: IDBObjectStore;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Identity keys store: key = userId, value = { publicKey, privateKey }
|
||||
if (!db.objectStoreNames.contains("identityKeys")) {
|
||||
db.createObjectStore("identityKeys", { keyPath: "userId" });
|
||||
}
|
||||
|
||||
// Prekeys store: key = userId + preKeyId, value = { userId, preKeyId, publicKey, privateKey }
|
||||
if (!db.objectStoreNames.contains("preKeys")) {
|
||||
const preKeysStore = db.createObjectStore("preKeys", { keyPath: ["userId", "preKeyId"] });
|
||||
preKeysStore.createIndex("userId", "userId", { unique: false });
|
||||
}
|
||||
|
||||
// Signed prekeys store: key = userId, value = { userId, keyId, publicKey, privateKey, signature }
|
||||
if (!db.objectStoreNames.contains("signedPreKeys")) {
|
||||
db.createObjectStore("signedPreKeys", { keyPath: "userId" });
|
||||
}
|
||||
|
||||
// Sessions store: key = userId + deviceId, value = { userId, deviceId, record }
|
||||
if (!db.objectStoreNames.contains("sessions")) {
|
||||
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] });
|
||||
sessionsStore.createIndex("userId", "userId", { unique: false });
|
||||
}
|
||||
|
||||
// Registration ID store: key = userId, value = { userId, registrationId }
|
||||
if (!db.objectStoreNames.contains("registrationId")) {
|
||||
db.createObjectStore("registrationId", { keyPath: "userId" });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "readonly"): Promise<IDBObjectStore> {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction([storeName], mode);
|
||||
return tx.objectStore(storeName);
|
||||
}
|
||||
|
||||
// Helper to convert Uint8Array to ArrayBuffer
|
||||
function toArrayBuffer(u8: Uint8Array | ArrayBuffer): ArrayBuffer {
|
||||
if (u8 instanceof ArrayBuffer) return u8;
|
||||
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
// Helper to convert ArrayBuffer to Uint8Array
|
||||
function toUint8Array(ab: ArrayBuffer | Uint8Array): Uint8Array {
|
||||
if (ab instanceof Uint8Array) return ab;
|
||||
return new Uint8Array(ab);
|
||||
}
|
||||
|
||||
export class SignalProtocolStorage implements StorageType {
|
||||
private userId: string;
|
||||
|
||||
constructor(userId: string) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
// 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 request = store.get(this.userId);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
if (!data) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
pubKey: toArrayBuffer(data.publicKey),
|
||||
privKey: toArrayBuffer(data.privateKey)
|
||||
});
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getLocalRegistrationId(): Promise<number | undefined> {
|
||||
const store = await getStore("registrationId");
|
||||
const result = await new Promise<{ registrationId: number } | undefined>((resolve, reject) => {
|
||||
const request = store.get(this.userId);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
resolve(data ? { registrationId: data.registrationId } : undefined);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return result?.registrationId;
|
||||
}
|
||||
|
||||
async isTrustedIdentity(identifier: string, identityKey: ArrayBuffer, direction: Direction): Promise<boolean> {
|
||||
// For now, always trust (can be enhanced with key verification)
|
||||
// In production, you'd check against previously stored identity keys
|
||||
return true;
|
||||
}
|
||||
|
||||
async saveIdentity(encodedAddress: string, publicKey: ArrayBuffer, nonblockingApproval?: boolean): Promise<boolean> {
|
||||
// Store other users' identity keys if needed
|
||||
// For now, we trust all identities
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper methods for initialization (not part of StorageType interface)
|
||||
async saveIdentityKeyPair(keyPair: KeyPairType): Promise<void> {
|
||||
const store = await getStore("identityKeys", "readwrite");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put({
|
||||
userId: this.userId,
|
||||
publicKey: toUint8Array(keyPair.pubKey),
|
||||
privateKey: toUint8Array(keyPair.privKey)
|
||||
});
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveLocalRegistrationId(registrationId: number): Promise<void> {
|
||||
const store = await getStore("registrationId", "readwrite");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put({
|
||||
userId: this.userId,
|
||||
registrationId: registrationId
|
||||
});
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// PreKey Management
|
||||
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 request = store.get([this.userId, preKeyId]);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
if (!data) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
pubKey: toArrayBuffer(data.publicKey),
|
||||
privKey: toArrayBuffer(data.privateKey)
|
||||
});
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async storePreKey(keyId: number | string, keyPair: KeyPairType): Promise<void> {
|
||||
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||
const store = await getStore("preKeys", "readwrite");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put({
|
||||
userId: this.userId,
|
||||
preKeyId: preKeyId,
|
||||
publicKey: toUint8Array(keyPair.pubKey),
|
||||
privateKey: toUint8Array(keyPair.privKey)
|
||||
});
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async removePreKey(keyId: number | string): Promise<void> {
|
||||
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||
const store = await getStore("preKeys", "readwrite");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete([this.userId, preKeyId]);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Signed PreKey Management
|
||||
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 request = store.get(this.userId);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
if (!data || data.keyId !== signedPreKeyId) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
pubKey: toArrayBuffer(data.publicKey),
|
||||
privKey: toArrayBuffer(data.privateKey)
|
||||
});
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType): 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({
|
||||
userId: this.userId,
|
||||
keyId: signedPreKeyId,
|
||||
publicKey: toUint8Array(keyPair.pubKey),
|
||||
privateKey: toUint8Array(keyPair.privKey)
|
||||
});
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async removeSignedPreKey(keyId: number | string): Promise<void> {
|
||||
const store = await getStore("signedPreKeys", "readwrite");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete(this.userId);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Session Management
|
||||
async loadSession(encodedAddress: string): Promise<string | undefined> {
|
||||
// encodedAddress format: "userId.deviceId"
|
||||
const parts = encodedAddress.split(".");
|
||||
const deviceId = parts.length > 1 ? parts[1] : encodedAddress;
|
||||
|
||||
const store = await getStore("sessions");
|
||||
const result = await new Promise<{ record: string } | undefined>((resolve, reject) => {
|
||||
const request = store.get([this.userId, deviceId]);
|
||||
request.onsuccess = () => {
|
||||
const data = request.result;
|
||||
resolve(data ? data.record : undefined);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async storeSession(encodedAddress: string, record: 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.put({
|
||||
userId: this.userId,
|
||||
deviceId: deviceId,
|
||||
record: record
|
||||
});
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user