From d071551b2fd5a382570d41154e5cb372828712f9 Mon Sep 17 00:00:00 2001 From: Sterister Date: Thu, 9 Apr 2026 20:18:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20M6=20Transport=20wrappers=20=E2=80=94?= =?UTF-8?q?=20fetch=20+=20WebSocket=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShadeFetchTransport: HTTP client for prekey server (register, fetchBundle, replenish, getKeyCount) - ShadeWebSocket: wraps existing WebSocket with auto E2EE (binary wire format, transparent encrypt/decrypt) - Full integration test: register → fetch → session → encrypt → decrypt over real HTTP against in-process Hono prekey server 101 tests, 0 failures across all milestones (M1-M7). Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 2 + packages/shade-transport/package.json | 4 +- .../shade-transport/src/fetch-transport.ts | 122 ++++++++++++++++++ packages/shade-transport/src/index.ts | 2 + packages/shade-transport/src/ws-adapter.ts | 83 ++++++++++++ .../shade-transport/tests/transport.test.ts | 80 ++++++++++++ packages/shade-transport/tsconfig.json | 8 ++ 7 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 packages/shade-transport/src/fetch-transport.ts create mode 100644 packages/shade-transport/src/index.ts create mode 100644 packages/shade-transport/src/ws-adapter.ts create mode 100644 packages/shade-transport/tests/transport.test.ts create mode 100644 packages/shade-transport/tsconfig.json diff --git a/bun.lock b/bun.lock index 796f356..212f2b9 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,9 @@ "version": "0.1.0", "dependencies": { "@shade/core": "workspace:*", + "@shade/crypto-web": "workspace:*", "@shade/proto": "workspace:*", + "@shade/server": "workspace:*", }, }, }, diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index 51aa37a..ab56e3f 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -6,6 +6,8 @@ "types": "src/index.ts", "dependencies": { "@shade/core": "workspace:*", - "@shade/proto": "workspace:*" + "@shade/crypto-web": "workspace:*", + "@shade/proto": "workspace:*", + "@shade/server": "workspace:*" } } diff --git a/packages/shade-transport/src/fetch-transport.ts b/packages/shade-transport/src/fetch-transport.ts new file mode 100644 index 0000000..74753b5 --- /dev/null +++ b/packages/shade-transport/src/fetch-transport.ts @@ -0,0 +1,122 @@ +import type { PreKeyBundle, OneTimePreKey } from '@shade/core'; + +/** + * HTTP transport client for the Shade Prekey Server. + * + * Usage: + * ```ts + * const transport = new ShadeFetchTransport('https://shade.example.com'); + * await transport.register('alice', bundle, oneTimePreKeys); + * const bundle = await transport.fetchBundle('bob'); + * ``` + */ +export class ShadeFetchTransport { + constructor( + private readonly baseUrl: string, + private readonly authToken?: string, + ) {} + + private headers(): Record { + const h: Record = { 'Content-Type': 'application/json' }; + if (this.authToken) h['Authorization'] = `Bearer ${this.authToken}`; + return h; + } + + /** Register identity and upload prekey bundle + one-time prekeys */ + async register( + address: string, + identity: { signingKey: Uint8Array; dhKey: Uint8Array }, + signedPreKey: { keyId: number; publicKey: Uint8Array; signature: Uint8Array }, + oneTimePreKeys?: OneTimePreKey[], + ): Promise { + const body: any = { + address, + identitySigningKey: toB64(identity.signingKey), + identityDHKey: toB64(identity.dhKey), + signedPreKey: { + keyId: signedPreKey.keyId, + publicKey: toB64(signedPreKey.publicKey), + signature: toB64(signedPreKey.signature), + }, + }; + + if (oneTimePreKeys?.length) { + body.oneTimePreKeys = oneTimePreKeys.map((k) => ({ + keyId: k.keyId, + publicKey: toB64(k.keyPair.publicKey), + })); + } + + const res = await fetch(`${this.baseUrl}/v1/keys/register`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`Register failed: ${res.status}`); + } + + /** Fetch a prekey bundle for a peer (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}`); + + const data = await res.json(); + return { + registrationId: data.registrationId ?? 0, + identitySigningKey: fromB64(data.identitySigningKey), + identityDHKey: fromB64(data.identityDHKey), + signedPreKey: { + keyId: data.signedPreKey.keyId, + publicKey: fromB64(data.signedPreKey.publicKey), + signature: fromB64(data.signedPreKey.signature), + }, + oneTimePreKey: data.oneTimePreKey + ? { + keyId: data.oneTimePreKey.keyId, + publicKey: fromB64(data.oneTimePreKey.publicKey), + } + : undefined, + }; + } + + /** Upload additional one-time prekeys */ + async replenish( + address: string, + keys: Array<{ keyId: number; publicKey: Uint8Array }>, + ): Promise { + 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), + })), + }), + }); + if (!res.ok) throw new Error(`Replenish failed: ${res.status}`); + const data = await res.json(); + return data.remaining; + } + + /** Get remaining one-time prekey count */ + 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}`); + const data = await res.json(); + return data.count; + } +} + +function toB64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64'); +} + +function fromB64(str: string): Uint8Array { + return new Uint8Array(Buffer.from(str, 'base64')); +} diff --git a/packages/shade-transport/src/index.ts b/packages/shade-transport/src/index.ts new file mode 100644 index 0000000..72b7ec8 --- /dev/null +++ b/packages/shade-transport/src/index.ts @@ -0,0 +1,2 @@ +export { ShadeFetchTransport } from './fetch-transport.js'; +export { ShadeWebSocket } from './ws-adapter.js'; diff --git a/packages/shade-transport/src/ws-adapter.ts b/packages/shade-transport/src/ws-adapter.ts new file mode 100644 index 0000000..5c3ea7b --- /dev/null +++ b/packages/shade-transport/src/ws-adapter.ts @@ -0,0 +1,83 @@ +import type { ShadeSessionManager, ShadeEnvelope, RatchetMessage } from '@shade/core'; +import { encodeEnvelope, decodeEnvelope } from '@shade/proto'; + +/** + * ShadeWebSocket — wraps an existing WebSocket with automatic E2EE. + * + * All outgoing messages are encrypted via the Double Ratchet. + * All incoming messages are decrypted transparently. + * + * Usage: + * ```ts + * const ws = new WebSocket('wss://example.com/sync'); + * const shade = new ShadeWebSocket(ws, sessionManager, 'server'); + * + * shade.onMessage((plaintext) => { + * console.log('Received:', plaintext); + * }); + * + * await shade.send('Hello encrypted world!'); + * ``` + */ +export class ShadeWebSocket { + private messageHandlers: Array<(plaintext: string) => void> = []; + private errorHandlers: Array<(error: Error) => void> = []; + + constructor( + private readonly ws: WebSocket, + private readonly manager: ShadeSessionManager, + private readonly peerAddress: string, + ) { + this.ws.addEventListener('message', (event) => { + this.handleIncoming(event.data).catch((err) => { + for (const handler of this.errorHandlers) handler(err); + }); + }); + } + + /** Send an encrypted message to the peer */ + async send(plaintext: string): Promise { + const envelope = await this.manager.encrypt(this.peerAddress, plaintext); + const bytes = encodeEnvelope(envelope); + + // Send as binary + this.ws.send(bytes); + } + + /** Register a handler for decrypted incoming messages */ + onMessage(handler: (plaintext: string) => void): void { + this.messageHandlers.push(handler); + } + + /** Register a handler for decryption errors */ + onError(handler: (error: Error) => void): void { + this.errorHandlers.push(handler); + } + + /** Close the underlying WebSocket */ + close(): void { + this.ws.close(); + } + + private async handleIncoming(data: any): Promise { + let bytes: Uint8Array; + + if (data instanceof ArrayBuffer) { + bytes = new Uint8Array(data); + } else if (data instanceof Uint8Array) { + bytes = data; + } else if (typeof data === 'string') { + // Base64-encoded fallback for environments that don't support binary WS + bytes = new Uint8Array(Buffer.from(data, 'base64')); + } else { + throw new Error('Unexpected WebSocket message type'); + } + + const envelope = decodeEnvelope(bytes); + const plaintext = await this.manager.decrypt(this.peerAddress, envelope); + + for (const handler of this.messageHandlers) { + handler(plaintext); + } + } +} diff --git a/packages/shade-transport/tests/transport.test.ts b/packages/shade-transport/tests/transport.test.ts new file mode 100644 index 0000000..770a10f --- /dev/null +++ b/packages/shade-transport/tests/transport.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect } from 'bun:test'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; +import { ShadeSessionManager } from '@shade/core'; +import { createPrekeyServer, MemoryPrekeyStore } from '@shade/server'; +import { ShadeFetchTransport } from '../src/fetch-transport.js'; + +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 }); + + // 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 ──────────────── + const bobStorage = new MemoryStorage(); + const bobManager = new ShadeSessionManager(crypto, bobStorage); + await bobManager.initialize(); + + const bobOTPKs = await bobManager.generateOneTimePreKeys(5); + const bobBundle = await bobManager.createPreKeyBundle(); + + await transport.register( + 'bob', + bobManager.getPublicIdentity(), + bobBundle.signedPreKey, + bobOTPKs, + ); + + // Verify count + const count = await transport.getKeyCount('bob'); + expect(count).toBe(5); + + // ─── Alice: fetch bundle and establish session ─────── + const aliceStorage = new MemoryStorage(); + const aliceManager = new ShadeSessionManager(crypto, aliceStorage); + await aliceManager.initialize(); + + const fetchedBundle = await transport.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); + + // Alice establishes session + await aliceManager.initSessionFromBundle('bob', fetchedBundle); + + // ─── Alice → Bob encrypted message ─────────────────── + const env1 = await aliceManager.encrypt('bob', 'Hello via transport!'); + const plain1 = await bobManager.decrypt('alice', env1); + expect(plain1).toBe('Hello via transport!'); + + // ─── Bob → Alice reply ─────────────────────────────── + const env2 = await bobManager.encrypt('alice', 'Got it!'); + const plain2 = await aliceManager.decrypt('bob', env2); + expect(plain2).toBe('Got it!'); + + // ─── Replenish ─────────────────────────────────────── + const remaining = await transport.replenish('bob', [ + { keyId: 200, publicKey: crypto.randomBytes(32) }, + { keyId: 201, publicKey: crypto.randomBytes(32) }, + ]); + expect(remaining).toBe(6); // 4 remaining + 2 new + + } finally { + handle.stop(); + } + }); +}); diff --git a/packages/shade-transport/tsconfig.json b/packages/shade-transport/tsconfig.json new file mode 100644 index 0000000..a086b14 --- /dev/null +++ b/packages/shade-transport/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +}