feat: M6 Transport wrappers — fetch + WebSocket adapters
- 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) <noreply@anthropic.com>
This commit is contained in:
83
packages/shade-transport/src/ws-adapter.ts
Normal file
83
packages/shade-transport/src/ws-adapter.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user