Files
Shade/packages/shade-core/src/ratchet.ts

300 lines
10 KiB
TypeScript
Raw Normal View History

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> {
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
// Advance sending chain — old chain key is replaced, zero the old one
const oldChainKey = session.sendChain.chainKey;
const { newChainKey, messageKey } = await kdfChainKey(crypto, oldChainKey);
crypto.zeroize(oldChainKey);
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);
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
// Encrypt, then zero the message key (used once)
const { ciphertext, nonce } = await crypto.aesGcmEncrypt(messageKey, plaintext, aad);
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
crypto.zeroize(messageKey);
// 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);
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
try {
return await decryptWithKey(crypto, skippedKey, message);
} finally {
crypto.zeroize(skippedKey);
}
}
// 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
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
const oldChainKey = session.receiveChain.chainKey;
const { newChainKey, messageKey } = await kdfChainKey(crypto, oldChainKey);
crypto.zeroize(oldChainKey);
session.receiveChain.chainKey = newChainKey;
session.receiveChain.counter = message.counter + 1;
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
try {
return await decryptWithKey(crypto, messageKey, message);
} finally {
crypto.zeroize(messageKey);
}
}
// ─── 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);
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
const oldRootKey1 = session.rootKey;
const recv = await kdfRootKey(crypto, oldRootKey1, dh1);
crypto.zeroize(oldRootKey1);
crypto.zeroize(dh1);
session.rootKey = recv.newRootKey;
session.receiveChain = { chainKey: recv.chainKey, counter: 0 };
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
// Generate new DH keypair, zero the old one's private key
const oldDhPrivate = session.dhSend.privateKey;
session.dhSend = await crypto.generateX25519KeyPair();
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
crypto.zeroize(oldDhPrivate);
// DH with new send key → new sending chain
const dh2 = await crypto.x25519(session.dhSend.privateKey, remoteDHKey);
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
const oldRootKey2 = session.rootKey;
const send = await kdfRootKey(crypto, oldRootKey2, dh2);
crypto.zeroize(oldRootKey2);
crypto.zeroize(dh2);
session.rootKey = send.newRootKey;
feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
// Zero the old send chain key if present
if (session.sendChain.chainKey.length > 0) {
crypto.zeroize(session.sendChain.chainKey);
}
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;
}