e92c747a5b
Adds robust session serialization/deserialization for ratchet state, session cleanup for corrupted data, and improved error handling in chat encryption hooks. Enhances chat delivery to support both local and remote recipients, ensures conversation creation, and adds timeouts to key fetches. Also introduces utilities for deleting and clearing encrypted session data from storage.
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
|
|
/**
|
|
* Synapsis Double Ratchet & X3DH Implementation
|
|
*
|
|
* Implements the Double Ratchet Algorithm + X3DH Key Agreement.
|
|
* Adheres to Signal specifications using the "SynapsisV2" HKDF info binding.
|
|
*/
|
|
|
|
import {
|
|
KeyPair,
|
|
computeSharedSecret,
|
|
hkdf,
|
|
encrypt as aeadEncrypt,
|
|
decrypt as aeadDecrypt,
|
|
importX25519PublicKey,
|
|
importX25519PrivateKey,
|
|
exportKey,
|
|
generateX25519KeyPair,
|
|
base64ToArrayBuffer,
|
|
arrayBufferToBase64
|
|
} from './e2ee';
|
|
|
|
// Constants
|
|
const KDF_INFO = 'SynapsisV2';
|
|
const RK_SIZE = 32; // 32 bytes for Root Key
|
|
const CK_SIZE = 32; // 32 bytes for Chain Key
|
|
const MK_SIZE = 32; // 32 bytes for Message Key
|
|
|
|
export interface RatchetState {
|
|
// DH Ratchet
|
|
dhPair: KeyPair;
|
|
remoteDhPub: CryptoKey;
|
|
rootKey: ArrayBuffer;
|
|
|
|
// Symm Ratchets
|
|
chainKeySend: ArrayBuffer;
|
|
chainKeyRecv: ArrayBuffer;
|
|
|
|
// Message Numbers
|
|
ns: number; // Send count
|
|
nr: number; // Recv count
|
|
pn: number; // Previous chain count
|
|
}
|
|
|
|
export interface Header {
|
|
dh: string; // Base64 public key
|
|
pn: number;
|
|
n: number;
|
|
}
|
|
|
|
export interface CiphertextMessage {
|
|
header: Header;
|
|
ciphertext: string;
|
|
iv: string;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 1. X3DH Key Agreement
|
|
// ----------------------------------------------------------------------------
|
|
|
|
export async function x3dhSender(
|
|
aliceIdentity: KeyPair,
|
|
bobBundle: {
|
|
identityKey: CryptoKey,
|
|
signedPreKey: CryptoKey,
|
|
oneTimeKey?: CryptoKey
|
|
},
|
|
contextInfo: string // "SynapsisV2" + DIDs + DeviceIDs
|
|
): Promise<{ sk: ArrayBuffer, ephemeralKey: KeyPair }> {
|
|
|
|
// 1. Generate Ephemeral Key (EK_a)
|
|
const ephemeralKey = await generateX25519KeyPair();
|
|
|
|
// 2. DH1 = DH(IK_a, SPK_b)
|
|
const dh1 = await computeSharedSecret(aliceIdentity.privateKey, bobBundle.signedPreKey);
|
|
|
|
// 3. DH2 = DH(EK_a, IK_b)
|
|
const dh2 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.identityKey);
|
|
|
|
// 4. DH3 = DH(EK_a, SPK_b)
|
|
const dh3 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.signedPreKey);
|
|
|
|
// 5. DH4 = DH(EK_a, OPK_b) -- Optional
|
|
let dh4: ArrayBuffer | undefined;
|
|
if (bobBundle.oneTimeKey) {
|
|
dh4 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.oneTimeKey);
|
|
}
|
|
|
|
// 6. Concatenate
|
|
const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0));
|
|
let offset = 0;
|
|
km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength;
|
|
km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength;
|
|
km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength;
|
|
if (dh4) km.set(new Uint8Array(dh4), offset);
|
|
|
|
// 7. KDF
|
|
// Output 32 bytes for Root Key
|
|
const encoder = new TextEncoder();
|
|
return {
|
|
sk: await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32),
|
|
ephemeralKey
|
|
};
|
|
}
|
|
|
|
export async function x3dhReceiver(
|
|
bobIdentity: KeyPair,
|
|
bobSignedPreKey: KeyPair,
|
|
bobOneTimeKey: KeyPair | undefined, // The one used by Alice
|
|
aliceIdentityKey: CryptoKey,
|
|
aliceEphemeralKey: CryptoKey,
|
|
contextInfo: string
|
|
): Promise<ArrayBuffer> {
|
|
|
|
// 1. DH1 = DH(SPK_b, IK_a) -- Note: Order of keys in computeSharedSecret usually (private, public)
|
|
const dh1 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceIdentityKey);
|
|
|
|
// 2. DH2 = DH(IK_b, EK_a)
|
|
const dh2 = await computeSharedSecret(bobIdentity.privateKey, aliceEphemeralKey);
|
|
|
|
// 3. DH3 = DH(SPK_b, EK_a)
|
|
const dh3 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceEphemeralKey);
|
|
|
|
// 4. DH4 = DH(OPK_b, EK_a)
|
|
let dh4: ArrayBuffer | undefined;
|
|
if (bobOneTimeKey) {
|
|
dh4 = await computeSharedSecret(bobOneTimeKey.privateKey, aliceEphemeralKey);
|
|
}
|
|
|
|
const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0));
|
|
let offset = 0;
|
|
km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength;
|
|
km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength;
|
|
km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength;
|
|
if (dh4) km.set(new Uint8Array(dh4), offset);
|
|
|
|
const encoder = new TextEncoder();
|
|
return await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 2. KDF Chains (Symmetric Ratchet)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// Constants for HMAC
|
|
const ONE = new Uint8Array([0x01]);
|
|
const TWO = new Uint8Array([0x02]);
|
|
|
|
async function kdfChain(ck: ArrayBuffer): Promise<{ ck: ArrayBuffer, mk: ArrayBuffer }> {
|
|
// HMAC-SHA256(CK, 1) -> MK
|
|
// HMAC-SHA256(CK, 2) -> NextCK
|
|
// Implementing via HKDF for simplicity/consistency or WebCrypto HMAC
|
|
|
|
// Actually standard says:
|
|
// HMAC-SHA256(ck, input)
|
|
// We can use HKDF-Expand logic here or pure hmac.
|
|
// Let's use custom HKDF expand with fixed info
|
|
const mk = await hkdf(new Uint8Array(0), ck, ONE, 32);
|
|
const nextCk = await hkdf(new Uint8Array(0), ck, TWO, 32);
|
|
|
|
return { ck: nextCk, mk };
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 3. DHRatchet (Root Chain)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async function kdfRoot(rootKey: ArrayBuffer, dhOut: ArrayBuffer): Promise<{ rootKey: ArrayBuffer, chainKey: ArrayBuffer }> {
|
|
// HKDF(root, dh, info, 64) -> 32 root, 32 chain
|
|
const encoder = new TextEncoder();
|
|
const output = await hkdf(
|
|
rootKey,
|
|
dhOut,
|
|
encoder.encode("SynapsisRatchet"),
|
|
64
|
|
);
|
|
|
|
const bytes = new Uint8Array(output);
|
|
return {
|
|
rootKey: bytes.slice(0, 32).buffer,
|
|
chainKey: bytes.slice(32, 64).buffer
|
|
};
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 4. Initializers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
export async function initSender(
|
|
sharedSecret: ArrayBuffer,
|
|
bobRatchetKey: CryptoKey
|
|
): Promise<RatchetState> {
|
|
const dhPair = await generateX25519KeyPair();
|
|
|
|
// Sender starts by sending a new DH ratchet.
|
|
// Root Key = sharedSecret.
|
|
// First, we need to generate a chain key for sending.
|
|
// Standard: Alice initializes with SK. Bob's ratchet key is remote.
|
|
// Alice generates `dhPair`.
|
|
// She performs a DH ratchet Step immediately?
|
|
// Protocol:
|
|
// Alice: RK = SK.
|
|
// Alice performs DH(alice_priv, bob_pub).
|
|
// Calculates RK, CK_send.
|
|
|
|
const dhOut = await computeSharedSecret(dhPair.privateKey, bobRatchetKey);
|
|
const kdf = await kdfRoot(sharedSecret, dhOut);
|
|
|
|
return {
|
|
dhPair,
|
|
remoteDhPub: bobRatchetKey,
|
|
rootKey: kdf.rootKey,
|
|
chainKeySend: kdf.chainKey,
|
|
chainKeyRecv: new Uint8Array(0).buffer, // Empty until Bob replies
|
|
ns: 0,
|
|
nr: 0,
|
|
pn: 0
|
|
};
|
|
}
|
|
|
|
export async function initReceiver(
|
|
sharedSecret: ArrayBuffer,
|
|
bobDhKeyPair: KeyPair // This is the SPK key pair used in X3DH
|
|
): Promise<RatchetState> {
|
|
// Bob: RK = SK.
|
|
// Bob has consistent state.
|
|
return {
|
|
dhPair: bobDhKeyPair,
|
|
remoteDhPub: bobDhKeyPair.publicKey, // Placeholder, will be updated on first msg
|
|
rootKey: sharedSecret,
|
|
chainKeySend: new Uint8Array(0).buffer,
|
|
chainKeyRecv: new Uint8Array(0).buffer, // Will be derived on first msg
|
|
ns: 0,
|
|
nr: 0,
|
|
pn: 0
|
|
};
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 5. Encrypt / Decrypt Message
|
|
// ----------------------------------------------------------------------------
|
|
|
|
export async function ratchetEncrypt(
|
|
state: RatchetState,
|
|
plaintext: string
|
|
): Promise<{
|
|
ciphertext: CiphertextMessage,
|
|
newState: RatchetState
|
|
}> {
|
|
// 1. Advance chain
|
|
const { ck: nextCk, mk } = await kdfChain(state.chainKeySend);
|
|
state.chainKeySend = nextCk;
|
|
|
|
// 2. Encrypt
|
|
const header: Header = {
|
|
dh: await exportKey(state.dhPair.publicKey),
|
|
pn: state.pn,
|
|
n: state.ns
|
|
};
|
|
|
|
const associatedData = new TextEncoder().encode(JSON.stringify(header));
|
|
const encrypted = await aeadEncrypt(mk, plaintext, associatedData);
|
|
|
|
state.ns += 1;
|
|
|
|
return {
|
|
ciphertext: {
|
|
header,
|
|
ciphertext: encrypted.ciphertext,
|
|
iv: encrypted.iv
|
|
},
|
|
newState: state
|
|
};
|
|
}
|
|
|
|
// Note: Decryption requires handling out-of-order messages and ratcheting steps.
|
|
// This is complex logic. For V2.1 baseline, we implement the core ratcheting step if header key differs.
|
|
|
|
export async function ratchetDecrypt(
|
|
state: RatchetState,
|
|
message: CiphertextMessage
|
|
): Promise<{ plaintext: string, newState: RatchetState }> {
|
|
// Check if DH ratchet step needed
|
|
// If message.header.dh != state.remoteDhPub
|
|
|
|
// Note: Comparing CryptoKeys directly is hard. We compare Base64 export.
|
|
const remoteKeyStr = await exportKey(state.remoteDhPub);
|
|
|
|
if (message.header.dh !== remoteKeyStr) {
|
|
// Ratchet Step!
|
|
const newRemoteKey = await importX25519PublicKey(message.header.dh);
|
|
|
|
// 1. DHRatchet(remote_new) -> RX step
|
|
const dhOut1 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey);
|
|
const kdf1 = await kdfRoot(state.rootKey, dhOut1);
|
|
state.rootKey = kdf1.rootKey;
|
|
state.chainKeyRecv = kdf1.chainKey;
|
|
|
|
// 2. Sender step (We generate new key)
|
|
state.pn = state.ns;
|
|
state.ns = 0;
|
|
state.nr = 0;
|
|
state.dhPair = await generateX25519KeyPair();
|
|
|
|
// 3. DHRatchet(remote_new) -> TX step
|
|
const dhOut2 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey);
|
|
const kdf2 = await kdfRoot(state.rootKey, dhOut2);
|
|
state.rootKey = kdf2.rootKey;
|
|
state.chainKeySend = kdf2.chainKey;
|
|
|
|
state.remoteDhPub = newRemoteKey;
|
|
}
|
|
|
|
// 3. Symmetric Ratchet to catch up to n
|
|
// (Skipping skipped-message buffering for now - assumes ordered delivery for V2.1 baseline)
|
|
|
|
// Advance Chain Recv to n
|
|
// Real impl buffers skipped keys.
|
|
// Warning: If n > nr, we must loop.
|
|
// For now, assuming direct sequence.
|
|
|
|
const { ck: nextCk, mk } = await kdfChain(state.chainKeyRecv);
|
|
state.chainKeyRecv = nextCk;
|
|
state.nr += 1;
|
|
|
|
// 4. Decrypt
|
|
const associatedData = new TextEncoder().encode(JSON.stringify(message.header));
|
|
const plaintext = await aeadDecrypt(mk, message.ciphertext, message.iv, associatedData);
|
|
|
|
return { plaintext, newState: state };
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 6. Serialization Helpers (CRITICAL: CryptoKeys and Buffers don't JSON stringify)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
export interface SerializedRatchetState {
|
|
dhPair: { pub: string, priv: string };
|
|
remoteDhPub: string;
|
|
rootKey: string;
|
|
chainKeySend: string;
|
|
chainKeyRecv: string;
|
|
ns: number;
|
|
nr: number;
|
|
pn: number;
|
|
}
|
|
|
|
export async function serializeRatchetState(state: RatchetState): Promise<SerializedRatchetState> {
|
|
return {
|
|
dhPair: {
|
|
pub: await exportKey(state.dhPair.publicKey),
|
|
priv: await exportKey(state.dhPair.privateKey)
|
|
},
|
|
remoteDhPub: await exportKey(state.remoteDhPub),
|
|
rootKey: arrayBufferToBase64(state.rootKey),
|
|
chainKeySend: arrayBufferToBase64(state.chainKeySend),
|
|
chainKeyRecv: arrayBufferToBase64(state.chainKeyRecv),
|
|
ns: state.ns,
|
|
nr: state.nr,
|
|
pn: state.pn
|
|
};
|
|
}
|
|
|
|
export async function deserializeRatchetState(data: SerializedRatchetState): Promise<RatchetState> {
|
|
// Validate integrity - check all required fields exist (but allow empty strings for buffers that can be empty)
|
|
if (!data ||
|
|
!data.rootKey ||
|
|
!data.dhPair ||
|
|
!data.dhPair.pub ||
|
|
!data.dhPair.priv ||
|
|
!data.remoteDhPub ||
|
|
data.chainKeySend === undefined || data.chainKeySend === null ||
|
|
data.chainKeyRecv === undefined || data.chainKeyRecv === null) {
|
|
throw new Error('Invalid serialized state: missing required fields');
|
|
}
|
|
|
|
return {
|
|
dhPair: {
|
|
publicKey: await importX25519PublicKey(data.dhPair.pub),
|
|
privateKey: await importX25519PrivateKey(data.dhPair.priv)
|
|
},
|
|
remoteDhPub: await importX25519PublicKey(data.remoteDhPub),
|
|
rootKey: base64ToArrayBuffer(data.rootKey),
|
|
chainKeySend: data.chainKeySend ? base64ToArrayBuffer(data.chainKeySend) : new Uint8Array(0).buffer,
|
|
chainKeyRecv: data.chainKeyRecv ? base64ToArrayBuffer(data.chainKeyRecv) : new Uint8Array(0).buffer,
|
|
ns: data.ns,
|
|
nr: data.nr,
|
|
pn: data.pn
|
|
};
|
|
}
|