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;64export const SYNC_REASSEMBLY_TIMEOUT_MS = 30_000;6566export interface SyncSession {67 send(plaintext: Uint8Array): void;68 receive(): Promise<Uint8Array>;69 close(): Promise<void>;70 remoteStaticPublicKey: Uint8Array;71}72737475export interface SyncPeerIdentity {76 deviceId: string;77 deviceName: string;78 deviceType: DeviceType;79 longTermPublicKeyHex: string;80 81 longTermPrivateKey: Uint8Array;82 signingPublicKeyHex?: string;83}8485export class SyncFrameTooLargeError extends Error {86 constructor(message = "sync frame too large (WebSocket close 1009)") {87 super(message);88 this.name = "SyncFrameTooLargeError";89 }90}9192export class SyncTransportResourceLimitError extends Error {93 constructor() {94 super("sync transport resource limit exceeded");95 this.name = "SyncTransportResourceLimitError";96 }97}9899export class SyncFrameReassemblyTimeoutError extends Error {100 constructor() {101 super("sync frame reassembly timeout");102 this.name = "SyncFrameReassemblyTimeoutError";103 }104}105106export interface SyncSessionOptions {107 connectTimeoutMs?: number;108 handshakeTimeoutMs?: number;109 signal?: AbortSignal;110}111112function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {113 return new TextEncoder().encode(JSON.stringify({114 type: "PAIR_IDENTITY",115 device_id: id.deviceId,116 device_name: id.deviceName,117 device_type: id.deviceType,118 long_term_public_key_hex: id.longTermPublicKeyHex,119 signing_public_key_hex: id.signingPublicKeyHex,120 }));121}122123124125126127export async function openRelaySyncSession(128 relayUrl: string,129 ourIdentity: SyncPeerIdentity,130 expectedRemotePubkeyHex: string,131 rendezvousId: string,132 options: SyncSessionOptions = {},133): Promise<SyncSession> {134 return openSyncSessionAtUrl(135 buildRendezvousUrl(relayUrl, rendezvousId),136 "relay",137 ourIdentity,138 expectedRemotePubkeyHex,139 rendezvousId,140 options,141 );142}143144145146147148149150export async function openRelaySyncResponderSession(151 relayUrl: string,152 ourIdentity: SyncPeerIdentity,153 expectedRemotePubkeyHex: string,154 rendezvousId: string,155 options: SyncSessionOptions & { keepaliveMs?: number } = {},156): Promise<SyncSession> {157 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {158 throw new Error("reconnect requires a valid rotating rendezvous id");159 }160 if (ourIdentity.longTermPrivateKey.length !== 32) {161 throw new Error("local reconnect private key must be exactly 32 bytes");162 }163 const ws = await connectWebSocketUrl(164 buildRendezvousUrl(relayUrl, rendezvousId),165 "relay",166 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,167 );168 const inbox: Uint8Array[] = [];169 let inboxBytes = 0;170 const readers: Array<(b: Uint8Array) => void> = [];171 let closed = false;172 let closeError: Error = new Error("WebSocket closed");173 const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS;174 const abortWaiter = () => {175 try { ws.close(1000, "relay waiter replaced"); } catch { }176 };177 const keepalive = setInterval(() => {178 if (ws.readyState !== WebSocket.OPEN) return;179 try { ws.send(new Uint8Array(0)); } catch { }180 }, keepaliveMs);181182 ws.binaryType = "arraybuffer";183 ws.onmessage = (event: MessageEvent) => {184 if (closed) return;185 if (!(event.data instanceof ArrayBuffer)) return;186 if (event.data.byteLength === 0) return;187 if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {188 closeError = new SyncFrameTooLargeError(189 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,190 );191 try { ws.close(1009, "sync frame too large"); } catch { }192 return;193 }194 const bytes = new Uint8Array(event.data);195 const reader = readers.shift();196 if (reader) reader(bytes);197 else if (198 inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||199 inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES200 ) {201 closeError = new SyncTransportResourceLimitError();202 closed = true;203 inbox.length = 0;204 inboxBytes = 0;205 try { ws.close(1009, "sync inbox limit"); } catch { }206 } else {207 inbox.push(bytes);208 inboxBytes += bytes.byteLength;209 }210 };211 ws.onerror = () => {212 try { ws.close(); } catch { }213 };214 ws.onclose = (event: CloseEvent) => {215 clearInterval(keepalive);216 options.signal?.removeEventListener("abort", abortWaiter);217 closed = true;218 inbox.length = 0;219 inboxBytes = 0;220 if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {221 closeError = new SyncFrameTooLargeError();222 }223 while (readers.length > 0) readers.shift()!(new Uint8Array(0));224 };225 226 227 options.signal?.addEventListener("abort", abortWaiter, { once: true });228 if (options.signal?.aborted) abortWaiter();229230 const send = async (bytes: Uint8Array): Promise<void> => {231 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {232 throw new SyncFrameTooLargeError(233 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,234 );235 }236 ws.send(bytes as Uint8Array<ArrayBuffer>);237 };238 const receive = (): Promise<Uint8Array> => {239 if (closed) return Promise.reject(closeError);240 const buffered = inbox.shift();241 if (buffered) {242 inboxBytes -= buffered.byteLength;243 return Promise.resolve(buffered);244 }245 return new Promise<Uint8Array>((resolve, reject) => {246 readers.push((bytes) => {247 if (bytes.length === 0 && closed) reject(closeError);248 else resolve(bytes);249 });250 });251 };252253 let noiseSession: NoiseNKSession | undefined;254 try {255 const selectorBytes = await receive();256 const selector = JSON.parse(new TextDecoder().decode(selectorBytes));257 if (258 selector?.type !== "PEACH_RECONNECT" ||259 typeof selector.rendezvous_id !== "string" ||260 !equalAscii(selector.rendezvous_id, rendezvousId)261 ) {262 throw new Error("reconnect capability does not match relay room");263 }264 const handshakeTimer = setTimeout(() => {265 closeError = new Error("sync_handshake_timeout");266 try { ws.close(1000, "sync handshake timeout"); } catch { }267 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);268 try {269 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);270 noiseSession = await noiseKKResponder(271 ourIdentity.longTermPrivateKey,272 ourPublicKey,273 hexToPubkey(expectedRemotePubkeyHex),274 send,275 receive,276 );277 const theirFrame = JSON.parse(278 new TextDecoder().decode(279 unpadSyncFrame(noiseSession.decrypt(await receive())),280 ),281 );282 if (theirFrame?.type !== "PAIR_IDENTITY") {283 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);284 }285 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {286 throw new Error("peer identity does not match authenticated KK key");287 }288 await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));289 } finally {290 clearTimeout(handshakeTimer);291 }292 } catch (error) {293 clearInterval(keepalive);294 options.signal?.removeEventListener("abort", abortWaiter);295 noiseSession?.close();296 try { ws.close(); } catch { }297 throw error;298 }299300 return buildEncryptedSyncSession(301 ws,302 noiseSession,303 expectedRemotePubkeyHex,304 receive,305 () => {306 clearInterval(keepalive);307 options.signal?.removeEventListener("abort", abortWaiter);308 },309 );310}311312export async function openSyncSessionAtUrl(313 url: string,314 targetLabel: string,315 ourIdentity: SyncPeerIdentity,316 expectedRemotePubkeyHex: string,317 rendezvousId: string,318 options: SyncSessionOptions = {},319): Promise<SyncSession> {320 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {321 throw new Error("reconnect requires a valid rotating rendezvous id");322 }323 const ws = await connectWebSocketUrl(324 url,325 targetLabel,326 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,327 );328 const inbox: Uint8Array[] = [];329 let inboxBytes = 0;330 const readers: Array<(b: Uint8Array) => void> = [];331 let closed = false;332 let closeError: Error = new Error("WebSocket closed");333334 ws.binaryType = "arraybuffer";335 ws.onmessage = (ev: MessageEvent) => {336 if (closed) return;337 const data = ev.data;338 if (data instanceof ArrayBuffer) {339 340 341 342 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {343 344 345 346 console.warn(347 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,348 );349 closeError = new SyncFrameTooLargeError(350 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,351 );352 try { ws.close(1009, "sync frame too large"); } catch { }353 return;354 }355 const bytes = new Uint8Array(data);356 const r = readers.shift();357 if (r) r(bytes);358 else if (359 inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||360 inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES361 ) {362 closeError = new SyncTransportResourceLimitError();363 closed = true;364 inbox.length = 0;365 inboxBytes = 0;366 try { ws.close(1009, "sync inbox limit"); } catch { }367 } else {368 inbox.push(bytes);369 inboxBytes += bytes.byteLength;370 }371 }372 };373 ws.onerror = () => {374 375 376 377 console.warn("[Peach] sync WebSocket error; closing transport");378 try { ws.close(); } catch { }379 };380 ws.onclose = (event: CloseEvent) => {381 closed = true;382 inbox.length = 0;383 inboxBytes = 0;384 if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {385 closeError = new SyncFrameTooLargeError();386 }387 while (readers.length > 0) {388 const r = readers.shift()!;389 r(new Uint8Array(0));390 }391 };392393 function send(bytes: Uint8Array): Promise<void> {394 return new Promise((resolve, reject) => {395 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {396 reject(397 new SyncFrameTooLargeError(398 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,399 ),400 );401 return;402 }403 try {404 ws.send(bytes as Uint8Array<ArrayBuffer>);405 resolve();406 } catch (err) {407 reject(err as Error);408 }409 });410 }411 function receive(): Promise<Uint8Array> {412 if (closed) return Promise.reject(closeError);413 const v = inbox.shift();414 if (v) {415 inboxBytes -= v.byteLength;416 return Promise.resolve(v);417 }418 return new Promise<Uint8Array>((resolve, reject) => {419 readers.push((b) => {420 if (b.length === 0 && closed) reject(closeError);421 else resolve(b);422 });423 });424 }425426 const handshakeTimer = setTimeout(() => {427 closeError = new Error("sync_handshake_timeout");428 try { ws.close(1000, "sync handshake timeout"); } catch { }429 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);430 let session: NoiseNKSession;431 try {432 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);433 if (ourIdentity.longTermPrivateKey.length !== 32) {434 throw new Error("local reconnect private key must be exactly 32 bytes");435 }436 437 438 439 await send(new TextEncoder().encode(JSON.stringify({440 type: "PEACH_RECONNECT",441 rendezvous_id: rendezvousId,442 })));443 session = await noiseKKInitiator(444 ourIdentity.longTermPrivateKey,445 ourPublicKey,446 hexToPubkey(expectedRemotePubkeyHex),447 send,448 receive,449 );450 } catch (err) {451 clearTimeout(handshakeTimer);452 try { ws.close(); } catch { }453 throw err;454 }455456 try {457 await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));458 const theirFrame = JSON.parse(459 new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),460 );461 if (theirFrame?.type !== "PAIR_IDENTITY") {462 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);463 }464 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {465 throw new Error("peer identity does not match authenticated KK key");466 }467 clearTimeout(handshakeTimer);468 } catch (err) {469 clearTimeout(handshakeTimer);470 session.close();471 try { ws.close(); } catch { }472 throw err;473 }474475 return buildEncryptedSyncSession(476 ws,477 session,478 expectedRemotePubkeyHex,479 receive,480 );481}482483484export function buildEncryptedSyncSession(485 ws: WebSocket,486 session: NoiseNKSession,487 expectedRemotePubkeyHex: string,488 receiveCiphertext: () => Promise<Uint8Array>,489 beforeClose: () => void = () => undefined,490 reassemblyTimeoutMs = SYNC_REASSEMBLY_TIMEOUT_MS,491): SyncSession {492 return {493 async send(plaintext: Uint8Array) {494 495 496 497 498 499 500 501 502 503 const padded = padSyncFrame(plaintext);504 if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {505 throw new SyncFrameTooLargeError(506 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,507 );508 }509 510 511 512 if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) {513 const ct = session.encrypt(padded);514 ws.send(ct as Uint8Array<ArrayBuffer>);515 return;516 }517 for (const plaintext of chunkPaddedSyncFrame(padded)) {518 if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {519 throw new Error(520 `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,521 );522 }523 const ct = session.encrypt(plaintext);524 if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {525 throw new SyncFrameTooLargeError(526 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,527 );528 }529 ws.send(ct as Uint8Array<ArrayBuffer>);530 }531 },532 async receive() {533 534 535 536 537 let buffered: Uint8Array | null = null;538 let deadlineTimer: ReturnType<typeof setTimeout> | undefined;539 let deadline: Promise<never> | undefined;540 try {541 while (true) {542 const ct = await (deadline543 ? Promise.race([receiveCiphertext(), deadline])544 : receiveCiphertext());545 const pt = session.decrypt(ct);546 if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {547 548 buffered = pt.subarray(1);549 deadline = new Promise<never>((_resolve, reject) => {550 deadlineTimer = setTimeout(() => {551 session.close();552 try { ws.close(1008, "sync frame timeout"); } catch { }553 reject(new SyncFrameReassemblyTimeoutError());554 }, reassemblyTimeoutMs);555 });556 continue;557 }558 if (buffered !== null) {559 if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {560 const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1);561 merged.set(buffered, 0);562 merged.set(pt.subarray(1), buffered.length);563 buffered = merged;564 565 566 567 568 569 if (buffered.length > SYNC_LIMITS.MAX_FRAME_BYTES) {570 throw new SyncFrameTooLargeError(571 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,572 );573 }574 continue;575 }576 577 578 579 if (buffered.length + pt.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {580 throw new SyncFrameTooLargeError(581 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,582 );583 }584 const reassembled = new Uint8Array(buffered.length + pt.byteLength);585 reassembled.set(buffered, 0);586 reassembled.set(pt, buffered.length);587 return unpadSyncFrame(reassembled);588 }589 590 return unpadSyncFrame(pt);591 }592 } finally {593 if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);594 }595 },596 async close() {597 beforeClose();598 try { ws.close(); } catch { }599 session.close();600 },601 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),602 };603}604605function equalAscii(left: string, right: string): boolean {606 const a = new TextEncoder().encode(left);607 const b = new TextEncoder().encode(right);608 if (a.length !== b.length) return false;609 let diff = 0;610 for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];611 return diff === 0;612}613614function connectWebSocketUrl(615 url: string,616 targetLabel: string,617 timeoutMs: number,618): Promise<WebSocket> {619 return new Promise((resolve, reject) => {620 let ws: WebSocket;621 try {622 ws = new WebSocket(url);623 } catch (err) {624 reject(err as Error);625 return;626 }627 const t = setTimeout(() => {628 try { ws.close(); } catch { }629 reject(new Error(`${targetLabel} WebSocket connect timed out`));630 }, timeoutMs);631 ws.onopen = () => {632 clearTimeout(t);633 resolve(ws);634 };635 ws.onerror = () => {636 clearTimeout(t);637 reject(new Error(`${targetLabel} WebSocket connect errored`));638 };639 });640}