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,10 @@
{
"name": "@shade/core",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"peerDependencies": {
"@shade/crypto-web": "workspace:*"
}
}

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

View File

@@ -0,0 +1,183 @@
import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import {
generateIdentityKeyPair,
generateSignedPreKey,
generateOneTimePreKeys,
createPreKeyBundle,
processPreKeyBundle,
processPreKeyMessage,
initSenderSession,
initReceiverSession,
ratchetEncrypt,
ratchetDecrypt,
} from '../src/index.js';
const crypto = new SubtleCryptoProvider();
const enc = new TextEncoder();
const dec = new TextDecoder();
describe('Full E2EE Integration: X3DH → Double Ratchet', () => {
test('complete conversation between Alice and Bob', async () => {
// ─── Setup Bob (publishes prekey bundle) ─────────────────
const bobStorage = new MemoryStorage();
const bobIdentity = await generateIdentityKeyPair(crypto);
await bobStorage.saveIdentityKeyPair(bobIdentity);
await bobStorage.saveLocalRegistrationId(42);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 10);
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
// ─── Alice initiates (processes bundle, creates session) ──
const aliceStorage = new MemoryStorage();
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
// Alice initializes her ratchet session
const aliceSession = await initSenderSession(
crypto,
x3dhResult.rootKey,
x3dhResult.remoteIdentityKey,
x3dhResult.remoteSignedPreKey, // Bob's signed prekey = initial DH ratchet key
);
// Alice encrypts her first message
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('Hello Bob! This is E2EE.'));
// Alice sends a PreKeyMessage to Bob
const preKeyMessage = {
registrationId: 1,
preKeyId: x3dhResult.preKeyId,
signedPreKeyId: x3dhResult.signedPreKeyId,
ephemeralKey: x3dhResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: firstMsg,
};
// ─── Bob receives and processes ──────────────────────────
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
expect(bobX3dh.rootKey).toEqual(x3dhResult.rootKey);
// Bob initializes his ratchet session
const bobSession = initReceiverSession(
bobX3dh.rootKey,
bobX3dh.remoteIdentityKey,
bobSignedPreKey.keyPair, // Bob's signed prekey as his initial DH keypair
);
// Bob decrypts Alice's first message
const plaintext1 = await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage);
expect(dec.decode(plaintext1)).toBe('Hello Bob! This is E2EE.');
// ─── Full conversation ───────────────────────────────────
// Alice sends more
const m2 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Are you there?'));
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m2))).toBe('Are you there?');
// Bob replies (DH ratchet triggers)
const m3 = await ratchetEncrypt(crypto, bobSession, enc.encode('Yes! Forward secrecy is active.'));
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, m3))).toBe('Yes! Forward secrecy is active.');
// Alice replies
const m4 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Every message has a unique key.'));
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m4))).toBe('Every message has a unique key.');
// Multiple back-and-forth
for (let i = 0; i < 10; i++) {
const sender = i % 2 === 0 ? aliceSession : bobSession;
const receiver = i % 2 === 0 ? bobSession : aliceSession;
const text = `Turn ${i}: ${i % 2 === 0 ? 'Alice' : 'Bob'} speaking`;
const msg = await ratchetEncrypt(crypto, sender, enc.encode(text));
expect(dec.decode(await ratchetDecrypt(crypto, receiver, msg))).toBe(text);
}
});
test('works without one-time prekey', async () => {
const bobStorage = new MemoryStorage();
const bobIdentity = await generateIdentityKeyPair(crypto);
await bobStorage.saveIdentityKeyPair(bobIdentity);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
// No one-time prekeys
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
const aliceStorage = new MemoryStorage();
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
const aliceSession = await initSenderSession(
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
);
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('No OTPK needed'));
const preKeyMessage = {
registrationId: 1,
signedPreKeyId: x3dhResult.signedPreKeyId,
ephemeralKey: x3dhResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: firstMsg,
};
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
const bobSession = initReceiverSession(
bobX3dh.rootKey, bobX3dh.remoteIdentityKey, bobSignedPreKey.keyPair,
);
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage)))
.toBe('No OTPK needed');
// Continue conversation
const reply = await ratchetEncrypt(crypto, bobSession, enc.encode('Got it!'));
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, reply))).toBe('Got it!');
});
test('one-time prekey consumed after use', async () => {
const bobStorage = new MemoryStorage();
const bobIdentity = await generateIdentityKeyPair(crypto);
await bobStorage.saveIdentityKeyPair(bobIdentity);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
// Alice uses OTPK 100
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
const aliceStorage = new MemoryStorage();
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
const aliceSession = await initSenderSession(
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
);
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('test'));
await processPreKeyMessage(crypto, bobStorage, {
registrationId: 1,
preKeyId: 100,
signedPreKeyId: 1,
ephemeralKey: x3dhResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: firstMsg,
});
// OTPK 100 consumed, 101 and 102 remain
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
});
});

