331 lines
10 KiB
TypeScript
331 lines
10 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,
|
|
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 };
|
|
}
|