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>
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
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);
|
|
}
|