View File

@@ -0,0 +1,179 @@
import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { kdfRootKey, kdfChainKey, deriveInitialRootKey } from '../src/keys.js';
const crypto = new SubtleCryptoProvider();
describe('KDF Chain Functions', () => {
describe('kdfRootKey', () => {
test('produces 32-byte root key and 32-byte chain key', async () => {
const rootKey = crypto.randomBytes(32);
const dhOutput = crypto.randomBytes(32);
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
expect(newRootKey.length).toBe(32);
expect(chainKey.length).toBe(32);
});
test('new root key differs from input root key', async () => {
const rootKey = crypto.randomBytes(32);
const dhOutput = crypto.randomBytes(32);
const { newRootKey } = await kdfRootKey(crypto, rootKey, dhOutput);
expect(newRootKey).not.toEqual(rootKey);
});
test('root key and chain key differ from each other', async () => {
const rootKey = crypto.randomBytes(32);
const dhOutput = crypto.randomBytes(32);
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
expect(newRootKey).not.toEqual(chainKey);
});
test('deterministic: same inputs produce same outputs', async () => {
const rootKey = new Uint8Array(32).fill(0x11);
const dhOutput = new Uint8Array(32).fill(0x22);
const a = await kdfRootKey(crypto, rootKey, dhOutput);
const b = await kdfRootKey(crypto, rootKey, dhOutput);
expect(a.newRootKey).toEqual(b.newRootKey);
expect(a.chainKey).toEqual(b.chainKey);
});
test('different DH output produces different keys', async () => {
const rootKey = crypto.randomBytes(32);
const dh1 = crypto.randomBytes(32);
const dh2 = crypto.randomBytes(32);
const a = await kdfRootKey(crypto, rootKey, dh1);
const b = await kdfRootKey(crypto, rootKey, dh2);
expect(a.newRootKey).not.toEqual(b.newRootKey);
expect(a.chainKey).not.toEqual(b.chainKey);
});
});
describe('kdfChainKey', () => {
test('produces 32-byte chain key and 32-byte message key', async () => {
const chainKey = crypto.randomBytes(32);
const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey);
expect(newChainKey.length).toBe(32);
expect(messageKey.length).toBe(32);
});
test('chain key and message key differ', async () => {
const chainKey = crypto.randomBytes(32);
const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey);
expect(newChainKey).not.toEqual(messageKey);
});
test('chain ratchet is one-way: cannot derive previous chain key', async () => {
const ck0 = crypto.randomBytes(32);
const { newChainKey: ck1 } = await kdfChainKey(crypto, ck0);
const { newChainKey: ck2 } = await kdfChainKey(crypto, ck1);
// All three are different
expect(ck0).not.toEqual(ck1);
expect(ck1).not.toEqual(ck2);
expect(ck0).not.toEqual(ck2);
});
test('deterministic: same input produces same output', async () => {
const chainKey = new Uint8Array(32).fill(0x33);
const a = await kdfChainKey(crypto, chainKey);
const b = await kdfChainKey(crypto, chainKey);
expect(a.newChainKey).toEqual(b.newChainKey);
expect(a.messageKey).toEqual(b.messageKey);
});
test('sequential chain steps produce unique message keys', async () => {
let ck = crypto.randomBytes(32);
const messageKeys: Uint8Array[] = [];
for (let i = 0; i < 10; i++) {
const { newChainKey, messageKey } = await kdfChainKey(crypto, ck);
messageKeys.push(messageKey);
ck = newChainKey;
}
// All message keys should be unique
for (let i = 0; i < messageKeys.length; i++) {
for (let j = i + 1; j < messageKeys.length; j++) {
expect(messageKeys[i]).not.toEqual(messageKeys[j]);
}
}
});
});
describe('deriveInitialRootKey', () => {
test('produces 32-byte root key from multiple DH outputs', async () => {
const secrets = [
crypto.randomBytes(32),
crypto.randomBytes(32),
crypto.randomBytes(32),
];
const rootKey = await deriveInitialRootKey(crypto, secrets);
expect(rootKey.length).toBe(32);
});
test('works with 3 secrets (no one-time prekey)', async () => {
const secrets = [
crypto.randomBytes(32),
crypto.randomBytes(32),
crypto.randomBytes(32),
];
const rootKey = await deriveInitialRootKey(crypto, secrets);
expect(rootKey.length).toBe(32);
});
test('works with 4 secrets (with one-time prekey)', async () => {
const secrets = [
crypto.randomBytes(32),
crypto.randomBytes(32),
crypto.randomBytes(32),
crypto.randomBytes(32),
];
const rootKey = await deriveInitialRootKey(crypto, secrets);
expect(rootKey.length).toBe(32);
});
test('deterministic: same secrets produce same root key', async () => {
const secrets = [
new Uint8Array(32).fill(0xaa),
new Uint8Array(32).fill(0xbb),
new Uint8Array(32).fill(0xcc),
];
const a = await deriveInitialRootKey(crypto, secrets);
const b = await deriveInitialRootKey(crypto, secrets);
expect(a).toEqual(b);
});
test('different secrets produce different root keys', async () => {
const secretsA = [crypto.randomBytes(32), crypto.randomBytes(32), crypto.randomBytes(32)];
const secretsB = [crypto.randomBytes(32), crypto.randomBytes(32), crypto.randomBytes(32)];
const a = await deriveInitialRootKey(crypto, secretsA);
const b = await deriveInitialRootKey(crypto, secretsB);
expect(a).not.toEqual(b);
});
test('adding a 4th secret changes the root key', async () => {
const base = [
new Uint8Array(32).fill(0x11),
new Uint8Array(32).fill(0x22),
new Uint8Array(32).fill(0x33),
];
const without = await deriveInitialRootKey(crypto, base);
const withExtra = await deriveInitialRootKey(crypto, [...base, new Uint8Array(32).fill(0x44)]);
expect(without).not.toEqual(withExtra);
});
});
});

View File

@@ -0,0 +1,262 @@
import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
initSenderSession,
initReceiverSession,
ratchetEncrypt,
ratchetDecrypt,
MaxSkipExceededError,
DecryptionError,
} from '../src/index.js';
import type { SessionState, RatchetMessage } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
const enc = new TextEncoder();
const dec = new TextDecoder();
/** Helper: set up Alice (sender) and Bob (receiver) sessions from a shared root key */
async function setupPair(): Promise<{ alice: SessionState; bob: SessionState }> {
const rootKey = crypto.randomBytes(32);
const remoteIdentityKey = crypto.randomBytes(32);
// Bob's initial DH keypair (would be his signed prekey in real X3DH)
const bobDH = await crypto.generateX25519KeyPair();
const alice = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey);
const bob = initReceiverSession(rootKey, remoteIdentityKey, bobDH);
return { alice, bob };
}
describe('Double Ratchet', () => {
// ─── Basic Send/Receive ──────────────────────────────────
describe('basic send/receive', () => {
test('Alice encrypts, Bob decrypts', async () => {
const { alice, bob } = await setupPair();
const msg = await ratchetEncrypt(crypto, alice, enc.encode('hello bob'));
const plaintext = await ratchetDecrypt(crypto, bob, msg);
expect(dec.decode(plaintext)).toBe('hello bob');
});
test('multiple messages in same direction', async () => {
const { alice, bob } = await setupPair();
const messages = ['first', 'second', 'third'];
const encrypted: RatchetMessage[] = [];
for (const text of messages) {
encrypted.push(await ratchetEncrypt(crypto, alice, enc.encode(text)));
}
for (let i = 0; i < messages.length; i++) {
const plaintext = await ratchetDecrypt(crypto, bob, encrypted[i]);
expect(dec.decode(plaintext)).toBe(messages[i]);
}
});
test('counter increments with each message', async () => {
const { alice } = await setupPair();
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('b'));
const m2 = await ratchetEncrypt(crypto, alice, enc.encode('c'));
expect(m0.counter).toBe(0);
expect(m1.counter).toBe(1);
expect(m2.counter).toBe(2);
// All use the same DH key (no ratchet step yet)
expect(m0.dhPublicKey).toEqual(m1.dhPublicKey);
expect(m1.dhPublicKey).toEqual(m2.dhPublicKey);
});
test('each message has a unique nonce', async () => {
const { alice } = await setupPair();
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
expect(m0.nonce).not.toEqual(m1.nonce);
expect(m0.ciphertext).not.toEqual(m1.ciphertext);
});
});
// ─── Ping-Pong (DH Ratchet) ──────────────────────────────
describe('ping-pong conversation', () => {
test('alternating messages trigger DH ratchets', async () => {
const { alice, bob } = await setupPair();
// Alice → Bob
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('hi bob'));
expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('hi bob');
// Bob → Alice (new DH key)
const m2 = await ratchetEncrypt(crypto, bob, enc.encode('hi alice'));
expect(m2.dhPublicKey).not.toEqual(m1.dhPublicKey); // DH ratchet happened
expect(dec.decode(await ratchetDecrypt(crypto, alice, m2))).toBe('hi alice');
// Alice → Bob (another new DH key)
const m3 = await ratchetEncrypt(crypto, alice, enc.encode('how are you'));
expect(m3.dhPublicKey).not.toEqual(m1.dhPublicKey);
expect(m3.dhPublicKey).not.toEqual(m2.dhPublicKey);
expect(dec.decode(await ratchetDecrypt(crypto, bob, m3))).toBe('how are you');
// Bob → Alice
const m4 = await ratchetEncrypt(crypto, bob, enc.encode('great!'));
expect(dec.decode(await ratchetDecrypt(crypto, alice, m4))).toBe('great!');
});
test('extended conversation with many turns', async () => {
const { alice, bob } = await setupPair();
for (let i = 0; i < 20; i++) {
const sender = i % 2 === 0 ? alice : bob;
const receiver = i % 2 === 0 ? bob : alice;
const text = `message ${i}`;
const msg = await ratchetEncrypt(crypto, sender, enc.encode(text));
const plain = await ratchetDecrypt(crypto, receiver, msg);
expect(dec.decode(plain)).toBe(text);
}
});
test('burst messages then reply', async () => {
const { alice, bob } = await setupPair();
// Alice sends 5 messages
const burst: RatchetMessage[] = [];
for (let i = 0; i < 5; i++) {
burst.push(await ratchetEncrypt(crypto, alice, enc.encode(`alice-${i}`)));
}
// Bob receives all 5
for (let i = 0; i < 5; i++) {
expect(dec.decode(await ratchetDecrypt(crypto, bob, burst[i]))).toBe(`alice-${i}`);
}
// Bob replies (triggers DH ratchet)
const reply = await ratchetEncrypt(crypto, bob, enc.encode('got them all'));
expect(dec.decode(await ratchetDecrypt(crypto, alice, reply))).toBe('got them all');
// Alice sends more
const m = await ratchetEncrypt(crypto, alice, enc.encode('great!'));
expect(dec.decode(await ratchetDecrypt(crypto, bob, m))).toBe('great!');
});
});
// ─── Out-of-Order Messages ────────────────────────────────
describe('out-of-order delivery', () => {
test('messages received in reverse order', async () => {
const { alice, bob } = await setupPair();
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('first'));
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('second'));
const m2 = await ratchetEncrypt(crypto, alice, enc.encode('third'));
// Deliver in reverse
expect(dec.decode(await ratchetDecrypt(crypto, bob, m2))).toBe('third');
expect(dec.decode(await ratchetDecrypt(crypto, bob, m0))).toBe('first');
expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('second');
});
test('skip some messages, then receive them later', async () => {
const { alice, bob } = await setupPair();
const messages: RatchetMessage[] = [];
for (let i = 0; i < 10; i++) {
messages.push(await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`)));
}
// Receive only even-numbered messages first
for (let i = 0; i < 10; i += 2) {
expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`);
}
// Then receive odd-numbered (skipped) messages
for (let i = 1; i < 10; i += 2) {
expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`);
}
});
test('out-of-order across DH ratchet boundaries', async () => {
const { alice, bob } = await setupPair();
// Alice sends 3 messages
const a0 = await ratchetEncrypt(crypto, alice, enc.encode('a0'));
const a1 = await ratchetEncrypt(crypto, alice, enc.encode('a1'));
const a2 = await ratchetEncrypt(crypto, alice, enc.encode('a2'));
// Bob receives only a2 (skips a0, a1)
expect(dec.decode(await ratchetDecrypt(crypto, bob, a2))).toBe('a2');
// Bob replies (DH ratchet)
const b0 = await ratchetEncrypt(crypto, bob, enc.encode('b0'));
expect(dec.decode(await ratchetDecrypt(crypto, alice, b0))).toBe('b0');
// Now Bob receives the skipped a0 and a1 (from the old chain)
expect(dec.decode(await ratchetDecrypt(crypto, bob, a0))).toBe('a0');
expect(dec.decode(await ratchetDecrypt(crypto, bob, a1))).toBe('a1');
});
});
// ─── Error Cases ──────────────────────────────────────────
describe('error cases', () => {
test('max skip exceeded throws', async () => {
const { alice, bob } = await setupPair();
// Encrypt 1002 messages but only try to decrypt the last one
let lastMsg: RatchetMessage | undefined;
for (let i = 0; i < 1002; i++) {
lastMsg = await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`));
}
expect(ratchetDecrypt(crypto, bob, lastMsg!)).rejects.toThrow(MaxSkipExceededError);
});
test('tampered ciphertext fails', async () => {
const { alice, bob } = await setupPair();
const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret'));
msg.ciphertext[0] ^= 0xff;
expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow(DecryptionError);
});
test('tampered header (counter) fails due to AAD', async () => {
const { alice, bob } = await setupPair();
const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret'));
msg.counter = 999; // tamper with counter
expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow();
});
});
// ─── Long Conversation ────────────────────────────────────
describe('stress test', () => {
test('100+ message conversation with alternating turns', async () => {
const { alice, bob } = await setupPair();
for (let i = 0; i < 50; i++) {
// Alice sends 2 messages
for (let j = 0; j < 2; j++) {
const text = `alice-${i}-${j}`;
const msg = await ratchetEncrypt(crypto, alice, enc.encode(text));
expect(dec.decode(await ratchetDecrypt(crypto, bob, msg))).toBe(text);
}
// Bob sends 1 message
const text = `bob-${i}`;
const msg = await ratchetEncrypt(crypto, bob, enc.encode(text));
expect(dec.decode(await ratchetDecrypt(crypto, alice, msg))).toBe(text);
}
});
});
});

