From bd6452044f1d9c5fc6ac4075b77e8bd55adb5334 Mon Sep 17 00:00:00 2001 From: Sterister Date: Thu, 9 Apr 2026 20:08:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Shade=20E2EE=20library=20=E2=80=94=20M1?= =?UTF-8?q?-M3=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 4 + bun.lock | 78 +++++ package.json | 20 ++ packages/shade-core/package.json | 10 + packages/shade-core/src/crypto.ts | 62 ++++ packages/shade-core/src/errors.ts | 63 ++++ packages/shade-core/src/index.ts | 7 + packages/shade-core/src/keys.ts | 83 +++++ packages/shade-core/src/ratchet.ts | 274 +++++++++++++++ packages/shade-core/src/storage.ts | 68 ++++ packages/shade-core/src/types.ts | 129 +++++++ packages/shade-core/src/x3dh.ts | 246 ++++++++++++++ packages/shade-core/tests/integration.test.ts | 183 ++++++++++ packages/shade-core/tests/keys.test.ts | 179 ++++++++++ packages/shade-core/tests/ratchet.test.ts | 262 ++++++++++++++ packages/shade-core/tests/x3dh.test.ts | 321 ++++++++++++++++++ packages/shade-core/tsconfig.json | 8 + packages/shade-crypto-web/package.json | 12 + packages/shade-crypto-web/src/index.ts | 2 + .../shade-crypto-web/src/memory-storage.ts | 98 ++++++ packages/shade-crypto-web/src/provider.ts | 118 +++++++ .../shade-crypto-web/tests/provider.test.ts | 236 +++++++++++++ packages/shade-crypto-web/tsconfig.json | 8 + packages/shade-proto/package.json | 10 + packages/shade-server/package.json | 11 + packages/shade-transport/package.json | 11 + tsconfig.json | 14 + 27 files changed, 2517 insertions(+) create mode 100644 .gitignore create mode 100644 bun.lock create mode 100644 package.json create mode 100644 packages/shade-core/package.json create mode 100644 packages/shade-core/src/crypto.ts create mode 100644 packages/shade-core/src/errors.ts create mode 100644 packages/shade-core/src/index.ts create mode 100644 packages/shade-core/src/keys.ts create mode 100644 packages/shade-core/src/ratchet.ts create mode 100644 packages/shade-core/src/storage.ts create mode 100644 packages/shade-core/src/types.ts create mode 100644 packages/shade-core/src/x3dh.ts create mode 100644 packages/shade-core/tests/integration.test.ts create mode 100644 packages/shade-core/tests/keys.test.ts create mode 100644 packages/shade-core/tests/ratchet.test.ts create mode 100644 packages/shade-core/tests/x3dh.test.ts create mode 100644 packages/shade-core/tsconfig.json create mode 100644 packages/shade-crypto-web/package.json create mode 100644 packages/shade-crypto-web/src/index.ts create mode 100644 packages/shade-crypto-web/src/memory-storage.ts create mode 100644 packages/shade-crypto-web/src/provider.ts create mode 100644 packages/shade-crypto-web/tests/provider.test.ts create mode 100644 packages/shade-crypto-web/tsconfig.json create mode 100644 packages/shade-proto/package.json create mode 100644 packages/shade-server/package.json create mode 100644 packages/shade-transport/package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ccde4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8860431 --- /dev/null +++ b/bun.lock @@ -0,0 +1,78 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "shade", + "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + }, + "devDependencies": { + "bun-types": "^1.3.11", + }, + }, + "packages/shade-core": { + "name": "@shade/core", + "version": "0.1.0", + "peerDependencies": { + "@shade/crypto-web": "workspace:*", + }, + }, + "packages/shade-crypto-web": { + "name": "@shade/crypto-web", + "version": "0.1.0", + "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@shade/core": "workspace:*", + }, + }, + "packages/shade-proto": { + "name": "@shade/proto", + "version": "0.1.0", + "dependencies": { + "@shade/core": "workspace:*", + }, + }, + "packages/shade-server": { + "name": "@shade/server", + "version": "0.1.0", + "dependencies": { + "@shade/core": "workspace:*", + "hono": "^4.0.0", + }, + }, + "packages/shade-transport": { + "name": "@shade/transport", + "version": "0.1.0", + "dependencies": { + "@shade/core": "workspace:*", + "@shade/proto": "workspace:*", + }, + }, + }, + "packages": { + "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "@shade/core": ["@shade/core@workspace:packages/shade-core"], + + "@shade/crypto-web": ["@shade/crypto-web@workspace:packages/shade-crypto-web"], + + "@shade/proto": ["@shade/proto@workspace:packages/shade-proto"], + + "@shade/server": ["@shade/server@workspace:packages/shade-server"], + + "@shade/transport": ["@shade/transport@workspace:packages/shade-transport"], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f9d26c5 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "shade", + "private": true, + "workspaces": ["packages/*"], + "scripts": { + "test": "bun test --recursive", + "test:core": "cd packages/shade-core && bun test", + "test:crypto": "cd packages/shade-crypto-web && bun test", + "test:proto": "cd packages/shade-proto && bun test", + "test:server": "cd packages/shade-server && bun test", + "test:transport": "cd packages/shade-transport && bun test" + }, + "devDependencies": { + "bun-types": "^1.3.11" + }, + "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1" + } +} diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json new file mode 100644 index 0000000..cbdfdfc --- /dev/null +++ b/packages/shade-core/package.json @@ -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:*" + } +} diff --git a/packages/shade-core/src/crypto.ts b/packages/shade-core/src/crypto.ts new file mode 100644 index 0000000..ae08e37 --- /dev/null +++ b/packages/shade-core/src/crypto.ts @@ -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; + + // ─── 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; + + /** Verify an Ed25519 signature. Returns true if valid. */ + verify(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): Promise; + + // ─── 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; + + // ─── Key Derivation ──────────────────────────────────────── + + /** HKDF-SHA256: derive `length` bytes from input keying material */ + hkdf( + ikm: Uint8Array, + salt: Uint8Array, + info: Uint8Array, + length: number, + ): Promise; + + /** HMAC-SHA256: returns 32-byte MAC */ + hmacSha256(key: Uint8Array, data: Uint8Array): Promise; + + // ─── Random ──────────────────────────────────────────────── + + /** Generate cryptographically secure random bytes */ + randomBytes(length: number): Uint8Array; +} diff --git a/packages/shade-core/src/errors.ts b/packages/shade-core/src/errors.ts new file mode 100644 index 0000000..a69e1a6 --- /dev/null +++ b/packages/shade-core/src/errors.ts @@ -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'; + } +} diff --git a/packages/shade-core/src/index.ts b/packages/shade-core/src/index.ts new file mode 100644 index 0000000..e8733ae --- /dev/null +++ b/packages/shade-core/src/index.ts @@ -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'; diff --git a/packages/shade-core/src/keys.ts b/packages/shade-core/src/keys.ts new file mode 100644 index 0000000..5f66f9b --- /dev/null +++ b/packages/shade-core/src/keys.ts @@ -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 { + // 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); +} diff --git a/packages/shade-core/src/ratchet.ts b/packages/shade-core/src/ratchet.ts new file mode 100644 index 0000000..ae224d8 --- /dev/null +++ b/packages/shade-core/src/ratchet.ts @@ -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): 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 { + // 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 { + // Advance sending chain + const { newChainKey, messageKey } = await kdfChainKey(crypto, session.sendChain.chainKey); + const counter = session.sendChain.counter; + + // Build header for AAD + const header: Pick = { + 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 { + // 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 { + // 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 { + 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 { + 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; +} diff --git a/packages/shade-core/src/storage.ts b/packages/shade-core/src/storage.ts new file mode 100644 index 0000000..5252936 --- /dev/null +++ b/packages/shade-core/src/storage.ts @@ -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; + + /** Persist our local identity keypair */ + saveIdentityKeyPair(keyPair: IdentityKeyPair): Promise; + + /** Get our local registration ID (unique per installation) */ + getLocalRegistrationId(): Promise; + + /** Save our local registration ID */ + saveLocalRegistrationId(id: number): Promise; + + // ─── Signed Pre-Keys ────────────────────────────────────── + + /** Get a signed prekey by ID */ + getSignedPreKey(keyId: number): Promise; + + /** Persist a signed prekey */ + saveSignedPreKey(key: SignedPreKey): Promise; + + /** Remove a signed prekey (after rotation grace period) */ + removeSignedPreKey(keyId: number): Promise; + + // ─── One-Time Pre-Keys ──────────────────────────────────── + + /** Get a one-time prekey by ID */ + getOneTimePreKey(keyId: number): Promise; + + /** Persist a one-time prekey */ + saveOneTimePreKey(key: OneTimePreKey): Promise; + + /** Remove a consumed one-time prekey */ + removeOneTimePreKey(keyId: number): Promise; + + /** Count remaining one-time prekeys */ + getOneTimePreKeyCount(): Promise; + + // ─── Sessions ───────────────────────────────────────────── + + /** Get session state for a peer address (e.g. "device:abc123") */ + getSession(address: string): Promise; + + /** Persist session state for a peer */ + saveSession(address: string, state: SessionState): Promise; + + /** Remove session for a peer */ + removeSession(address: string): Promise; + + /** Check if we trust a remote identity key (for TOFU or pinned keys) */ + isTrustedIdentity(address: string, identityKey: Uint8Array): Promise; + + /** Save a trusted remote identity key */ + saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise; +} diff --git a/packages/shade-core/src/types.ts b/packages/shade-core/src/types.ts new file mode 100644 index 0000000..6ed039c --- /dev/null +++ b/packages/shade-core/src/types.ts @@ -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; +} + +// ─── 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; diff --git a/packages/shade-core/src/x3dh.ts b/packages/shade-core/src/x3dh.ts new file mode 100644 index 0000000..df986ae --- /dev/null +++ b/packages/shade-core/src/x3dh.ts @@ -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 { + 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 { + 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 { + 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 { + // 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 { + 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, + }; +} diff --git a/packages/shade-core/tests/integration.test.ts b/packages/shade-core/tests/integration.test.ts new file mode 100644 index 0000000..2bbf556 --- /dev/null +++ b/packages/shade-core/tests/integration.test.ts @@ -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(); + }); +}); diff --git a/packages/shade-core/tests/keys.test.ts b/packages/shade-core/tests/keys.test.ts new file mode 100644 index 0000000..39ccb91 --- /dev/null +++ b/packages/shade-core/tests/keys.test.ts @@ -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); + }); + }); +}); diff --git a/packages/shade-core/tests/ratchet.test.ts b/packages/shade-core/tests/ratchet.test.ts new file mode 100644 index 0000000..380f523 --- /dev/null +++ b/packages/shade-core/tests/ratchet.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/shade-core/tests/x3dh.test.ts b/packages/shade-core/tests/x3dh.test.ts new file mode 100644 index 0000000..31e55ed --- /dev/null +++ b/packages/shade-core/tests/x3dh.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/shade-core/tsconfig.json b/packages/shade-core/tsconfig.json new file mode 100644 index 0000000..a086b14 --- /dev/null +++ b/packages/shade-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json new file mode 100644 index 0000000..fe7b45e --- /dev/null +++ b/packages/shade-crypto-web/package.json @@ -0,0 +1,12 @@ +{ + "name": "@shade/crypto-web", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@shade/core": "workspace:*" + } +} diff --git a/packages/shade-crypto-web/src/index.ts b/packages/shade-crypto-web/src/index.ts new file mode 100644 index 0000000..97319f9 --- /dev/null +++ b/packages/shade-crypto-web/src/index.ts @@ -0,0 +1,2 @@ +export { SubtleCryptoProvider } from './provider.js'; +export { MemoryStorage } from './memory-storage.js'; diff --git a/packages/shade-crypto-web/src/memory-storage.ts b/packages/shade-crypto-web/src/memory-storage.ts new file mode 100644 index 0000000..e2483d5 --- /dev/null +++ b/packages/shade-crypto-web/src/memory-storage.ts @@ -0,0 +1,98 @@ +import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from '@shade/core'; + +/** + * In-memory StorageProvider for testing and embedded use. + * All data is lost when the instance is garbage collected. + */ +export class MemoryStorage implements StorageProvider { + private identityKeyPair: IdentityKeyPair | null = null; + private registrationId: number = 0; + private signedPreKeys = new Map(); + private oneTimePreKeys = new Map(); + private sessions = new Map(); + private trustedIdentities = new Map(); + + // ─── Identity ────────────────────────────────────────────── + + async getIdentityKeyPair(): Promise { + return this.identityKeyPair; + } + + async saveIdentityKeyPair(keyPair: IdentityKeyPair): Promise { + this.identityKeyPair = keyPair; + } + + async getLocalRegistrationId(): Promise { + return this.registrationId; + } + + async saveLocalRegistrationId(id: number): Promise { + this.registrationId = id; + } + + // ─── Signed Pre-Keys ────────────────────────────────────── + + async getSignedPreKey(keyId: number): Promise { + return this.signedPreKeys.get(keyId) ?? null; + } + + async saveSignedPreKey(key: SignedPreKey): Promise { + this.signedPreKeys.set(key.keyId, key); + } + + async removeSignedPreKey(keyId: number): Promise { + this.signedPreKeys.delete(keyId); + } + + // ─── One-Time Pre-Keys ──────────────────────────────────── + + async getOneTimePreKey(keyId: number): Promise { + return this.oneTimePreKeys.get(keyId) ?? null; + } + + async saveOneTimePreKey(key: OneTimePreKey): Promise { + this.oneTimePreKeys.set(key.keyId, key); + } + + async removeOneTimePreKey(keyId: number): Promise { + this.oneTimePreKeys.delete(keyId); + } + + async getOneTimePreKeyCount(): Promise { + return this.oneTimePreKeys.size; + } + + // ─── Sessions ───────────────────────────────────────────── + + async getSession(address: string): Promise { + return this.sessions.get(address) ?? null; + } + + async saveSession(address: string, state: SessionState): Promise { + this.sessions.set(address, state); + } + + async removeSession(address: string): Promise { + this.sessions.delete(address); + } + + // ─── Trust ──────────────────────────────────────────────── + + async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + const stored = this.trustedIdentities.get(address); + if (!stored) return true; // TOFU: trust on first use + return arraysEqual(stored, identityKey); + } + + async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + this.trustedIdentities.set(address, identityKey); + } +} + +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; +} diff --git a/packages/shade-crypto-web/src/provider.ts b/packages/shade-crypto-web/src/provider.ts new file mode 100644 index 0000000..61b092c --- /dev/null +++ b/packages/shade-crypto-web/src/provider.ts @@ -0,0 +1,118 @@ +import type { CryptoProvider } from '@shade/core'; +import { x25519 } from '@noble/curves/ed25519.js'; +import { ed25519 } from '@noble/curves/ed25519.js'; + +/** + * SubtleCrypto + noble/curves implementation of CryptoProvider. + * + * Uses @noble/curves for X25519 and Ed25519 (reliable across all runtimes) + * and Web Crypto API for AES-256-GCM, HKDF, and HMAC (hardware-accelerated). + */ +export class SubtleCryptoProvider implements CryptoProvider { + private readonly subtle: SubtleCrypto; + + constructor(subtle?: SubtleCrypto) { + this.subtle = subtle ?? globalThis.crypto.subtle; + } + + // ─── X25519 (via @noble/curves) ──────────────────────────── + + async generateX25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> { + const privateKey = this.randomBytes(32); + const publicKey = x25519.getPublicKey(privateKey); + return { publicKey, privateKey }; + } + + async x25519(privateKey: Uint8Array, publicKey: Uint8Array): Promise { + return x25519.getSharedSecret(privateKey, publicKey); + } + + // ─── Ed25519 (via @noble/curves) ─────────────────────────── + + async generateEd25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> { + const privateKey = this.randomBytes(32); + const publicKey = ed25519.getPublicKey(privateKey); + return { publicKey, privateKey }; + } + + async sign(privateKey: Uint8Array, message: Uint8Array): Promise { + return ed25519.sign(message, privateKey); + } + + async verify(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): Promise { + try { + return ed25519.verify(signature, message, publicKey); + } catch { + return false; + } + } + + // ─── AES-256-GCM (via SubtleCrypto) ─────────────────────── + + async aesGcmEncrypt( + key: Uint8Array, + plaintext: Uint8Array, + aad?: Uint8Array, + ): Promise<{ ciphertext: Uint8Array; nonce: Uint8Array }> { + const nonce = this.randomBytes(12); + const aesKey = await this.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']); + const encrypted = await this.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: aad }, + aesKey, + plaintext, + ); + return { ciphertext: new Uint8Array(encrypted), nonce }; + } + + async aesGcmDecrypt( + key: Uint8Array, + ciphertext: Uint8Array, + nonce: Uint8Array, + aad?: Uint8Array, + ): Promise { + const aesKey = await this.subtle.importKey('raw', key, 'AES-GCM', false, ['decrypt']); + const decrypted = await this.subtle.decrypt( + { name: 'AES-GCM', iv: nonce, additionalData: aad }, + aesKey, + ciphertext, + ); + return new Uint8Array(decrypted); + } + + // ─── Key Derivation (via SubtleCrypto) ───────────────────── + + async hkdf( + ikm: Uint8Array, + salt: Uint8Array, + info: Uint8Array, + length: number, + ): Promise { + const baseKey = await this.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); + const bits = await this.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt, info }, + baseKey, + length * 8, + ); + return new Uint8Array(bits); + } + + async hmacSha256(key: Uint8Array, data: Uint8Array): Promise { + const hmacKey = await this.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = await this.subtle.sign('HMAC', hmacKey, data); + return new Uint8Array(sig); + } + + // ─── Random ──────────────────────────────────────────────── + + randomBytes(length: number): Uint8Array { + const buf = new Uint8Array(length); + globalThis.crypto.getRandomValues(buf); + return buf; + } +} diff --git a/packages/shade-crypto-web/tests/provider.test.ts b/packages/shade-crypto-web/tests/provider.test.ts new file mode 100644 index 0000000..d6f407d --- /dev/null +++ b/packages/shade-crypto-web/tests/provider.test.ts @@ -0,0 +1,236 @@ +import { describe, test, expect } from 'bun:test'; +import { SubtleCryptoProvider } from '../src/provider.js'; + +const crypto = new SubtleCryptoProvider(); + +describe('SubtleCryptoProvider', () => { + // ─── X25519 ────────────────────────────────────────────── + + describe('X25519', () => { + test('generates keypair with correct byte lengths', async () => { + const kp = await crypto.generateX25519KeyPair(); + expect(kp.publicKey).toBeInstanceOf(Uint8Array); + expect(kp.privateKey).toBeInstanceOf(Uint8Array); + expect(kp.publicKey.length).toBe(32); + expect(kp.privateKey.length).toBe(32); + }); + + test('two keypairs produce different keys', async () => { + const a = await crypto.generateX25519KeyPair(); + const b = await crypto.generateX25519KeyPair(); + expect(a.publicKey).not.toEqual(b.publicKey); + expect(a.privateKey).not.toEqual(b.privateKey); + }); + + test('DH agreement: both sides derive same shared secret', async () => { + const alice = await crypto.generateX25519KeyPair(); + const bob = await crypto.generateX25519KeyPair(); + + const secretA = await crypto.x25519(alice.privateKey, bob.publicKey); + const secretB = await crypto.x25519(bob.privateKey, alice.publicKey); + + expect(secretA.length).toBe(32); + expect(secretA).toEqual(secretB); + }); + + test('DH with different peers produces different secrets', async () => { + const alice = await crypto.generateX25519KeyPair(); + const bob = await crypto.generateX25519KeyPair(); + const charlie = await crypto.generateX25519KeyPair(); + + const secretAB = await crypto.x25519(alice.privateKey, bob.publicKey); + const secretAC = await crypto.x25519(alice.privateKey, charlie.publicKey); + + expect(secretAB).not.toEqual(secretAC); + }); + }); + + // ─── Ed25519 ───────────────────────────────────────────── + + describe('Ed25519', () => { + test('generates keypair with correct byte lengths', async () => { + const kp = await crypto.generateEd25519KeyPair(); + expect(kp.publicKey.length).toBe(32); + expect(kp.privateKey.length).toBe(32); + }); + + test('sign and verify roundtrip', async () => { + const kp = await crypto.generateEd25519KeyPair(); + const message = new TextEncoder().encode('hello shade'); + + const sig = await crypto.sign(kp.privateKey, message); + expect(sig.length).toBe(64); + + const valid = await crypto.verify(kp.publicKey, message, sig); + expect(valid).toBe(true); + }); + + test('verify fails with wrong public key', async () => { + const alice = await crypto.generateEd25519KeyPair(); + const bob = await crypto.generateEd25519KeyPair(); + const message = new TextEncoder().encode('hello shade'); + + const sig = await crypto.sign(alice.privateKey, message); + const valid = await crypto.verify(bob.publicKey, message, sig); + expect(valid).toBe(false); + }); + + test('verify fails with tampered message', async () => { + const kp = await crypto.generateEd25519KeyPair(); + const message = new TextEncoder().encode('hello shade'); + const tampered = new TextEncoder().encode('hello SHADE'); + + const sig = await crypto.sign(kp.privateKey, message); + const valid = await crypto.verify(kp.publicKey, tampered, sig); + expect(valid).toBe(false); + }); + }); + + // ─── AES-256-GCM ──────────────────────────────────────── + + describe('AES-256-GCM', () => { + test('encrypt/decrypt roundtrip', async () => { + const key = crypto.randomBytes(32); + const plaintext = new TextEncoder().encode('secret message'); + + const { ciphertext, nonce } = await crypto.aesGcmEncrypt(key, plaintext); + expect(nonce.length).toBe(12); + expect(ciphertext.length).toBeGreaterThan(plaintext.length); // includes auth tag + + const decrypted = await crypto.aesGcmDecrypt(key, ciphertext, nonce); + expect(decrypted).toEqual(plaintext); + }); + + test('each encryption produces unique nonce', async () => { + const key = crypto.randomBytes(32); + const plaintext = new TextEncoder().encode('same message'); + + const a = await crypto.aesGcmEncrypt(key, plaintext); + const b = await crypto.aesGcmEncrypt(key, plaintext); + + expect(a.nonce).not.toEqual(b.nonce); + expect(a.ciphertext).not.toEqual(b.ciphertext); + }); + + test('wrong key fails decryption', async () => { + const key1 = crypto.randomBytes(32); + const key2 = crypto.randomBytes(32); + const plaintext = new TextEncoder().encode('secret'); + + const { ciphertext, nonce } = await crypto.aesGcmEncrypt(key1, plaintext); + + expect(crypto.aesGcmDecrypt(key2, ciphertext, nonce)).rejects.toThrow(); + }); + + test('tampered ciphertext fails decryption', async () => { + const key = crypto.randomBytes(32); + const plaintext = new TextEncoder().encode('secret'); + + const { ciphertext, nonce } = await crypto.aesGcmEncrypt(key, plaintext); + ciphertext[0] ^= 0xff; // flip a byte + + expect(crypto.aesGcmDecrypt(key, ciphertext, nonce)).rejects.toThrow(); + }); + + test('associated data (AAD) is authenticated', async () => { + const key = crypto.randomBytes(32); + const plaintext = new TextEncoder().encode('secret'); + const aad = new TextEncoder().encode('header data'); + const wrongAad = new TextEncoder().encode('wrong header'); + + const { ciphertext, nonce } = await crypto.aesGcmEncrypt(key, plaintext, aad); + + // Correct AAD works + const decrypted = await crypto.aesGcmDecrypt(key, ciphertext, nonce, aad); + expect(decrypted).toEqual(plaintext); + + // Wrong AAD fails + expect(crypto.aesGcmDecrypt(key, ciphertext, nonce, wrongAad)).rejects.toThrow(); + + // Missing AAD fails + expect(crypto.aesGcmDecrypt(key, ciphertext, nonce)).rejects.toThrow(); + }); + }); + + // ─── HKDF ─────────────────────────────────────────────── + + describe('HKDF-SHA256', () => { + test('produces correct output length', async () => { + const ikm = crypto.randomBytes(32); + const salt = crypto.randomBytes(32); + const info = new TextEncoder().encode('test'); + + const out32 = await crypto.hkdf(ikm, salt, info, 32); + expect(out32.length).toBe(32); + + const out64 = await crypto.hkdf(ikm, salt, info, 64); + expect(out64.length).toBe(64); + }); + + test('deterministic: same inputs produce same output', async () => { + const ikm = new Uint8Array(32).fill(0xab); + const salt = new Uint8Array(32).fill(0xcd); + const info = new TextEncoder().encode('deterministic test'); + + const a = await crypto.hkdf(ikm, salt, info, 32); + const b = await crypto.hkdf(ikm, salt, info, 32); + expect(a).toEqual(b); + }); + + test('different info produces different output', async () => { + const ikm = crypto.randomBytes(32); + const salt = crypto.randomBytes(32); + + const a = await crypto.hkdf(ikm, salt, new TextEncoder().encode('info-a'), 32); + const b = await crypto.hkdf(ikm, salt, new TextEncoder().encode('info-b'), 32); + expect(a).not.toEqual(b); + }); + }); + + // ─── HMAC-SHA256 ──────────────────────────────────────── + + describe('HMAC-SHA256', () => { + test('produces 32-byte output', async () => { + const key = crypto.randomBytes(32); + const data = new TextEncoder().encode('test data'); + + const mac = await crypto.hmacSha256(key, data); + expect(mac.length).toBe(32); + }); + + test('deterministic: same inputs produce same MAC', async () => { + const key = new Uint8Array(32).fill(0x42); + const data = new TextEncoder().encode('deterministic'); + + const a = await crypto.hmacSha256(key, data); + const b = await crypto.hmacSha256(key, data); + expect(a).toEqual(b); + }); + + test('different key produces different MAC', async () => { + const key1 = crypto.randomBytes(32); + const key2 = crypto.randomBytes(32); + const data = new TextEncoder().encode('test'); + + const a = await crypto.hmacSha256(key1, data); + const b = await crypto.hmacSha256(key2, data); + expect(a).not.toEqual(b); + }); + }); + + // ─── randomBytes ──────────────────────────────────────── + + describe('randomBytes', () => { + test('produces correct length', () => { + expect(crypto.randomBytes(16).length).toBe(16); + expect(crypto.randomBytes(32).length).toBe(32); + expect(crypto.randomBytes(64).length).toBe(64); + }); + + test('produces different values each call', () => { + const a = crypto.randomBytes(32); + const b = crypto.randomBytes(32); + expect(a).not.toEqual(b); + }); + }); +}); diff --git a/packages/shade-crypto-web/tsconfig.json b/packages/shade-crypto-web/tsconfig.json new file mode 100644 index 0000000..a086b14 --- /dev/null +++ b/packages/shade-crypto-web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json new file mode 100644 index 0000000..eb286c8 --- /dev/null +++ b/packages/shade-proto/package.json @@ -0,0 +1,10 @@ +{ + "name": "@shade/proto", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@shade/core": "workspace:*" + } +} diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json new file mode 100644 index 0000000..2d4b827 --- /dev/null +++ b/packages/shade-server/package.json @@ -0,0 +1,11 @@ +{ + "name": "@shade/server", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@shade/core": "workspace:*", + "hono": "^4.0.0" + } +} diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json new file mode 100644 index 0000000..51aa37a --- /dev/null +++ b/packages/shade-transport/package.json @@ -0,0 +1,11 @@ +{ + "name": "@shade/transport", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@shade/core": "workspace:*", + "@shade/proto": "workspace:*" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2b921a4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun-types"] + } +}