Files
Synapsis/src/lib/crypto/ratchet.ts
T
Christopher e92c747a5b Improve chat encryption, delivery, and session handling
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.
2026-01-27 20:36:03 -08:00

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
};
}