View File

@@ -0,0 +1,321 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import {
generateIdentityKeyPair,
generateSignedPreKey,
generateOneTimePreKeys,
createPreKeyBundle,
processPreKeyBundle,
processPreKeyMessage,
InvalidSignatureError,
PreKeyNotFoundError,
} from '../src/index.js';
import type { RatchetMessage } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
/** Create a dummy RatchetMessage for testing (X3DH doesn't care about the content) */
function dummyRatchetMessage(): RatchetMessage {
return {
dhPublicKey: crypto.randomBytes(32),
previousCounter: 0,
counter: 0,
ciphertext: crypto.randomBytes(48),
nonce: crypto.randomBytes(12),
};
}
describe('X3DH', () => {
let aliceStorage: MemoryStorage;
let bobStorage: MemoryStorage;
beforeEach(() => {
aliceStorage = new MemoryStorage();
bobStorage = new MemoryStorage();
});
// ─── Key Generation ────────────────────────────────────────
describe('key generation', () => {
test('generates identity keypair with correct lengths', async () => {
const id = await generateIdentityKeyPair(crypto);
expect(id.signingPublicKey.length).toBe(32);
expect(id.signingPrivateKey.length).toBe(32);
expect(id.dhPublicKey.length).toBe(32);
expect(id.dhPrivateKey.length).toBe(32);
});
test('signing and DH keys are different', async () => {
const id = await generateIdentityKeyPair(crypto);
expect(id.signingPublicKey).not.toEqual(id.dhPublicKey);
expect(id.signingPrivateKey).not.toEqual(id.dhPrivateKey);
});
test('generates signed prekey with valid signature', async () => {
const id = await generateIdentityKeyPair(crypto);
const spk = await generateSignedPreKey(crypto, id, 1);
expect(spk.keyId).toBe(1);
expect(spk.keyPair.publicKey.length).toBe(32);
expect(spk.signature.length).toBe(64);
// Verify the signature
const valid = await crypto.verify(id.signingPublicKey, spk.keyPair.publicKey, spk.signature);
expect(valid).toBe(true);
});
test('generates batch of one-time prekeys', async () => {
const otpks = await generateOneTimePreKeys(crypto, 100, 5);
expect(otpks.length).toBe(5);
for (let i = 0; i < 5; i++) {
expect(otpks[i].keyId).toBe(100 + i);
expect(otpks[i].keyPair.publicKey.length).toBe(32);
}
// All keys are unique
const pubKeys = otpks.map((k) => Array.from(k.keyPair.publicKey).join(','));
expect(new Set(pubKeys).size).toBe(5);
});
});
// ─── Full Handshake ────────────────────────────────────────
describe('full handshake', () => {
test('Alice and Bob derive the same root key (with one-time prekey)', async () => {
// Bob generates keys
const bobIdentity = await generateIdentityKeyPair(crypto);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
const bobOneTimePreKeys = await generateOneTimePreKeys(crypto, 100, 3);
// Bob stores his keys
await bobStorage.saveIdentityKeyPair(bobIdentity);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
for (const otpk of bobOneTimePreKeys) {
await bobStorage.saveOneTimePreKey(otpk);
}
// Bob publishes a bundle (server would store this)
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOneTimePreKeys[0]);
// Alice generates her identity
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
// Alice processes the bundle
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
expect(aliceResult.rootKey.length).toBe(32);
expect(aliceResult.signedPreKeyId).toBe(1);
expect(aliceResult.preKeyId).toBe(100);
expect(aliceResult.ephemeralPublicKey.length).toBe(32);
// Alice creates a PreKeyMessage
const preKeyMessage = {
registrationId: 1,
preKeyId: aliceResult.preKeyId,
signedPreKeyId: aliceResult.signedPreKeyId,
ephemeralKey: aliceResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: dummyRatchetMessage(),
};
// Bob processes the PreKeyMessage
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
// Both derive the same root key
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
expect(bobResult.remoteIdentityKey).toEqual(aliceIdentity.dhPublicKey);
expect(bobResult.remoteEphemeralKey).toEqual(aliceResult.ephemeralPublicKey);
});
test('Alice and Bob derive the same root key (without one-time prekey)', async () => {
const bobIdentity = await generateIdentityKeyPair(crypto);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveIdentityKeyPair(bobIdentity);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
// Bundle without one-time prekey
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
expect(aliceResult.preKeyId).toBeUndefined();
const preKeyMessage = {
registrationId: 1,
signedPreKeyId: aliceResult.signedPreKeyId,
ephemeralKey: aliceResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: dummyRatchetMessage(),
};
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
});
test('different handshakes produce different root keys', async () => {
const bobIdentity = await generateIdentityKeyPair(crypto);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveIdentityKeyPair(bobIdentity);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
// Alice 1
const alice1Id = await generateIdentityKeyPair(crypto);
const alice1Storage = new MemoryStorage();
await alice1Storage.saveIdentityKeyPair(alice1Id);
const result1 = await processPreKeyBundle(crypto, alice1Storage, bundle);
// Alice 2 (different identity)
const alice2Id = await generateIdentityKeyPair(crypto);
const alice2Storage = new MemoryStorage();
await alice2Storage.saveIdentityKeyPair(alice2Id);
const result2 = await processPreKeyBundle(crypto, alice2Storage, bundle);
expect(result1.rootKey).not.toEqual(result2.rootKey);
});
});
// ─── Signature Verification ────────────────────────────────
describe('signature verification', () => {
test('rejects bundle with invalid signed prekey signature', async () => {
const bobIdentity = await generateIdentityKeyPair(crypto);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
// Tamper with the signature
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
bundle.signedPreKey.signature[0] ^= 0xff;
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
});
test('rejects bundle with wrong identity key signing', async () => {
const bobIdentity = await generateIdentityKeyPair(crypto);
const eveIdentity = await generateIdentityKeyPair(crypto);
// Eve signs the prekey, but claims to be Bob
const eveSignedPreKey = await generateSignedPreKey(crypto, eveIdentity, 1);
const bundle = createPreKeyBundle(42, bobIdentity, eveSignedPreKey);
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
});
});
// ─── One-Time Prekey Consumption ───────────────────────────
describe('one-time prekey consumption', () => {
test('one-time prekey is deleted after use', async () => {
const bobIdentity = await generateIdentityKeyPair(crypto);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
await bobStorage.saveIdentityKeyPair(bobIdentity);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
const preKeyMessage = {
registrationId: 1,
preKeyId: aliceResult.preKeyId,
signedPreKeyId: aliceResult.signedPreKeyId,
ephemeralKey: aliceResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: dummyRatchetMessage(),
};
await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
// One-time prekey 100 should be consumed
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
// Others remain
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
expect(await bobStorage.getOneTimePreKey(102)).not.toBeNull();
});
test('fails when referenced one-time prekey does not exist', async () => {
const bobIdentity = await generateIdentityKeyPair(crypto);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveIdentityKeyPair(bobIdentity);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
// No one-time prekeys stored
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const preKeyMessage = {
registrationId: 1,
preKeyId: 999, // doesn't exist
signedPreKeyId: 1,
ephemeralKey: crypto.randomBytes(32),
identityDHKey: aliceIdentity.dhPublicKey,
message: dummyRatchetMessage(),
};
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
});
test('fails when referenced signed prekey does not exist', async () => {
const bobIdentity = await generateIdentityKeyPair(crypto);
await bobStorage.saveIdentityKeyPair(bobIdentity);
// No signed prekey stored
const preKeyMessage = {
registrationId: 1,
signedPreKeyId: 999,
ephemeralKey: crypto.randomBytes(32),
identityDHKey: crypto.randomBytes(32),
message: dummyRatchetMessage(),
};
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
});
});
// ─── PreKey Bundle Assembly ────────────────────────────────
describe('createPreKeyBundle', () => {
test('assembles bundle with one-time prekey', async () => {
const id = await generateIdentityKeyPair(crypto);
const spk = await generateSignedPreKey(crypto, id, 5);
const otpk = (await generateOneTimePreKeys(crypto, 200, 1))[0];
const bundle = createPreKeyBundle(42, id, spk, otpk);
expect(bundle.registrationId).toBe(42);
expect(bundle.identitySigningKey).toEqual(id.signingPublicKey);
expect(bundle.identityDHKey).toEqual(id.dhPublicKey);
expect(bundle.signedPreKey.keyId).toBe(5);
expect(bundle.signedPreKey.publicKey).toEqual(spk.keyPair.publicKey);
expect(bundle.signedPreKey.signature).toEqual(spk.signature);
expect(bundle.oneTimePreKey?.keyId).toBe(200);
expect(bundle.oneTimePreKey?.publicKey).toEqual(otpk.keyPair.publicKey);
});
test('assembles bundle without one-time prekey', async () => {
const id = await generateIdentityKeyPair(crypto);
const spk = await generateSignedPreKey(crypto, id, 1);
const bundle = createPreKeyBundle(42, id, spk);
expect(bundle.oneTimePreKey).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}