feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet: - M1: Core types, CryptoProvider interface, KDF chain functions, SubtleCrypto+noble/curves provider, MemoryStorage - M2: X3DH key agreement (identity keys, signed prekeys, one-time prekeys, bundle processing for both initiator and responder) - M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped message key cache, out-of-order delivery, AAD-bound headers) 68 tests, 0 failures — including full integration test of X3DH handshake → Double Ratchet conversation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
78
bun.lock
Normal file
78
bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
20
package.json
Normal file
20
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
10
packages/shade-core/package.json
Normal file
10
packages/shade-core/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@shade/core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"peerDependencies": {
|
||||
"@shade/crypto-web": "workspace:*"
|
||||
}
|
||||
}
|
||||
62
packages/shade-core/src/crypto.ts
Normal file
62
packages/shade-core/src/crypto.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* CryptoProvider — platform-agnostic interface for all cryptographic primitives.
|
||||
*
|
||||
* Implementations:
|
||||
* - @shade/crypto-web: SubtleCrypto (Bun, Node.js, browser)
|
||||
* - shade-android: Google Tink (Kotlin/Android)
|
||||
*/
|
||||
export interface CryptoProvider {
|
||||
// ─── X25519 (Diffie-Hellman) ───────────────────────────────
|
||||
|
||||
/** Generate an X25519 keypair (32-byte public + 32-byte private) */
|
||||
generateX25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>;
|
||||
|
||||
/** Perform X25519 Diffie-Hellman: returns 32-byte shared secret */
|
||||
x25519(privateKey: Uint8Array, publicKey: Uint8Array): Promise<Uint8Array>;
|
||||
|
||||
// ─── Ed25519 (Signing) ─────────────────────────────────────
|
||||
|
||||
/** Generate an Ed25519 keypair (32-byte public + 32-byte private) */
|
||||
generateEd25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>;
|
||||
|
||||
/** Sign a message with Ed25519. Returns a 64-byte signature. */
|
||||
sign(privateKey: Uint8Array, message: Uint8Array): Promise<Uint8Array>;
|
||||
|
||||
/** Verify an Ed25519 signature. Returns true if valid. */
|
||||
verify(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): Promise<boolean>;
|
||||
|
||||
// ─── AES-256-GCM (Symmetric Encryption) ────────────────────
|
||||
|
||||
/** Encrypt plaintext with AES-256-GCM. Generates a random 12-byte nonce. */
|
||||
aesGcmEncrypt(
|
||||
key: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
aad?: Uint8Array,
|
||||
): Promise<{ ciphertext: Uint8Array; nonce: Uint8Array }>;
|
||||
|
||||
/** Decrypt ciphertext with AES-256-GCM. Throws on authentication failure. */
|
||||
aesGcmDecrypt(
|
||||
key: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
aad?: Uint8Array,
|
||||
): Promise<Uint8Array>;
|
||||
|
||||
// ─── Key Derivation ────────────────────────────────────────
|
||||
|
||||
/** HKDF-SHA256: derive `length` bytes from input keying material */
|
||||
hkdf(
|
||||
ikm: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
info: Uint8Array,
|
||||
length: number,
|
||||
): Promise<Uint8Array>;
|
||||
|
||||
/** HMAC-SHA256: returns 32-byte MAC */
|
||||
hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
|
||||
|
||||
// ─── Random ────────────────────────────────────────────────
|
||||
|
||||
/** Generate cryptographically secure random bytes */
|
||||
randomBytes(length: number): Uint8Array;
|
||||
}
|
||||
63
packages/shade-core/src/errors.ts
Normal file
63
packages/shade-core/src/errors.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Base class for all Shade errors */
|
||||
export class ShadeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ShadeError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Signature verification failed (e.g. invalid signed prekey) */
|
||||
export class InvalidSignatureError extends ShadeError {
|
||||
constructor(message = 'Signature verification failed') {
|
||||
super(message);
|
||||
this.name = 'InvalidSignatureError';
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM decryption failed (wrong key, tampered ciphertext, or bad nonce) */
|
||||
export class DecryptionError extends ShadeError {
|
||||
constructor(message = 'Decryption failed') {
|
||||
super(message);
|
||||
this.name = 'DecryptionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** No session exists for the given address */
|
||||
export class NoSessionError extends ShadeError {
|
||||
constructor(address: string) {
|
||||
super(`No session for address: ${address}`);
|
||||
this.name = 'NoSessionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Too many skipped messages in a chain (possible DoS or sync issue) */
|
||||
export class MaxSkipExceededError extends ShadeError {
|
||||
constructor(requested: number, max: number) {
|
||||
super(`Cannot skip ${requested} messages (max: ${max})`);
|
||||
this.name = 'MaxSkipExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Duplicate message detected (message key already consumed) */
|
||||
export class DuplicateMessageError extends ShadeError {
|
||||
constructor() {
|
||||
super('Duplicate message: key already consumed');
|
||||
this.name = 'DuplicateMessageError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Remote identity key has changed unexpectedly */
|
||||
export class UntrustedIdentityError extends ShadeError {
|
||||
constructor(address: string) {
|
||||
super(`Untrusted identity key for: ${address}`);
|
||||
this.name = 'UntrustedIdentityError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Required prekey not found in storage */
|
||||
export class PreKeyNotFoundError extends ShadeError {
|
||||
constructor(keyId: number, type: 'signed' | 'one-time') {
|
||||
super(`${type} prekey not found: ${keyId}`);
|
||||
this.name = 'PreKeyNotFoundError';
|
||||
}
|
||||
}
|
||||
7
packages/shade-core/src/index.ts
Normal file
7
packages/shade-core/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './types.js';
|
||||
export * from './crypto.js';
|
||||
export * from './storage.js';
|
||||
export * from './keys.js';
|
||||
export * from './errors.js';
|
||||
export * from './x3dh.js';
|
||||
export * from './ratchet.js';
|
||||
83
packages/shade-core/src/keys.ts
Normal file
83
packages/shade-core/src/keys.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { CryptoProvider } from './crypto.js';
|
||||
|
||||
/**
|
||||
* Signal Protocol KDF chain functions.
|
||||
*
|
||||
* These implement the key derivation logic used by both X3DH (initial root key)
|
||||
* and the Double Ratchet (root key ratchet + chain key ratchet).
|
||||
*
|
||||
* References:
|
||||
* - Signal Double Ratchet spec, section 2.2 "KDF chains"
|
||||
* - Signal X3DH spec, section 2.4 "Key derivation"
|
||||
*/
|
||||
|
||||
// Info strings used in HKDF derivations (must match across all platforms)
|
||||
const ROOT_KDF_INFO = new TextEncoder().encode('ShadeRootRatchet');
|
||||
const CHAIN_KEY_CONSTANT = new Uint8Array([0x01]);
|
||||
const MESSAGE_KEY_CONSTANT = new Uint8Array([0x02]);
|
||||
|
||||
/**
|
||||
* Root key ratchet step: given the current root key and a DH output,
|
||||
* derive a new root key and a new chain key.
|
||||
*
|
||||
* Uses HKDF with the DH output as IKM and the current root key as salt.
|
||||
* Output is 64 bytes: first 32 = new root key, last 32 = new chain key.
|
||||
*/
|
||||
export async function kdfRootKey(
|
||||
crypto: CryptoProvider,
|
||||
rootKey: Uint8Array,
|
||||
dhOutput: Uint8Array,
|
||||
): Promise<{ newRootKey: Uint8Array; chainKey: Uint8Array }> {
|
||||
const derived = await crypto.hkdf(dhOutput, rootKey, ROOT_KDF_INFO, 64);
|
||||
return {
|
||||
newRootKey: derived.slice(0, 32),
|
||||
chainKey: derived.slice(32, 64),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain key ratchet step: derive the next chain key and a message key.
|
||||
*
|
||||
* Chain key → HMAC(chainKey, 0x01) = new chain key
|
||||
* Chain key → HMAC(chainKey, 0x02) = message key (used to encrypt one message)
|
||||
*
|
||||
* The message key is consumed (used once), the chain key advances.
|
||||
*/
|
||||
export async function kdfChainKey(
|
||||
crypto: CryptoProvider,
|
||||
chainKey: Uint8Array,
|
||||
): Promise<{ newChainKey: Uint8Array; messageKey: Uint8Array }> {
|
||||
const [newChainKey, messageKey] = await Promise.all([
|
||||
crypto.hmacSha256(chainKey, CHAIN_KEY_CONSTANT),
|
||||
crypto.hmacSha256(chainKey, MESSAGE_KEY_CONSTANT),
|
||||
]);
|
||||
return { newChainKey, messageKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the initial root key from X3DH shared secrets.
|
||||
*
|
||||
* Takes the concatenated DH outputs from X3DH (DH1 || DH2 || DH3 [|| DH4])
|
||||
* and derives a 32-byte root key using HKDF.
|
||||
*
|
||||
* Salt: 32 zero bytes (as per Signal spec)
|
||||
* Info: "ShadeX3DH"
|
||||
*/
|
||||
const X3DH_INFO = new TextEncoder().encode('ShadeX3DH');
|
||||
const X3DH_SALT = new Uint8Array(32); // 32 zero bytes
|
||||
|
||||
export async function deriveInitialRootKey(
|
||||
crypto: CryptoProvider,
|
||||
sharedSecrets: Uint8Array[],
|
||||
): Promise<Uint8Array> {
|
||||
// Concatenate all DH outputs
|
||||
const totalLength = sharedSecrets.reduce((sum, s) => sum + s.length, 0);
|
||||
const ikm = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const secret of sharedSecrets) {
|
||||
ikm.set(secret, offset);
|
||||
offset += secret.length;
|
||||
}
|
||||
|
||||
return crypto.hkdf(ikm, X3DH_SALT, X3DH_INFO, 32);
|
||||
}
|
||||
274
packages/shade-core/src/ratchet.ts
Normal file
274
packages/shade-core/src/ratchet.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { CryptoProvider } from './crypto.js';
|
||||
import type { KeyPair, SessionState, ChainState, RatchetMessage } from './types.js';
|
||||
import { MAX_SKIP, MAX_CACHED_SKIPPED_KEYS } from './types.js';
|
||||
import { kdfRootKey, kdfChainKey } from './keys.js';
|
||||
import { DecryptionError, MaxSkipExceededError, DuplicateMessageError } from './errors.js';
|
||||
|
||||
/**
|
||||
* Double Ratchet — per-message forward secrecy and post-compromise recovery.
|
||||
*
|
||||
* Combines a symmetric-key ratchet (chain keys → message keys) with a
|
||||
* Diffie-Hellman ratchet (new DH keypair per conversation turn).
|
||||
*
|
||||
* Reference: https://signal.org/docs/specifications/doubleratchet/
|
||||
*/
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────
|
||||
|
||||
function toBase64(buf: Uint8Array): string {
|
||||
// Use a simple hex encoding for map keys (avoids btoa issues with Uint8Array)
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function skippedKeyId(dhPublicKey: Uint8Array, counter: number): string {
|
||||
return `${toBase64(dhPublicKey)}:${counter}`;
|
||||
}
|
||||
|
||||
/** Encode a RatchetMessage header as bytes for use as AES-GCM AAD */
|
||||
function encodeHeader(msg: Pick<RatchetMessage, 'dhPublicKey' | 'previousCounter' | 'counter'>): Uint8Array {
|
||||
// dhPublicKey (32) + previousCounter (4, big-endian) + counter (4, big-endian)
|
||||
const buf = new Uint8Array(40);
|
||||
buf.set(msg.dhPublicKey, 0);
|
||||
new DataView(buf.buffer).setUint32(32, msg.previousCounter, false);
|
||||
new DataView(buf.buffer).setUint32(36, msg.counter, false);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ─── Session Initialization ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize a session as the sender (Alice, after X3DH).
|
||||
*
|
||||
* Alice knows the root key and Bob's signed prekey (initial remote DH key).
|
||||
* She generates her first DH ratchet keypair and performs the first DH ratchet step.
|
||||
*/
|
||||
export async function initSenderSession(
|
||||
crypto: CryptoProvider,
|
||||
rootKey: Uint8Array,
|
||||
remoteIdentityKey: Uint8Array,
|
||||
remoteDHPublicKey: Uint8Array,
|
||||
): Promise<SessionState> {
|
||||
// Generate first DH ratchet keypair
|
||||
const dhSend = await crypto.generateX25519KeyPair();
|
||||
|
||||
// First DH ratchet step
|
||||
const dhOutput = await crypto.x25519(dhSend.privateKey, remoteDHPublicKey);
|
||||
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
|
||||
return {
|
||||
remoteIdentityKey,
|
||||
rootKey: newRootKey,
|
||||
sendChain: { chainKey, counter: 0 },
|
||||
receiveChain: null,
|
||||
dhSend,
|
||||
dhReceive: remoteDHPublicKey,
|
||||
previousSendCounter: 0,
|
||||
skippedKeys: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a session as the receiver (Bob, after X3DH).
|
||||
*
|
||||
* Bob knows the root key and his own signed prekey (which was used as
|
||||
* the initial DH ratchet keypair).
|
||||
*/
|
||||
export function initReceiverSession(
|
||||
rootKey: Uint8Array,
|
||||
remoteIdentityKey: Uint8Array,
|
||||
localDHKeyPair: KeyPair,
|
||||
): SessionState {
|
||||
return {
|
||||
remoteIdentityKey,
|
||||
rootKey,
|
||||
sendChain: { chainKey: new Uint8Array(32), counter: 0 },
|
||||
receiveChain: null,
|
||||
dhSend: localDHKeyPair,
|
||||
dhReceive: null,
|
||||
previousSendCounter: 0,
|
||||
skippedKeys: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Encrypt ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext message using the Double Ratchet.
|
||||
*
|
||||
* Advances the sending chain by one step, derives a message key,
|
||||
* encrypts the plaintext with AES-256-GCM, and returns a RatchetMessage.
|
||||
*/
|
||||
export async function ratchetEncrypt(
|
||||
crypto: CryptoProvider,
|
||||
session: SessionState,
|
||||
plaintext: Uint8Array,
|
||||
): Promise<RatchetMessage> {
|
||||
// Advance sending chain
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, session.sendChain.chainKey);
|
||||
const counter = session.sendChain.counter;
|
||||
|
||||
// Build header for AAD
|
||||
const header: Pick<RatchetMessage, 'dhPublicKey' | 'previousCounter' | 'counter'> = {
|
||||
dhPublicKey: session.dhSend.publicKey,
|
||||
previousCounter: session.previousSendCounter,
|
||||
counter,
|
||||
};
|
||||
const aad = encodeHeader(header);
|
||||
|
||||
// Encrypt
|
||||
const { ciphertext, nonce } = await crypto.aesGcmEncrypt(messageKey, plaintext, aad);
|
||||
|
||||
// Update session state
|
||||
session.sendChain.chainKey = newChainKey;
|
||||
session.sendChain.counter = counter + 1;
|
||||
|
||||
return {
|
||||
dhPublicKey: session.dhSend.publicKey,
|
||||
previousCounter: session.previousSendCounter,
|
||||
counter,
|
||||
ciphertext,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Decrypt ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decrypt a RatchetMessage using the Double Ratchet.
|
||||
*
|
||||
* Handles three cases:
|
||||
* 1. Message from a skipped key (out-of-order delivery)
|
||||
* 2. Message from the current receiving chain
|
||||
* 3. Message with a new DH key (triggers a DH ratchet step)
|
||||
*/
|
||||
export async function ratchetDecrypt(
|
||||
crypto: CryptoProvider,
|
||||
session: SessionState,
|
||||
message: RatchetMessage,
|
||||
): Promise<Uint8Array> {
|
||||
// Case 1: Try skipped keys first
|
||||
const skipId = skippedKeyId(message.dhPublicKey, message.counter);
|
||||
const skippedKey = session.skippedKeys.get(skipId);
|
||||
if (skippedKey) {
|
||||
session.skippedKeys.delete(skipId);
|
||||
return decryptWithKey(crypto, skippedKey, message);
|
||||
}
|
||||
|
||||
// Case 2 or 3: Check if this is a new DH ratchet
|
||||
const isNewRatchet = !session.dhReceive || !arraysEqual(message.dhPublicKey, session.dhReceive);
|
||||
|
||||
if (isNewRatchet) {
|
||||
// Skip any remaining messages in the current receiving chain
|
||||
if (session.receiveChain && session.dhReceive) {
|
||||
await skipMessageKeys(crypto, session, session.dhReceive, session.receiveChain, message.previousCounter);
|
||||
}
|
||||
|
||||
// Perform DH ratchet step
|
||||
await performDHRatchetStep(crypto, session, message.dhPublicKey);
|
||||
}
|
||||
|
||||
// Skip to the message's counter in the current receiving chain
|
||||
if (!session.receiveChain) {
|
||||
throw new DecryptionError('No receiving chain available');
|
||||
}
|
||||
await skipMessageKeys(crypto, session, message.dhPublicKey, session.receiveChain, message.counter);
|
||||
|
||||
// Advance the receiving chain one more step to get this message's key
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, session.receiveChain.chainKey);
|
||||
session.receiveChain.chainKey = newChainKey;
|
||||
session.receiveChain.counter = message.counter + 1;
|
||||
|
||||
return decryptWithKey(crypto, messageKey, message);
|
||||
}
|
||||
|
||||
// ─── DH Ratchet Step ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform a DH ratchet step when receiving a message with a new DH public key.
|
||||
*
|
||||
* 1. DH(current send key, new remote key) → advance root key, get new receiving chain
|
||||
* 2. Generate new DH keypair
|
||||
* 3. DH(new keypair, remote key) → advance root key, get new sending chain
|
||||
*/
|
||||
async function performDHRatchetStep(
|
||||
crypto: CryptoProvider,
|
||||
session: SessionState,
|
||||
remoteDHKey: Uint8Array,
|
||||
): Promise<void> {
|
||||
// Save previous send counter
|
||||
session.previousSendCounter = session.sendChain.counter;
|
||||
|
||||
// Update remote DH key
|
||||
session.dhReceive = remoteDHKey;
|
||||
|
||||
// DH with current send key → new receiving chain
|
||||
const dh1 = await crypto.x25519(session.dhSend.privateKey, remoteDHKey);
|
||||
const recv = await kdfRootKey(crypto, session.rootKey, dh1);
|
||||
session.rootKey = recv.newRootKey;
|
||||
session.receiveChain = { chainKey: recv.chainKey, counter: 0 };
|
||||
|
||||
// Generate new DH keypair
|
||||
session.dhSend = await crypto.generateX25519KeyPair();
|
||||
|
||||
// DH with new send key → new sending chain
|
||||
const dh2 = await crypto.x25519(session.dhSend.privateKey, remoteDHKey);
|
||||
const send = await kdfRootKey(crypto, session.rootKey, dh2);
|
||||
session.rootKey = send.newRootKey;
|
||||
session.sendChain = { chainKey: send.chainKey, counter: 0 };
|
||||
}
|
||||
|
||||
// ─── Skip Message Keys ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Advance a chain, caching skipped message keys for out-of-order decryption.
|
||||
*/
|
||||
async function skipMessageKeys(
|
||||
crypto: CryptoProvider,
|
||||
session: SessionState,
|
||||
dhPublicKey: Uint8Array,
|
||||
chain: ChainState,
|
||||
untilCounter: number,
|
||||
): Promise<void> {
|
||||
const toSkip = untilCounter - chain.counter;
|
||||
if (toSkip < 0) return; // already past this point
|
||||
if (toSkip > MAX_SKIP) {
|
||||
throw new MaxSkipExceededError(toSkip, MAX_SKIP);
|
||||
}
|
||||
|
||||
for (let i = chain.counter; i < untilCounter; i++) {
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, chain.chainKey);
|
||||
const id = skippedKeyId(dhPublicKey, i);
|
||||
session.skippedKeys.set(id, messageKey);
|
||||
chain.chainKey = newChainKey;
|
||||
chain.counter = i + 1;
|
||||
|
||||
// Evict oldest if we have too many cached keys
|
||||
if (session.skippedKeys.size > MAX_CACHED_SKIPPED_KEYS) {
|
||||
const firstKey = session.skippedKeys.keys().next().value;
|
||||
if (firstKey) session.skippedKeys.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
async function decryptWithKey(
|
||||
crypto: CryptoProvider,
|
||||
messageKey: Uint8Array,
|
||||
message: RatchetMessage,
|
||||
): Promise<Uint8Array> {
|
||||
const aad = encodeHeader(message);
|
||||
try {
|
||||
return await crypto.aesGcmDecrypt(messageKey, message.ciphertext, message.nonce, aad);
|
||||
} catch {
|
||||
throw new DecryptionError('Failed to decrypt message — wrong key or tampered data');
|
||||
}
|
||||
}
|
||||
|
||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
68
packages/shade-core/src/storage.ts
Normal file
68
packages/shade-core/src/storage.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from './types.js';
|
||||
|
||||
/**
|
||||
* StorageProvider — abstract interface for persisting cryptographic state.
|
||||
*
|
||||
* Implementations per platform:
|
||||
* - In-memory (testing)
|
||||
* - IndexedDB (browser)
|
||||
* - SQLite/PostgreSQL (server)
|
||||
* - EncryptedSharedPreferences (Android)
|
||||
*/
|
||||
export interface StorageProvider {
|
||||
// ─── Identity ──────────────────────────────────────────────
|
||||
|
||||
/** Get our local identity keypair, or null if not yet generated */
|
||||
getIdentityKeyPair(): Promise<IdentityKeyPair | null>;
|
||||
|
||||
/** Persist our local identity keypair */
|
||||
saveIdentityKeyPair(keyPair: IdentityKeyPair): Promise<void>;
|
||||
|
||||
/** Get our local registration ID (unique per installation) */
|
||||
getLocalRegistrationId(): Promise<number>;
|
||||
|
||||
/** Save our local registration ID */
|
||||
saveLocalRegistrationId(id: number): Promise<void>;
|
||||
|
||||
// ─── Signed Pre-Keys ──────────────────────────────────────
|
||||
|
||||
/** Get a signed prekey by ID */
|
||||
getSignedPreKey(keyId: number): Promise<SignedPreKey | null>;
|
||||
|
||||
/** Persist a signed prekey */
|
||||
saveSignedPreKey(key: SignedPreKey): Promise<void>;
|
||||
|
||||
/** Remove a signed prekey (after rotation grace period) */
|
||||
removeSignedPreKey(keyId: number): Promise<void>;
|
||||
|
||||
// ─── One-Time Pre-Keys ────────────────────────────────────
|
||||
|
||||
/** Get a one-time prekey by ID */
|
||||
getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null>;
|
||||
|
||||
/** Persist a one-time prekey */
|
||||
saveOneTimePreKey(key: OneTimePreKey): Promise<void>;
|
||||
|
||||
/** Remove a consumed one-time prekey */
|
||||
removeOneTimePreKey(keyId: number): Promise<void>;
|
||||
|
||||
/** Count remaining one-time prekeys */
|
||||
getOneTimePreKeyCount(): Promise<number>;
|
||||
|
||||
// ─── Sessions ─────────────────────────────────────────────
|
||||
|
||||
/** Get session state for a peer address (e.g. "device:abc123") */
|
||||
getSession(address: string): Promise<SessionState | null>;
|
||||
|
||||
/** Persist session state for a peer */
|
||||
saveSession(address: string, state: SessionState): Promise<void>;
|
||||
|
||||
/** Remove session for a peer */
|
||||
removeSession(address: string): Promise<void>;
|
||||
|
||||
/** Check if we trust a remote identity key (for TOFU or pinned keys) */
|
||||
isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean>;
|
||||
|
||||
/** Save a trusted remote identity key */
|
||||
saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void>;
|
||||
}
|
||||
129
packages/shade-core/src/types.ts
Normal file
129
packages/shade-core/src/types.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// ─── Key Types ─────────────────────────────────────────────────
|
||||
|
||||
/** Long-term identity: Ed25519 for signing + X25519 for DH */
|
||||
export interface IdentityKeyPair {
|
||||
/** Ed25519 public key (32 bytes) — used to sign prekeys */
|
||||
signingPublicKey: Uint8Array;
|
||||
/** Ed25519 private key (32 bytes) */
|
||||
signingPrivateKey: Uint8Array;
|
||||
/** X25519 public key (32 bytes) — used for DH in X3DH */
|
||||
dhPublicKey: Uint8Array;
|
||||
/** X25519 private key (32 bytes) */
|
||||
dhPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
/** Generic asymmetric keypair (X25519 or Ed25519) */
|
||||
export interface KeyPair {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
/** Medium-term signed pre-key (rotated periodically, e.g. weekly) */
|
||||
export interface SignedPreKey {
|
||||
keyId: number;
|
||||
keyPair: KeyPair;
|
||||
/** Ed25519 signature over the public key, by the identity signing key */
|
||||
signature: Uint8Array;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Single-use one-time pre-key (consumed during X3DH, then deleted) */
|
||||
export interface OneTimePreKey {
|
||||
keyId: number;
|
||||
keyPair: KeyPair;
|
||||
}
|
||||
|
||||
// ─── PreKey Bundle (published to prekey server) ────────────────
|
||||
|
||||
/** Bundle fetched from the prekey server to initiate an X3DH session */
|
||||
export interface PreKeyBundle {
|
||||
registrationId: number;
|
||||
/** Ed25519 public key (for signature verification) */
|
||||
identitySigningKey: Uint8Array;
|
||||
/** X25519 public key (for DH) */
|
||||
identityDHKey: Uint8Array;
|
||||
signedPreKey: {
|
||||
keyId: number;
|
||||
publicKey: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
};
|
||||
oneTimePreKey?: {
|
||||
keyId: number;
|
||||
publicKey: Uint8Array;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Wire Messages ─────────────────────────────────────────────
|
||||
|
||||
/** Initial message establishing a new session (contains X3DH info + first ratchet message) */
|
||||
export interface PreKeyMessage {
|
||||
registrationId: number;
|
||||
/** One-time prekey ID consumed (if any) */
|
||||
preKeyId?: number;
|
||||
signedPreKeyId: number;
|
||||
/** Sender's ephemeral X25519 public key (generated for this X3DH) */
|
||||
ephemeralKey: Uint8Array;
|
||||
/** Sender's identity X25519 DH public key */
|
||||
identityDHKey: Uint8Array;
|
||||
/** The first ratchet message, encrypted under the X3DH-derived key */
|
||||
message: RatchetMessage;
|
||||
}
|
||||
|
||||
/** Standard Double Ratchet message (used after session is established) */
|
||||
export interface RatchetMessage {
|
||||
/** Sender's current DH ratchet public key */
|
||||
dhPublicKey: Uint8Array;
|
||||
/** Number of messages in the previous sending chain */
|
||||
previousCounter: number;
|
||||
/** Message number in the current sending chain */
|
||||
counter: number;
|
||||
/** AES-256-GCM encrypted payload */
|
||||
ciphertext: Uint8Array;
|
||||
/** 12-byte GCM nonce */
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
/** Envelope wrapping either a PreKeyMessage or RatchetMessage on the wire */
|
||||
export interface ShadeEnvelope {
|
||||
type: 'prekey' | 'ratchet';
|
||||
content: PreKeyMessage | RatchetMessage;
|
||||
timestamp: number;
|
||||
senderAddress: string;
|
||||
}
|
||||
|
||||
// ─── Session State ─────────────────────────────────────────────
|
||||
|
||||
export interface ChainState {
|
||||
/** Current chain key (32 bytes) — ratcheted forward with each message */
|
||||
chainKey: Uint8Array;
|
||||
/** Number of messages sent/received in this chain */
|
||||
counter: number;
|
||||
}
|
||||
|
||||
/** Serializable Double Ratchet session state */
|
||||
export interface SessionState {
|
||||
/** Remote peer's identity DH public key (for verification) */
|
||||
remoteIdentityKey: Uint8Array;
|
||||
/** Current root key (32 bytes) */
|
||||
rootKey: Uint8Array;
|
||||
/** Our current sending chain */
|
||||
sendChain: ChainState;
|
||||
/** Our current receiving chain (null before first received message in a new DH ratchet) */
|
||||
receiveChain: ChainState | null;
|
||||
/** Our current DH ratchet keypair */
|
||||
dhSend: KeyPair;
|
||||
/** Remote's current DH ratchet public key */
|
||||
dhReceive: Uint8Array | null;
|
||||
/** Message count of the previous sending chain (sent in message headers) */
|
||||
previousSendCounter: number;
|
||||
/** Skipped message keys: Map<"base64(dhPub):counter", messageKey> for out-of-order decryption */
|
||||
skippedKeys: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────
|
||||
|
||||
/** Max number of message keys to skip in a single chain (DoS protection) */
|
||||
export const MAX_SKIP = 1000;
|
||||
|
||||
/** Max total skipped keys to cache per session */
|
||||
export const MAX_CACHED_SKIPPED_KEYS = 2000;
|
||||
246
packages/shade-core/src/x3dh.ts
Normal file
246
packages/shade-core/src/x3dh.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { CryptoProvider } from './crypto.js';
|
||||
import type { StorageProvider } from './storage.js';
|
||||
import type {
|
||||
IdentityKeyPair,
|
||||
KeyPair,
|
||||
SignedPreKey,
|
||||
OneTimePreKey,
|
||||
PreKeyBundle,
|
||||
PreKeyMessage,
|
||||
RatchetMessage,
|
||||
SessionState,
|
||||
} from './types.js';
|
||||
import { deriveInitialRootKey } from './keys.js';
|
||||
import { InvalidSignatureError, PreKeyNotFoundError, UntrustedIdentityError } from './errors.js';
|
||||
|
||||
/**
|
||||
* X3DH — Extended Triple Diffie-Hellman key agreement.
|
||||
*
|
||||
* Establishes a shared secret between two parties (Alice and Bob) even when
|
||||
* Bob is offline, using prekey bundles published to a server.
|
||||
*
|
||||
* Reference: https://signal.org/docs/specifications/x3dh/
|
||||
*/
|
||||
|
||||
// ─── Key Generation ──────────────────────────────────────────
|
||||
|
||||
/** Generate a new identity keypair: Ed25519 (signing) + X25519 (DH) */
|
||||
export async function generateIdentityKeyPair(crypto: CryptoProvider): Promise<IdentityKeyPair> {
|
||||
const [signing, dh] = await Promise.all([
|
||||
crypto.generateEd25519KeyPair(),
|
||||
crypto.generateX25519KeyPair(),
|
||||
]);
|
||||
return {
|
||||
signingPublicKey: signing.publicKey,
|
||||
signingPrivateKey: signing.privateKey,
|
||||
dhPublicKey: dh.publicKey,
|
||||
dhPrivateKey: dh.privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate a signed prekey: X25519 keypair signed by the identity key */
|
||||
export async function generateSignedPreKey(
|
||||
crypto: CryptoProvider,
|
||||
identityKey: IdentityKeyPair,
|
||||
keyId: number,
|
||||
): Promise<SignedPreKey> {
|
||||
const keyPair = await crypto.generateX25519KeyPair();
|
||||
const signature = await crypto.sign(identityKey.signingPrivateKey, keyPair.publicKey);
|
||||
return {
|
||||
keyId,
|
||||
keyPair,
|
||||
signature,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate a batch of one-time prekeys */
|
||||
export async function generateOneTimePreKeys(
|
||||
crypto: CryptoProvider,
|
||||
startId: number,
|
||||
count: number,
|
||||
): Promise<OneTimePreKey[]> {
|
||||
const keys: OneTimePreKey[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const keyPair = await crypto.generateX25519KeyPair();
|
||||
keys.push({ keyId: startId + i, keyPair });
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Assemble a prekey bundle for publishing to the prekey server */
|
||||
export function createPreKeyBundle(
|
||||
registrationId: number,
|
||||
identityKey: IdentityKeyPair,
|
||||
signedPreKey: SignedPreKey,
|
||||
oneTimePreKey?: OneTimePreKey,
|
||||
): PreKeyBundle {
|
||||
return {
|
||||
registrationId,
|
||||
identitySigningKey: identityKey.signingPublicKey,
|
||||
identityDHKey: identityKey.dhPublicKey,
|
||||
signedPreKey: {
|
||||
keyId: signedPreKey.keyId,
|
||||
publicKey: signedPreKey.keyPair.publicKey,
|
||||
signature: signedPreKey.signature,
|
||||
},
|
||||
oneTimePreKey: oneTimePreKey
|
||||
? { keyId: oneTimePreKey.keyId, publicKey: oneTimePreKey.keyPair.publicKey }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Alice: Initiate Session ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process a prekey bundle to establish a new session (Alice's side).
|
||||
*
|
||||
* Steps:
|
||||
* 1. Verify the signed prekey's signature using Bob's identity signing key
|
||||
* 2. Generate an ephemeral X25519 keypair
|
||||
* 3. Compute 3 or 4 DH shared secrets:
|
||||
* DH1 = DH(Alice identity DH, Bob signed prekey)
|
||||
* DH2 = DH(Alice ephemeral, Bob identity DH)
|
||||
* DH3 = DH(Alice ephemeral, Bob signed prekey)
|
||||
* DH4 = DH(Alice ephemeral, Bob one-time prekey) — if available
|
||||
* 4. Derive the initial root key from the concatenated DH outputs
|
||||
* 5. Return the X3DH result with all info needed to create a session + PreKeyMessage
|
||||
*/
|
||||
export interface X3DHInitResult {
|
||||
/** Initial root key (32 bytes) — seeds the Double Ratchet */
|
||||
rootKey: Uint8Array;
|
||||
/** Alice's ephemeral X25519 public key (sent to Bob in the PreKeyMessage) */
|
||||
ephemeralPublicKey: Uint8Array;
|
||||
/** Bob's signed prekey ID (so Bob can look up the key) */
|
||||
signedPreKeyId: number;
|
||||
/** Bob's one-time prekey ID if consumed */
|
||||
preKeyId?: number;
|
||||
/** Bob's identity DH public key */
|
||||
remoteIdentityKey: Uint8Array;
|
||||
/** Bob's signed prekey public key (used as initial DH ratchet remote key) */
|
||||
remoteSignedPreKey: Uint8Array;
|
||||
}
|
||||
|
||||
export async function processPreKeyBundle(
|
||||
crypto: CryptoProvider,
|
||||
storage: StorageProvider,
|
||||
bundle: PreKeyBundle,
|
||||
): Promise<X3DHInitResult> {
|
||||
// 1. Verify signed prekey signature
|
||||
const valid = await crypto.verify(
|
||||
bundle.identitySigningKey,
|
||||
bundle.signedPreKey.publicKey,
|
||||
bundle.signedPreKey.signature,
|
||||
);
|
||||
if (!valid) {
|
||||
throw new InvalidSignatureError('Signed prekey signature is invalid');
|
||||
}
|
||||
|
||||
// 2. Check trust (TOFU or pinned)
|
||||
// We trust based on the DH key since that's what we use in the protocol
|
||||
const identityKey = await storage.getIdentityKeyPair();
|
||||
if (!identityKey) throw new Error('No local identity key — call initialize() first');
|
||||
|
||||
// 3. Generate ephemeral keypair
|
||||
const ephemeral = await crypto.generateX25519KeyPair();
|
||||
|
||||
// 4. Compute DH shared secrets
|
||||
const dh1 = await crypto.x25519(identityKey.dhPrivateKey, bundle.signedPreKey.publicKey);
|
||||
const dh2 = await crypto.x25519(ephemeral.privateKey, bundle.identityDHKey);
|
||||
const dh3 = await crypto.x25519(ephemeral.privateKey, bundle.signedPreKey.publicKey);
|
||||
|
||||
const secrets = [dh1, dh2, dh3];
|
||||
|
||||
let preKeyId: number | undefined;
|
||||
if (bundle.oneTimePreKey) {
|
||||
const dh4 = await crypto.x25519(ephemeral.privateKey, bundle.oneTimePreKey.publicKey);
|
||||
secrets.push(dh4);
|
||||
preKeyId = bundle.oneTimePreKey.keyId;
|
||||
}
|
||||
|
||||
// 5. Derive initial root key
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
|
||||
// 6. Save trust for remote identity
|
||||
await storage.saveTrustedIdentity('pending', bundle.identityDHKey);
|
||||
|
||||
return {
|
||||
rootKey,
|
||||
ephemeralPublicKey: ephemeral.publicKey,
|
||||
signedPreKeyId: bundle.signedPreKey.keyId,
|
||||
preKeyId,
|
||||
remoteIdentityKey: bundle.identityDHKey,
|
||||
remoteSignedPreKey: bundle.signedPreKey.publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Bob: Respond to PreKeyMessage ───────────────────────────
|
||||
|
||||
/**
|
||||
* Process an incoming PreKeyMessage to establish a session (Bob's side).
|
||||
*
|
||||
* Steps:
|
||||
* 1. Look up the signed prekey and optionally the one-time prekey
|
||||
* 2. Compute the same 3 or 4 DH shared secrets (from Bob's perspective)
|
||||
* 3. Derive the same initial root key
|
||||
* 4. Delete the consumed one-time prekey
|
||||
* 5. Return the X3DH result to seed the Double Ratchet
|
||||
*/
|
||||
export interface X3DHResponseResult {
|
||||
/** Initial root key (32 bytes) — must match Alice's */
|
||||
rootKey: Uint8Array;
|
||||
/** Alice's identity DH public key */
|
||||
remoteIdentityKey: Uint8Array;
|
||||
/** Alice's ephemeral public key (used as initial DH ratchet remote key) */
|
||||
remoteEphemeralKey: Uint8Array;
|
||||
/** The embedded first ratchet message to decrypt */
|
||||
initialMessage: RatchetMessage;
|
||||
}
|
||||
|
||||
export async function processPreKeyMessage(
|
||||
crypto: CryptoProvider,
|
||||
storage: StorageProvider,
|
||||
message: PreKeyMessage,
|
||||
): Promise<X3DHResponseResult> {
|
||||
const identityKey = await storage.getIdentityKeyPair();
|
||||
if (!identityKey) throw new Error('No local identity key — call initialize() first');
|
||||
|
||||
// 1. Look up signed prekey
|
||||
const signedPreKey = await storage.getSignedPreKey(message.signedPreKeyId);
|
||||
if (!signedPreKey) {
|
||||
throw new PreKeyNotFoundError(message.signedPreKeyId, 'signed');
|
||||
}
|
||||
|
||||
// 2. Compute DH shared secrets (Bob's perspective — mirrored from Alice)
|
||||
const dh1 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.identityDHKey);
|
||||
const dh2 = await crypto.x25519(identityKey.dhPrivateKey, message.ephemeralKey);
|
||||
const dh3 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.ephemeralKey);
|
||||
|
||||
const secrets = [dh1, dh2, dh3];
|
||||
|
||||
// 3. If a one-time prekey was used, include DH4
|
||||
if (message.preKeyId != null) {
|
||||
const oneTimePreKey = await storage.getOneTimePreKey(message.preKeyId);
|
||||
if (!oneTimePreKey) {
|
||||
throw new PreKeyNotFoundError(message.preKeyId, 'one-time');
|
||||
}
|
||||
const dh4 = await crypto.x25519(oneTimePreKey.keyPair.privateKey, message.ephemeralKey);
|
||||
secrets.push(dh4);
|
||||
|
||||
// 4. Consume (delete) the one-time prekey
|
||||
await storage.removeOneTimePreKey(message.preKeyId);
|
||||
}
|
||||
|
||||
// 5. Derive the initial root key (should match Alice's)
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
|
||||
// 6. Save trust for remote identity
|
||||
await storage.saveTrustedIdentity('pending', message.identityDHKey);
|
||||
|
||||
return {
|
||||
rootKey,
|
||||
remoteIdentityKey: message.identityDHKey,
|
||||
remoteEphemeralKey: message.ephemeralKey,
|
||||
initialMessage: message.message,
|
||||
};
|
||||
}
|
||||
183
packages/shade-core/tests/integration.test.ts
Normal file
183
packages/shade-core/tests/integration.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||||
import {
|
||||
generateIdentityKeyPair,
|
||||
generateSignedPreKey,
|
||||
generateOneTimePreKeys,
|
||||
createPreKeyBundle,
|
||||
processPreKeyBundle,
|
||||
processPreKeyMessage,
|
||||
initSenderSession,
|
||||
initReceiverSession,
|
||||
ratchetEncrypt,
|
||||
ratchetDecrypt,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
describe('Full E2EE Integration: X3DH → Double Ratchet', () => {
|
||||
test('complete conversation between Alice and Bob', async () => {
|
||||
// ─── Setup Bob (publishes prekey bundle) ─────────────────
|
||||
const bobStorage = new MemoryStorage();
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveLocalRegistrationId(42);
|
||||
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
|
||||
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 10);
|
||||
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
|
||||
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
|
||||
|
||||
// ─── Alice initiates (processes bundle, creates session) ──
|
||||
const aliceStorage = new MemoryStorage();
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
|
||||
// Alice initializes her ratchet session
|
||||
const aliceSession = await initSenderSession(
|
||||
crypto,
|
||||
x3dhResult.rootKey,
|
||||
x3dhResult.remoteIdentityKey,
|
||||
x3dhResult.remoteSignedPreKey, // Bob's signed prekey = initial DH ratchet key
|
||||
);
|
||||
|
||||
// Alice encrypts her first message
|
||||
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('Hello Bob! This is E2EE.'));
|
||||
|
||||
// Alice sends a PreKeyMessage to Bob
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
preKeyId: x3dhResult.preKeyId,
|
||||
signedPreKeyId: x3dhResult.signedPreKeyId,
|
||||
ephemeralKey: x3dhResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: firstMsg,
|
||||
};
|
||||
|
||||
// ─── Bob receives and processes ──────────────────────────
|
||||
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
expect(bobX3dh.rootKey).toEqual(x3dhResult.rootKey);
|
||||
|
||||
// Bob initializes his ratchet session
|
||||
const bobSession = initReceiverSession(
|
||||
bobX3dh.rootKey,
|
||||
bobX3dh.remoteIdentityKey,
|
||||
bobSignedPreKey.keyPair, // Bob's signed prekey as his initial DH keypair
|
||||
);
|
||||
|
||||
// Bob decrypts Alice's first message
|
||||
const plaintext1 = await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage);
|
||||
expect(dec.decode(plaintext1)).toBe('Hello Bob! This is E2EE.');
|
||||
|
||||
// ─── Full conversation ───────────────────────────────────
|
||||
|
||||
// Alice sends more
|
||||
const m2 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Are you there?'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m2))).toBe('Are you there?');
|
||||
|
||||
// Bob replies (DH ratchet triggers)
|
||||
const m3 = await ratchetEncrypt(crypto, bobSession, enc.encode('Yes! Forward secrecy is active.'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, m3))).toBe('Yes! Forward secrecy is active.');
|
||||
|
||||
// Alice replies
|
||||
const m4 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Every message has a unique key.'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m4))).toBe('Every message has a unique key.');
|
||||
|
||||
// Multiple back-and-forth
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const sender = i % 2 === 0 ? aliceSession : bobSession;
|
||||
const receiver = i % 2 === 0 ? bobSession : aliceSession;
|
||||
const text = `Turn ${i}: ${i % 2 === 0 ? 'Alice' : 'Bob'} speaking`;
|
||||
|
||||
const msg = await ratchetEncrypt(crypto, sender, enc.encode(text));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, receiver, msg))).toBe(text);
|
||||
}
|
||||
});
|
||||
|
||||
test('works without one-time prekey', async () => {
|
||||
const bobStorage = new MemoryStorage();
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
|
||||
// No one-time prekeys
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||||
|
||||
const aliceStorage = new MemoryStorage();
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
const aliceSession = await initSenderSession(
|
||||
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
|
||||
);
|
||||
|
||||
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('No OTPK needed'));
|
||||
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
signedPreKeyId: x3dhResult.signedPreKeyId,
|
||||
ephemeralKey: x3dhResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: firstMsg,
|
||||
};
|
||||
|
||||
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
const bobSession = initReceiverSession(
|
||||
bobX3dh.rootKey, bobX3dh.remoteIdentityKey, bobSignedPreKey.keyPair,
|
||||
);
|
||||
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage)))
|
||||
.toBe('No OTPK needed');
|
||||
|
||||
// Continue conversation
|
||||
const reply = await ratchetEncrypt(crypto, bobSession, enc.encode('Got it!'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, reply))).toBe('Got it!');
|
||||
});
|
||||
|
||||
test('one-time prekey consumed after use', async () => {
|
||||
const bobStorage = new MemoryStorage();
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
|
||||
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
|
||||
|
||||
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
|
||||
|
||||
// Alice uses OTPK 100
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
|
||||
const aliceStorage = new MemoryStorage();
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
const aliceSession = await initSenderSession(
|
||||
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
|
||||
);
|
||||
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('test'));
|
||||
|
||||
await processPreKeyMessage(crypto, bobStorage, {
|
||||
registrationId: 1,
|
||||
preKeyId: 100,
|
||||
signedPreKeyId: 1,
|
||||
ephemeralKey: x3dhResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: firstMsg,
|
||||
});
|
||||
|
||||
// OTPK 100 consumed, 101 and 102 remain
|
||||
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
|
||||
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
|
||||
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
179
packages/shade-core/tests/keys.test.ts
Normal file
179
packages/shade-core/tests/keys.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { kdfRootKey, kdfChainKey, deriveInitialRootKey } from '../src/keys.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
describe('KDF Chain Functions', () => {
|
||||
describe('kdfRootKey', () => {
|
||||
test('produces 32-byte root key and 32-byte chain key', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dhOutput = crypto.randomBytes(32);
|
||||
|
||||
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(newRootKey.length).toBe(32);
|
||||
expect(chainKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('new root key differs from input root key', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dhOutput = crypto.randomBytes(32);
|
||||
|
||||
const { newRootKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(newRootKey).not.toEqual(rootKey);
|
||||
});
|
||||
|
||||
test('root key and chain key differ from each other', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dhOutput = crypto.randomBytes(32);
|
||||
|
||||
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(newRootKey).not.toEqual(chainKey);
|
||||
});
|
||||
|
||||
test('deterministic: same inputs produce same outputs', async () => {
|
||||
const rootKey = new Uint8Array(32).fill(0x11);
|
||||
const dhOutput = new Uint8Array(32).fill(0x22);
|
||||
|
||||
const a = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
const b = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(a.newRootKey).toEqual(b.newRootKey);
|
||||
expect(a.chainKey).toEqual(b.chainKey);
|
||||
});
|
||||
|
||||
test('different DH output produces different keys', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dh1 = crypto.randomBytes(32);
|
||||
const dh2 = crypto.randomBytes(32);
|
||||
|
||||
const a = await kdfRootKey(crypto, rootKey, dh1);
|
||||
const b = await kdfRootKey(crypto, rootKey, dh2);
|
||||
expect(a.newRootKey).not.toEqual(b.newRootKey);
|
||||
expect(a.chainKey).not.toEqual(b.chainKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kdfChainKey', () => {
|
||||
test('produces 32-byte chain key and 32-byte message key', async () => {
|
||||
const chainKey = crypto.randomBytes(32);
|
||||
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey);
|
||||
expect(newChainKey.length).toBe(32);
|
||||
expect(messageKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('chain key and message key differ', async () => {
|
||||
const chainKey = crypto.randomBytes(32);
|
||||
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey);
|
||||
expect(newChainKey).not.toEqual(messageKey);
|
||||
});
|
||||
|
||||
test('chain ratchet is one-way: cannot derive previous chain key', async () => {
|
||||
const ck0 = crypto.randomBytes(32);
|
||||
const { newChainKey: ck1 } = await kdfChainKey(crypto, ck0);
|
||||
const { newChainKey: ck2 } = await kdfChainKey(crypto, ck1);
|
||||
|
||||
// All three are different
|
||||
expect(ck0).not.toEqual(ck1);
|
||||
expect(ck1).not.toEqual(ck2);
|
||||
expect(ck0).not.toEqual(ck2);
|
||||
});
|
||||
|
||||
test('deterministic: same input produces same output', async () => {
|
||||
const chainKey = new Uint8Array(32).fill(0x33);
|
||||
|
||||
const a = await kdfChainKey(crypto, chainKey);
|
||||
const b = await kdfChainKey(crypto, chainKey);
|
||||
expect(a.newChainKey).toEqual(b.newChainKey);
|
||||
expect(a.messageKey).toEqual(b.messageKey);
|
||||
});
|
||||
|
||||
test('sequential chain steps produce unique message keys', async () => {
|
||||
let ck = crypto.randomBytes(32);
|
||||
const messageKeys: Uint8Array[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, ck);
|
||||
messageKeys.push(messageKey);
|
||||
ck = newChainKey;
|
||||
}
|
||||
|
||||
// All message keys should be unique
|
||||
for (let i = 0; i < messageKeys.length; i++) {
|
||||
for (let j = i + 1; j < messageKeys.length; j++) {
|
||||
expect(messageKeys[i]).not.toEqual(messageKeys[j]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveInitialRootKey', () => {
|
||||
test('produces 32-byte root key from multiple DH outputs', async () => {
|
||||
const secrets = [
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
];
|
||||
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(rootKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('works with 3 secrets (no one-time prekey)', async () => {
|
||||
const secrets = [
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
];
|
||||
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(rootKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('works with 4 secrets (with one-time prekey)', async () => {
|
||||
const secrets = [
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
];
|
||||
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(rootKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('deterministic: same secrets produce same root key', async () => {
|
||||
const secrets = [
|
||||
new Uint8Array(32).fill(0xaa),
|
||||
new Uint8Array(32).fill(0xbb),
|
||||
new Uint8Array(32).fill(0xcc),
|
||||
];
|
||||
|
||||
const a = await deriveInitialRootKey(crypto, secrets);
|
||||
const b = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('different secrets produce different root keys', async () => {
|
||||
const secretsA = [crypto.randomBytes(32), crypto.randomBytes(32), crypto.randomBytes(32)];
|
||||
const secretsB = [crypto.randomBytes(32), crypto.randomBytes(32), crypto.randomBytes(32)];
|
||||
|
||||
const a = await deriveInitialRootKey(crypto, secretsA);
|
||||
const b = await deriveInitialRootKey(crypto, secretsB);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('adding a 4th secret changes the root key', async () => {
|
||||
const base = [
|
||||
new Uint8Array(32).fill(0x11),
|
||||
new Uint8Array(32).fill(0x22),
|
||||
new Uint8Array(32).fill(0x33),
|
||||
];
|
||||
|
||||
const without = await deriveInitialRootKey(crypto, base);
|
||||
const withExtra = await deriveInitialRootKey(crypto, [...base, new Uint8Array(32).fill(0x44)]);
|
||||
expect(without).not.toEqual(withExtra);
|
||||
});
|
||||
});
|
||||
});
|
||||
262
packages/shade-core/tests/ratchet.test.ts
Normal file
262
packages/shade-core/tests/ratchet.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
initSenderSession,
|
||||
initReceiverSession,
|
||||
ratchetEncrypt,
|
||||
ratchetDecrypt,
|
||||
MaxSkipExceededError,
|
||||
DecryptionError,
|
||||
} from '../src/index.js';
|
||||
import type { SessionState, RatchetMessage } from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
/** Helper: set up Alice (sender) and Bob (receiver) sessions from a shared root key */
|
||||
async function setupPair(): Promise<{ alice: SessionState; bob: SessionState }> {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const remoteIdentityKey = crypto.randomBytes(32);
|
||||
|
||||
// Bob's initial DH keypair (would be his signed prekey in real X3DH)
|
||||
const bobDH = await crypto.generateX25519KeyPair();
|
||||
|
||||
const alice = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey);
|
||||
const bob = initReceiverSession(rootKey, remoteIdentityKey, bobDH);
|
||||
|
||||
return { alice, bob };
|
||||
}
|
||||
|
||||
describe('Double Ratchet', () => {
|
||||
// ─── Basic Send/Receive ──────────────────────────────────
|
||||
|
||||
describe('basic send/receive', () => {
|
||||
test('Alice encrypts, Bob decrypts', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
const msg = await ratchetEncrypt(crypto, alice, enc.encode('hello bob'));
|
||||
const plaintext = await ratchetDecrypt(crypto, bob, msg);
|
||||
|
||||
expect(dec.decode(plaintext)).toBe('hello bob');
|
||||
});
|
||||
|
||||
test('multiple messages in same direction', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
const messages = ['first', 'second', 'third'];
|
||||
const encrypted: RatchetMessage[] = [];
|
||||
|
||||
for (const text of messages) {
|
||||
encrypted.push(await ratchetEncrypt(crypto, alice, enc.encode(text)));
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const plaintext = await ratchetDecrypt(crypto, bob, encrypted[i]);
|
||||
expect(dec.decode(plaintext)).toBe(messages[i]);
|
||||
}
|
||||
});
|
||||
|
||||
test('counter increments with each message', async () => {
|
||||
const { alice } = await setupPair();
|
||||
|
||||
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
|
||||
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('b'));
|
||||
const m2 = await ratchetEncrypt(crypto, alice, enc.encode('c'));
|
||||
|
||||
expect(m0.counter).toBe(0);
|
||||
expect(m1.counter).toBe(1);
|
||||
expect(m2.counter).toBe(2);
|
||||
|
||||
// All use the same DH key (no ratchet step yet)
|
||||
expect(m0.dhPublicKey).toEqual(m1.dhPublicKey);
|
||||
expect(m1.dhPublicKey).toEqual(m2.dhPublicKey);
|
||||
});
|
||||
|
||||
test('each message has a unique nonce', async () => {
|
||||
const { alice } = await setupPair();
|
||||
|
||||
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
|
||||
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
|
||||
|
||||
expect(m0.nonce).not.toEqual(m1.nonce);
|
||||
expect(m0.ciphertext).not.toEqual(m1.ciphertext);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ping-Pong (DH Ratchet) ──────────────────────────────
|
||||
|
||||
describe('ping-pong conversation', () => {
|
||||
test('alternating messages trigger DH ratchets', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
// Alice → Bob
|
||||
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('hi bob'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('hi bob');
|
||||
|
||||
// Bob → Alice (new DH key)
|
||||
const m2 = await ratchetEncrypt(crypto, bob, enc.encode('hi alice'));
|
||||
expect(m2.dhPublicKey).not.toEqual(m1.dhPublicKey); // DH ratchet happened
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, alice, m2))).toBe('hi alice');
|
||||
|
||||
// Alice → Bob (another new DH key)
|
||||
const m3 = await ratchetEncrypt(crypto, alice, enc.encode('how are you'));
|
||||
expect(m3.dhPublicKey).not.toEqual(m1.dhPublicKey);
|
||||
expect(m3.dhPublicKey).not.toEqual(m2.dhPublicKey);
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, m3))).toBe('how are you');
|
||||
|
||||
// Bob → Alice
|
||||
const m4 = await ratchetEncrypt(crypto, bob, enc.encode('great!'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, alice, m4))).toBe('great!');
|
||||
});
|
||||
|
||||
test('extended conversation with many turns', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const sender = i % 2 === 0 ? alice : bob;
|
||||
const receiver = i % 2 === 0 ? bob : alice;
|
||||
const text = `message ${i}`;
|
||||
|
||||
const msg = await ratchetEncrypt(crypto, sender, enc.encode(text));
|
||||
const plain = await ratchetDecrypt(crypto, receiver, msg);
|
||||
expect(dec.decode(plain)).toBe(text);
|
||||
}
|
||||
});
|
||||
|
||||
test('burst messages then reply', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
// Alice sends 5 messages
|
||||
const burst: RatchetMessage[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
burst.push(await ratchetEncrypt(crypto, alice, enc.encode(`alice-${i}`)));
|
||||
}
|
||||
|
||||
// Bob receives all 5
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, burst[i]))).toBe(`alice-${i}`);
|
||||
}
|
||||
|
||||
// Bob replies (triggers DH ratchet)
|
||||
const reply = await ratchetEncrypt(crypto, bob, enc.encode('got them all'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, alice, reply))).toBe('got them all');
|
||||
|
||||
// Alice sends more
|
||||
const m = await ratchetEncrypt(crypto, alice, enc.encode('great!'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, m))).toBe('great!');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Out-of-Order Messages ────────────────────────────────
|
||||
|
||||
describe('out-of-order delivery', () => {
|
||||
test('messages received in reverse order', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('first'));
|
||||
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('second'));
|
||||
const m2 = await ratchetEncrypt(crypto, alice, enc.encode('third'));
|
||||
|
||||
// Deliver in reverse
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, m2))).toBe('third');
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, m0))).toBe('first');
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('second');
|
||||
});
|
||||
|
||||
test('skip some messages, then receive them later', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
const messages: RatchetMessage[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
messages.push(await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`)));
|
||||
}
|
||||
|
||||
// Receive only even-numbered messages first
|
||||
for (let i = 0; i < 10; i += 2) {
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`);
|
||||
}
|
||||
|
||||
// Then receive odd-numbered (skipped) messages
|
||||
for (let i = 1; i < 10; i += 2) {
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('out-of-order across DH ratchet boundaries', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
// Alice sends 3 messages
|
||||
const a0 = await ratchetEncrypt(crypto, alice, enc.encode('a0'));
|
||||
const a1 = await ratchetEncrypt(crypto, alice, enc.encode('a1'));
|
||||
const a2 = await ratchetEncrypt(crypto, alice, enc.encode('a2'));
|
||||
|
||||
// Bob receives only a2 (skips a0, a1)
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, a2))).toBe('a2');
|
||||
|
||||
// Bob replies (DH ratchet)
|
||||
const b0 = await ratchetEncrypt(crypto, bob, enc.encode('b0'));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, alice, b0))).toBe('b0');
|
||||
|
||||
// Now Bob receives the skipped a0 and a1 (from the old chain)
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, a0))).toBe('a0');
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, a1))).toBe('a1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Cases ──────────────────────────────────────────
|
||||
|
||||
describe('error cases', () => {
|
||||
test('max skip exceeded throws', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
// Encrypt 1002 messages but only try to decrypt the last one
|
||||
let lastMsg: RatchetMessage | undefined;
|
||||
for (let i = 0; i < 1002; i++) {
|
||||
lastMsg = await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`));
|
||||
}
|
||||
|
||||
expect(ratchetDecrypt(crypto, bob, lastMsg!)).rejects.toThrow(MaxSkipExceededError);
|
||||
});
|
||||
|
||||
test('tampered ciphertext fails', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret'));
|
||||
msg.ciphertext[0] ^= 0xff;
|
||||
|
||||
expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow(DecryptionError);
|
||||
});
|
||||
|
||||
test('tampered header (counter) fails due to AAD', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret'));
|
||||
msg.counter = 999; // tamper with counter
|
||||
|
||||
expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Long Conversation ────────────────────────────────────
|
||||
|
||||
describe('stress test', () => {
|
||||
test('100+ message conversation with alternating turns', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
// Alice sends 2 messages
|
||||
for (let j = 0; j < 2; j++) {
|
||||
const text = `alice-${i}-${j}`;
|
||||
const msg = await ratchetEncrypt(crypto, alice, enc.encode(text));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, bob, msg))).toBe(text);
|
||||
}
|
||||
|
||||
// Bob sends 1 message
|
||||
const text = `bob-${i}`;
|
||||
const msg = await ratchetEncrypt(crypto, bob, enc.encode(text));
|
||||
expect(dec.decode(await ratchetDecrypt(crypto, alice, msg))).toBe(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
321
packages/shade-core/tests/x3dh.test.ts
Normal file
321
packages/shade-core/tests/x3dh.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||||
import {
|
||||
generateIdentityKeyPair,
|
||||
generateSignedPreKey,
|
||||
generateOneTimePreKeys,
|
||||
createPreKeyBundle,
|
||||
processPreKeyBundle,
|
||||
processPreKeyMessage,
|
||||
InvalidSignatureError,
|
||||
PreKeyNotFoundError,
|
||||
} from '../src/index.js';
|
||||
import type { RatchetMessage } from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
/** Create a dummy RatchetMessage for testing (X3DH doesn't care about the content) */
|
||||
function dummyRatchetMessage(): RatchetMessage {
|
||||
return {
|
||||
dhPublicKey: crypto.randomBytes(32),
|
||||
previousCounter: 0,
|
||||
counter: 0,
|
||||
ciphertext: crypto.randomBytes(48),
|
||||
nonce: crypto.randomBytes(12),
|
||||
};
|
||||
}
|
||||
|
||||
describe('X3DH', () => {
|
||||
let aliceStorage: MemoryStorage;
|
||||
let bobStorage: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
aliceStorage = new MemoryStorage();
|
||||
bobStorage = new MemoryStorage();
|
||||
});
|
||||
|
||||
// ─── Key Generation ────────────────────────────────────────
|
||||
|
||||
describe('key generation', () => {
|
||||
test('generates identity keypair with correct lengths', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
expect(id.signingPublicKey.length).toBe(32);
|
||||
expect(id.signingPrivateKey.length).toBe(32);
|
||||
expect(id.dhPublicKey.length).toBe(32);
|
||||
expect(id.dhPrivateKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('signing and DH keys are different', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
expect(id.signingPublicKey).not.toEqual(id.dhPublicKey);
|
||||
expect(id.signingPrivateKey).not.toEqual(id.dhPrivateKey);
|
||||
});
|
||||
|
||||
test('generates signed prekey with valid signature', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const spk = await generateSignedPreKey(crypto, id, 1);
|
||||
|
||||
expect(spk.keyId).toBe(1);
|
||||
expect(spk.keyPair.publicKey.length).toBe(32);
|
||||
expect(spk.signature.length).toBe(64);
|
||||
|
||||
// Verify the signature
|
||||
const valid = await crypto.verify(id.signingPublicKey, spk.keyPair.publicKey, spk.signature);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
test('generates batch of one-time prekeys', async () => {
|
||||
const otpks = await generateOneTimePreKeys(crypto, 100, 5);
|
||||
expect(otpks.length).toBe(5);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(otpks[i].keyId).toBe(100 + i);
|
||||
expect(otpks[i].keyPair.publicKey.length).toBe(32);
|
||||
}
|
||||
|
||||
// All keys are unique
|
||||
const pubKeys = otpks.map((k) => Array.from(k.keyPair.publicKey).join(','));
|
||||
expect(new Set(pubKeys).size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Full Handshake ────────────────────────────────────────
|
||||
|
||||
describe('full handshake', () => {
|
||||
test('Alice and Bob derive the same root key (with one-time prekey)', async () => {
|
||||
// Bob generates keys
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
const bobOneTimePreKeys = await generateOneTimePreKeys(crypto, 100, 3);
|
||||
|
||||
// Bob stores his keys
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
for (const otpk of bobOneTimePreKeys) {
|
||||
await bobStorage.saveOneTimePreKey(otpk);
|
||||
}
|
||||
|
||||
// Bob publishes a bundle (server would store this)
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOneTimePreKeys[0]);
|
||||
|
||||
// Alice generates her identity
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
// Alice processes the bundle
|
||||
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
|
||||
expect(aliceResult.rootKey.length).toBe(32);
|
||||
expect(aliceResult.signedPreKeyId).toBe(1);
|
||||
expect(aliceResult.preKeyId).toBe(100);
|
||||
expect(aliceResult.ephemeralPublicKey.length).toBe(32);
|
||||
|
||||
// Alice creates a PreKeyMessage
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
preKeyId: aliceResult.preKeyId,
|
||||
signedPreKeyId: aliceResult.signedPreKeyId,
|
||||
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
// Bob processes the PreKeyMessage
|
||||
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
|
||||
// Both derive the same root key
|
||||
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
|
||||
expect(bobResult.remoteIdentityKey).toEqual(aliceIdentity.dhPublicKey);
|
||||
expect(bobResult.remoteEphemeralKey).toEqual(aliceResult.ephemeralPublicKey);
|
||||
});
|
||||
|
||||
test('Alice and Bob derive the same root key (without one-time prekey)', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
|
||||
// Bundle without one-time prekey
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
expect(aliceResult.preKeyId).toBeUndefined();
|
||||
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
signedPreKeyId: aliceResult.signedPreKeyId,
|
||||
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
|
||||
});
|
||||
|
||||
test('different handshakes produce different root keys', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||||
|
||||
// Alice 1
|
||||
const alice1Id = await generateIdentityKeyPair(crypto);
|
||||
const alice1Storage = new MemoryStorage();
|
||||
await alice1Storage.saveIdentityKeyPair(alice1Id);
|
||||
const result1 = await processPreKeyBundle(crypto, alice1Storage, bundle);
|
||||
|
||||
// Alice 2 (different identity)
|
||||
const alice2Id = await generateIdentityKeyPair(crypto);
|
||||
const alice2Storage = new MemoryStorage();
|
||||
await alice2Storage.saveIdentityKeyPair(alice2Id);
|
||||
const result2 = await processPreKeyBundle(crypto, alice2Storage, bundle);
|
||||
|
||||
expect(result1.rootKey).not.toEqual(result2.rootKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Signature Verification ────────────────────────────────
|
||||
|
||||
describe('signature verification', () => {
|
||||
test('rejects bundle with invalid signed prekey signature', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
|
||||
// Tamper with the signature
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||||
bundle.signedPreKey.signature[0] ^= 0xff;
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
|
||||
});
|
||||
|
||||
test('rejects bundle with wrong identity key signing', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const eveIdentity = await generateIdentityKeyPair(crypto);
|
||||
|
||||
// Eve signs the prekey, but claims to be Bob
|
||||
const eveSignedPreKey = await generateSignedPreKey(crypto, eveIdentity, 1);
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, eveSignedPreKey);
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One-Time Prekey Consumption ───────────────────────────
|
||||
|
||||
describe('one-time prekey consumption', () => {
|
||||
test('one-time prekey is deleted after use', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
|
||||
|
||||
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
|
||||
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
preKeyId: aliceResult.preKeyId,
|
||||
signedPreKeyId: aliceResult.signedPreKeyId,
|
||||
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
|
||||
// One-time prekey 100 should be consumed
|
||||
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
|
||||
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
|
||||
// Others remain
|
||||
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
|
||||
expect(await bobStorage.getOneTimePreKey(102)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('fails when referenced one-time prekey does not exist', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
// No one-time prekeys stored
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
preKeyId: 999, // doesn't exist
|
||||
signedPreKeyId: 1,
|
||||
ephemeralKey: crypto.randomBytes(32),
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
|
||||
});
|
||||
|
||||
test('fails when referenced signed prekey does not exist', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
// No signed prekey stored
|
||||
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
signedPreKeyId: 999,
|
||||
ephemeralKey: crypto.randomBytes(32),
|
||||
identityDHKey: crypto.randomBytes(32),
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PreKey Bundle Assembly ────────────────────────────────
|
||||
|
||||
describe('createPreKeyBundle', () => {
|
||||
test('assembles bundle with one-time prekey', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const spk = await generateSignedPreKey(crypto, id, 5);
|
||||
const otpk = (await generateOneTimePreKeys(crypto, 200, 1))[0];
|
||||
|
||||
const bundle = createPreKeyBundle(42, id, spk, otpk);
|
||||
|
||||
expect(bundle.registrationId).toBe(42);
|
||||
expect(bundle.identitySigningKey).toEqual(id.signingPublicKey);
|
||||
expect(bundle.identityDHKey).toEqual(id.dhPublicKey);
|
||||
expect(bundle.signedPreKey.keyId).toBe(5);
|
||||
expect(bundle.signedPreKey.publicKey).toEqual(spk.keyPair.publicKey);
|
||||
expect(bundle.signedPreKey.signature).toEqual(spk.signature);
|
||||
expect(bundle.oneTimePreKey?.keyId).toBe(200);
|
||||
expect(bundle.oneTimePreKey?.publicKey).toEqual(otpk.keyPair.publicKey);
|
||||
});
|
||||
|
||||
test('assembles bundle without one-time prekey', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const spk = await generateSignedPreKey(crypto, id, 1);
|
||||
|
||||
const bundle = createPreKeyBundle(42, id, spk);
|
||||
|
||||
expect(bundle.oneTimePreKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/shade-core/tsconfig.json
Normal file
8
packages/shade-core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
packages/shade-crypto-web/package.json
Normal file
12
packages/shade-crypto-web/package.json
Normal file
@@ -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:*"
|
||||
}
|
||||
}
|
||||
2
packages/shade-crypto-web/src/index.ts
Normal file
2
packages/shade-crypto-web/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SubtleCryptoProvider } from './provider.js';
|
||||
export { MemoryStorage } from './memory-storage.js';
|
||||
98
packages/shade-crypto-web/src/memory-storage.ts
Normal file
98
packages/shade-crypto-web/src/memory-storage.ts
Normal file
@@ -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<number, SignedPreKey>();
|
||||
private oneTimePreKeys = new Map<number, OneTimePreKey>();
|
||||
private sessions = new Map<string, SessionState>();
|
||||
private trustedIdentities = new Map<string, Uint8Array>();
|
||||
|
||||
// ─── Identity ──────────────────────────────────────────────
|
||||
|
||||
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
|
||||
return this.identityKeyPair;
|
||||
}
|
||||
|
||||
async saveIdentityKeyPair(keyPair: IdentityKeyPair): Promise<void> {
|
||||
this.identityKeyPair = keyPair;
|
||||
}
|
||||
|
||||
async getLocalRegistrationId(): Promise<number> {
|
||||
return this.registrationId;
|
||||
}
|
||||
|
||||
async saveLocalRegistrationId(id: number): Promise<void> {
|
||||
this.registrationId = id;
|
||||
}
|
||||
|
||||
// ─── Signed Pre-Keys ──────────────────────────────────────
|
||||
|
||||
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
|
||||
return this.signedPreKeys.get(keyId) ?? null;
|
||||
}
|
||||
|
||||
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
|
||||
this.signedPreKeys.set(key.keyId, key);
|
||||
}
|
||||
|
||||
async removeSignedPreKey(keyId: number): Promise<void> {
|
||||
this.signedPreKeys.delete(keyId);
|
||||
}
|
||||
|
||||
// ─── One-Time Pre-Keys ────────────────────────────────────
|
||||
|
||||
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
|
||||
return this.oneTimePreKeys.get(keyId) ?? null;
|
||||
}
|
||||
|
||||
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
|
||||
this.oneTimePreKeys.set(key.keyId, key);
|
||||
}
|
||||
|
||||
async removeOneTimePreKey(keyId: number): Promise<void> {
|
||||
this.oneTimePreKeys.delete(keyId);
|
||||
}
|
||||
|
||||
async getOneTimePreKeyCount(): Promise<number> {
|
||||
return this.oneTimePreKeys.size;
|
||||
}
|
||||
|
||||
// ─── Sessions ─────────────────────────────────────────────
|
||||
|
||||
async getSession(address: string): Promise<SessionState | null> {
|
||||
return this.sessions.get(address) ?? null;
|
||||
}
|
||||
|
||||
async saveSession(address: string, state: SessionState): Promise<void> {
|
||||
this.sessions.set(address, state);
|
||||
}
|
||||
|
||||
async removeSession(address: string): Promise<void> {
|
||||
this.sessions.delete(address);
|
||||
}
|
||||
|
||||
// ─── Trust ────────────────────────────────────────────────
|
||||
|
||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
118
packages/shade-crypto-web/src/provider.ts
Normal file
118
packages/shade-crypto-web/src/provider.ts
Normal file
@@ -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<Uint8Array> {
|
||||
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<Uint8Array> {
|
||||
return ed25519.sign(message, privateKey);
|
||||
}
|
||||
|
||||
async verify(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): Promise<boolean> {
|
||||
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<Uint8Array> {
|
||||
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<Uint8Array> {
|
||||
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<Uint8Array> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
236
packages/shade-crypto-web/tests/provider.test.ts
Normal file
236
packages/shade-crypto-web/tests/provider.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/shade-crypto-web/tsconfig.json
Normal file
8
packages/shade-crypto-web/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
packages/shade-proto/package.json
Normal file
10
packages/shade-proto/package.json
Normal file
@@ -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:*"
|
||||
}
|
||||
}
|
||||
11
packages/shade-server/package.json
Normal file
11
packages/shade-server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
11
packages/shade-transport/package.json
Normal file
11
packages/shade-transport/package.json
Normal file
@@ -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:*"
|
||||
}
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user