diff --git a/bun.lock b/bun.lock index 8860431..796f356 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,7 @@ "version": "0.1.0", "dependencies": { "@shade/core": "workspace:*", - "hono": "^4.0.0", + "hono": "^4.12.12", }, }, "packages/shade-transport": { diff --git a/packages/shade-proto/src/index.ts b/packages/shade-proto/src/index.ts new file mode 100644 index 0000000..49153f5 --- /dev/null +++ b/packages/shade-proto/src/index.ts @@ -0,0 +1 @@ +export { encodeEnvelope, decodeEnvelope, encodePreKeyMessage, encodeRatchetMessage } from './wire.js'; diff --git a/packages/shade-proto/src/wire.ts b/packages/shade-proto/src/wire.ts new file mode 100644 index 0000000..7256506 --- /dev/null +++ b/packages/shade-proto/src/wire.ts @@ -0,0 +1,172 @@ +/** + * Shade Wire Format — compact binary encoding for protocol messages. + * + * Format: [version:1][type:1][payload...] + * + * Types: + * 0x01 = PreKeyMessage + * 0x02 = RatchetMessage + * + * All multi-byte integers are big-endian. + * All byte arrays are length-prefixed (2-byte length + data). + */ + +import type { PreKeyMessage, RatchetMessage, ShadeEnvelope } from '@shade/core'; + +const VERSION = 0x01; + +const TYPE_PREKEY = 0x01; +const TYPE_RATCHET = 0x02; + +// ─── Encode ────────────────────────────────────────────────── + +export function encodeEnvelope(envelope: ShadeEnvelope): Uint8Array { + if (envelope.type === 'prekey') { + return encodePreKeyMessage(envelope.content as PreKeyMessage); + } + return encodeRatchetMessage(envelope.content as RatchetMessage); +} + +export function encodePreKeyMessage(msg: PreKeyMessage): Uint8Array { + const ratchetBytes = encodeRatchetMessageInner(msg.message); + const parts: Uint8Array[] = []; + + // Header + parts.push(new Uint8Array([VERSION, TYPE_PREKEY])); + + // registrationId (4 bytes) + parts.push(uint32(msg.registrationId)); + + // preKeyId (4 bytes, 0xFFFFFFFF = none) + parts.push(uint32(msg.preKeyId ?? 0xFFFFFFFF)); + + // signedPreKeyId (4 bytes) + parts.push(uint32(msg.signedPreKeyId)); + + // ephemeralKey (length-prefixed) + parts.push(lpBytes(msg.ephemeralKey)); + + // identityDHKey (length-prefixed) + parts.push(lpBytes(msg.identityDHKey)); + + // embedded ratchet message (length-prefixed) + parts.push(lpBytes(ratchetBytes)); + + return concat(parts); +} + +export function encodeRatchetMessage(msg: RatchetMessage): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(new Uint8Array([VERSION, TYPE_RATCHET])); + parts.push(encodeRatchetMessageInner(msg)); + return concat(parts); +} + +function encodeRatchetMessageInner(msg: RatchetMessage): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(lpBytes(msg.dhPublicKey)); + parts.push(uint32(msg.previousCounter)); + parts.push(uint32(msg.counter)); + parts.push(lpBytes(msg.ciphertext)); + parts.push(lpBytes(msg.nonce)); + return concat(parts); +} + +// ─── Decode ────────────────────────────────────────────────── + +export function decodeEnvelope(data: Uint8Array): ShadeEnvelope { + if (data.length < 2) throw new Error('Too short'); + const version = data[0]; + if (version !== VERSION) throw new Error(`Unknown version: ${version}`); + + const type = data[1]; + const payload = data.slice(2); + + if (type === TYPE_PREKEY) { + const msg = decodePreKeyMessageInner(payload); + return { type: 'prekey', content: msg, timestamp: 0, senderAddress: '' }; + } + if (type === TYPE_RATCHET) { + const msg = decodeRatchetMessageInner(payload, 0).value; + return { type: 'ratchet', content: msg, timestamp: 0, senderAddress: '' }; + } + throw new Error(`Unknown type: ${type}`); +} + +function decodePreKeyMessageInner(data: Uint8Array): PreKeyMessage { + let offset = 0; + + const registrationId = readUint32(data, offset); offset += 4; + const preKeyIdRaw = readUint32(data, offset); offset += 4; + const preKeyId = preKeyIdRaw === 0xFFFFFFFF ? undefined : preKeyIdRaw; + const signedPreKeyId = readUint32(data, offset); offset += 4; + + const ephemeral = readLP(data, offset); offset = ephemeral.end; + const identityDH = readLP(data, offset); offset = identityDH.end; + const ratchetData = readLP(data, offset); offset = ratchetData.end; + + const ratchet = decodeRatchetMessageInner(ratchetData.value, 0); + + return { + registrationId, + preKeyId, + signedPreKeyId, + ephemeralKey: ephemeral.value, + identityDHKey: identityDH.value, + message: ratchet.value, + }; +} + +function decodeRatchetMessageInner(data: Uint8Array, offset: number): { value: RatchetMessage; end: number } { + const dhPub = readLP(data, offset); offset = dhPub.end; + const prevCounter = readUint32(data, offset); offset += 4; + const counter = readUint32(data, offset); offset += 4; + const ciphertext = readLP(data, offset); offset = ciphertext.end; + const nonce = readLP(data, offset); offset = nonce.end; + + return { + value: { + dhPublicKey: dhPub.value, + previousCounter: prevCounter, + counter, + ciphertext: ciphertext.value, + nonce: nonce.value, + }, + end: offset, + }; +} + +// ─── Helpers ───────────────────────────────────────────────── + +function uint32(n: number): Uint8Array { + const buf = new Uint8Array(4); + new DataView(buf.buffer).setUint32(0, n, false); + return buf; +} + +function lpBytes(data: Uint8Array): Uint8Array { + const len = new Uint8Array(2); + new DataView(len.buffer).setUint16(0, data.length, false); + return concat([len, data]); +} + +function readUint32(data: Uint8Array, offset: number): number { + return new DataView(data.buffer, data.byteOffset + offset).getUint32(0, false); +} + +function readLP(data: Uint8Array, offset: number): { value: Uint8Array; end: number } { + const len = new DataView(data.buffer, data.byteOffset + offset).getUint16(0, false); + const value = data.slice(offset + 2, offset + 2 + len); + return { value, end: offset + 2 + len }; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const result = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + result.set(p, offset); + offset += p.length; + } + return result; +} diff --git a/packages/shade-proto/tests/wire.test.ts b/packages/shade-proto/tests/wire.test.ts new file mode 100644 index 0000000..b9d6d36 --- /dev/null +++ b/packages/shade-proto/tests/wire.test.ts @@ -0,0 +1,185 @@ +import { describe, test, expect } from 'bun:test'; +import { encodeEnvelope, decodeEnvelope, encodePreKeyMessage, encodeRatchetMessage } from '../src/index.js'; +import type { PreKeyMessage, RatchetMessage, ShadeEnvelope } from '@shade/core'; + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + crypto.getRandomValues(buf); + return buf; +} + +function makeRatchetMessage(): RatchetMessage { + return { + dhPublicKey: randBytes(32), + previousCounter: 42, + counter: 7, + ciphertext: randBytes(64), + nonce: randBytes(12), + }; +} + +function makePreKeyMessage(): PreKeyMessage { + return { + registrationId: 12345, + preKeyId: 100, + signedPreKeyId: 1, + ephemeralKey: randBytes(32), + identityDHKey: randBytes(32), + message: makeRatchetMessage(), + }; +} + +describe('Wire Format', () => { + // ─── RatchetMessage ──────────────────────────────────────── + + describe('RatchetMessage', () => { + test('encode/decode roundtrip', () => { + const msg = makeRatchetMessage(); + const envelope: ShadeEnvelope = { + type: 'ratchet', + content: msg, + timestamp: Date.now(), + senderAddress: 'alice', + }; + + const encoded = encodeEnvelope(envelope); + const decoded = decodeEnvelope(encoded); + + expect(decoded.type).toBe('ratchet'); + const rm = decoded.content as RatchetMessage; + expect(rm.dhPublicKey).toEqual(msg.dhPublicKey); + expect(rm.previousCounter).toBe(42); + expect(rm.counter).toBe(7); + expect(rm.ciphertext).toEqual(msg.ciphertext); + expect(rm.nonce).toEqual(msg.nonce); + }); + + test('compact size (smaller than JSON)', () => { + const msg = makeRatchetMessage(); + const envelope: ShadeEnvelope = { + type: 'ratchet', + content: msg, + timestamp: 0, + senderAddress: '', + }; + + const binary = encodeEnvelope(envelope); + const json = new TextEncoder().encode(JSON.stringify(envelope)); + + // Binary should be significantly smaller + expect(binary.length).toBeLessThan(json.length); + }); + }); + + // ─── PreKeyMessage ───────────────────────────────────────── + + describe('PreKeyMessage', () => { + test('encode/decode roundtrip with preKeyId', () => { + const msg = makePreKeyMessage(); + const envelope: ShadeEnvelope = { + type: 'prekey', + content: msg, + timestamp: Date.now(), + senderAddress: 'alice', + }; + + const encoded = encodeEnvelope(envelope); + const decoded = decodeEnvelope(encoded); + + expect(decoded.type).toBe('prekey'); + const pm = decoded.content as PreKeyMessage; + expect(pm.registrationId).toBe(12345); + expect(pm.preKeyId).toBe(100); + expect(pm.signedPreKeyId).toBe(1); + expect(pm.ephemeralKey).toEqual(msg.ephemeralKey); + expect(pm.identityDHKey).toEqual(msg.identityDHKey); + + // Nested ratchet message + expect(pm.message.dhPublicKey).toEqual(msg.message.dhPublicKey); + expect(pm.message.counter).toBe(msg.message.counter); + expect(pm.message.ciphertext).toEqual(msg.message.ciphertext); + expect(pm.message.nonce).toEqual(msg.message.nonce); + }); + + test('encode/decode roundtrip without preKeyId', () => { + const msg = makePreKeyMessage(); + msg.preKeyId = undefined; + + const envelope: ShadeEnvelope = { + type: 'prekey', + content: msg, + timestamp: 0, + senderAddress: '', + }; + + const encoded = encodeEnvelope(envelope); + const decoded = decodeEnvelope(encoded); + + const pm = decoded.content as PreKeyMessage; + expect(pm.preKeyId).toBeUndefined(); + }); + }); + + // ─── Edge Cases ──────────────────────────────────────────── + + describe('edge cases', () => { + test('empty ciphertext', () => { + const msg: RatchetMessage = { + dhPublicKey: randBytes(32), + previousCounter: 0, + counter: 0, + ciphertext: new Uint8Array(0), + nonce: randBytes(12), + }; + + const encoded = encodeRatchetMessage(msg); + const decoded = decodeEnvelope(encoded); + expect((decoded.content as RatchetMessage).ciphertext.length).toBe(0); + }); + + test('large ciphertext (10KB)', () => { + const msg: RatchetMessage = { + dhPublicKey: randBytes(32), + previousCounter: 0, + counter: 0, + ciphertext: randBytes(10240), + nonce: randBytes(12), + }; + + const encoded = encodeRatchetMessage(msg); + const decoded = decodeEnvelope(encoded); + expect((decoded.content as RatchetMessage).ciphertext).toEqual(msg.ciphertext); + }); + + test('max counter values', () => { + const msg: RatchetMessage = { + dhPublicKey: randBytes(32), + previousCounter: 0xFFFFFFFF - 1, + counter: 0xFFFFFFFF - 1, + ciphertext: randBytes(16), + nonce: randBytes(12), + }; + + const encoded = encodeRatchetMessage(msg); + const decoded = decodeEnvelope(encoded); + const rm = decoded.content as RatchetMessage; + expect(rm.previousCounter).toBe(0xFFFFFFFF - 1); + expect(rm.counter).toBe(0xFFFFFFFF - 1); + }); + + test('rejects unknown version', () => { + const data = new Uint8Array([0xFF, 0x01]); + expect(() => decodeEnvelope(data)).toThrow('Unknown version'); + }); + + test('rejects unknown type', () => { + const data = new Uint8Array([0x01, 0xFF]); + expect(() => decodeEnvelope(data)).toThrow('Unknown type'); + }); + + test('rejects too-short data', () => { + expect(() => decodeEnvelope(new Uint8Array([0x01]))).toThrow('Too short'); + expect(() => decodeEnvelope(new Uint8Array([]))).toThrow('Too short'); + }); + }); +}); diff --git a/packages/shade-proto/tsconfig.json b/packages/shade-proto/tsconfig.json new file mode 100644 index 0000000..a086b14 --- /dev/null +++ b/packages/shade-proto/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shade-server/Dockerfile b/packages/shade-server/Dockerfile new file mode 100644 index 0000000..097289a --- /dev/null +++ b/packages/shade-server/Dockerfile @@ -0,0 +1,11 @@ +FROM oven/bun:1-alpine + +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --production --frozen-lockfile + +COPY src/ src/ + +EXPOSE 3900 + +CMD ["bun", "run", "src/standalone.ts"] diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index 2d4b827..aa41aa8 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -6,6 +6,6 @@ "types": "src/index.ts", "dependencies": { "@shade/core": "workspace:*", - "hono": "^4.0.0" + "hono": "^4.12.12" } } diff --git a/packages/shade-server/src/index.ts b/packages/shade-server/src/index.ts new file mode 100644 index 0000000..9b6bd6b --- /dev/null +++ b/packages/shade-server/src/index.ts @@ -0,0 +1,28 @@ +import { Hono } from 'hono'; +import { createPrekeyRoutes } from './routes.js'; +import { MemoryPrekeyStore } from './memory-store.js'; +import type { PrekeyStore } from './store.js'; + +export { createPrekeyRoutes } from './routes.js'; +export { MemoryPrekeyStore } from './memory-store.js'; +export type { PrekeyStore } from './store.js'; + +/** + * Create a standalone Shade Prekey Server. + * + * Can be used standalone (Docker) or embedded in another Hono app. + * + * Standalone: + * const server = createPrekeyServer(); + * export default { port: 3900, fetch: server.fetch }; + * + * Embedded: + * const app = new Hono(); + * app.route('/shade', createPrekeyServer()); + */ +export function createPrekeyServer(options?: { + store?: PrekeyStore; +}): Hono { + const store = options?.store ?? new MemoryPrekeyStore(); + return createPrekeyRoutes(store); +} diff --git a/packages/shade-server/src/memory-store.ts b/packages/shade-server/src/memory-store.ts new file mode 100644 index 0000000..5252ef8 --- /dev/null +++ b/packages/shade-server/src/memory-store.ts @@ -0,0 +1,64 @@ +import type { PrekeyStore } from './store.js'; + +interface IdentityRecord { + identitySigningKey: Uint8Array; + identityDHKey: Uint8Array; +} + +interface SignedPreKeyRecord { + keyId: number; + publicKey: Uint8Array; + signature: Uint8Array; +} + +interface OneTimePreKeyRecord { + keyId: number; + publicKey: Uint8Array; +} + +/** + * In-memory PrekeyStore for testing and embedded use. + */ +export class MemoryPrekeyStore implements PrekeyStore { + private identities = new Map(); + private signedPreKeys = new Map(); + private oneTimePreKeys = new Map(); + + async saveIdentity(address: string, identitySigningKey: Uint8Array, identityDHKey: Uint8Array): Promise { + this.identities.set(address, { identitySigningKey, identityDHKey }); + } + + async getIdentity(address: string): Promise { + return this.identities.get(address) ?? null; + } + + async saveSignedPreKey(address: string, keyId: number, publicKey: Uint8Array, signature: Uint8Array): Promise { + this.signedPreKeys.set(address, { keyId, publicKey, signature }); + } + + async getSignedPreKey(address: string): Promise { + return this.signedPreKeys.get(address) ?? null; + } + + async saveOneTimePreKeys(address: string, keys: Array<{ keyId: number; publicKey: Uint8Array }>): Promise { + const existing = this.oneTimePreKeys.get(address) ?? []; + existing.push(...keys); + this.oneTimePreKeys.set(address, existing); + } + + async consumeOneTimePreKey(address: string): Promise { + const keys = this.oneTimePreKeys.get(address); + if (!keys || keys.length === 0) return null; + return keys.shift()!; + } + + async getOneTimePreKeyCount(address: string): Promise { + return this.oneTimePreKeys.get(address)?.length ?? 0; + } + + async deleteAll(address: string): Promise { + this.identities.delete(address); + this.signedPreKeys.delete(address); + this.oneTimePreKeys.delete(address); + } +} diff --git a/packages/shade-server/src/routes.ts b/packages/shade-server/src/routes.ts new file mode 100644 index 0000000..7b46815 --- /dev/null +++ b/packages/shade-server/src/routes.ts @@ -0,0 +1,130 @@ +import { Hono } from 'hono'; +import type { PrekeyStore } from './store.js'; + +/** + * 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) + */ +export function createPrekeyRoutes(store: PrekeyStore): Hono { + const app = new Hono(); + + // ─── Register ────────────────────────────────────────────── + app.post('/v1/keys/register', async (c) => { + const body = await c.req.json(); + const { address, identitySigningKey, identityDHKey, signedPreKey, oneTimePreKeys } = body; + + if (!address || !identitySigningKey || !identityDHKey || !signedPreKey) { + return c.json({ error: 'Missing required fields' }, 400); + } + + // Decode base64 keys + const signingKey = b64ToBytes(identitySigningKey); + const dhKey = b64ToBytes(identityDHKey); + + await store.saveIdentity(address, signingKey, dhKey); + await store.saveSignedPreKey( + address, + signedPreKey.keyId, + b64ToBytes(signedPreKey.publicKey), + b64ToBytes(signedPreKey.signature), + ); + + if (oneTimePreKeys && Array.isArray(oneTimePreKeys)) { + const keys = oneTimePreKeys.map((k: any) => ({ + keyId: k.keyId, + publicKey: b64ToBytes(k.publicKey), + })); + await store.saveOneTimePreKeys(address, keys); + } + + return c.json({ ok: true }); + }); + + // ─── Fetch Bundle ────────────────────────────────────────── + app.get('/v1/keys/bundle/:address', async (c) => { + const address = c.req.param('address'); + + const identity = await store.getIdentity(address); + if (!identity) { + return c.json({ error: 'Address not found' }, 404); + } + + const signedPreKey = await store.getSignedPreKey(address); + if (!signedPreKey) { + return c.json({ error: 'No signed prekey' }, 404); + } + + // Consume one one-time prekey (if available) + const oneTimePreKey = await store.consumeOneTimePreKey(address); + + const bundle: any = { + identitySigningKey: bytesToB64(identity.identitySigningKey), + identityDHKey: bytesToB64(identity.identityDHKey), + signedPreKey: { + keyId: signedPreKey.keyId, + publicKey: bytesToB64(signedPreKey.publicKey), + signature: bytesToB64(signedPreKey.signature), + }, + }; + + if (oneTimePreKey) { + bundle.oneTimePreKey = { + keyId: oneTimePreKey.keyId, + publicKey: bytesToB64(oneTimePreKey.publicKey), + }; + } + + return c.json(bundle); + }); + + // ─── Replenish One-Time Prekeys ──────────────────────────── + app.post('/v1/keys/replenish', async (c) => { + const body = await c.req.json(); + const { address, oneTimePreKeys } = body; + + if (!address || !oneTimePreKeys || !Array.isArray(oneTimePreKeys)) { + return c.json({ error: 'Missing address or oneTimePreKeys' }, 400); + } + + const keys = oneTimePreKeys.map((k: any) => ({ + keyId: k.keyId, + publicKey: b64ToBytes(k.publicKey), + })); + await store.saveOneTimePreKeys(address, keys); + + const count = await store.getOneTimePreKeyCount(address); + return c.json({ ok: true, remaining: count }); + }); + + // ─── Get Count ───────────────────────────────────────────── + app.get('/v1/keys/count/:address', async (c) => { + const address = c.req.param('address'); + const count = await store.getOneTimePreKeyCount(address); + return c.json({ count }); + }); + + // ─── Delete ──────────────────────────────────────────────── + app.delete('/v1/keys/:address', async (c) => { + const address = c.req.param('address'); + await store.deleteAll(address); + return c.json({ ok: true }); + }); + + return app; +} + +// ─── Base64 helpers ────────────────────────────────────────── + +function bytesToB64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64'); +} + +function b64ToBytes(b64: string): Uint8Array { + return new Uint8Array(Buffer.from(b64, 'base64')); +} diff --git a/packages/shade-server/src/standalone.ts b/packages/shade-server/src/standalone.ts new file mode 100644 index 0000000..6fd4bf9 --- /dev/null +++ b/packages/shade-server/src/standalone.ts @@ -0,0 +1,10 @@ +import { createPrekeyServer } from './index.js'; + +const server = createPrekeyServer(); + +export default { + port: Number(process.env.PORT ?? 3900), + fetch: server.fetch, +}; + +console.log(`Shade Prekey Server listening on port ${process.env.PORT ?? 3900}`); diff --git a/packages/shade-server/src/store.ts b/packages/shade-server/src/store.ts new file mode 100644 index 0000000..e873683 --- /dev/null +++ b/packages/shade-server/src/store.ts @@ -0,0 +1,48 @@ +/** + * PrekeyStore — server-side storage interface for prekey bundles. + * + * The prekey server stores public keys only (never private keys). + */ +export interface PrekeyStore { + /** Save or update an identity for an address */ + saveIdentity( + address: string, + identitySigningKey: Uint8Array, + identityDHKey: Uint8Array, + ): Promise; + + /** Get an identity by address */ + getIdentity( + address: string, + ): Promise<{ identitySigningKey: Uint8Array; identityDHKey: Uint8Array } | null>; + + /** Save or update a signed prekey for an address */ + saveSignedPreKey( + address: string, + keyId: number, + publicKey: Uint8Array, + signature: Uint8Array, + ): Promise; + + /** Get the current signed prekey for an address */ + getSignedPreKey( + address: string, + ): Promise<{ keyId: number; publicKey: Uint8Array; signature: Uint8Array } | null>; + + /** Add one-time prekeys for an address */ + saveOneTimePreKeys( + address: string, + keys: Array<{ keyId: number; publicKey: Uint8Array }>, + ): Promise; + + /** Consume (pop) one one-time prekey for an address. Returns null if none left. */ + consumeOneTimePreKey( + address: string, + ): Promise<{ keyId: number; publicKey: Uint8Array } | null>; + + /** Get remaining one-time prekey count for an address */ + getOneTimePreKeyCount(address: string): Promise; + + /** Delete all keys for an address */ + deleteAll(address: string): Promise; +} diff --git a/packages/shade-server/tests/server.test.ts b/packages/shade-server/tests/server.test.ts new file mode 100644 index 0000000..558ed9d --- /dev/null +++ b/packages/shade-server/tests/server.test.ts @@ -0,0 +1,231 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { createPrekeyServer, MemoryPrekeyStore } from '../src/index.js'; +import type { PrekeyStore } from '../src/index.js'; + +function b64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64'); +} + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + crypto.getRandomValues(buf); + return buf; +} + +describe('Shade Prekey Server', () => { + let store: PrekeyStore; + let app: ReturnType; + + beforeEach(() => { + store = new MemoryPrekeyStore(); + app = createPrekeyServer({ store }); + }); + + function req(method: string, path: string, body?: any) { + const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } }; + if (body) init.body = JSON.stringify(body); + return app.request(path, init); + } + + // ─── 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)), + }, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + test('registers with one-time prekeys', 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)), + }, + oneTimePreKeys: [ + { keyId: 100, publicKey: b64(randBytes(32)) }, + { keyId: 101, publicKey: b64(randBytes(32)) }, + { keyId: 102, publicKey: b64(randBytes(32)) }, + ], + }); + 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' }); + expect(res.status).toBe(400); + }); + }); + + // ─── Fetch Bundle ────────────────────────────────────────── + + 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 }], + }); + + 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.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)) }, + }); + + 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); + }); + }); + + // ─── Replenish ───────────────────────────────────────────── + + 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)) }], + }); + + const res = await req('POST', '/v1/keys/replenish', { + address: 'bob', + oneTimePreKeys: [ + { keyId: 200, publicKey: b64(randBytes(32)) }, + { keyId: 201, publicKey: b64(randBytes(32)) }, + ], + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.remaining).toBe(3); // 1 original + 2 new + }); + }); + + // ─── Delete ──────────────────────────────────────────────── + + 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)) }], + }); + + const delRes = await req('DELETE', '/v1/keys/bob'); + expect(delRes.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); + }); + }); + + // ─── Multiple Addresses ──────────────────────────────────── + + 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)) }], + }); + } + + // Delete bob, others remain + await req('DELETE', '/v1/keys/bob'); + + expect((await req('GET', '/v1/keys/bundle/alice')).status).toBe(200); + expect((await req('GET', '/v1/keys/bundle/bob')).status).toBe(404); + expect((await req('GET', '/v1/keys/bundle/charlie')).status).toBe(200); + }); + }); +}); diff --git a/packages/shade-server/tsconfig.json b/packages/shade-server/tsconfig.json new file mode 100644 index 0000000..a086b14 --- /dev/null +++ b/packages/shade-server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +}