1234567891011121314151617181920212223242526import {27 noiseKKInitiator,28 noiseKKResponder,29 type NoiseNKSession,30} from "./noise-js";31import { SYNC_LIMITS } from "./sync-limits";32import {33 padSyncFrame,34 unpadSyncFrame,35 MAX_NOISE_PLAINTEXT_BYTES,36 FRAME_CONTINUATION_MARKER,37 chunkPaddedSyncFrame,38} from "./sync-padding";39import { hexToPubkey } from "./sync-identity";40import { buildRendezvousUrl } from "./rendezvous";41import type { DeviceType } from "./sync-types";4243444546474849505152535455565758596061const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;62const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;63export const RELAY_WAITER_KEEPALIVE_MS = 20_000;6465export interface SyncSession {66 send(plaintext: Uint8Array): void;67 receive(): Promise<Uint8Array>;68 close(): Promise<void>;69 remoteStaticPublicKey: Uint8Array;70}71727374export interface SyncPeerIdentity {75 deviceId: string;76 deviceName: string;77 deviceType: DeviceType;78 longTermPublicKeyHex: string;79 80 longTermPrivateKey: Uint8Array;81 signingPublicKeyHex?: string;82}8384export class SyncFrameTooLargeError extends Error {85 constructor(message = "sync frame too large (WebSocket close 1009)") {86 super(message);87 this.name = "SyncFrameTooLargeError";88 }89}9091export class SyncTransportResourceLimitError extends Error {92 constructor() {93 super("sync transport resource limit exceeded");94 this.name = "SyncTransportResourceLimitError";95 }96}9798export interface SyncSessionOptions {99 connectTimeoutMs?: number;100 handshakeTimeoutMs?: number;101 signal?: AbortSignal;102}103104function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {105 return new TextEncoder().encode(JSON.stringify({106 type: "PAIR_IDENTITY",107 device_id: id.deviceId,108 device_name: id.deviceName,109 device_type: id.deviceType,110 long_term_public_key_hex: id.longTermPublicKeyHex,111 signing_public_key_hex: id.signingPublicKeyHex,112 }));113}114115116117118119export async function openRelaySyncSession(120 relayUrl: string,121 ourIdentity: SyncPeerIdentity,122 expectedRemotePubkeyHex: string,123 rendezvousId: string,124 options: SyncSessionOptions = {},125): Promise<SyncSession> {126 return openSyncSessionAtUrl(127 buildRendezvousUrl(relayUrl, rendezvousId),128 "relay",129 ourIdentity,130 expectedRemotePubkeyHex,131 rendezvousId,132 options,133 );134}135136137138139140141142export async function openRelaySyncResponderSession(143 relayUrl: string,144 ourIdentity: SyncPeerIdentity,145 expectedRemotePubkeyHex: string,146 rendezvousId: string,147 options: SyncSessionOptions & { keepaliveMs?: number } = {},148): Promise<SyncSession> {149 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {150 throw new Error("reconnect requires a valid rotating rendezvous id");151 }152 if (ourIdentity.longTermPrivateKey.length !== 32) {153 throw new Error("local reconnect private key must be exactly 32 bytes");154 }155 const ws = await connectWebSocketUrl(156 buildRendezvousUrl(relayUrl, rendezvousId),157 "relay",158 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,159 );160 const inbox: Uint8Array[] = [];161 let inboxBytes = 0;162 const readers: Array<(b: Uint8Array) => void> = [];163 let closed = false;164 let closeError: Error = new Error("WebSocket closed");165 const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS;166 const abortWaiter = () => {167 try { ws.close(1000, "relay waiter replaced"); } catch { }168 };169 const keepalive = setInterval(() => {170 if (ws.readyState !== WebSocket.OPEN) return;171 try { ws.send(new Uint8Array(0)); } catch { }172 }, keepaliveMs);173174 ws.binaryType = "arraybuffer";175 ws.onmessage = (event: MessageEvent) => {176 if (closed) return;177 if (!(event.data instanceof ArrayBuffer)) return;178 if (event.data.byteLength === 0) return;179 if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {180 closeError = new SyncFrameTooLargeError(181 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,182 );183 try { ws.close(1009, "sync frame too large"); } catch { }184 return;185 }186 const bytes = new Uint8Array(event.data);187 const reader = readers.shift();188 if (reader) reader(bytes);189 else if (190 inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||191 inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES192 ) {193 closeError = new SyncTransportResourceLimitError();194 closed = true;195 inbox.length = 0;196 inboxBytes = 0;197 try { ws.close(1009, "sync inbox limit"); } catch { }198 } else {199 inbox.push(bytes);200 inboxBytes += bytes.byteLength;201 }202 };203 ws.onerror = () => {204 try { ws.close(); } catch { }205 };206 ws.onclose = (event: CloseEvent) => {207 clearInterval(keepalive);208 options.signal?.removeEventListener("abort", abortWaiter);209 closed = true;210 inbox.length = 0;211 inboxBytes = 0;212 if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {213 closeError = new SyncFrameTooLargeError();214 }215 while (readers.length > 0) readers.shift()!(new Uint8Array(0));216 };217 218 219 options.signal?.addEventListener("abort", abortWaiter, { once: true });220 if (options.signal?.aborted) abortWaiter();221222 const send = async (bytes: Uint8Array): Promise<void> => {223 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {224 throw new SyncFrameTooLargeError(225 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,226 );227 }228 ws.send(bytes as Uint8Array<ArrayBuffer>);229 };230 const receive = (): Promise<Uint8Array> => {231 if (closed) return Promise.reject(closeError);232 const buffered = inbox.shift();233 if (buffered) {234 inboxBytes -= buffered.byteLength;235 return Promise.resolve(buffered);236 }237 return new Promise<Uint8Array>((resolve, reject) => {238 readers.push((bytes) => {239 if (bytes.length === 0 && closed) reject(closeError);240 else resolve(bytes);241 });242 });243 };244245 let noiseSession: NoiseNKSession | undefined;246 try {247 const selectorBytes = await receive();248 const selector = JSON.parse(new TextDecoder().decode(selectorBytes));249 if (250 selector?.type !== "PEACH_RECONNECT" ||251 typeof selector.rendezvous_id !== "string" ||252 !equalAscii(selector.rendezvous_id, rendezvousId)253 ) {254 throw new Error("reconnect capability does not match relay room");255 }256 const handshakeTimer = setTimeout(() => {257 closeError = new Error("sync_handshake_timeout");258 try { ws.close(1000, "sync handshake timeout"); } catch { }259 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);260 try {261 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);262 noiseSession = await noiseKKResponder(263 ourIdentity.longTermPrivateKey,264 ourPublicKey,265 hexToPubkey(expectedRemotePubkeyHex),266 send,267 receive,268 );269 const theirFrame = JSON.parse(270 new TextDecoder().decode(271 unpadSyncFrame(noiseSession.decrypt(await receive())),272 ),273 );274 if (theirFrame?.type !== "PAIR_IDENTITY") {275 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);276 }277 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {278 throw new Error("peer identity does not match authenticated KK key");279 }280 await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));281 } finally {282 clearTimeout(handshakeTimer);283 }284 } catch (error) {285 clearInterval(keepalive);286 options.signal?.removeEventListener("abort", abortWaiter);287 noiseSession?.close();288 try { ws.close(); } catch { }289 throw error;290 }291292 return buildEncryptedSyncSession(293 ws,294 noiseSession,295 expectedRemotePubkeyHex,296 receive,297 () => {298 clearInterval(keepalive);299 options.signal?.removeEventListener("abort", abortWaiter);300 },301 );302}303304export async function openSyncSessionAtUrl(305 url: string,306 targetLabel: string,307 ourIdentity: SyncPeerIdentity,308 expectedRemotePubkeyHex: string,309 rendezvousId: string,310 options: SyncSessionOptions = {},311): Promise<SyncSession> {312 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {313 throw new Error("reconnect requires a valid rotating rendezvous id");314 }315 const ws = await connectWebSocketUrl(316 url,317 targetLabel,318 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,319 );320 const inbox: Uint8Array[] = [];321 let inboxBytes = 0;322 const readers: Array<(b: Uint8Array) => void> = [];323 let closed = false;324 let closeError: Error = new Error("WebSocket closed");325326 ws.binaryType = "arraybuffer";327 ws.onmessage = (ev: MessageEvent) => {328 if (closed) return;329 const data = ev.data;330 if (data instanceof ArrayBuffer) {331 332 333 334 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {335 336 337 338 console.warn(339 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,340 );341 closeError = new SyncFrameTooLargeError(342 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,343 );344 try { ws.close(1009, "sync frame too large"); } catch { }345 return;346 }347 const bytes = new Uint8Array(data);348 const r = readers.shift();349 if (r) r(bytes);350 else if (351 inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||352 inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES353 ) {354 closeError = new SyncTransportResourceLimitError();355 closed = true;356 inbox.length = 0;357 inboxBytes = 0;358 try { ws.close(1009, "sync inbox limit"); } catch { }359 } else {360 inbox.push(bytes);361 inboxBytes += bytes.byteLength;362 }363 }364 };365 ws.onerror = () => {366 367 368 369 console.warn("[Peach] sync WebSocket error; closing transport");370 try { ws.close(); } catch { }371 };372 ws.onclose = (event: CloseEvent) => {373 closed = true;374 inbox.length = 0;375 inboxBytes = 0;376 if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {377 closeError = new SyncFrameTooLargeError();378 }379 while (readers.length > 0) {380 const r = readers.shift()!;381 r(new Uint8Array(0));382 }383 };384385 function send(bytes: Uint8Array): Promise<void> {386 return new Promise((resolve, reject) => {387 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {388 reject(389 new SyncFrameTooLargeError(390 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,391 ),392 );393 return;394 }395 try {396 ws.send(bytes as Uint8Array<ArrayBuffer>);397 resolve();398 } catch (err) {399 reject(err as Error);400 }401 });402 }403 function receive(): Promise<Uint8Array> {404 if (closed) return Promise.reject(closeError);405 const v = inbox.shift();406 if (v) {407 inboxBytes -= v.byteLength;408 return Promise.resolve(v);409 }410 return new Promise<Uint8Array>((resolve, reject) => {411 readers.push((b) => {412 if (b.length === 0 && closed) reject(closeError);413 else resolve(b);414 });415 });416 }417418 const handshakeTimer = setTimeout(() => {419 closeError = new Error("sync_handshake_timeout");420 try { ws.close(1000, "sync handshake timeout"); } catch { }421 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);422 let session: NoiseNKSession;423 try {424 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);425 if (ourIdentity.longTermPrivateKey.length !== 32) {426 throw new Error("local reconnect private key must be exactly 32 bytes");427 }428 429 430 431 await send(new TextEncoder().encode(JSON.stringify({432 type: "PEACH_RECONNECT",433 rendezvous_id: rendezvousId,434 })));435 session = await noiseKKInitiator(436 ourIdentity.longTermPrivateKey,437 ourPublicKey,438 hexToPubkey(expectedRemotePubkeyHex),439 send,440 receive,441 );442 } catch (err) {443 clearTimeout(handshakeTimer);444 try { ws.close(); } catch { }445 throw err;446 }447448 try {449 await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));450 const theirFrame = JSON.parse(451 new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),452 );453 if (theirFrame?.type !== "PAIR_IDENTITY") {454 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);455 }456 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {457 throw new Error("peer identity does not match authenticated KK key");458 }459 clearTimeout(handshakeTimer);460 } catch (err) {461 clearTimeout(handshakeTimer);462 session.close();463 try { ws.close(); } catch { }464 throw err;465 }466467 return buildEncryptedSyncSession(468 ws,469 session,470 expectedRemotePubkeyHex,471 receive,472 );473}474475476export function buildEncryptedSyncSession(477 ws: WebSocket,478 session: NoiseNKSession,479 expectedRemotePubkeyHex: string,480 receiveCiphertext: () => Promise<Uint8Array>,481 beforeClose: () => void = () => undefined,482): SyncSession {483 return {484 async send(plaintext: Uint8Array) {485 486 487 488 489 490 491 492 493 494 const padded = padSyncFrame(plaintext);495 if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {496 throw new SyncFrameTooLargeError(497 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,498 );499 }500 501 502 503 if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) {504 const ct = session.encrypt(padded);505 ws.send(ct as Uint8Array<ArrayBuffer>);506 return;507 }508 for (const plaintext of chunkPaddedSyncFrame(padded)) {509 if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {510 throw new Error(511 `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,512 );513 }514 const ct = session.encrypt(plaintext);515 if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {516 throw new SyncFrameTooLargeError(517 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,518 );519 }520 ws.send(ct as Uint8Array<ArrayBuffer>);521 }522 },523 async receive() {524 525 526 527 528 let buffered: Uint8Array | null = null;529 while (true) {530 const ct = await receiveCiphertext();531 const pt = session.decrypt(ct);532 if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {533 534 buffered = pt.subarray(1);535 continue;536 }537 if (buffered !== null) {538 if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {539 const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1);540 merged.set(buffered, 0);541 merged.set(pt.subarray(1), buffered.length);542 buffered = merged;543 544 545 546 547 548 if (buffered.length > SYNC_LIMITS.MAX_FRAME_BYTES) {549 throw new SyncFrameTooLargeError(550 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,551 );552 }553 continue;554 }555 556 557 558 if (buffered.length + pt.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {559 throw new SyncFrameTooLargeError(560 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,561 );562 }563 const reassembled = new Uint8Array(buffered.length + pt.byteLength);564 reassembled.set(buffered, 0);565 reassembled.set(pt, buffered.length);566 return unpadSyncFrame(reassembled);567 }568 569 return unpadSyncFrame(pt);570 }571 },572 async close() {573 beforeClose();574 try { ws.close(); } catch { }575 session.close();576 },577 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),578 };579}580581function equalAscii(left: string, right: string): boolean {582 const a = new TextEncoder().encode(left);583 const b = new TextEncoder().encode(right);584 if (a.length !== b.length) return false;585 let diff = 0;586 for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];587 return diff === 0;588}589590function connectWebSocketUrl(591 url: string,592 targetLabel: string,593 timeoutMs: number,594): Promise<WebSocket> {595 return new Promise((resolve, reject) => {596 let ws: WebSocket;597 try {598 ws = new WebSocket(url);599 } catch (err) {600 reject(err as Error);601 return;602 }603 const t = setTimeout(() => {604 try { ws.close(); } catch { }605 reject(new Error(`${targetLabel} WebSocket connect timed out`));606 }, timeoutMs);607 ws.onopen = () => {608 clearTimeout(t);609 resolve(ws);610 };611 ws.onerror = () => {612 clearTimeout(t);613 reject(new Error(`${targetLabel} WebSocket connect errored`));614 };615 });616}