feat: Shade E2EE library — M1-M3 complete

Signal Protocol implementation with full X3DH + Double Ratchet:

- M1: Core types, CryptoProvider interface, KDF chain functions,
  SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
  prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
  message key cache, out-of-order delivery, AAD-bound headers)

68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:08:19 +02:00
commit bd6452044f
27 changed files with 2517 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
/**
* CryptoProvider — platform-agnostic interface for all cryptographic primitives.
*
* Implementations:
* - @shade/crypto-web: SubtleCrypto (Bun, Node.js, browser)
* - shade-android: Google Tink (Kotlin/Android)
*/
export interface CryptoProvider {
// ─── X25519 (Diffie-Hellman) ───────────────────────────────
/** Generate an X25519 keypair (32-byte public + 32-byte private) */
generateX25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>;
/** Perform X25519 Diffie-Hellman: returns 32-byte shared secret */
x25519(privateKey: Uint8Array, publicKey: Uint8Array): Promise<Uint8Array>;
// ─── Ed25519 (Signing) ─────────────────────────────────────
/** Generate an Ed25519 keypair (32-byte public + 32-byte private) */
generateEd25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>;
/** Sign a message with Ed25519. Returns a 64-byte signature. */
sign(privateKey: Uint8Array, message: Uint8Array): Promise<Uint8Array>;
/** Verify an Ed25519 signature. Returns true if valid. */
verify(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): Promise<boolean>;
// ─── AES-256-GCM (Symmetric Encryption) ────────────────────
/** Encrypt plaintext with AES-256-GCM. Generates a random 12-byte nonce. */
aesGcmEncrypt(
key: Uint8Array,
plaintext: Uint8Array,
aad?: Uint8Array,
): Promise<{ ciphertext: Uint8Array; nonce: Uint8Array }>;
/** Decrypt ciphertext with AES-256-GCM. Throws on authentication failure. */
aesGcmDecrypt(
key: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array,
): Promise<Uint8Array>;
// ─── Key Derivation ────────────────────────────────────────
/** HKDF-SHA256: derive `length` bytes from input keying material */
hkdf(
ikm: Uint8Array,
salt: Uint8Array,
info: Uint8Array,
length: number,
): Promise<Uint8Array>;
/** HMAC-SHA256: returns 32-byte MAC */
hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
// ─── Random ────────────────────────────────────────────────
/** Generate cryptographically secure random bytes */
randomBytes(length: number): Uint8Array;
}

View File

@@ -0,0 +1,63 @@
/** Base class for all Shade errors */
export class ShadeError extends Error {
constructor(message: string) {
super(message);
this.name = 'ShadeError';
}
}
/** Signature verification failed (e.g. invalid signed prekey) */
export class InvalidSignatureError extends ShadeError {
constructor(message = 'Signature verification failed') {
super(message);
this.name = 'InvalidSignatureError';
}
}
/** AES-GCM decryption failed (wrong key, tampered ciphertext, or bad nonce) */
export class DecryptionError extends ShadeError {
constructor(message = 'Decryption failed') {
super(message);
this.name = 'DecryptionError';
}
}
/** No session exists for the given address */
export class NoSessionError extends ShadeError {
constructor(address: string) {
super(`No session for address: ${address}`);
this.name = 'NoSessionError';
}
}
/** Too many skipped messages in a chain (possible DoS or sync issue) */
export class MaxSkipExceededError extends ShadeError {
constructor(requested: number, max: number) {
super(`Cannot skip ${requested} messages (max: ${max})`);
this.name = 'MaxSkipExceededError';
}
}
/** Duplicate message detected (message key already consumed) */
export class DuplicateMessageError extends ShadeError {
constructor() {
super('Duplicate message: key already consumed');
this.name = 'DuplicateMessageError';
}
}
/** Remote identity key has changed unexpectedly */
export class UntrustedIdentityError extends ShadeError {
constructor(address: string) {
super(`Untrusted identity key for: ${address}`);
this.name = 'UntrustedIdentityError';
}
}
/** Required prekey not found in storage */
export class PreKeyNotFoundError extends ShadeError {
constructor(keyId: number, type: 'signed' | 'one-time') {
super(`${type} prekey not found: ${keyId}`);
this.name = 'PreKeyNotFoundError';
}
}

