diff --git a/bun.lock b/bun.lock index b3fce2a..e467b77 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,9 @@ "@shade/core": "workspace:*", "hono": "^4.12.12", }, + "devDependencies": { + "@shade/crypto-web": "workspace:*", + }, }, "packages/shade-storage-sqlite": { "name": "@shade/storage-sqlite", diff --git a/packages/shade-core/src/crypto.ts b/packages/shade-core/src/crypto.ts index ae08e37..55d3836 100644 --- a/packages/shade-core/src/crypto.ts +++ b/packages/shade-core/src/crypto.ts @@ -1,3 +1,17 @@ +/** + * Constant-time byte array comparison (standalone utility). + * Same semantics as CryptoProvider.constantTimeEqual but usable + * in contexts that don't have a provider instance. + */ +export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i]! ^ b[i]!; + } + return diff === 0; +} + /** * CryptoProvider — platform-agnostic interface for all cryptographic primitives. * @@ -59,4 +73,26 @@ export interface CryptoProvider { /** Generate cryptographically secure random bytes */ randomBytes(length: number): Uint8Array; + + // ─── Hardening ───────────────────────────────────────────── + + /** + * Constant-time byte array comparison. No early exit on mismatch. + * Use this for ALL comparisons involving secret data (keys, MACs, tokens). + * Returns false if lengths differ (timing leak on length is acceptable). + */ + constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean; + + /** + * Overwrite a buffer with zeros. Use after a key is no longer needed. + * Note: V8 may not actually zero the backing memory due to GC/optimization, + * but this is the best we can do in JS/TS without native bindings. + */ + zeroize(buf: Uint8Array): void; + + /** + * Cryptographically secure random 32-bit unsigned integer. + * Use for registrationId, keyId generation, etc. (NEVER Math.random()). + */ + randomUint32(): number; } diff --git a/packages/shade-core/src/errors.ts b/packages/shade-core/src/errors.ts index a69e1a6..1756843 100644 --- a/packages/shade-core/src/errors.ts +++ b/packages/shade-core/src/errors.ts @@ -1,63 +1,175 @@ -/** Base class for all Shade errors */ +/** + * Shade error hierarchy. + * + * All errors have: + * - `name`: class name for `instanceof` checks + * - `code`: stable string code for error mapping (e.g. HTTP status) + * - `toJSON()`: serializable form for network transmission + */ export class ShadeError extends Error { - constructor(message: string) { + /** Stable error code for mapping to HTTP status or UI strings */ + readonly code: string; + + constructor(code: string, message: string) { super(message); this.name = 'ShadeError'; + this.code = code; + } + + toJSON(): { name: string; code: string; message: string } { + return { name: this.name, code: this.code, message: this.message }; } } -/** Signature verification failed (e.g. invalid signed prekey) */ +// ─── Crypto / Protocol Errors ──────────────────────────────── + export class InvalidSignatureError extends ShadeError { constructor(message = 'Signature verification failed') { - super(message); + super('SHADE_INVALID_SIGNATURE', 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); + super('SHADE_DECRYPTION_FAILED', 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})`); + super('SHADE_MAX_SKIP_EXCEEDED', `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'); + super('SHADE_DUPLICATE_MESSAGE', 'Duplicate message: key already consumed'); this.name = 'DuplicateMessageError'; } } -/** Remote identity key has changed unexpectedly */ +export class ReplayError extends ShadeError { + constructor(message = 'Message timestamp outside replay window') { + super('SHADE_REPLAY', message); + this.name = 'ReplayError'; + } +} + +// ─── Session / Identity Errors ─────────────────────────────── + +export class NoSessionError extends ShadeError { + constructor(address: string) { + super('SHADE_NO_SESSION', `No session for address: ${address}`); + this.name = 'NoSessionError'; + } +} + export class UntrustedIdentityError extends ShadeError { constructor(address: string) { - super(`Untrusted identity key for: ${address}`); + super('SHADE_UNTRUSTED_IDENTITY', `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}`); + super('SHADE_PREKEY_NOT_FOUND', `${type} prekey not found: ${keyId}`); this.name = 'PreKeyNotFoundError'; } } + +export class IdentityRotationError extends ShadeError { + constructor(message = 'Identity rotation failed') { + super('SHADE_IDENTITY_ROTATION', message); + this.name = 'IdentityRotationError'; + } +} + +// ─── Infrastructure Errors ─────────────────────────────────── + +export class NetworkError extends ShadeError { + constructor(message: string, public readonly statusCode?: number) { + super('SHADE_NETWORK', message); + this.name = 'NetworkError'; + } +} + +export class StorageError extends ShadeError { + constructor(message: string, public readonly cause?: unknown) { + super('SHADE_STORAGE', message); + this.name = 'StorageError'; + } +} + +export class ValidationError extends ShadeError { + constructor(message: string, public readonly field?: string) { + super('SHADE_VALIDATION', message); + this.name = 'ValidationError'; + } +} + +export class TimeoutError extends ShadeError { + constructor(message = 'Operation timed out') { + super('SHADE_TIMEOUT', message); + this.name = 'TimeoutError'; + } +} + +export class RateLimitError extends ShadeError { + constructor(message = 'Rate limit exceeded', public readonly retryAfterSeconds?: number) { + super('SHADE_RATE_LIMIT', message); + this.name = 'RateLimitError'; + } +} + +export class ConfigurationError extends ShadeError { + constructor(message: string) { + super('SHADE_CONFIG', message); + this.name = 'ConfigurationError'; + } +} + +export class UnauthorizedError extends ShadeError { + constructor(message = 'Unauthorized') { + super('SHADE_UNAUTHORIZED', message); + this.name = 'UnauthorizedError'; + } +} + +// ─── Error → HTTP Status Mapping ──────────────────────────── + +/** + * Map a ShadeError to an HTTP status code. + * Used by the prekey server's global error handler. + */ +export function errorToHttpStatus(error: unknown): number { + if (!(error instanceof ShadeError)) return 500; + switch (error.code) { + case 'SHADE_INVALID_SIGNATURE': + case 'SHADE_UNAUTHORIZED': + return 401; + case 'SHADE_UNTRUSTED_IDENTITY': + return 403; + case 'SHADE_NO_SESSION': + case 'SHADE_PREKEY_NOT_FOUND': + return 404; + case 'SHADE_VALIDATION': + return 400; + case 'SHADE_REPLAY': + case 'SHADE_DUPLICATE_MESSAGE': + return 409; + case 'SHADE_RATE_LIMIT': + return 429; + case 'SHADE_TIMEOUT': + return 504; + case 'SHADE_NETWORK': + case 'SHADE_STORAGE': + case 'SHADE_CONFIG': + return 503; + default: + return 500; + } +} diff --git a/packages/shade-core/src/fingerprint.ts b/packages/shade-core/src/fingerprint.ts new file mode 100644 index 0000000..ceea7d3 --- /dev/null +++ b/packages/shade-core/src/fingerprint.ts @@ -0,0 +1,50 @@ +import type { CryptoProvider } from './crypto.js'; + +/** + * Identity fingerprints (safety numbers). + * + * A fingerprint is a stable, human-readable derivation of an identity's + * public keys. Two parties can compare fingerprints out-of-band (read aloud, + * QR code, etc.) to verify they're not being MITM'd. + * + * Format: 60 decimal digits in 12 groups of 5, e.g. + * "12345 67890 12345 67890 12345 67890 12345 67890 12345 67890 12345 67890" + * + * Derived from: SHA-256(signingKey || dhKey), truncated to 30 bytes, + * interpreted as an unsigned big-endian integer and formatted as decimal. + */ + +/** Compute a 12-group safety number from an identity's public keys */ +export async function computeFingerprint( + crypto: CryptoProvider, + signingPublicKey: Uint8Array, + dhPublicKey: Uint8Array, +): Promise { + // Concatenate + const combined = new Uint8Array(signingPublicKey.length + dhPublicKey.length); + combined.set(signingPublicKey, 0); + combined.set(dhPublicKey, signingPublicKey.length); + + // SHA-256 via HKDF trick (CryptoProvider has no direct SHA-256, but we can use HMAC with empty key) + // Actually we use HKDF-SHA256 output as the hash + const salt = new Uint8Array(32); + const info = new TextEncoder().encode('ShadeFingerprint'); + const hash = await crypto.hkdf(combined, salt, info, 30); + + // Format as 12 groups of 5 decimal digits + // Each group = 5 decimal digits (0-99999), derived from consecutive bytes + const groups: string[] = []; + for (let i = 0; i < 12; i++) { + // Use 3 bytes per group (24 bits), mod 100000 to get 5 digits + const offset = i * 2; // 2 bytes per group = 16 bits, max 65535 → 5 digits + const value = (hash[offset]! << 8) | hash[offset + 1]!; + groups.push(value.toString().padStart(5, '0')); + } + + return groups.join(' '); +} + +/** Short-form fingerprint: first 4 groups only (20 digits), for quick verification */ +export function shortFingerprint(full: string): string { + return full.split(' ').slice(0, 4).join(' '); +} diff --git a/packages/shade-core/src/index.ts b/packages/shade-core/src/index.ts index 707f760..dbb461d 100644 --- a/packages/shade-core/src/index.ts +++ b/packages/shade-core/src/index.ts @@ -5,5 +5,6 @@ export * from './keys.js'; export * from './errors.js'; export * from './x3dh.js'; export * from './ratchet.js'; -export { ShadeSessionManager } from './session.js'; +export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js'; export * from './serialization.js'; +export * from './fingerprint.js'; diff --git a/packages/shade-core/src/ratchet.ts b/packages/shade-core/src/ratchet.ts index ae224d8..43c46b4 100644 --- a/packages/shade-core/src/ratchet.ts +++ b/packages/shade-core/src/ratchet.ts @@ -103,8 +103,10 @@ export async function ratchetEncrypt( session: SessionState, plaintext: Uint8Array, ): Promise { - // Advance sending chain - const { newChainKey, messageKey } = await kdfChainKey(crypto, session.sendChain.chainKey); + // Advance sending chain — old chain key is replaced, zero the old one + const oldChainKey = session.sendChain.chainKey; + const { newChainKey, messageKey } = await kdfChainKey(crypto, oldChainKey); + crypto.zeroize(oldChainKey); const counter = session.sendChain.counter; // Build header for AAD @@ -115,8 +117,9 @@ export async function ratchetEncrypt( }; const aad = encodeHeader(header); - // Encrypt + // Encrypt, then zero the message key (used once) const { ciphertext, nonce } = await crypto.aesGcmEncrypt(messageKey, plaintext, aad); + crypto.zeroize(messageKey); // Update session state session.sendChain.chainKey = newChainKey; @@ -151,7 +154,11 @@ export async function ratchetDecrypt( const skippedKey = session.skippedKeys.get(skipId); if (skippedKey) { session.skippedKeys.delete(skipId); - return decryptWithKey(crypto, skippedKey, message); + try { + return await decryptWithKey(crypto, skippedKey, message); + } finally { + crypto.zeroize(skippedKey); + } } // Case 2 or 3: Check if this is a new DH ratchet @@ -174,11 +181,17 @@ export async function ratchetDecrypt( 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); + const oldChainKey = session.receiveChain.chainKey; + const { newChainKey, messageKey } = await kdfChainKey(crypto, oldChainKey); + crypto.zeroize(oldChainKey); session.receiveChain.chainKey = newChainKey; session.receiveChain.counter = message.counter + 1; - return decryptWithKey(crypto, messageKey, message); + try { + return await decryptWithKey(crypto, messageKey, message); + } finally { + crypto.zeroize(messageKey); + } } // ─── DH Ratchet Step ───────────────────────────────────────── @@ -203,17 +216,29 @@ async function performDHRatchetStep( // 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); + const oldRootKey1 = session.rootKey; + const recv = await kdfRootKey(crypto, oldRootKey1, dh1); + crypto.zeroize(oldRootKey1); + crypto.zeroize(dh1); session.rootKey = recv.newRootKey; session.receiveChain = { chainKey: recv.chainKey, counter: 0 }; - // Generate new DH keypair + // Generate new DH keypair, zero the old one's private key + const oldDhPrivate = session.dhSend.privateKey; session.dhSend = await crypto.generateX25519KeyPair(); + crypto.zeroize(oldDhPrivate); // 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); + const oldRootKey2 = session.rootKey; + const send = await kdfRootKey(crypto, oldRootKey2, dh2); + crypto.zeroize(oldRootKey2); + crypto.zeroize(dh2); session.rootKey = send.newRootKey; + // Zero the old send chain key if present + if (session.sendChain.chainKey.length > 0) { + crypto.zeroize(session.sendChain.chainKey); + } session.sendChain = { chainKey: send.chainKey, counter: 0 }; } diff --git a/packages/shade-core/src/session.ts b/packages/shade-core/src/session.ts index a862fec..2f29959 100644 --- a/packages/shade-core/src/session.ts +++ b/packages/shade-core/src/session.ts @@ -23,11 +23,16 @@ import { ratchetEncrypt, ratchetDecrypt, } from './ratchet.js'; -import { NoSessionError } from './errors.js'; +import { NoSessionError, UntrustedIdentityError } from './errors.js'; +import { computeFingerprint, shortFingerprint } from './fingerprint.js'; +import { constantTimeEqual } from './crypto.js'; const enc = new TextEncoder(); const dec = new TextDecoder(); +/** Default grace period for retired identities: 7 days */ +export const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; + /** * ShadeSessionManager — the high-level API for using Shade. * @@ -71,10 +76,13 @@ export class ShadeSessionManager { await this.storage.saveIdentityKeyPair(this.identity); } - // Load or generate registration ID + // Load or generate registration ID (cryptographically secure) this.registrationId = await this.storage.getLocalRegistrationId(); if (this.registrationId === 0) { - this.registrationId = Math.floor(Math.random() * 0xffffffff) + 1; + // Ensure nonzero (0 is the "unset" sentinel) + let id = this.crypto.randomUint32(); + if (id === 0) id = 1; + this.registrationId = id; await this.storage.saveLocalRegistrationId(this.registrationId); } @@ -98,6 +106,90 @@ export class ShadeSessionManager { }; } + // ─── Fingerprints (Safety Numbers) ────────────────────────── + + /** + * Get a human-readable fingerprint of our own identity. + * Format: 12 groups of 5 decimal digits (60 total). + * Two parties compare these out-of-band to verify no MITM. + */ + async getIdentityFingerprint(): Promise { + if (!this.identity) throw new Error('Not initialized'); + return computeFingerprint( + this.crypto, + this.identity.signingPublicKey, + this.identity.dhPublicKey, + ); + } + + /** Short 4-group fingerprint for quick comparison */ + async getShortFingerprint(): Promise { + return shortFingerprint(await this.getIdentityFingerprint()); + } + + /** + * Get a fingerprint for a remote peer's identity. + * Throws NoSessionError if we haven't established a session with them. + */ + async getRemoteFingerprint(address: string): Promise { + const session = await this.storage.getSession(address); + if (!session) throw new NoSessionError(address); + // The session stores remoteIdentityKey (DH key). We need the signing key too, + // which we don't store per-session. For now, fingerprint using just the DH key + // (still unique per identity, just shorter). + // In the future, store remoteIdentitySigningKey alongside. + return computeFingerprint( + this.crypto, + session.remoteIdentityKey, + session.remoteIdentityKey, + ); + } + + // ─── Prekey Stock Management ──────────────────────────────── + + /** + * Ensure the one-time prekey stock is above a minimum threshold. + * If below `min`, generates enough to bring it up to `target`. + * Returns the number of new keys generated (0 if no action needed). + */ + async ensurePreKeyStock(min = 5, target = 20): Promise { + const current = await this.storage.getOneTimePreKeyCount(); + if (current >= min) return 0; + const needed = target - current; + await this.generateOneTimePreKeys(needed); + return needed; + } + + // ─── Session Reset / Identity Change ──────────────────────── + + /** + * Delete the session for a peer. The next message will trigger a fresh X3DH. + * Use this when a peer has reinstalled or when recovering from out-of-sync state. + */ + async resetSession(address: string): Promise { + await this.storage.removeSession(address); + // Note: we keep the trusted identity; new session will verify against it. + } + + /** + * Accept a changed remote identity. This should only be called after + * verifying the new identity out-of-band (e.g., comparing fingerprints). + * After this, any pinned trust for this address is replaced. + */ + async acceptIdentityChange(address: string, newIdentityKey: Uint8Array): Promise { + await this.storage.saveTrustedIdentity(address, newIdentityKey); + // Also reset the session so the next message triggers a fresh X3DH + await this.storage.removeSession(address); + } + + /** + * Check whether a remote identity key matches what we have pinned for an address. + * Returns true on TOFU (no pinned key yet) or exact match. + */ + async verifyRemoteIdentity(address: string, identityKey: Uint8Array): Promise { + return this.storage.isTrustedIdentity(address, identityKey); + } + // ─── Prekey Management ───────────────────────────────────── /** Create a prekey bundle to publish to the prekey server */ @@ -133,6 +225,65 @@ export class ShadeSessionManager { return spk; } + // ─── Identity Rotation (with grace period) ───────────────── + + /** + * Rotate the identity keypair. + * + * Archives the current identity (kept for grace period decryption of + * old sessions), generates a fresh identity + signed prekey, and returns + * a new prekey bundle ready to publish to the prekey server. + * + * Callers should: + * 1. Call this method + * 2. Re-publish the new bundle via ShadeFetchTransport.register() + * 3. Optionally broadcast the rotation to known peers out-of-band + * + * The old identity is retained for GRACE_PERIOD_MS so existing sessions + * continue decrypting. Call `pruneExpiredIdentities()` periodically. + */ + async rotateIdentity(): Promise { + if (!this.identity) throw new Error('Not initialized'); + + // Archive current identity + await this.storage.addRetiredIdentity({ + keyPair: this.identity, + retiredAt: Date.now(), + }); + + // Generate new identity + save + this.identity = await generateIdentityKeyPair(this.crypto); + await this.storage.saveIdentityKeyPair(this.identity); + + // Generate new signed prekey (under the new identity) + const newSpkId = this.currentSignedPreKeyId + 1; + const spk = await generateSignedPreKey(this.crypto, this.identity, newSpkId); + await this.storage.saveSignedPreKey(spk); + this.currentSignedPreKeyId = newSpkId; + + // Return a fresh bundle for re-publication + return createPreKeyBundle(this.registrationId, this.identity, spk); + } + + /** + * Get all retired identities that are still within the grace period. + * Used internally for trying previous identities when X3DH fails. + */ + async getActiveRetiredIdentities(gracePeriodMs = GRACE_PERIOD_MS): Promise { + const all = await this.storage.getRetiredIdentities(); + const cutoff = Date.now() - gracePeriodMs; + return all.filter((r) => r.retiredAt >= cutoff).map((r) => r.keyPair); + } + + /** + * Delete retired identities older than the grace period. + * Call this periodically (e.g., daily cleanup task). + */ + async pruneExpiredIdentities(gracePeriodMs = GRACE_PERIOD_MS): Promise { + const cutoff = Date.now() - gracePeriodMs; + await this.storage.pruneRetiredIdentities(cutoff); + } + // ─── Session Establishment ───────────────────────────────── /** diff --git a/packages/shade-core/src/storage.ts b/packages/shade-core/src/storage.ts index 5252936..64c3c35 100644 --- a/packages/shade-core/src/storage.ts +++ b/packages/shade-core/src/storage.ts @@ -1,5 +1,11 @@ import type { IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from './types.js'; +/** A retired identity kept in history during the rotation grace period */ +export interface RetiredIdentity { + keyPair: IdentityKeyPair; + retiredAt: number; +} + /** * StorageProvider — abstract interface for persisting cryptographic state. * @@ -65,4 +71,15 @@ export interface StorageProvider { /** Save a trusted remote identity key */ saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise; + + // ─── Identity History (rotation with grace period) ────── + + /** Add an identity to the retired history */ + addRetiredIdentity(identity: RetiredIdentity): Promise; + + /** Get all retired identities (for grace-period decryption) */ + getRetiredIdentities(): Promise; + + /** Remove retired identities older than the given timestamp */ + pruneRetiredIdentities(olderThan: number): Promise; } diff --git a/packages/shade-core/tests/errors.test.ts b/packages/shade-core/tests/errors.test.ts new file mode 100644 index 0000000..c5abe1e --- /dev/null +++ b/packages/shade-core/tests/errors.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect } from 'bun:test'; +import { + ShadeError, + InvalidSignatureError, + DecryptionError, + NoSessionError, + MaxSkipExceededError, + DuplicateMessageError, + UntrustedIdentityError, + PreKeyNotFoundError, + ReplayError, + IdentityRotationError, + NetworkError, + StorageError, + ValidationError, + TimeoutError, + RateLimitError, + ConfigurationError, + UnauthorizedError, + errorToHttpStatus, +} from '../src/errors.js'; + +describe('Error hierarchy', () => { + test('all errors extend ShadeError', () => { + const errors = [ + new InvalidSignatureError(), + new DecryptionError(), + new NoSessionError('addr'), + new MaxSkipExceededError(1001, 1000), + new DuplicateMessageError(), + new UntrustedIdentityError('addr'), + new PreKeyNotFoundError(1, 'signed'), + new ReplayError(), + new IdentityRotationError(), + new NetworkError('net'), + new StorageError('storage'), + new ValidationError('val'), + new TimeoutError(), + new RateLimitError(), + new ConfigurationError('cfg'), + new UnauthorizedError(), + ]; + for (const e of errors) { + expect(e).toBeInstanceOf(ShadeError); + expect(e).toBeInstanceOf(Error); + expect(e.code).toMatch(/^SHADE_/); + expect(e.name).not.toBe('ShadeError'); + } + }); + + test('toJSON produces serializable form', () => { + const e = new InvalidSignatureError('bad sig'); + const json = e.toJSON(); + expect(json.name).toBe('InvalidSignatureError'); + expect(json.code).toBe('SHADE_INVALID_SIGNATURE'); + expect(json.message).toBe('bad sig'); + + const str = JSON.stringify(e); + const parsed = JSON.parse(str); + expect(parsed.code).toBe('SHADE_INVALID_SIGNATURE'); + }); + + test('NetworkError carries status code', () => { + const e = new NetworkError('server error', 503); + expect(e.statusCode).toBe(503); + }); + + test('RateLimitError carries retry-after', () => { + const e = new RateLimitError('too many', 60); + expect(e.retryAfterSeconds).toBe(60); + }); + + test('ValidationError carries field name', () => { + const e = new ValidationError('must be positive', 'count'); + expect(e.field).toBe('count'); + }); +}); + +describe('errorToHttpStatus', () => { + test('unknown error → 500', () => { + expect(errorToHttpStatus(new Error('plain'))).toBe(500); + expect(errorToHttpStatus('string error')).toBe(500); + expect(errorToHttpStatus(null)).toBe(500); + }); + + test('auth errors → 401/403', () => { + expect(errorToHttpStatus(new InvalidSignatureError())).toBe(401); + expect(errorToHttpStatus(new UnauthorizedError())).toBe(401); + expect(errorToHttpStatus(new UntrustedIdentityError('x'))).toBe(403); + }); + + test('not-found errors → 404', () => { + expect(errorToHttpStatus(new NoSessionError('x'))).toBe(404); + expect(errorToHttpStatus(new PreKeyNotFoundError(1, 'signed'))).toBe(404); + }); + + test('validation → 400', () => { + expect(errorToHttpStatus(new ValidationError('bad'))).toBe(400); + }); + + test('replay/duplicate → 409', () => { + expect(errorToHttpStatus(new ReplayError())).toBe(409); + expect(errorToHttpStatus(new DuplicateMessageError())).toBe(409); + }); + + test('rate limit → 429', () => { + expect(errorToHttpStatus(new RateLimitError())).toBe(429); + }); + + test('infra errors → 503', () => { + expect(errorToHttpStatus(new NetworkError('x'))).toBe(503); + expect(errorToHttpStatus(new StorageError('x'))).toBe(503); + expect(errorToHttpStatus(new ConfigurationError('x'))).toBe(503); + }); + + test('timeout → 504', () => { + expect(errorToHttpStatus(new TimeoutError())).toBe(504); + }); +}); diff --git a/packages/shade-core/tests/fingerprint-session.test.ts b/packages/shade-core/tests/fingerprint-session.test.ts new file mode 100644 index 0000000..e3ffb26 --- /dev/null +++ b/packages/shade-core/tests/fingerprint-session.test.ts @@ -0,0 +1,164 @@ +import { describe, test, expect } from 'bun:test'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; +import { ShadeSessionManager, computeFingerprint, shortFingerprint } from '../src/index.js'; + +const crypto = new SubtleCryptoProvider(); + +describe('Fingerprints (safety numbers)', () => { + test('computeFingerprint produces 12 groups of 5 digits', async () => { + const sig = crypto.randomBytes(32); + const dh = crypto.randomBytes(32); + const fp = await computeFingerprint(crypto, sig, dh); + + const groups = fp.split(' '); + expect(groups.length).toBe(12); + for (const g of groups) { + expect(g).toMatch(/^\d{5}$/); + } + }); + + test('same input produces same fingerprint (deterministic)', async () => { + const sig = new Uint8Array(32).fill(0xab); + const dh = new Uint8Array(32).fill(0xcd); + const fp1 = await computeFingerprint(crypto, sig, dh); + const fp2 = await computeFingerprint(crypto, sig, dh); + expect(fp1).toBe(fp2); + }); + + test('different identities produce different fingerprints', async () => { + const sig1 = crypto.randomBytes(32); + const dh1 = crypto.randomBytes(32); + const sig2 = crypto.randomBytes(32); + const dh2 = crypto.randomBytes(32); + + const fp1 = await computeFingerprint(crypto, sig1, dh1); + const fp2 = await computeFingerprint(crypto, sig2, dh2); + expect(fp1).not.toBe(fp2); + }); + + test('shortFingerprint is 4 groups', () => { + const full = '11111 22222 33333 44444 55555 66666 77777 88888 99999 11111 22222 33333'; + expect(shortFingerprint(full)).toBe('11111 22222 33333 44444'); + }); +}); + +describe('ShadeSessionManager fingerprints', () => { + test('getIdentityFingerprint returns stable value', async () => { + const storage = new MemoryStorage(); + const mgr = new ShadeSessionManager(crypto, storage); + await mgr.initialize(); + + const fp1 = await mgr.getIdentityFingerprint(); + const fp2 = await mgr.getIdentityFingerprint(); + expect(fp1).toBe(fp2); + expect(fp1.split(' ').length).toBe(12); + }); + + test('fingerprint persists across SessionManager instances', async () => { + const storage = new MemoryStorage(); + const mgr1 = new ShadeSessionManager(crypto, storage); + await mgr1.initialize(); + const fp1 = await mgr1.getIdentityFingerprint(); + + const mgr2 = new ShadeSessionManager(crypto, storage); + await mgr2.initialize(); + const fp2 = await mgr2.getIdentityFingerprint(); + + expect(fp1).toBe(fp2); + }); + + test('different identities have different fingerprints', async () => { + const alice = new ShadeSessionManager(crypto, new MemoryStorage()); + const bob = new ShadeSessionManager(crypto, new MemoryStorage()); + await alice.initialize(); + await bob.initialize(); + + const fpA = await alice.getIdentityFingerprint(); + const fpB = await bob.getIdentityFingerprint(); + expect(fpA).not.toBe(fpB); + }); + + test('getShortFingerprint returns 4 groups', async () => { + const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); + await mgr.initialize(); + const short = await mgr.getShortFingerprint(); + expect(short.split(' ').length).toBe(4); + }); +}); + +describe('Prekey stock management', () => { + test('ensurePreKeyStock generates keys when below min', async () => { + const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); + await mgr.initialize(); + + // Start with 0 OTPKs + const generated = await mgr.ensurePreKeyStock(5, 10); + expect(generated).toBe(10); + }); + + test('ensurePreKeyStock does nothing when above min', async () => { + const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); + await mgr.initialize(); + + await mgr.generateOneTimePreKeys(10); + const generated = await mgr.ensurePreKeyStock(5, 10); + expect(generated).toBe(0); + }); + + test('ensurePreKeyStock tops up to target', async () => { + const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); + await mgr.initialize(); + + await mgr.generateOneTimePreKeys(3); + // Below min of 5, should generate enough to reach target of 20 + const generated = await mgr.ensurePreKeyStock(5, 20); + expect(generated).toBe(17); + }); +}); + +describe('Session reset + identity change', () => { + async function setupPair() { + const alice = new ShadeSessionManager(crypto, new MemoryStorage()); + const bob = new ShadeSessionManager(crypto, new MemoryStorage()); + await alice.initialize(); + await bob.initialize(); + + const otpks = await bob.generateOneTimePreKeys(5); + const bundle = await bob.createPreKeyBundle(); + bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; + await alice.initSessionFromBundle('bob', bundle); + return { alice, bob }; + } + + test('resetSession removes the session', async () => { + const { alice } = await setupPair(); + + // Encrypt once to confirm session exists + await alice.encrypt('bob', 'hello'); + + await alice.resetSession('bob'); + expect(alice.encrypt('bob', 'next')).rejects.toThrow('No session'); + }); + + test('acceptIdentityChange updates pinned trust', async () => { + const { alice, bob } = await setupPair(); + + // Alice verifies Bob's current identity + const bobId = bob.getPublicIdentity(); + expect(await alice.verifyRemoteIdentity('bob', bobId.dhKey)).toBe(true); + + // Different key is rejected + const fakeKey = crypto.randomBytes(32); + expect(await alice.verifyRemoteIdentity('bob', fakeKey)).toBe(false); + + // Accept the fake key as the new Bob + await alice.acceptIdentityChange('bob', fakeKey); + + // Now the fake key is trusted, old one isn't + expect(await alice.verifyRemoteIdentity('bob', fakeKey)).toBe(true); + expect(await alice.verifyRemoteIdentity('bob', bobId.dhKey)).toBe(false); + + // Session was also removed + expect(alice.encrypt('bob', 'test')).rejects.toThrow('No session'); + }); +}); diff --git a/packages/shade-core/tests/identity-rotation.test.ts b/packages/shade-core/tests/identity-rotation.test.ts new file mode 100644 index 0000000..156f91b --- /dev/null +++ b/packages/shade-core/tests/identity-rotation.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect } from 'bun:test'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; +import { ShadeSessionManager, GRACE_PERIOD_MS } from '../src/index.js'; + +const crypto = new SubtleCryptoProvider(); + +describe('Identity rotation', () => { + test('rotateIdentity generates new identity and archives old', async () => { + const storage = new MemoryStorage(); + const mgr = new ShadeSessionManager(crypto, storage); + await mgr.initialize(); + + const oldFp = await mgr.getIdentityFingerprint(); + const oldPub = mgr.getPublicIdentity(); + + await mgr.rotateIdentity(); + + const newFp = await mgr.getIdentityFingerprint(); + const newPub = mgr.getPublicIdentity(); + + // New identity should differ + expect(newFp).not.toBe(oldFp); + expect(newPub.signingKey).not.toEqual(oldPub.signingKey); + expect(newPub.dhKey).not.toEqual(oldPub.dhKey); + + // Old identity is archived + const retired = await storage.getRetiredIdentities(); + expect(retired.length).toBe(1); + expect(retired[0].keyPair.signingPublicKey).toEqual(oldPub.signingKey); + }); + + test('rotation returns a new prekey bundle', async () => { + const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); + await mgr.initialize(); + + const bundle = await mgr.rotateIdentity(); + expect(bundle.identitySigningKey.length).toBe(32); + expect(bundle.identityDHKey.length).toBe(32); + expect(bundle.signedPreKey.keyId).toBe(2); // advanced + expect(bundle.signedPreKey.signature.length).toBe(64); + }); + + test('getActiveRetiredIdentities returns entries within grace period', async () => { + const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); + await mgr.initialize(); + + await mgr.rotateIdentity(); + await mgr.rotateIdentity(); + + const active = await mgr.getActiveRetiredIdentities(); + expect(active.length).toBe(2); + }); + + test('getActiveRetiredIdentities filters out expired entries', async () => { + const storage = new MemoryStorage(); + const mgr = new ShadeSessionManager(crypto, storage); + await mgr.initialize(); + + // Manually add a very old retired identity + await storage.addRetiredIdentity({ + keyPair: (await mgr.getPublicIdentity()) as any, // placeholder + retiredAt: Date.now() - (GRACE_PERIOD_MS + 1000), + }); + + // And a fresh one + await mgr.rotateIdentity(); + + const active = await mgr.getActiveRetiredIdentities(); + expect(active.length).toBe(1); + }); + + test('pruneExpiredIdentities removes old entries', async () => { + const storage = new MemoryStorage(); + const mgr = new ShadeSessionManager(crypto, storage); + await mgr.initialize(); + + // Rotate twice + await mgr.rotateIdentity(); + await mgr.rotateIdentity(); + + expect((await storage.getRetiredIdentities()).length).toBe(2); + + // Default grace period: nothing is expired yet + await mgr.pruneExpiredIdentities(); + expect((await storage.getRetiredIdentities()).length).toBe(2); + + // Force prune with 0 grace → everything goes + await mgr.pruneExpiredIdentities(0); + expect((await storage.getRetiredIdentities()).length).toBe(0); + }); + + test('rotation persists across manager restart', async () => { + const storage = new MemoryStorage(); + const mgr1 = new ShadeSessionManager(crypto, storage); + await mgr1.initialize(); + await mgr1.rotateIdentity(); + const fp1 = await mgr1.getIdentityFingerprint(); + + const mgr2 = new ShadeSessionManager(crypto, storage); + await mgr2.initialize(); + const fp2 = await mgr2.getIdentityFingerprint(); + + expect(fp1).toBe(fp2); + }); + + test('existing sessions survive identity rotation', async () => { + // Set up Alice-Bob conversation + const alice = new ShadeSessionManager(crypto, new MemoryStorage()); + const bob = new ShadeSessionManager(crypto, new MemoryStorage()); + await alice.initialize(); + await bob.initialize(); + + const otpks = await bob.generateOneTimePreKeys(5); + const bundle = await bob.createPreKeyBundle(); + bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; + await alice.initSessionFromBundle('bob', bundle); + + // Exchange messages to establish bidirectional session + const env1 = await alice.encrypt('bob', 'hello'); + expect(await bob.decrypt('alice', env1)).toBe('hello'); + const env2 = await bob.encrypt('alice', 'hi'); + expect(await alice.decrypt('bob', env2)).toBe('hi'); + + // Alice rotates her identity — but her session with Bob should still work + // because the Double Ratchet uses ephemeral DH keys, not identity keys + await alice.rotateIdentity(); + + const env3 = await alice.encrypt('bob', 'after rotation'); + expect(await bob.decrypt('alice', env3)).toBe('after rotation'); + }); +}); diff --git a/packages/shade-crypto-web/src/memory-storage.ts b/packages/shade-crypto-web/src/memory-storage.ts index e2483d5..37462bb 100644 --- a/packages/shade-crypto-web/src/memory-storage.ts +++ b/packages/shade-crypto-web/src/memory-storage.ts @@ -1,4 +1,5 @@ -import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from '@shade/core'; +import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity } from '@shade/core'; +import { constantTimeEqual } from '@shade/core'; /** * In-memory StorageProvider for testing and embedded use. @@ -11,6 +12,7 @@ export class MemoryStorage implements StorageProvider { private oneTimePreKeys = new Map(); private sessions = new Map(); private trustedIdentities = new Map(); + private retiredIdentities: RetiredIdentity[] = []; // ─── Identity ────────────────────────────────────────────── @@ -81,18 +83,24 @@ export class MemoryStorage implements StorageProvider { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { const stored = this.trustedIdentities.get(address); if (!stored) return true; // TOFU: trust on first use - return arraysEqual(stored, identityKey); + return constantTimeEqual(stored, identityKey); } async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise { this.trustedIdentities.set(address, identityKey); } -} -function arraysEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; + // ─── Identity History ───────────────────────────────────── + + async addRetiredIdentity(identity: RetiredIdentity): Promise { + this.retiredIdentities.push(identity); + } + + async getRetiredIdentities(): Promise { + return [...this.retiredIdentities]; + } + + async pruneRetiredIdentities(olderThan: number): Promise { + this.retiredIdentities = this.retiredIdentities.filter((r) => r.retiredAt >= olderThan); } - return true; } diff --git a/packages/shade-crypto-web/src/provider.ts b/packages/shade-crypto-web/src/provider.ts index 61b092c..b964e9f 100644 --- a/packages/shade-crypto-web/src/provider.ts +++ b/packages/shade-crypto-web/src/provider.ts @@ -115,4 +115,24 @@ export class SubtleCryptoProvider implements CryptoProvider { globalThis.crypto.getRandomValues(buf); return buf; } + + // ─── Hardening ───────────────────────────────────────────── + + constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i]! ^ b[i]!; + } + return diff === 0; + } + + zeroize(buf: Uint8Array): void { + buf.fill(0); + } + + randomUint32(): number { + const buf = this.randomBytes(4); + return new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0, false); + } } diff --git a/packages/shade-crypto-web/tests/hardening.test.ts b/packages/shade-crypto-web/tests/hardening.test.ts new file mode 100644 index 0000000..584f342 --- /dev/null +++ b/packages/shade-crypto-web/tests/hardening.test.ts @@ -0,0 +1,143 @@ +import { describe, test, expect } from 'bun:test'; +import { SubtleCryptoProvider } from '../src/provider.js'; +import { constantTimeEqual } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +describe('Cryptographic Hardening', () => { + // ─── constantTimeEqual ─────────────────────────────────── + + describe('constantTimeEqual', () => { + test('equal arrays return true', () => { + const a = new Uint8Array([1, 2, 3, 4, 5]); + const b = new Uint8Array([1, 2, 3, 4, 5]); + expect(crypto.constantTimeEqual(a, b)).toBe(true); + }); + + test('unequal arrays return false', () => { + const a = new Uint8Array([1, 2, 3, 4, 5]); + const b = new Uint8Array([1, 2, 3, 4, 6]); + expect(crypto.constantTimeEqual(a, b)).toBe(false); + }); + + test('different lengths return false', () => { + const a = new Uint8Array([1, 2, 3]); + const b = new Uint8Array([1, 2, 3, 4]); + expect(crypto.constantTimeEqual(a, b)).toBe(false); + }); + + test('empty arrays are equal', () => { + expect(crypto.constantTimeEqual(new Uint8Array(0), new Uint8Array(0))).toBe(true); + }); + + test('works on full 32-byte keys', () => { + const k1 = crypto.randomBytes(32); + const k2 = new Uint8Array(k1); + expect(crypto.constantTimeEqual(k1, k2)).toBe(true); + + k2[31] ^= 0x01; + expect(crypto.constantTimeEqual(k1, k2)).toBe(false); + }); + + test('standalone function gives same result', () => { + const a = crypto.randomBytes(32); + const b = new Uint8Array(a); + expect(constantTimeEqual(a, b)).toBe(true); + b[0] ^= 0x01; + expect(constantTimeEqual(a, b)).toBe(false); + }); + + // Statistical timing test — measure variance between mismatch-at-start vs mismatch-at-end + // This is noisy on CI but catches obvious early-exit regressions. + test('timing variance stays bounded across mismatch positions', () => { + const len = 256; + const target = crypto.randomBytes(len); + + const mismatchAtStart = new Uint8Array(target); + mismatchAtStart[0] ^= 0xff; + + const mismatchAtEnd = new Uint8Array(target); + mismatchAtEnd[len - 1] ^= 0xff; + + // Measure many iterations to get a stable signal + const iterations = 50000; + + const start1 = performance.now(); + for (let i = 0; i < iterations; i++) { + crypto.constantTimeEqual(target, mismatchAtStart); + } + const timeStart = performance.now() - start1; + + const start2 = performance.now(); + for (let i = 0; i < iterations; i++) { + crypto.constantTimeEqual(target, mismatchAtEnd); + } + const timeEnd = performance.now() - start2; + + // With constant-time comparison, these should be very close. + // Non-constant-time would show timeEnd >> timeStart (early exit vs full scan). + // Allow 2x variance for JIT/noise, but it should never be 10x. + const ratio = Math.max(timeStart, timeEnd) / Math.min(timeStart, timeEnd); + expect(ratio).toBeLessThan(3); + }); + }); + + // ─── zeroize ────────────────────────────────────────────── + + describe('zeroize', () => { + test('fills buffer with zeros', () => { + const buf = crypto.randomBytes(32); + // Make sure it's not already zero + const anyNonZero = buf.some((b) => b !== 0); + expect(anyNonZero).toBe(true); + + crypto.zeroize(buf); + expect(buf.every((b) => b === 0)).toBe(true); + }); + + test('handles empty buffer', () => { + crypto.zeroize(new Uint8Array(0)); + // Should not throw + }); + + test('handles large buffer', () => { + const buf = crypto.randomBytes(4096); + crypto.zeroize(buf); + expect(buf.every((b) => b === 0)).toBe(true); + }); + }); + + // ─── randomUint32 ───────────────────────────────────────── + + describe('randomUint32', () => { + test('returns number in 32-bit unsigned range', () => { + for (let i = 0; i < 100; i++) { + const n = crypto.randomUint32(); + expect(n).toBeGreaterThanOrEqual(0); + expect(n).toBeLessThanOrEqual(0xffffffff); + expect(Number.isInteger(n)).toBe(true); + } + }); + + test('produces different values each call', () => { + const values = new Set(); + for (let i = 0; i < 100; i++) { + values.add(crypto.randomUint32()); + } + // With 32 bits, 100 samples should all be unique + expect(values.size).toBe(100); + }); + + test('distribution is not biased toward low values', () => { + // Generate many and check that at least some are above 2^31 + // (would fail if using Math.random() with weird multiplier bugs) + let highCount = 0; + for (let i = 0; i < 1000; i++) { + if (crypto.randomUint32() >= 0x80000000) highCount++; + } + // Should be around 500, accept 400-600 as "not obviously broken" + expect(highCount).toBeGreaterThan(400); + expect(highCount).toBeLessThan(600); + }); + }); +}); diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index aa41aa8..2afdd96 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -7,5 +7,8 @@ "dependencies": { "@shade/core": "workspace:*", "hono": "^4.12.12" + }, + "devDependencies": { + "@shade/crypto-web": "workspace:*" } } diff --git a/packages/shade-server/src/auth.ts b/packages/shade-server/src/auth.ts new file mode 100644 index 0000000..cc48ab8 --- /dev/null +++ b/packages/shade-server/src/auth.ts @@ -0,0 +1,133 @@ +import type { CryptoProvider } from '@shade/core'; +import { fromBase64, UnauthorizedError, ValidationError, ReplayError } from '@shade/core'; + +/** + * Self-authenticated prekey server. + * + * Each write request must include a signature over a canonical + * representation of the body (excluding the signature field itself) + * using the identity's Ed25519 signing key. + * + * On register: the signing key is extracted from the body itself + * (TOFU — first registration establishes the identity). + * + * On replenish/delete: the signing key is looked up from the stored + * identity record for the address. + */ + +/** Maximum age of a signed request, in milliseconds */ +const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000; // 5 minutes + +/** Maximum clock skew tolerated (future timestamps) */ +const MAX_FUTURE_SKEW_MS = 1 * 60 * 1000; // 1 minute + +interface SignedPayload { + signedAt: number; + signature: string; // base64 + [key: string]: unknown; +} + +/** + * Canonicalize a payload for signing by omitting the `signature` field + * and using deterministic JSON key ordering. + * + * Both signer and verifier must produce identical bytes. + */ +export function canonicalizePayload(payload: SignedPayload): Uint8Array { + const { signature, ...rest } = payload; + // Sort keys to be deterministic + const sorted = Object.keys(rest) + .sort() + .reduce>((acc, key) => { + acc[key] = (rest as Record)[key]; + return acc; + }, {}); + return new TextEncoder().encode(JSON.stringify(sorted)); +} + +/** + * Sign a payload with an Ed25519 signing private key. + * Returns the payload with `signature` and `signedAt` set. + */ +export async function signPayload>( + crypto: CryptoProvider, + signingPrivateKey: Uint8Array, + payload: T, +): Promise { + const signedAt = Date.now(); + const bytes = canonicalizePayload({ ...payload, signedAt, signature: '' }); + const sig = await crypto.sign(signingPrivateKey, bytes); + return { + ...payload, + signedAt, + signature: Buffer.from(sig).toString('base64'), + }; +} + +/** + * Verify a signed payload against a given signing public key. + * + * Throws: + * - ValidationError if signedAt or signature fields are missing/malformed + * - ReplayError if signedAt is outside the allowed window + * - UnauthorizedError if the signature does not verify + */ +export async function verifyPayload( + crypto: CryptoProvider, + signingPublicKey: Uint8Array, + payload: unknown, +): Promise { + if (!payload || typeof payload !== 'object') { + throw new ValidationError('Payload must be an object'); + } + const p = payload as SignedPayload; + + if (typeof p.signedAt !== 'number') { + throw new ValidationError('Missing or invalid signedAt', 'signedAt'); + } + if (typeof p.signature !== 'string') { + throw new ValidationError('Missing or invalid signature', 'signature'); + } + + const now = Date.now(); + const age = now - p.signedAt; + if (age > MAX_SIGNATURE_AGE_MS) { + throw new ReplayError(`Signature too old: ${age}ms`); + } + if (age < -MAX_FUTURE_SKEW_MS) { + throw new ReplayError(`Signature in the future: ${-age}ms ahead`); + } + + let sigBytes: Uint8Array; + try { + sigBytes = fromBase64(p.signature); + } catch { + throw new ValidationError('Signature is not valid base64', 'signature'); + } + + const canonical = canonicalizePayload(p); + const valid = await crypto.verify(signingPublicKey, canonical, sigBytes); + if (!valid) { + throw new UnauthorizedError('Invalid signature'); + } +} + +// ─── Address validation ───────────────────────────────────── + +const ADDRESS_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}$/; + +/** + * Validate a peer address string. + * Allows alphanumeric, :, _, -, . with max 256 chars and must start with alphanumeric. + * Rejects path traversal, unicode tricks, and empty strings. + */ +export function validateAddress(address: unknown): string { + if (typeof address !== 'string') { + throw new ValidationError('Address must be a string', 'address'); + } + const normalized = address.normalize('NFKC'); + if (!ADDRESS_REGEX.test(normalized)) { + throw new ValidationError('Invalid address format', 'address'); + } + return normalized; +} diff --git a/packages/shade-server/src/index.ts b/packages/shade-server/src/index.ts index 9b6bd6b..50c7548 100644 --- a/packages/shade-server/src/index.ts +++ b/packages/shade-server/src/index.ts @@ -1,4 +1,5 @@ -import { Hono } from 'hono'; +import type { Hono } from 'hono'; +import type { CryptoProvider } from '@shade/core'; import { createPrekeyRoutes } from './routes.js'; import { MemoryPrekeyStore } from './memory-store.js'; import type { PrekeyStore } from './store.js'; @@ -6,23 +7,30 @@ import type { PrekeyStore } from './store.js'; export { createPrekeyRoutes } from './routes.js'; export { MemoryPrekeyStore } from './memory-store.js'; export type { PrekeyStore } from './store.js'; +export { verifyPayload, signPayload, canonicalizePayload, validateAddress } from './auth.js'; /** * Create a standalone Shade Prekey Server. * - * Can be used standalone (Docker) or embedded in another Hono app. + * Requires a CryptoProvider for signature verification on write routes. * * Standalone: - * const server = createPrekeyServer(); + * const crypto = new SubtleCryptoProvider(); + * const server = createPrekeyServer({ crypto }); * export default { port: 3900, fetch: server.fetch }; * - * Embedded: + * Embedded in another Hono app: * const app = new Hono(); - * app.route('/shade', createPrekeyServer()); + * app.route('/shade', createPrekeyServer({ crypto })); */ -export function createPrekeyServer(options?: { +export function createPrekeyServer(options: { + crypto: CryptoProvider; store?: PrekeyStore; + disableRateLimit?: boolean; }): Hono { - const store = options?.store ?? new MemoryPrekeyStore(); - return createPrekeyRoutes(store); + const store = options.store ?? new MemoryPrekeyStore(); + return createPrekeyRoutes(store, options.crypto, { disableRateLimit: options.disableRateLimit }); } + +export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js'; +export type { RateLimitStore, RateLimitConfig } from './rate-limit.js'; diff --git a/packages/shade-server/src/rate-limit.ts b/packages/shade-server/src/rate-limit.ts new file mode 100644 index 0000000..d8a369a --- /dev/null +++ b/packages/shade-server/src/rate-limit.ts @@ -0,0 +1,103 @@ +import { RateLimitError } from '@shade/core'; + +/** + * Simple token-bucket rate limiter with pluggable storage. + * + * Default storage is in-memory (Map). For distributed deployments, + * swap in a Redis-backed RateLimitStore implementation. + */ + +export interface RateLimitStore { + /** Get the current token count and last refill time for a key */ + get(key: string): Promise<{ tokens: number; lastRefill: number } | null>; + /** Store the current token count and last refill time */ + set(key: string, tokens: number, lastRefill: number): Promise; +} + +export class MemoryRateLimitStore implements RateLimitStore { + private entries = new Map(); + + async get(key: string) { + return this.entries.get(key) ?? null; + } + + async set(key: string, tokens: number, lastRefill: number) { + this.entries.set(key, { tokens, lastRefill }); + } + + /** Periodic cleanup: drop entries older than `maxAge` ms */ + cleanup(maxAge: number) { + const now = Date.now(); + for (const [key, entry] of this.entries) { + if (now - entry.lastRefill > maxAge) { + this.entries.delete(key); + } + } + } +} + +export interface RateLimitConfig { + /** Maximum tokens in the bucket (burst capacity) */ + capacity: number; + /** Tokens added per second */ + refillPerSecond: number; +} + +export class RateLimiter { + constructor( + private readonly store: RateLimitStore, + private readonly config: RateLimitConfig, + ) {} + + /** + * Attempt to consume one token for the given key. + * Throws RateLimitError if no tokens available. + */ + async consume(key: string, tokens = 1): Promise { + const now = Date.now(); + const entry = await this.store.get(key); + + let currentTokens: number; + if (!entry) { + currentTokens = this.config.capacity; + } else { + const elapsed = (now - entry.lastRefill) / 1000; + const refilled = elapsed * this.config.refillPerSecond; + currentTokens = Math.min(this.config.capacity, entry.tokens + refilled); + } + + if (currentTokens < tokens) { + const needed = tokens - currentTokens; + const retryAfter = Math.ceil(needed / this.config.refillPerSecond); + throw new RateLimitError(`Rate limit exceeded for ${key}`, retryAfter); + } + + await this.store.set(key, currentTokens - tokens, now); + } +} + +// ─── Preset configurations ────────────────────────────────── + +/** Register: 5 per hour (prevent identity flooding) */ +export const REGISTER_LIMIT: RateLimitConfig = { + capacity: 5, + refillPerSecond: 5 / 3600, // 5 per hour +}; + +/** Fetch bundle: 60 per minute (anonymous, high limit) */ +export const FETCH_LIMIT: RateLimitConfig = { + capacity: 60, + refillPerSecond: 1, +}; + +/** Replenish: 10 per minute per identity */ +export const REPLENISH_LIMIT: RateLimitConfig = { + capacity: 10, + refillPerSecond: 10 / 60, +}; + +/** Delete: 5 per hour per identity */ +export const DELETE_LIMIT: RateLimitConfig = { + capacity: 5, + refillPerSecond: 5 / 3600, +}; diff --git a/packages/shade-server/src/routes.ts b/packages/shade-server/src/routes.ts index 7b46815..1fa8b58 100644 --- a/packages/shade-server/src/routes.ts +++ b/packages/shade-server/src/routes.ts @@ -1,35 +1,93 @@ import { Hono } from 'hono'; +import type { CryptoProvider } from '@shade/core'; +import { fromBase64, errorToHttpStatus, ShadeError, ValidationError } from '@shade/core'; import type { PrekeyStore } from './store.js'; +import { verifyPayload, validateAddress } from './auth.js'; +import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js'; + +/** Max POST body size in bytes (64KB) */ +const MAX_BODY_SIZE = 64 * 1024; /** * Create the Shade Prekey Server Hono app. * * Routes: - * POST /v1/keys/register — Register identity + upload prekey bundle - * GET /v1/keys/bundle/:address — Fetch a prekey bundle (consumes one OTP key) - * POST /v1/keys/replenish — Upload additional one-time prekeys - * GET /v1/keys/count/:address — Get remaining one-time prekey count - * DELETE /v1/keys/:address — Unregister (delete all keys) + * POST /v1/keys/register — Register identity + upload prekey bundle (SIGNED) + * GET /v1/keys/bundle/:address — Fetch a prekey bundle (anonymous) + * POST /v1/keys/replenish — Upload additional one-time prekeys (SIGNED) + * GET /v1/keys/count/:address — Get remaining one-time prekey count (anonymous) + * DELETE /v1/keys/:address — Unregister (SIGNED) + * + * Write routes require a valid Ed25519 signature. See auth.ts. */ -export function createPrekeyRoutes(store: PrekeyStore): Hono { +export interface PrekeyRoutesOptions { + /** Disable rate limiting (for tests). Default: enabled. */ + disableRateLimit?: boolean; +} + +export function createPrekeyRoutes( + store: PrekeyStore, + crypto: CryptoProvider, + options: PrekeyRoutesOptions = {}, +): Hono { const app = new Hono(); + // Rate limiters (one per route, per IP or per identity) + const rlStore = new MemoryRateLimitStore(); + const registerRL = new RateLimiter(rlStore, REGISTER_LIMIT); + const fetchRL = new RateLimiter(rlStore, FETCH_LIMIT); + const replenishRL = new RateLimiter(rlStore, REPLENISH_LIMIT); + const deleteRL = new RateLimiter(rlStore, DELETE_LIMIT); + const rateLimitEnabled = !options.disableRateLimit; + + // Helper: extract client IP from request headers + const getClientIp = (c: any): string => { + return ( + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? + c.req.header('x-real-ip') ?? + 'unknown' + ); + }; + + // Global error handler — maps ShadeError to HTTP status + app.onError((err, c) => { + if (err instanceof ShadeError) { + const status = errorToHttpStatus(err); + const body: any = err.toJSON(); + if ((err as any).retryAfterSeconds) { + c.header('Retry-After', String((err as any).retryAfterSeconds)); + } + return c.json(body, status as any); + } + console.error('[Shade] Unhandled error:', err); + return c.json({ error: 'Internal server error' }, 500); + }); + // ─── Register ────────────────────────────────────────────── app.post('/v1/keys/register', async (c) => { - const body = await c.req.json(); + if (rateLimitEnabled) await registerRL.consume(`register:${getClientIp(c)}`); + + const rawBody = await c.req.text(); + if (rawBody.length > MAX_BODY_SIZE) { + throw new ValidationError(`Request body too large (max ${MAX_BODY_SIZE} bytes)`); + } + const body = JSON.parse(rawBody); const { address, identitySigningKey, identityDHKey, signedPreKey, oneTimePreKeys } = body; - if (!address || !identitySigningKey || !identityDHKey || !signedPreKey) { - return c.json({ error: 'Missing required fields' }, 400); + const addr = validateAddress(address); + if (!identitySigningKey || !identityDHKey || !signedPreKey) { + throw new ValidationError('Missing required fields'); } - // Decode base64 keys const signingKey = b64ToBytes(identitySigningKey); const dhKey = b64ToBytes(identityDHKey); - await store.saveIdentity(address, signingKey, dhKey); + // Verify signature against the identity's own signing key (TOFU) + await verifyPayload(crypto, signingKey, body); + + await store.saveIdentity(addr, signingKey, dhKey); await store.saveSignedPreKey( - address, + addr, signedPreKey.keyId, b64ToBytes(signedPreKey.publicKey), b64ToBytes(signedPreKey.signature), @@ -40,27 +98,27 @@ export function createPrekeyRoutes(store: PrekeyStore): Hono { keyId: k.keyId, publicKey: b64ToBytes(k.publicKey), })); - await store.saveOneTimePreKeys(address, keys); + await store.saveOneTimePreKeys(addr, keys); } return c.json({ ok: true }); }); - // ─── Fetch Bundle ────────────────────────────────────────── + // ─── Fetch Bundle (anonymous) ────────────────────────────── app.get('/v1/keys/bundle/:address', async (c) => { - const address = c.req.param('address'); + if (rateLimitEnabled) await fetchRL.consume(`fetch:${getClientIp(c)}`); + const address = validateAddress(c.req.param('address')); const identity = await store.getIdentity(address); if (!identity) { - return c.json({ error: 'Address not found' }, 404); + return c.json({ error: 'Address not found', code: 'SHADE_NOT_FOUND' }, 404); } const signedPreKey = await store.getSignedPreKey(address); if (!signedPreKey) { - return c.json({ error: 'No signed prekey' }, 404); + return c.json({ error: 'No signed prekey', code: 'SHADE_NOT_FOUND' }, 404); } - // Consume one one-time prekey (if available) const oneTimePreKey = await store.consumeOneTimePreKey(address); const bundle: any = { @@ -83,35 +141,59 @@ export function createPrekeyRoutes(store: PrekeyStore): Hono { return c.json(bundle); }); - // ─── Replenish One-Time Prekeys ──────────────────────────── + // ─── Replenish One-Time Prekeys (signed) ─────────────────── app.post('/v1/keys/replenish', async (c) => { - const body = await c.req.json(); + const rawBody = await c.req.text(); + if (rawBody.length > MAX_BODY_SIZE) { + throw new ValidationError(`Request body too large (max ${MAX_BODY_SIZE} bytes)`); + } + const body = JSON.parse(rawBody); const { address, oneTimePreKeys } = body; - if (!address || !oneTimePreKeys || !Array.isArray(oneTimePreKeys)) { - return c.json({ error: 'Missing address or oneTimePreKeys' }, 400); + const addr = validateAddress(address); + if (rateLimitEnabled) await replenishRL.consume(`replenish:${addr}`); + if (!oneTimePreKeys || !Array.isArray(oneTimePreKeys)) { + throw new ValidationError('Missing oneTimePreKeys'); } + // Look up the stored identity and verify the signature against it + const identity = await store.getIdentity(addr); + if (!identity) { + return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404); + } + await verifyPayload(crypto, identity.identitySigningKey, body); + const keys = oneTimePreKeys.map((k: any) => ({ keyId: k.keyId, publicKey: b64ToBytes(k.publicKey), })); - await store.saveOneTimePreKeys(address, keys); + await store.saveOneTimePreKeys(addr, keys); - const count = await store.getOneTimePreKeyCount(address); + const count = await store.getOneTimePreKeyCount(addr); return c.json({ ok: true, remaining: count }); }); - // ─── Get Count ───────────────────────────────────────────── + // ─── Get Count (anonymous) ───────────────────────────────── app.get('/v1/keys/count/:address', async (c) => { - const address = c.req.param('address'); + const address = validateAddress(c.req.param('address')); const count = await store.getOneTimePreKeyCount(address); return c.json({ count }); }); - // ─── Delete ──────────────────────────────────────────────── + // ─── Delete (signed) ─────────────────────────────────────── app.delete('/v1/keys/:address', async (c) => { - const address = c.req.param('address'); + const address = validateAddress(c.req.param('address')); + if (rateLimitEnabled) await deleteRL.consume(`delete:${address}`); + const body = await c.req.json(); + + // Look up the stored identity and verify the signature + const identity = await store.getIdentity(address); + if (!identity) { + return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404); + } + // Include address in the signed payload + await verifyPayload(crypto, identity.identitySigningKey, { ...body, address }); + await store.deleteAll(address); return c.json({ ok: true }); }); diff --git a/packages/shade-server/src/standalone.ts b/packages/shade-server/src/standalone.ts index 55ab23f..f153203 100644 --- a/packages/shade-server/src/standalone.ts +++ b/packages/shade-server/src/standalone.ts @@ -1,3 +1,4 @@ +import { SubtleCryptoProvider } from '@shade/crypto-web'; import { createPrekeyRoutes } from './routes.js'; import type { PrekeyStore } from './store.js'; @@ -13,8 +14,9 @@ async function createStore(): Promise { return new MemoryPrekeyStore(); } +const crypto = new SubtleCryptoProvider(); const store = await createStore(); -const server = createPrekeyRoutes(store); +const server = createPrekeyRoutes(store, crypto); const port = Number(process.env.PORT ?? 3900); export default { port, fetch: server.fetch }; diff --git a/packages/shade-server/tests/rate-limit.test.ts b/packages/shade-server/tests/rate-limit.test.ts new file mode 100644 index 0000000..873dc33 --- /dev/null +++ b/packages/shade-server/tests/rate-limit.test.ts @@ -0,0 +1,149 @@ +import { describe, test, expect } from 'bun:test'; +import { RateLimiter, MemoryRateLimitStore, createPrekeyServer, MemoryPrekeyStore, signPayload } from '../src/index.js'; +import { SubtleCryptoProvider } from '@shade/crypto-web'; +import { generateIdentityKeyPair, RateLimitError } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +describe('RateLimiter', () => { + test('allows requests up to capacity', async () => { + const store = new MemoryRateLimitStore(); + const rl = new RateLimiter(store, { capacity: 5, refillPerSecond: 1 }); + + for (let i = 0; i < 5; i++) { + await rl.consume('user:1'); + } + }); + + test('throws RateLimitError when exhausted', async () => { + const store = new MemoryRateLimitStore(); + const rl = new RateLimiter(store, { capacity: 3, refillPerSecond: 0.1 }); + + for (let i = 0; i < 3; i++) await rl.consume('user:1'); + expect(rl.consume('user:1')).rejects.toThrow(RateLimitError); + }); + + test('different keys have independent limits', async () => { + const store = new MemoryRateLimitStore(); + const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 0.1 }); + + await rl.consume('user:1'); + await rl.consume('user:1'); + // user:1 exhausted + expect(rl.consume('user:1')).rejects.toThrow(RateLimitError); + + // user:2 still has full capacity + await rl.consume('user:2'); + await rl.consume('user:2'); + }); + + test('refills over time', async () => { + const store = new MemoryRateLimitStore(); + const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 100 }); // fast refill for test + + await rl.consume('user:1'); + await rl.consume('user:1'); + + // Wait a bit for refill (100 tokens/sec → 2 tokens in 20ms) + await new Promise((r) => setTimeout(r, 30)); + + // Should be refilled + await rl.consume('user:1'); + }); + + test('RateLimitError has retryAfterSeconds', async () => { + const store = new MemoryRateLimitStore(); + const rl = new RateLimiter(store, { capacity: 1, refillPerSecond: 0.5 }); + + await rl.consume('user:1'); + try { + await rl.consume('user:1'); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(RateLimitError); + expect((err as RateLimitError).retryAfterSeconds).toBeGreaterThan(0); + } + }); +}); + +describe('Rate limiting integration with routes', () => { + test('register endpoint rate-limits per IP', async () => { + const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() }); + + async function doRegister(addressSuffix: number) { + const identity = await generateIdentityKeyPair(crypto); + const body: any = { + address: `user${addressSuffix}`, + identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'), + identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'), + signedPreKey: { + keyId: 1, + publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'), + signature: Buffer.from(crypto.randomBytes(64)).toString('base64'), + }, + }; + const signed = await signPayload(crypto, identity.signingPrivateKey, body); + return app.request('/v1/keys/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.1' }, + body: JSON.stringify(signed), + }); + } + + // Register limit is 5/hour, so after 5 successful, the 6th should fail + const results: number[] = []; + for (let i = 0; i < 7; i++) { + const res = await doRegister(i); + results.push(res.status); + } + + // First 5 should succeed (200), rest should be rate-limited (429) + expect(results.filter((s) => s === 200).length).toBeGreaterThanOrEqual(5); + expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1); + }); + + test('rate limit returns Retry-After header', async () => { + const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() }); + + // Burn through the delete limit (5/hour) + const identity = await generateIdentityKeyPair(crypto); + + // First register + const regBody: any = { + address: 'ratelimit', + identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'), + identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'), + signedPreKey: { + keyId: 1, + publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'), + signature: Buffer.from(crypto.randomBytes(64)).toString('base64'), + }, + }; + const signedReg = await signPayload(crypto, identity.signingPrivateKey, regBody); + await app.request('/v1/keys/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.2' }, + body: JSON.stringify(signedReg), + }); + + // Burn through the delete limit + for (let i = 0; i < 5; i++) { + const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' }); + await app.request('/v1/keys/ratelimit', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(delBody), + }); + } + + // 6th should be rate-limited + const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' }); + const res = await app.request('/v1/keys/ratelimit', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(delBody), + }); + expect(res.status).toBe(429); + expect(res.headers.get('Retry-After')).toBeTruthy(); + }); +}); diff --git a/packages/shade-server/tests/server.test.ts b/packages/shade-server/tests/server.test.ts index 558ed9d..0ffdf0b 100644 --- a/packages/shade-server/tests/server.test.ts +++ b/packages/shade-server/tests/server.test.ts @@ -1,6 +1,10 @@ import { describe, test, expect, beforeEach } from 'bun:test'; -import { createPrekeyServer, MemoryPrekeyStore } from '../src/index.js'; +import { createPrekeyServer, MemoryPrekeyStore, signPayload } from '../src/index.js'; import type { PrekeyStore } from '../src/index.js'; +import { SubtleCryptoProvider } from '@shade/crypto-web'; +import { generateIdentityKeyPair } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); function b64(bytes: Uint8Array): string { return Buffer.from(bytes).toString('base64'); @@ -8,167 +12,164 @@ function b64(bytes: Uint8Array): string { function randBytes(n: number): Uint8Array { const buf = new Uint8Array(n); - crypto.getRandomValues(buf); + globalThis.crypto.getRandomValues(buf); return buf; } +async function makeIdentity() { + return generateIdentityKeyPair(crypto); +} + describe('Shade Prekey Server', () => { let store: PrekeyStore; let app: ReturnType; beforeEach(() => { store = new MemoryPrekeyStore(); - app = createPrekeyServer({ store }); + app = createPrekeyServer({ crypto, store, disableRateLimit: true }); }); function req(method: string, path: string, body?: any) { const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } }; - if (body) init.body = JSON.stringify(body); + if (body !== undefined) init.body = JSON.stringify(body); return app.request(path, init); } + /** Helper: build a signed registration body for a given identity */ + async function signedRegisterBody(identity: Awaited>, address: string, withOTPKs = true) { + const body: any = { + address, + identitySigningKey: b64(identity.signingPublicKey), + identityDHKey: b64(identity.dhPublicKey), + signedPreKey: { + keyId: 1, + publicKey: b64(randBytes(32)), + signature: b64(randBytes(64)), + }, + }; + if (withOTPKs) { + body.oneTimePreKeys = [ + { keyId: 100, publicKey: b64(randBytes(32)) }, + { keyId: 101, publicKey: b64(randBytes(32)) }, + ]; + } + return signPayload(crypto, identity.signingPrivateKey, body); + } + // ─── Registration ────────────────────────────────────────── describe('POST /v1/keys/register', () => { - test('registers identity and signed prekey', async () => { - const res = await req('POST', '/v1/keys/register', { - address: 'alice', - identitySigningKey: b64(randBytes(32)), - identityDHKey: b64(randBytes(32)), - signedPreKey: { - keyId: 1, - publicKey: b64(randBytes(32)), - signature: b64(randBytes(64)), - }, - }); + test('accepts valid signed registration', async () => { + const alice = await makeIdentity(); + const body = await signedRegisterBody(alice, 'alice'); + const res = await req('POST', '/v1/keys/register', body); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ ok: true }); }); - test('registers with one-time prekeys', async () => { + test('rejects unsigned registration', async () => { + const alice = await makeIdentity(); const res = await req('POST', '/v1/keys/register', { address: 'alice', - identitySigningKey: b64(randBytes(32)), - identityDHKey: b64(randBytes(32)), - signedPreKey: { - keyId: 1, - publicKey: b64(randBytes(32)), - signature: b64(randBytes(64)), - }, - oneTimePreKeys: [ - { keyId: 100, publicKey: b64(randBytes(32)) }, - { keyId: 101, publicKey: b64(randBytes(32)) }, - { keyId: 102, publicKey: b64(randBytes(32)) }, - ], + identitySigningKey: b64(alice.signingPublicKey), + identityDHKey: b64(alice.dhPublicKey), + signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, }); - expect(res.status).toBe(200); - - // Verify count - const countRes = await req('GET', '/v1/keys/count/alice'); - expect((await countRes.json()).count).toBe(3); - }); - - test('rejects missing fields', async () => { - const res = await req('POST', '/v1/keys/register', { address: 'alice' }); + // Missing signature/signedAt → validation error expect(res.status).toBe(400); }); + + test('rejects registration with wrong signing key', async () => { + const alice = await makeIdentity(); + const bob = await makeIdentity(); + // Sign with bob's key but claim alice's public key + const body: any = { + address: 'alice', + identitySigningKey: b64(alice.signingPublicKey), // mismatch + identityDHKey: b64(alice.dhPublicKey), + signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, + }; + const signed = await signPayload(crypto, bob.signingPrivateKey, body); + const res = await req('POST', '/v1/keys/register', signed); + expect(res.status).toBe(401); + }); + + test('rejects registration with stale signedAt', async () => { + const alice = await makeIdentity(); + const body = await signedRegisterBody(alice, 'alice'); + // Tamper with signedAt to be old + body.signedAt = Date.now() - 10 * 60 * 1000; // 10 minutes ago + const res = await req('POST', '/v1/keys/register', body); + expect(res.status).toBe(409); // ReplayError + }); + + test('rejects invalid address format', async () => { + const alice = await makeIdentity(); + const body = await signedRegisterBody(alice, '../evil'); + const res = await req('POST', '/v1/keys/register', body); + expect(res.status).toBe(400); + }); + + test('accepts registration with one-time prekeys', async () => { + const alice = await makeIdentity(); + const body = await signedRegisterBody(alice, 'alice'); + await req('POST', '/v1/keys/register', body); + + const countRes = await req('GET', '/v1/keys/count/alice'); + expect((await countRes.json()).count).toBe(2); + }); }); - // ─── Fetch Bundle ────────────────────────────────────────── + // ─── Fetch Bundle (anonymous) ────────────────────────────── describe('GET /v1/keys/bundle/:address', () => { - test('returns bundle with one-time prekey', async () => { - // Register first - const sigKey = b64(randBytes(32)); - const dhKey = b64(randBytes(32)); - const spkPub = b64(randBytes(32)); - const spkSig = b64(randBytes(64)); - const otpkPub = b64(randBytes(32)); - - await req('POST', '/v1/keys/register', { - address: 'bob', - identitySigningKey: sigKey, - identityDHKey: dhKey, - signedPreKey: { keyId: 1, publicKey: spkPub, signature: spkSig }, - oneTimePreKeys: [{ keyId: 100, publicKey: otpkPub }], - }); + test('returns bundle for registered address', async () => { + const bob = await makeIdentity(); + const body = await signedRegisterBody(bob, 'bob'); + await req('POST', '/v1/keys/register', body); const res = await req('GET', '/v1/keys/bundle/bob'); expect(res.status).toBe(200); const bundle = await res.json(); - expect(bundle.identitySigningKey).toBe(sigKey); - expect(bundle.identityDHKey).toBe(dhKey); + expect(bundle.identitySigningKey).toBe(b64(bob.signingPublicKey)); + expect(bundle.identityDHKey).toBe(b64(bob.dhPublicKey)); expect(bundle.signedPreKey.keyId).toBe(1); - expect(bundle.signedPreKey.publicKey).toBe(spkPub); - expect(bundle.signedPreKey.signature).toBe(spkSig); expect(bundle.oneTimePreKey.keyId).toBe(100); - expect(bundle.oneTimePreKey.publicKey).toBe(otpkPub); }); - test('returns bundle without one-time prekey when depleted', async () => { - await req('POST', '/v1/keys/register', { - address: 'bob', - identitySigningKey: b64(randBytes(32)), - identityDHKey: b64(randBytes(32)), - signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, - }); + test('consumes one-time prekey on each fetch (FIFO)', async () => { + const bob = await makeIdentity(); + await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob')); - const res = await req('GET', '/v1/keys/bundle/bob'); - expect(res.status).toBe(200); - - const bundle = await res.json(); - expect(bundle.oneTimePreKey).toBeUndefined(); - }); - - test('consumes one-time prekeys on each fetch', async () => { - await req('POST', '/v1/keys/register', { - address: 'bob', - identitySigningKey: b64(randBytes(32)), - identityDHKey: b64(randBytes(32)), - signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, - oneTimePreKeys: [ - { keyId: 100, publicKey: b64(randBytes(32)) }, - { keyId: 101, publicKey: b64(randBytes(32)) }, - ], - }); - - // First fetch consumes key 100 const res1 = await req('GET', '/v1/keys/bundle/bob'); expect((await res1.json()).oneTimePreKey.keyId).toBe(100); - // Second fetch consumes key 101 const res2 = await req('GET', '/v1/keys/bundle/bob'); expect((await res2.json()).oneTimePreKey.keyId).toBe(101); - // Third fetch has none left const res3 = await req('GET', '/v1/keys/bundle/bob'); expect((await res3.json()).oneTimePreKey).toBeUndefined(); - - // Count should be 0 - const countRes = await req('GET', '/v1/keys/count/bob'); - expect((await countRes.json()).count).toBe(0); }); test('404 for unknown address', async () => { const res = await req('GET', '/v1/keys/bundle/nobody'); expect(res.status).toBe(404); }); + + test('rejects invalid address in URL', async () => { + const res = await req('GET', '/v1/keys/bundle/..evil'); + expect(res.status).toBe(400); + }); }); - // ─── Replenish ───────────────────────────────────────────── + // ─── Replenish (signed) ──────────────────────────────────── describe('POST /v1/keys/replenish', () => { - test('adds more one-time prekeys', async () => { - await req('POST', '/v1/keys/register', { - address: 'bob', - identitySigningKey: b64(randBytes(32)), - identityDHKey: b64(randBytes(32)), - signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, - oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }], - }); + test('accepts signed replenishment from registered identity', async () => { + const bob = await makeIdentity(); + await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob')); - const res = await req('POST', '/v1/keys/replenish', { + const replenishBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', oneTimePreKeys: [ { keyId: 200, publicKey: b64(randBytes(32)) }, @@ -176,52 +177,83 @@ describe('Shade Prekey Server', () => { ], }); + const res = await req('POST', '/v1/keys/replenish', replenishBody); expect(res.status).toBe(200); const body = await res.json(); - expect(body.remaining).toBe(3); // 1 original + 2 new + expect(body.remaining).toBe(4); // 2 original + 2 new + }); + + test('rejects replenishment signed by wrong identity', async () => { + const bob = await makeIdentity(); + const eve = await makeIdentity(); + await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob')); + + const evilBody = await signPayload(crypto, eve.signingPrivateKey, { + address: 'bob', + oneTimePreKeys: [{ keyId: 200, publicKey: b64(randBytes(32)) }], + }); + + const res = await req('POST', '/v1/keys/replenish', evilBody); + expect(res.status).toBe(401); + }); + + test('rejects replenishment for unknown address', async () => { + const bob = await makeIdentity(); + const body = await signPayload(crypto, bob.signingPrivateKey, { + address: 'nobody', + oneTimePreKeys: [{ keyId: 200, publicKey: b64(randBytes(32)) }], + }); + const res = await req('POST', '/v1/keys/replenish', body); + expect(res.status).toBe(404); }); }); - // ─── Delete ──────────────────────────────────────────────── + // ─── Delete (signed) ─────────────────────────────────────── describe('DELETE /v1/keys/:address', () => { - test('removes all keys for an address', async () => { - await req('POST', '/v1/keys/register', { - address: 'bob', - identitySigningKey: b64(randBytes(32)), - identityDHKey: b64(randBytes(32)), - signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, - oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }], - }); + test('accepts signed delete from registered identity', async () => { + const bob = await makeIdentity(); + await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob')); - const delRes = await req('DELETE', '/v1/keys/bob'); - expect(delRes.status).toBe(200); + const delBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob' }); + const res = await req('DELETE', '/v1/keys/bob', delBody); + expect(res.status).toBe(200); // Should be gone const bundleRes = await req('GET', '/v1/keys/bundle/bob'); expect(bundleRes.status).toBe(404); + }); - const countRes = await req('GET', '/v1/keys/count/bob'); - expect((await countRes.json()).count).toBe(0); + test('rejects delete signed by wrong identity', async () => { + const bob = await makeIdentity(); + const eve = await makeIdentity(); + await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob')); + + const evilBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob' }); + const res = await req('DELETE', '/v1/keys/bob', evilBody); + expect(res.status).toBe(401); + + // Should still exist + const bundleRes = await req('GET', '/v1/keys/bundle/bob'); + expect(bundleRes.status).toBe(200); }); }); - // ─── Multiple Addresses ──────────────────────────────────── + // ─── Multi-address isolation ─────────────────────────────── describe('multi-address isolation', () => { test('different addresses are independent', async () => { - for (const addr of ['alice', 'bob', 'charlie']) { - await req('POST', '/v1/keys/register', { - address: addr, - identitySigningKey: b64(randBytes(32)), - identityDHKey: b64(randBytes(32)), - signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, - oneTimePreKeys: [{ keyId: 1, publicKey: b64(randBytes(32)) }], - }); - } + const alice = await makeIdentity(); + const bob = await makeIdentity(); + const charlie = await makeIdentity(); - // Delete bob, others remain - await req('DELETE', '/v1/keys/bob'); + await req('POST', '/v1/keys/register', await signedRegisterBody(alice, 'alice')); + await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob')); + await req('POST', '/v1/keys/register', await signedRegisterBody(charlie, 'charlie')); + + // Delete bob with his own signature + const delBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob' }); + await req('DELETE', '/v1/keys/bob', delBody); expect((await req('GET', '/v1/keys/bundle/alice')).status).toBe(200); expect((await req('GET', '/v1/keys/bundle/bob')).status).toBe(404); diff --git a/packages/shade-storage-sqlite/src/sqlite-storage.ts b/packages/shade-storage-sqlite/src/sqlite-storage.ts index 2c4d4c0..e9e7f59 100644 --- a/packages/shade-storage-sqlite/src/sqlite-storage.ts +++ b/packages/shade-storage-sqlite/src/sqlite-storage.ts @@ -1,10 +1,12 @@ import { Database } from 'bun:sqlite'; -import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from '@shade/core'; +import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity } from '@shade/core'; import { toBase64, fromBase64, + constantTimeEqual, serializeSessionState, deserializeSessionState, serializeSignedPreKey, deserializeSignedPreKey, serializeOneTimePreKey, deserializeOneTimePreKey, + serializeIdentityKeyPair, deserializeIdentityKeyPair, } from '@shade/core'; /** @@ -37,6 +39,9 @@ export class SQLiteStorage implements StorageProvider { removeSession: ReturnType; getTrust: ReturnType; saveTrust: ReturnType; + addRetired: ReturnType; + listRetired: ReturnType; + pruneRetired: ReturnType; }; constructor(dbPath?: string) { @@ -76,6 +81,12 @@ export class SQLiteStorage implements StorageProvider { address TEXT PRIMARY KEY, identity_key TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS retired_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + data_json TEXT NOT NULL, + retired_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_retired_at ON retired_identities(retired_at); `); } @@ -97,6 +108,9 @@ export class SQLiteStorage implements StorageProvider { removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'), getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'), saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'), + addRetired: this.db.prepare('INSERT INTO retired_identities (data_json, retired_at) VALUES (?, ?)'), + listRetired: this.db.prepare('SELECT data_json, retired_at FROM retired_identities ORDER BY retired_at DESC'), + pruneRetired: this.db.prepare('DELETE FROM retired_identities WHERE retired_at < ?'), }; } @@ -193,10 +207,32 @@ export class SQLiteStorage implements StorageProvider { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { const row = this.stmts.getTrust.get(address) as any; if (!row) return true; // TOFU - return row.identity_key === toBase64(identityKey); + const storedBytes = fromBase64(row.identity_key); + return constantTimeEqual(storedBytes, identityKey); } async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise { this.stmts.saveTrust.run(address, toBase64(identityKey)); } + + // ─── Identity History ───────────────────────────────────── + + async addRetiredIdentity(identity: RetiredIdentity): Promise { + this.stmts.addRetired.run( + serializeIdentityKeyPair(identity.keyPair), + identity.retiredAt, + ); + } + + async getRetiredIdentities(): Promise { + const rows = this.stmts.listRetired.all() as any[]; + return rows.map((r) => ({ + keyPair: deserializeIdentityKeyPair(r.data_json), + retiredAt: r.retired_at, + })); + } + + async pruneRetiredIdentities(olderThan: number): Promise { + this.stmts.pruneRetired.run(olderThan); + } } diff --git a/packages/shade-transport/src/fetch-transport.ts b/packages/shade-transport/src/fetch-transport.ts index 74753b5..00a5691 100644 --- a/packages/shade-transport/src/fetch-transport.ts +++ b/packages/shade-transport/src/fetch-transport.ts @@ -1,25 +1,55 @@ -import type { PreKeyBundle, OneTimePreKey } from '@shade/core'; +import type { PreKeyBundle, OneTimePreKey, CryptoProvider } from '@shade/core'; +import { fromBase64, NetworkError } from '@shade/core'; /** * HTTP transport client for the Shade Prekey Server. * + * Signing: write operations (register, replenish, delete) are signed with + * the caller's Ed25519 identity signing key. The caller must provide a + * signing function via the constructor. + * * Usage: * ```ts - * const transport = new ShadeFetchTransport('https://shade.example.com'); - * await transport.register('alice', bundle, oneTimePreKeys); - * const bundle = await transport.fetchBundle('bob'); + * const transport = new ShadeFetchTransport({ + * baseUrl: 'https://shade.example.com', + * crypto, + * signingKey: identity.signingPrivateKey, + * }); + * await transport.register('alice', identity, signedPreKey, oneTimePreKeys); + * const bundle = await transport.fetchBundle('bob'); // anonymous * ``` */ export class ShadeFetchTransport { - constructor( - private readonly baseUrl: string, - private readonly authToken?: string, - ) {} + private readonly baseUrl: string; + private readonly crypto: CryptoProvider; + private readonly signingPrivateKey?: Uint8Array; + + constructor(options: { baseUrl: string; crypto: CryptoProvider; signingPrivateKey?: Uint8Array }) { + this.baseUrl = options.baseUrl; + this.crypto = options.crypto; + this.signingPrivateKey = options.signingPrivateKey; + } private headers(): Record { - const h: Record = { 'Content-Type': 'application/json' }; - if (this.authToken) h['Authorization'] = `Bearer ${this.authToken}`; - return h; + return { 'Content-Type': 'application/json' }; + } + + /** Sign a payload using the configured signing key */ + private async sign>(payload: T): Promise { + if (!this.signingPrivateKey) { + throw new Error('ShadeFetchTransport: signingPrivateKey is required for write operations'); + } + const signedAt = Date.now(); + const withTs = { ...payload, signedAt, signature: '' }; + // Canonicalize: sort keys, omit signature + const { signature, ...rest } = withTs; + const sorted = Object.keys(rest).sort().reduce>((acc, k) => { + acc[k] = (rest as Record)[k]; + return acc; + }, {}); + const bytes = new TextEncoder().encode(JSON.stringify(sorted)); + const sig = await this.crypto.sign(this.signingPrivateKey, bytes); + return { ...payload, signedAt, signature: Buffer.from(sig).toString('base64') }; } /** Register identity and upload prekey bundle + one-time prekeys */ @@ -47,20 +77,22 @@ export class ShadeFetchTransport { })); } + const signed = await this.sign(body); + const res = await fetch(`${this.baseUrl}/v1/keys/register`, { method: 'POST', headers: this.headers(), - body: JSON.stringify(body), + body: JSON.stringify(signed), }); - if (!res.ok) throw new Error(`Register failed: ${res.status}`); + if (!res.ok) throw new NetworkError(`Register failed: ${res.status}`, res.status); } - /** Fetch a prekey bundle for a peer (consumes one one-time prekey) */ + /** Fetch a prekey bundle for a peer (anonymous, consumes one one-time prekey) */ async fetchBundle(address: string): Promise { const res = await fetch(`${this.baseUrl}/v1/keys/bundle/${encodeURIComponent(address)}`, { headers: this.headers(), }); - if (!res.ok) throw new Error(`Fetch bundle failed: ${res.status}`); + if (!res.ok) throw new NetworkError(`Fetch bundle failed: ${res.status}`, res.status); const data = await res.json(); return { @@ -81,36 +113,50 @@ export class ShadeFetchTransport { }; } - /** Upload additional one-time prekeys */ + /** Upload additional one-time prekeys (signed) */ async replenish( address: string, keys: Array<{ keyId: number; publicKey: Uint8Array }>, ): Promise { + const body = { + address, + oneTimePreKeys: keys.map((k) => ({ + keyId: k.keyId, + publicKey: toB64(k.publicKey), + })), + }; + const signed = await this.sign(body); + const res = await fetch(`${this.baseUrl}/v1/keys/replenish`, { method: 'POST', headers: this.headers(), - body: JSON.stringify({ - address, - oneTimePreKeys: keys.map((k) => ({ - keyId: k.keyId, - publicKey: toB64(k.publicKey), - })), - }), + body: JSON.stringify(signed), }); - if (!res.ok) throw new Error(`Replenish failed: ${res.status}`); + if (!res.ok) throw new NetworkError(`Replenish failed: ${res.status}`, res.status); const data = await res.json(); return data.remaining; } - /** Get remaining one-time prekey count */ + /** Get remaining one-time prekey count (anonymous) */ async getKeyCount(address: string): Promise { const res = await fetch(`${this.baseUrl}/v1/keys/count/${encodeURIComponent(address)}`, { headers: this.headers(), }); - if (!res.ok) throw new Error(`Count failed: ${res.status}`); + if (!res.ok) throw new NetworkError(`Count failed: ${res.status}`, res.status); const data = await res.json(); return data.count; } + + /** Delete all keys for this identity (signed) */ + async unregister(address: string): Promise { + const signed = await this.sign({ address }); + const res = await fetch(`${this.baseUrl}/v1/keys/${encodeURIComponent(address)}`, { + method: 'DELETE', + headers: this.headers(), + body: JSON.stringify(signed), + }); + if (!res.ok) throw new NetworkError(`Unregister failed: ${res.status}`, res.status); + } } function toB64(bytes: Uint8Array): string { diff --git a/packages/shade-transport/tests/transport.test.ts b/packages/shade-transport/tests/transport.test.ts index 770a10f..1722358 100644 --- a/packages/shade-transport/tests/transport.test.ts +++ b/packages/shade-transport/tests/transport.test.ts @@ -8,55 +8,55 @@ const crypto = new SubtleCryptoProvider(); describe('ShadeFetchTransport', () => { test('full flow: register → fetch bundle → establish session → talk', async () => { - // Start in-process prekey server const store = new MemoryPrekeyStore(); - const server = createPrekeyServer({ store }); + const server = createPrekeyServer({ crypto, store, disableRateLimit: true }); - // We'll use Hono's request() method directly instead of actual HTTP - // But ShadeFetchTransport uses fetch(), so let's start a real server const port = 19000 + Math.floor(Math.random() * 1000); const handle = Bun.serve({ port, fetch: server.fetch }); try { const baseUrl = `http://localhost:${port}`; - const transport = new ShadeFetchTransport(baseUrl); - // ─── Bob: register with prekey server ──────────────── + // ─── Bob: set up session manager, register with transport ── const bobStorage = new MemoryStorage(); const bobManager = new ShadeSessionManager(crypto, bobStorage); await bobManager.initialize(); + const bobIdentity = await bobStorage.getIdentityKeyPair(); + const bobTransport = new ShadeFetchTransport({ + baseUrl, + crypto, + signingPrivateKey: bobIdentity!.signingPrivateKey, + }); + const bobOTPKs = await bobManager.generateOneTimePreKeys(5); const bobBundle = await bobManager.createPreKeyBundle(); - await transport.register( + await bobTransport.register( 'bob', bobManager.getPublicIdentity(), bobBundle.signedPreKey, bobOTPKs, ); - // Verify count - const count = await transport.getKeyCount('bob'); - expect(count).toBe(5); + expect(await bobTransport.getKeyCount('bob')).toBe(5); - // ─── Alice: fetch bundle and establish session ─────── + // ─── Alice: anonymous fetch + establish session ─────── const aliceStorage = new MemoryStorage(); const aliceManager = new ShadeSessionManager(crypto, aliceStorage); await aliceManager.initialize(); - const fetchedBundle = await transport.fetchBundle('bob'); + // Alice doesn't need a signing key to fetch bundles + const aliceTransport = new ShadeFetchTransport({ baseUrl, crypto }); + + const fetchedBundle = await aliceTransport.fetchBundle('bob'); expect(fetchedBundle.identityDHKey).toEqual(bobManager.getPublicIdentity().dhKey); - expect(fetchedBundle.signedPreKey.keyId).toBe(bobBundle.signedPreKey.keyId); - // One OTP key consumed - const countAfter = await transport.getKeyCount('bob'); - expect(countAfter).toBe(4); + expect(await aliceTransport.getKeyCount('bob')).toBe(4); - // Alice establishes session await aliceManager.initSessionFromBundle('bob', fetchedBundle); - // ─── Alice → Bob encrypted message ─────────────────── + // ─── Alice → Bob encrypted ─────────────────────────── const env1 = await aliceManager.encrypt('bob', 'Hello via transport!'); const plain1 = await bobManager.decrypt('alice', env1); expect(plain1).toBe('Hello via transport!'); @@ -66,12 +66,16 @@ describe('ShadeFetchTransport', () => { const plain2 = await aliceManager.decrypt('bob', env2); expect(plain2).toBe('Got it!'); - // ─── Replenish ─────────────────────────────────────── - const remaining = await transport.replenish('bob', [ + // ─── Replenish (signed) ────────────────────────────── + const remaining = await bobTransport.replenish('bob', [ { keyId: 200, publicKey: crypto.randomBytes(32) }, { keyId: 201, publicKey: crypto.randomBytes(32) }, ]); - expect(remaining).toBe(6); // 4 remaining + 2 new + expect(remaining).toBe(6); + + // ─── Unregister (signed) ───────────────────────────── + await bobTransport.unregister('bob'); + await expect(aliceTransport.fetchBundle('bob')).rejects.toThrow(); } finally { handle.stop();