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:
2026-04-09 20:18:21 +02:00
parent 740a652d51
commit d071551b2f
7 changed files with 300 additions and 1 deletions

View File

@@ -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<string, string> {
const h: Record<string, string> = { '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<void> {
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<PreKeyBundle> {
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<number> {
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<number> {
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'));
}

View File

@@ -0,0 +1,2 @@
export { ShadeFetchTransport } from './fetch-transport.js';
export { ShadeWebSocket } from './ws-adapter.js';

View 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);
}
}
}