View File

@@ -0,0 +1,7 @@
export * from './types.js';
export * from './crypto.js';
export * from './storage.js';
export * from './keys.js';
export * from './errors.js';
export * from './x3dh.js';
export * from './ratchet.js';

View File

@@ -0,0 +1,83 @@
import type { CryptoProvider } from './crypto.js';
/**
* Signal Protocol KDF chain functions.
*
* These implement the key derivation logic used by both X3DH (initial root key)
* and the Double Ratchet (root key ratchet + chain key ratchet).
*
* References:
* - Signal Double Ratchet spec, section 2.2 "KDF chains"
* - Signal X3DH spec, section 2.4 "Key derivation"
*/
// Info strings used in HKDF derivations (must match across all platforms)
const ROOT_KDF_INFO = new TextEncoder().encode('ShadeRootRatchet');
const CHAIN_KEY_CONSTANT = new Uint8Array([0x01]);
const MESSAGE_KEY_CONSTANT = new Uint8Array([0x02]);
/**
* Root key ratchet step: given the current root key and a DH output,
* derive a new root key and a new chain key.
*
* Uses HKDF with the DH output as IKM and the current root key as salt.
* Output is 64 bytes: first 32 = new root key, last 32 = new chain key.
*/
export async function kdfRootKey(
crypto: CryptoProvider,
rootKey: Uint8Array,
dhOutput: Uint8Array,
): Promise<{ newRootKey: Uint8Array; chainKey: Uint8Array }> {
const derived = await crypto.hkdf(dhOutput, rootKey, ROOT_KDF_INFO, 64);
return {
newRootKey: derived.slice(0, 32),
chainKey: derived.slice(32, 64),
};
}
/**
* Chain key ratchet step: derive the next chain key and a message key.
*
* Chain key → HMAC(chainKey, 0x01) = new chain key
* Chain key → HMAC(chainKey, 0x02) = message key (used to encrypt one message)
*
* The message key is consumed (used once), the chain key advances.
*/
export async function kdfChainKey(
crypto: CryptoProvider,
chainKey: Uint8Array,
): Promise<{ newChainKey: Uint8Array; messageKey: Uint8Array }> {
const [newChainKey, messageKey] = await Promise.all([
crypto.hmacSha256(chainKey, CHAIN_KEY_CONSTANT),
crypto.hmacSha256(chainKey, MESSAGE_KEY_CONSTANT),
]);
return { newChainKey, messageKey };
}
/**
* Derive the initial root key from X3DH shared secrets.
*
* Takes the concatenated DH outputs from X3DH (DH1 || DH2 || DH3 [|| DH4])
* and derives a 32-byte root key using HKDF.
*
* Salt: 32 zero bytes (as per Signal spec)
* Info: "ShadeX3DH"
*/
const X3DH_INFO = new TextEncoder().encode('ShadeX3DH');
const X3DH_SALT = new Uint8Array(32); // 32 zero bytes
export async function deriveInitialRootKey(
crypto: CryptoProvider,
sharedSecrets: Uint8Array[],
): Promise<Uint8Array> {
// Concatenate all DH outputs
const totalLength = sharedSecrets.reduce((sum, s) => sum + s.length, 0);
const ikm = new Uint8Array(totalLength);
let offset = 0;
for (const secret of sharedSecrets) {
ikm.set(secret, offset);
offset += secret.length;
}
return crypto.hkdf(ikm, X3DH_SALT, X3DH_INFO, 32);
}

View File

@@ -0,0 +1,274 @@
import type { CryptoProvider } from './crypto.js';
import type { KeyPair, SessionState, ChainState, RatchetMessage } from './types.js';
import { MAX_SKIP, MAX_CACHED_SKIPPED_KEYS } from './types.js';
import { kdfRootKey, kdfChainKey } from './keys.js';
import { DecryptionError, MaxSkipExceededError, DuplicateMessageError } from './errors.js';
/**
* Double Ratchet — per-message forward secrecy and post-compromise recovery.
*
* Combines a symmetric-key ratchet (chain keys → message keys) with a
* Diffie-Hellman ratchet (new DH keypair per conversation turn).
*
* Reference: https://signal.org/docs/specifications/doubleratchet/
*/
// ─── Utility ─────────────────────────────────────────────────
function toBase64(buf: Uint8Array): string {
// Use a simple hex encoding for map keys (avoids btoa issues with Uint8Array)
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
}
function skippedKeyId(dhPublicKey: Uint8Array, counter: number): string {
return `${toBase64(dhPublicKey)}:${counter}`;
}
/** Encode a RatchetMessage header as bytes for use as AES-GCM AAD */
function encodeHeader(msg: Pick<RatchetMessage, 'dhPublicKey' | 'previousCounter' | 'counter'>): Uint8Array {
// dhPublicKey (32) + previousCounter (4, big-endian) + counter (4, big-endian)
const buf = new Uint8Array(40);
buf.set(msg.dhPublicKey, 0);
new DataView(buf.buffer).setUint32(32, msg.previousCounter, false);
new DataView(buf.buffer).setUint32(36, msg.counter, false);
return buf;
}
// ─── Session Initialization ──────────────────────────────────
/**
* Initialize a session as the sender (Alice, after X3DH).
*
* Alice knows the root key and Bob's signed prekey (initial remote DH key).
* She generates her first DH ratchet keypair and performs the first DH ratchet step.
*/
export async function initSenderSession(
crypto: CryptoProvider,
rootKey: Uint8Array,
remoteIdentityKey: Uint8Array,
remoteDHPublicKey: Uint8Array,
): Promise<SessionState> {
// Generate first DH ratchet keypair
const dhSend = await crypto.generateX25519KeyPair();
// First DH ratchet step
const dhOutput = await crypto.x25519(dhSend.privateKey, remoteDHPublicKey);
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
return {
remoteIdentityKey,
rootKey: newRootKey,
sendChain: { chainKey, counter: 0 },
receiveChain: null,
dhSend,
dhReceive: remoteDHPublicKey,
previousSendCounter: 0,
skippedKeys: new Map(),
};
}
/**
* Initialize a session as the receiver (Bob, after X3DH).
*
* Bob knows the root key and his own signed prekey (which was used as
* the initial DH ratchet keypair).
*/
export function initReceiverSession(
rootKey: Uint8Array,
remoteIdentityKey: Uint8Array,
localDHKeyPair: KeyPair,
): SessionState {
return {
remoteIdentityKey,
rootKey,
sendChain: { chainKey: new Uint8Array(32), counter: 0 },
receiveChain: null,
dhSend: localDHKeyPair,
dhReceive: null,
previousSendCounter: 0,
skippedKeys: new Map(),
};
}
// ─── Encrypt ─────────────────────────────────────────────────
/**
* Encrypt a plaintext message using the Double Ratchet.
*
* Advances the sending chain by one step, derives a message key,
* encrypts the plaintext with AES-256-GCM, and returns a RatchetMessage.
*/
export async function ratchetEncrypt(
crypto: CryptoProvider,
session: SessionState,
plaintext: Uint8Array,
): Promise<RatchetMessage> {
// Advance sending chain
const { newChainKey, messageKey } = await kdfChainKey(crypto, session.sendChain.chainKey);
const counter = session.sendChain.counter;
// Build header for AAD
const header: Pick<RatchetMessage, 'dhPublicKey' | 'previousCounter' | 'counter'> = {
dhPublicKey: session.dhSend.publicKey,
previousCounter: session.previousSendCounter,
counter,
};
const aad = encodeHeader(header);
// Encrypt
const { ciphertext, nonce } = await crypto.aesGcmEncrypt(messageKey, plaintext, aad);
// Update session state
session.sendChain.chainKey = newChainKey;
session.sendChain.counter = counter + 1;
return {
dhPublicKey: session.dhSend.publicKey,
previousCounter: session.previousSendCounter,
counter,
ciphertext,
nonce,
};
}
// ─── Decrypt ─────────────────────────────────────────────────
/**
* Decrypt a RatchetMessage using the Double Ratchet.
*
* Handles three cases:
* 1. Message from a skipped key (out-of-order delivery)
* 2. Message from the current receiving chain
* 3. Message with a new DH key (triggers a DH ratchet step)
*/
export async function ratchetDecrypt(
crypto: CryptoProvider,
session: SessionState,
message: RatchetMessage,
): Promise<Uint8Array> {
// Case 1: Try skipped keys first
const skipId = skippedKeyId(message.dhPublicKey, message.counter);
const skippedKey = session.skippedKeys.get(skipId);
if (skippedKey) {
session.skippedKeys.delete(skipId);
return decryptWithKey(crypto, skippedKey, message);
}
// Case 2 or 3: Check if this is a new DH ratchet
const isNewRatchet = !session.dhReceive || !arraysEqual(message.dhPublicKey, session.dhReceive);
if (isNewRatchet) {
// Skip any remaining messages in the current receiving chain
if (session.receiveChain && session.dhReceive) {
await skipMessageKeys(crypto, session, session.dhReceive, session.receiveChain, message.previousCounter);
}
// Perform DH ratchet step
await performDHRatchetStep(crypto, session, message.dhPublicKey);
}
// Skip to the message's counter in the current receiving chain
if (!session.receiveChain) {
throw new DecryptionError('No receiving chain available');
}
await skipMessageKeys(crypto, session, message.dhPublicKey, session.receiveChain, message.counter);
// Advance the receiving chain one more step to get this message's key
const { newChainKey, messageKey } = await kdfChainKey(crypto, session.receiveChain.chainKey);
session.receiveChain.chainKey = newChainKey;
session.receiveChain.counter = message.counter + 1;
return decryptWithKey(crypto, messageKey, message);
}
// ─── DH Ratchet Step ─────────────────────────────────────────
/**
* Perform a DH ratchet step when receiving a message with a new DH public key.
*
* 1. DH(current send key, new remote key) → advance root key, get new receiving chain
* 2. Generate new DH keypair
* 3. DH(new keypair, remote key) → advance root key, get new sending chain
*/
async function performDHRatchetStep(
crypto: CryptoProvider,
session: SessionState,
remoteDHKey: Uint8Array,
): Promise<void> {
// Save previous send counter
session.previousSendCounter = session.sendChain.counter;
// Update remote DH key
session.dhReceive = remoteDHKey;
// DH with current send key → new receiving chain
const dh1 = await crypto.x25519(session.dhSend.privateKey, remoteDHKey);
const recv = await kdfRootKey(crypto, session.rootKey, dh1);
session.rootKey = recv.newRootKey;
session.receiveChain = { chainKey: recv.chainKey, counter: 0 };
// Generate new DH keypair
session.dhSend = await crypto.generateX25519KeyPair();
// DH with new send key → new sending chain
const dh2 = await crypto.x25519(session.dhSend.privateKey, remoteDHKey);
const send = await kdfRootKey(crypto, session.rootKey, dh2);
session.rootKey = send.newRootKey;
session.sendChain = { chainKey: send.chainKey, counter: 0 };
}
// ─── Skip Message Keys ──────────────────────────────────────
/**
* Advance a chain, caching skipped message keys for out-of-order decryption.
*/
async function skipMessageKeys(
crypto: CryptoProvider,
session: SessionState,
dhPublicKey: Uint8Array,
chain: ChainState,
untilCounter: number,
): Promise<void> {
const toSkip = untilCounter - chain.counter;
if (toSkip < 0) return; // already past this point
if (toSkip > MAX_SKIP) {
throw new MaxSkipExceededError(toSkip, MAX_SKIP);
}
for (let i = chain.counter; i < untilCounter; i++) {
const { newChainKey, messageKey } = await kdfChainKey(crypto, chain.chainKey);
const id = skippedKeyId(dhPublicKey, i);
session.skippedKeys.set(id, messageKey);
chain.chainKey = newChainKey;
chain.counter = i + 1;
// Evict oldest if we have too many cached keys
if (session.skippedKeys.size > MAX_CACHED_SKIPPED_KEYS) {
const firstKey = session.skippedKeys.keys().next().value;
if (firstKey) session.skippedKeys.delete(firstKey);
}
}
}
// ─── Helpers ─────────────────────────────────────────────────
async function decryptWithKey(
crypto: CryptoProvider,
messageKey: Uint8Array,
message: RatchetMessage,
): Promise<Uint8Array> {
const aad = encodeHeader(message);
try {
return await crypto.aesGcmDecrypt(messageKey, message.ciphertext, message.nonce, aad);
} catch {
throw new DecryptionError('Failed to decrypt message — wrong key or tampered data');
}
}
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}

View File

@@ -0,0 +1,68 @@
import type { IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from './types.js';
/**
* StorageProvider — abstract interface for persisting cryptographic state.
*
* Implementations per platform:
* - In-memory (testing)
* - IndexedDB (browser)
* - SQLite/PostgreSQL (server)
* - EncryptedSharedPreferences (Android)
*/
export interface StorageProvider {
// ─── Identity ──────────────────────────────────────────────
/** Get our local identity keypair, or null if not yet generated */
getIdentityKeyPair(): Promise<IdentityKeyPair | null>;
/** Persist our local identity keypair */
saveIdentityKeyPair(keyPair: IdentityKeyPair): Promise<void>;
/** Get our local registration ID (unique per installation) */
getLocalRegistrationId(): Promise<number>;
/** Save our local registration ID */
saveLocalRegistrationId(id: number): Promise<void>;
// ─── Signed Pre-Keys ──────────────────────────────────────
/** Get a signed prekey by ID */
getSignedPreKey(keyId: number): Promise<SignedPreKey | null>;
/** Persist a signed prekey */
saveSignedPreKey(key: SignedPreKey): Promise<void>;
/** Remove a signed prekey (after rotation grace period) */
removeSignedPreKey(keyId: number): Promise<void>;
// ─── One-Time Pre-Keys ────────────────────────────────────
/** Get a one-time prekey by ID */
getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null>;
/** Persist a one-time prekey */
saveOneTimePreKey(key: OneTimePreKey): Promise<void>;
/** Remove a consumed one-time prekey */
removeOneTimePreKey(keyId: number): Promise<void>;
/** Count remaining one-time prekeys */
getOneTimePreKeyCount(): Promise<number>;
// ─── Sessions ─────────────────────────────────────────────
/** Get session state for a peer address (e.g. "device:abc123") */
getSession(address: string): Promise<SessionState | null>;
/** Persist session state for a peer */
saveSession(address: string, state: SessionState): Promise<void>;
/** Remove session for a peer */
removeSession(address: string): Promise<void>;
/** Check if we trust a remote identity key (for TOFU or pinned keys) */
isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean>;
/** Save a trusted remote identity key */
saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void>;
}

View File

@@ -0,0 +1,129 @@
// ─── Key Types ─────────────────────────────────────────────────
/** Long-term identity: Ed25519 for signing + X25519 for DH */
export interface IdentityKeyPair {
/** Ed25519 public key (32 bytes) — used to sign prekeys */
signingPublicKey: Uint8Array;
/** Ed25519 private key (32 bytes) */
signingPrivateKey: Uint8Array;
/** X25519 public key (32 bytes) — used for DH in X3DH */
dhPublicKey: Uint8Array;
/** X25519 private key (32 bytes) */
dhPrivateKey: Uint8Array;
}
/** Generic asymmetric keypair (X25519 or Ed25519) */
export interface KeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
/** Medium-term signed pre-key (rotated periodically, e.g. weekly) */
export interface SignedPreKey {
keyId: number;
keyPair: KeyPair;
/** Ed25519 signature over the public key, by the identity signing key */
signature: Uint8Array;
timestamp: number;
}
/** Single-use one-time pre-key (consumed during X3DH, then deleted) */
export interface OneTimePreKey {
keyId: number;
keyPair: KeyPair;
}
// ─── PreKey Bundle (published to prekey server) ────────────────
/** Bundle fetched from the prekey server to initiate an X3DH session */
export interface PreKeyBundle {
registrationId: number;
/** Ed25519 public key (for signature verification) */
identitySigningKey: Uint8Array;
/** X25519 public key (for DH) */
identityDHKey: Uint8Array;
signedPreKey: {
keyId: number;
publicKey: Uint8Array;
signature: Uint8Array;
};
oneTimePreKey?: {
keyId: number;
publicKey: Uint8Array;
};
}
// ─── Wire Messages ─────────────────────────────────────────────
/** Initial message establishing a new session (contains X3DH info + first ratchet message) */
export interface PreKeyMessage {
registrationId: number;
/** One-time prekey ID consumed (if any) */
preKeyId?: number;
signedPreKeyId: number;
/** Sender's ephemeral X25519 public key (generated for this X3DH) */
ephemeralKey: Uint8Array;
/** Sender's identity X25519 DH public key */
identityDHKey: Uint8Array;
/** The first ratchet message, encrypted under the X3DH-derived key */
message: RatchetMessage;
}
/** Standard Double Ratchet message (used after session is established) */
export interface RatchetMessage {
/** Sender's current DH ratchet public key */
dhPublicKey: Uint8Array;
/** Number of messages in the previous sending chain */
previousCounter: number;
/** Message number in the current sending chain */
counter: number;
/** AES-256-GCM encrypted payload */
ciphertext: Uint8Array;
/** 12-byte GCM nonce */
nonce: Uint8Array;
}
/** Envelope wrapping either a PreKeyMessage or RatchetMessage on the wire */
export interface ShadeEnvelope {
type: 'prekey' | 'ratchet';
content: PreKeyMessage | RatchetMessage;
timestamp: number;
senderAddress: string;
}
// ─── Session State ─────────────────────────────────────────────
export interface ChainState {
/** Current chain key (32 bytes) — ratcheted forward with each message */
chainKey: Uint8Array;
/** Number of messages sent/received in this chain */
counter: number;
}
/** Serializable Double Ratchet session state */
export interface SessionState {
/** Remote peer's identity DH public key (for verification) */
remoteIdentityKey: Uint8Array;
/** Current root key (32 bytes) */
rootKey: Uint8Array;
/** Our current sending chain */
sendChain: ChainState;
/** Our current receiving chain (null before first received message in a new DH ratchet) */
receiveChain: ChainState | null;
/** Our current DH ratchet keypair */
dhSend: KeyPair;
/** Remote's current DH ratchet public key */
dhReceive: Uint8Array | null;
/** Message count of the previous sending chain (sent in message headers) */
previousSendCounter: number;
/** Skipped message keys: Map<"base64(dhPub):counter", messageKey> for out-of-order decryption */
skippedKeys: Map<string, Uint8Array>;
}
// ─── Constants ─────────────────────────────────────────────────
/** Max number of message keys to skip in a single chain (DoS protection) */
export const MAX_SKIP = 1000;
/** Max total skipped keys to cache per session */
export const MAX_CACHED_SKIPPED_KEYS = 2000;

View File

@@ -0,0 +1,246 @@
import type { CryptoProvider } from './crypto.js';
import type { StorageProvider } from './storage.js';
import type {
IdentityKeyPair,
KeyPair,
SignedPreKey,
OneTimePreKey,
PreKeyBundle,
PreKeyMessage,
RatchetMessage,
SessionState,
} from './types.js';
import { deriveInitialRootKey } from './keys.js';
import { InvalidSignatureError, PreKeyNotFoundError, UntrustedIdentityError } from './errors.js';
/**
* X3DH — Extended Triple Diffie-Hellman key agreement.
*
* Establishes a shared secret between two parties (Alice and Bob) even when
* Bob is offline, using prekey bundles published to a server.
*
* Reference: https://signal.org/docs/specifications/x3dh/
*/
// ─── Key Generation ──────────────────────────────────────────
/** Generate a new identity keypair: Ed25519 (signing) + X25519 (DH) */
export async function generateIdentityKeyPair(crypto: CryptoProvider): Promise<IdentityKeyPair> {
const [signing, dh] = await Promise.all([
crypto.generateEd25519KeyPair(),
crypto.generateX25519KeyPair(),
]);
return {
signingPublicKey: signing.publicKey,
signingPrivateKey: signing.privateKey,
dhPublicKey: dh.publicKey,
dhPrivateKey: dh.privateKey,
};
}
/** Generate a signed prekey: X25519 keypair signed by the identity key */
export async function generateSignedPreKey(
crypto: CryptoProvider,
identityKey: IdentityKeyPair,
keyId: number,
): Promise<SignedPreKey> {
const keyPair = await crypto.generateX25519KeyPair();
const signature = await crypto.sign(identityKey.signingPrivateKey, keyPair.publicKey);
return {
keyId,
keyPair,
signature,
timestamp: Date.now(),
};
}
/** Generate a batch of one-time prekeys */
export async function generateOneTimePreKeys(
crypto: CryptoProvider,
startId: number,
count: number,
): Promise<OneTimePreKey[]> {
const keys: OneTimePreKey[] = [];
for (let i = 0; i < count; i++) {
const keyPair = await crypto.generateX25519KeyPair();
keys.push({ keyId: startId + i, keyPair });
}
return keys;
}
/** Assemble a prekey bundle for publishing to the prekey server */
export function createPreKeyBundle(
registrationId: number,
identityKey: IdentityKeyPair,
signedPreKey: SignedPreKey,
oneTimePreKey?: OneTimePreKey,
): PreKeyBundle {
return {
registrationId,
identitySigningKey: identityKey.signingPublicKey,
identityDHKey: identityKey.dhPublicKey,
signedPreKey: {
keyId: signedPreKey.keyId,
publicKey: signedPreKey.keyPair.publicKey,
signature: signedPreKey.signature,
},
oneTimePreKey: oneTimePreKey
? { keyId: oneTimePreKey.keyId, publicKey: oneTimePreKey.keyPair.publicKey }
: undefined,
};
}
// ─── Alice: Initiate Session ─────────────────────────────────
/**
* Process a prekey bundle to establish a new session (Alice's side).
*
* Steps:
* 1. Verify the signed prekey's signature using Bob's identity signing key
* 2. Generate an ephemeral X25519 keypair
* 3. Compute 3 or 4 DH shared secrets:
* DH1 = DH(Alice identity DH, Bob signed prekey)
* DH2 = DH(Alice ephemeral, Bob identity DH)
* DH3 = DH(Alice ephemeral, Bob signed prekey)
* DH4 = DH(Alice ephemeral, Bob one-time prekey) — if available
* 4. Derive the initial root key from the concatenated DH outputs
* 5. Return the X3DH result with all info needed to create a session + PreKeyMessage
*/
export interface X3DHInitResult {
/** Initial root key (32 bytes) — seeds the Double Ratchet */
rootKey: Uint8Array;
/** Alice's ephemeral X25519 public key (sent to Bob in the PreKeyMessage) */
ephemeralPublicKey: Uint8Array;
/** Bob's signed prekey ID (so Bob can look up the key) */
signedPreKeyId: number;
/** Bob's one-time prekey ID if consumed */
preKeyId?: number;
/** Bob's identity DH public key */
remoteIdentityKey: Uint8Array;
/** Bob's signed prekey public key (used as initial DH ratchet remote key) */
remoteSignedPreKey: Uint8Array;
}
export async function processPreKeyBundle(
crypto: CryptoProvider,
storage: StorageProvider,
bundle: PreKeyBundle,
): Promise<X3DHInitResult> {
// 1. Verify signed prekey signature
const valid = await crypto.verify(
bundle.identitySigningKey,
bundle.signedPreKey.publicKey,
bundle.signedPreKey.signature,
);
if (!valid) {
throw new InvalidSignatureError('Signed prekey signature is invalid');
}
// 2. Check trust (TOFU or pinned)
// We trust based on the DH key since that's what we use in the protocol
const identityKey = await storage.getIdentityKeyPair();
if (!identityKey) throw new Error('No local identity key — call initialize() first');
// 3. Generate ephemeral keypair
const ephemeral = await crypto.generateX25519KeyPair();
// 4. Compute DH shared secrets
const dh1 = await crypto.x25519(identityKey.dhPrivateKey, bundle.signedPreKey.publicKey);
const dh2 = await crypto.x25519(ephemeral.privateKey, bundle.identityDHKey);
const dh3 = await crypto.x25519(ephemeral.privateKey, bundle.signedPreKey.publicKey);
const secrets = [dh1, dh2, dh3];
let preKeyId: number | undefined;
if (bundle.oneTimePreKey) {
const dh4 = await crypto.x25519(ephemeral.privateKey, bundle.oneTimePreKey.publicKey);
secrets.push(dh4);
preKeyId = bundle.oneTimePreKey.keyId;
}
// 5. Derive initial root key
const rootKey = await deriveInitialRootKey(crypto, secrets);
// 6. Save trust for remote identity
await storage.saveTrustedIdentity('pending', bundle.identityDHKey);
return {
rootKey,
ephemeralPublicKey: ephemeral.publicKey,
signedPreKeyId: bundle.signedPreKey.keyId,
preKeyId,
remoteIdentityKey: bundle.identityDHKey,
remoteSignedPreKey: bundle.signedPreKey.publicKey,
};
}
// ─── Bob: Respond to PreKeyMessage ───────────────────────────
/**
* Process an incoming PreKeyMessage to establish a session (Bob's side).
*
* Steps:
* 1. Look up the signed prekey and optionally the one-time prekey
* 2. Compute the same 3 or 4 DH shared secrets (from Bob's perspective)
* 3. Derive the same initial root key
* 4. Delete the consumed one-time prekey
* 5. Return the X3DH result to seed the Double Ratchet
*/
export interface X3DHResponseResult {
/** Initial root key (32 bytes) — must match Alice's */
rootKey: Uint8Array;
/** Alice's identity DH public key */
remoteIdentityKey: Uint8Array;
/** Alice's ephemeral public key (used as initial DH ratchet remote key) */
remoteEphemeralKey: Uint8Array;
/** The embedded first ratchet message to decrypt */
initialMessage: RatchetMessage;
}
export async function processPreKeyMessage(
crypto: CryptoProvider,
storage: StorageProvider,
message: PreKeyMessage,
): Promise<X3DHResponseResult> {
const identityKey = await storage.getIdentityKeyPair();
if (!identityKey) throw new Error('No local identity key — call initialize() first');
// 1. Look up signed prekey
const signedPreKey = await storage.getSignedPreKey(message.signedPreKeyId);
if (!signedPreKey) {
throw new PreKeyNotFoundError(message.signedPreKeyId, 'signed');
}
// 2. Compute DH shared secrets (Bob's perspective — mirrored from Alice)
const dh1 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.identityDHKey);
const dh2 = await crypto.x25519(identityKey.dhPrivateKey, message.ephemeralKey);
const dh3 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.ephemeralKey);
const secrets = [dh1, dh2, dh3];
// 3. If a one-time prekey was used, include DH4
if (message.preKeyId != null) {
const oneTimePreKey = await storage.getOneTimePreKey(message.preKeyId);
if (!oneTimePreKey) {
throw new PreKeyNotFoundError(message.preKeyId, 'one-time');
}
const dh4 = await crypto.x25519(oneTimePreKey.keyPair.privateKey, message.ephemeralKey);
secrets.push(dh4);
// 4. Consume (delete) the one-time prekey
await storage.removeOneTimePreKey(message.preKeyId);
}
// 5. Derive the initial root key (should match Alice's)
const rootKey = await deriveInitialRootKey(crypto, secrets);
// 6. Save trust for remote identity
await storage.saveTrustedIdentity('pending', message.identityDHKey);
return {
rootKey,
remoteIdentityKey: message.identityDHKey,
remoteEphemeralKey: message.ephemeralKey,
initialMessage: message.message,
};
}