rechrome 1.47.0 → 1.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shares.ts CHANGED
@@ -31,6 +31,8 @@ export type Share = {
31
31
  expiresAt?: number;
32
32
  maxDevices: number;
33
33
  approve: boolean; // each new device waits for `rech share approve`
34
+ reusable?: boolean; // the invite is not used up by one device: many redeem it (up to maxDevices) until it expires or is rotated
35
+ redeemed?: number; // how many times the invite was redeemed (devices removed since still count)
34
36
  devices: Device[];
35
37
  revokedAt?: number;
36
38
  };
@@ -112,9 +114,14 @@ export function findShare(config: ShareConfig, name: string): Share {
112
114
 
113
115
  export type NewShareOptions = {
114
116
  label: string; profiles: string[]; via: string[]; signal?: string;
115
- expiresAt?: number; inviteTtlMs?: number; maxDevices?: number; approve?: boolean; now?: number;
117
+ expiresAt?: number; inviteTtlMs?: number; maxDevices?: number; approve?: boolean; reusable?: boolean; now?: number;
116
118
  };
117
119
 
120
+ /** Device cap of a reusable invite when --devices isn't given: bounds what a leaked link can enroll. */
121
+ export const REUSABLE_MAX_DEVICES = 100;
122
+ /** Invite lifetime of a reusable invite when --ttl isn't given. */
123
+ export const REUSABLE_DEFAULT_TTL = "7d";
124
+
118
125
  export function createShare(config: ShareConfig, opts: NewShareOptions): Share {
119
126
  const now = opts.now ?? Date.now();
120
127
  if (!LABEL.test(opts.label)) throw new Error(`Share names use a-z, 0-9, "-" and "_" (up to 40), e.g. alice or ci-bot; got "${opts.label}"`);
@@ -134,10 +141,11 @@ export function createShare(config: ShareConfig, opts: NewShareOptions): Share {
134
141
  signal: opts.via.includes(WEBRTC) ? normalizeSignal(opts.signal ?? DEFAULT_SIGNAL) : undefined,
135
142
  createdAt: now,
136
143
  expiresAt: opts.expiresAt,
137
- maxDevices: opts.maxDevices ?? 1,
144
+ maxDevices: opts.maxDevices ?? (opts.reusable ? REUSABLE_MAX_DEVICES : 1),
138
145
  approve: !!opts.approve,
139
146
  devices: [],
140
147
  };
148
+ if (opts.reusable) { share.reusable = true; share.redeemed = 0; }
141
149
  config.shares.push(share);
142
150
  return share;
143
151
  }
@@ -190,11 +198,34 @@ export function shareProblem(share: Share, now = Date.now()): string | null {
190
198
 
191
199
  /** Why an unused invite can't be redeemed right now, or null. */
192
200
  export function inviteProblem(share: Share, now = Date.now()): string | null {
193
- if (share.inviteExpiresAt && now >= share.inviteExpiresAt) return `This invite expired unused. Ask the host for a fresh one: rech share rotate ${share.label}`;
201
+ if (share.inviteExpiresAt && now >= share.inviteExpiresAt) return `This invite expired${share.reusable ? "" : " unused"}. Ask the host for a fresh one: rech share rotate ${share.label}`;
202
+ if (share.reusable) {
203
+ if (share.devices.length >= share.maxDevices) return `This reusable invite reached its limit of ${share.maxDevices} devices. Ask the host to remove unused devices (rech share devices ${share.label} --rm <id>) or to rotate it.`;
204
+ return null;
205
+ }
194
206
  if (share.devices.length >= share.maxDevices) return `This invite was already used${share.maxDevices > 1 ? ` by ${share.maxDevices} devices` : ""}. Ask the host for a fresh one (rech share rotate ${share.label}); if you didn't use it, tell them, since someone else did.`;
195
207
  return null;
196
208
  }
197
209
 
210
+ /** What an invite's holder is told about it before redeeming (the /ping of an invite). */
211
+ export function inviteState(share: Share, now = Date.now()): { state: "open" | "used" | "expired"; maxDevices: number; used: number; expiresAt?: number } {
212
+ const used = share.devices.length;
213
+ const state = used >= share.maxDevices ? "used" : share.inviteExpiresAt && now >= share.inviteExpiresAt ? "expired" : "open";
214
+ return { state, maxDevices: share.maxDevices, used, ...(share.inviteExpiresAt ? { expiresAt: share.inviteExpiresAt } : {}) };
215
+ }
216
+
217
+ /** A share's live browser sessions: names and device names only, none of another share's. */
218
+ export function shareSessions(share: Share, owners: Iterable<[string, { owner: string; name: string }]>, live: ReadonlySet<string>): { name: string; device: string }[] {
219
+ const prefix = `share:${share.id}:`;
220
+ const out: { name: string; device: string }[] = [];
221
+ for (const [session, o] of owners) {
222
+ if (!o.owner.startsWith(prefix) || !live.has(session)) continue;
223
+ const device = share.devices.find(d => d.id === o.owner.slice(prefix.length));
224
+ if (device) out.push({ name: o.name, device: device.name });
225
+ }
226
+ return out;
227
+ }
228
+
198
229
  /**
199
230
  * Whether a credential may make this request. Invites may only ping and redeem; a device may
200
231
  * do everything its share allows once approved. `via` is the transport the request came in on.
@@ -228,6 +259,7 @@ export function redeemInvite(config: ShareConfig, shareId: string, name: string,
228
259
  token, addedAt: now, approved: !share.approve,
229
260
  };
230
261
  share.devices.push(device);
262
+ if (share.reusable) share.redeemed = (share.redeemed ?? 0) + 1;
231
263
  return device;
232
264
  }
233
265
 
@@ -254,9 +286,10 @@ export function webrtcCredentials(config: ShareConfig, now = Date.now(), rtcName
254
286
  const out: Credential[] = [];
255
287
  for (const share of config.shares) {
256
288
  if (!share.via.some(v => names.has(v)) || shareProblem(share, now)) continue;
257
- // A used-up invite keeps its room while the share lives, so whoever tries it again hears
258
- // "already used" (a sign the link leaked) rather than "host offline".
259
- if (!(share.inviteExpiresAt && now >= share.inviteExpiresAt)) out.push({ share, kind: "invite", token: share.invite });
289
+ // A used-up or expired invite keeps its room while the share lives, so whoever tries it hears
290
+ // "already used" (a sign the link leaked) or "expired" rather than "host offline". Such a
291
+ // room only ever answers with that refusal (see inviteProblem).
292
+ out.push({ share, kind: "invite", token: share.invite });
260
293
  for (const device of share.devices) out.push({ share, kind: "device", token: device.token, device });
261
294
  }
262
295
  return out;
package/token.js ADDED
@@ -0,0 +1,88 @@
1
+ // @bun
2
+ // token.ts
3
+ var TOKEN_PREFIX = "rech1_";
4
+ var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
5
+ var B58 = "[1-9A-HJ-NP-Za-km-z]";
6
+ var BODY_LEN = 22;
7
+ var SUM_LEN = 6;
8
+ var SECRET_BYTES = 16;
9
+ var TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
10
+ var EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
11
+ function base58(bytes, width) {
12
+ let n = 0n;
13
+ for (const b of bytes)
14
+ n = n << 8n | BigInt(b);
15
+ let s = "";
16
+ while (n > 0n) {
17
+ s = ALPHABET[Number(n % 58n)] + s;
18
+ n /= 58n;
19
+ }
20
+ if (s.length > width)
21
+ throw new Error("base58 value wider than its field");
22
+ return s.padStart(width, "1");
23
+ }
24
+ function unbase58(text, length) {
25
+ let n = 0n;
26
+ for (const c of text) {
27
+ const i = ALPHABET.indexOf(c);
28
+ if (i < 0)
29
+ return null;
30
+ n = n * 58n + BigInt(i);
31
+ }
32
+ const out = new Uint8Array(length);
33
+ for (let i = length - 1;i >= 0; i--) {
34
+ out[i] = Number(n & 0xffn);
35
+ n >>= 8n;
36
+ }
37
+ return n === 0n ? out : null;
38
+ }
39
+ var CRC_TABLE = (() => {
40
+ const t = new Uint32Array(256);
41
+ for (let i = 0;i < 256; i++) {
42
+ let c = i;
43
+ for (let k = 0;k < 8; k++)
44
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
45
+ t[i] = c >>> 0;
46
+ }
47
+ return t;
48
+ })();
49
+ function crc32(bytes) {
50
+ let c = 4294967295;
51
+ for (const b of bytes)
52
+ c = CRC_TABLE[(c ^ b) & 255] ^ c >>> 8;
53
+ return (c ^ 4294967295) >>> 0;
54
+ }
55
+ var u32 = (n) => new Uint8Array([n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255]);
56
+ function formatToken(secret) {
57
+ if (secret.length !== SECRET_BYTES)
58
+ throw new Error(`a share secret is ${SECRET_BYTES} bytes`);
59
+ return `${TOKEN_PREFIX}${base58(secret, BODY_LEN)}${base58(u32(crc32(secret)), SUM_LEN)}`;
60
+ }
61
+ function parseToken(token) {
62
+ const m = EXACT.exec(token.trim());
63
+ if (!m)
64
+ return { ok: false, reason: "format" };
65
+ const secret = unbase58(m[1], SECRET_BYTES), sum = unbase58(m[2], 4);
66
+ if (!secret || !sum)
67
+ return { ok: false, reason: "format" };
68
+ const expected = u32(crc32(secret));
69
+ return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
70
+ }
71
+ function isToken(text) {
72
+ return parseToken(text).ok;
73
+ }
74
+ function looksLikeToken(text) {
75
+ return EXACT.test(text.trim());
76
+ }
77
+ export {
78
+ SECRET_BYTES,
79
+ TOKEN_PATTERN,
80
+ TOKEN_PREFIX,
81
+ base58,
82
+ crc32,
83
+ formatToken,
84
+ isToken,
85
+ looksLikeToken,
86
+ parseToken,
87
+ unbase58
88
+ };
package/token.ts ADDED
@@ -0,0 +1,74 @@
1
+ // The rech1_ token format, with no Node imports: the invite page (public/invite.js) runs this too.
2
+ // Derivations and everything else about shares are in secret.ts, which re-exports all of this.
3
+
4
+ export const TOKEN_PREFIX = "rech1_";
5
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
6
+ const B58 = "[1-9A-HJ-NP-Za-km-z]";
7
+ const BODY_LEN = 22, SUM_LEN = 6;
8
+ export const SECRET_BYTES = 16;
9
+ /** Matches a token anywhere in text (logs, files); for scanners and redaction. */
10
+ export const TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
11
+ const EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
12
+
13
+ export function base58(bytes: Uint8Array, width: number): string {
14
+ let n = 0n;
15
+ for (const b of bytes) n = (n << 8n) | BigInt(b);
16
+ let s = "";
17
+ while (n > 0n) { s = ALPHABET[Number(n % 58n)] + s; n /= 58n; }
18
+ if (s.length > width) throw new Error("base58 value wider than its field");
19
+ return s.padStart(width, "1");
20
+ }
21
+
22
+ export function unbase58(text: string, length: number): Uint8Array | null {
23
+ let n = 0n;
24
+ for (const c of text) {
25
+ const i = ALPHABET.indexOf(c);
26
+ if (i < 0) return null;
27
+ n = n * 58n + BigInt(i);
28
+ }
29
+ const out = new Uint8Array(length);
30
+ for (let i = length - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
31
+ return n === 0n ? out : null; // leftover bits: not a fixed-width encoding of `length` bytes
32
+ }
33
+
34
+ const CRC_TABLE = (() => {
35
+ const t = new Uint32Array(256);
36
+ for (let i = 0; i < 256; i++) {
37
+ let c = i;
38
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
39
+ t[i] = c >>> 0;
40
+ }
41
+ return t;
42
+ })();
43
+ export function crc32(bytes: Uint8Array): number {
44
+ let c = 0xffffffff;
45
+ for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
46
+ return (c ^ 0xffffffff) >>> 0;
47
+ }
48
+ const u32 = (n: number) => new Uint8Array([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]);
49
+
50
+ export function formatToken(secret: Uint8Array): string {
51
+ if (secret.length !== SECRET_BYTES) throw new Error(`a share secret is ${SECRET_BYTES} bytes`);
52
+ return `${TOKEN_PREFIX}${base58(secret, BODY_LEN)}${base58(u32(crc32(secret)), SUM_LEN)}`;
53
+ }
54
+
55
+ export type ParsedToken = { ok: true; secret: Uint8Array } | { ok: false; reason: "format" | "checksum" };
56
+
57
+ /** Parse a token, telling a non-token apart from a damaged one (a truncated paste, a typo). */
58
+ export function parseToken(token: string): ParsedToken {
59
+ const m = EXACT.exec(token.trim());
60
+ if (!m) return { ok: false, reason: "format" };
61
+ const secret = unbase58(m[1], SECRET_BYTES), sum = unbase58(m[2], 4);
62
+ if (!secret || !sum) return { ok: false, reason: "format" };
63
+ const expected = u32(crc32(secret));
64
+ return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
65
+ }
66
+
67
+ export function isToken(text: string): boolean {
68
+ return parseToken(text).ok;
69
+ }
70
+
71
+ /** Looks like a token (prefix and shape) whether or not its checksum holds. */
72
+ export function looksLikeToken(text: string): boolean {
73
+ return EXACT.test(text.trim());
74
+ }
package/webrtc.js CHANGED
@@ -7,6 +7,8 @@ import { randomBytes as randomBytes2 } from "crypto";
7
7
 
8
8
  // secret.ts
9
9
  import { hkdfSync, randomBytes, timingSafeEqual } from "crypto";
10
+
11
+ // token.ts
10
12
  var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
11
13
  var B58 = "[1-9A-HJ-NP-Za-km-z]";
12
14
  var BODY_LEN = 22;
@@ -69,15 +71,16 @@ function parseToken(token) {
69
71
  const expected = u32(crc32(secret));
70
72
  return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
71
73
  }
72
- function secretOf(token) {
73
- const parsed = parseToken(token);
74
+ // secret.ts
75
+ function secretOf(token2) {
76
+ const parsed = parseToken(token2);
74
77
  if (!parsed.ok)
75
78
  throw new Error(parsed.reason === "checksum" ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped." : "Not a rech1_ link.");
76
79
  return parsed.secret;
77
80
  }
78
- var derive = (token, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token), new Uint8Array(0), `rech/${label}`, bytes));
79
- var roomOf = (token) => base58(derive(token, "room", 12), 17);
80
- var roomAuthOf = (token) => Buffer.from(derive(token, "auth", 32)).toString("hex");
81
+ var derive = (token2, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token2), new Uint8Array(0), `rech/${label}`, bytes));
82
+ var roomOf = (token2) => base58(derive(token2, "room", 12), 17);
83
+ var roomAuthOf = (token2) => Buffer.from(derive(token2, "auth", 32)).toString("hex");
81
84
 
82
85
  // e2e.ts
83
86
  var PROTO = "rech-e2e-1";
@@ -174,88 +177,13 @@ async function open(key, frame, th, state) {
174
177
  var pack = (obj) => enc.encode(JSON.stringify(obj));
175
178
  var unpack = (bytes) => JSON.parse(dec.decode(bytes));
176
179
  function randomHex(n) {
177
- return Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(n))).toString("hex");
180
+ return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, "0")).join("");
178
181
  }
179
182
 
180
- // webrtc.ts
183
+ // rtc-channel.ts
181
184
  var SIGNAL_PROTO = "rech-signal-1";
182
185
  var PING = '{"type":"ping"}';
183
186
  var KEEPALIVE_MS = 25000;
184
- var CONNECT_TIMEOUT_MS = 25000;
185
- var MAX_PEERS_PER_ROOM = 8;
186
- var DEFAULT_STUN = [{ urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }];
187
- var WEBRTC_RUNTIME_DIR = join(homedir(), ".rechrome", "webrtc");
188
- var rtcModule;
189
- function loadRTC(log = (m) => console.error(m)) {
190
- return rtcModule ??= (async () => {
191
- try {
192
- return await import("node-datachannel/polyfill");
193
- } catch {}
194
- const entry = () => {
195
- try {
196
- return Bun.resolveSync("node-datachannel/polyfill", WEBRTC_RUNTIME_DIR);
197
- } catch {
198
- return null;
199
- }
200
- };
201
- let path = entry();
202
- if (path)
203
- try {
204
- return await import(path);
205
- } catch {
206
- path = null;
207
- }
208
- log(`[rech] one-time setup: installing WebRTC support (node-datachannel) into ${WEBRTC_RUNTIME_DIR}`);
209
- mkdirSync(WEBRTC_RUNTIME_DIR, { recursive: true });
210
- writeFileSync(join(WEBRTC_RUNTIME_DIR, "package.json"), JSON.stringify({
211
- private: true,
212
- dependencies: { "node-datachannel": "^0.32.3" },
213
- trustedDependencies: ["node-datachannel"]
214
- }, null, 2));
215
- const proc = Bun.spawn([process.execPath, "install"], { cwd: WEBRTC_RUNTIME_DIR, stdout: "ignore", stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1" } });
216
- const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
217
- path = entry();
218
- if (status !== 0 || !path)
219
- throw new Error(`Could not install WebRTC support (node-datachannel): ${stderr.trim().slice(-400) || `bun install exited ${status}`}`);
220
- return import(path);
221
- })();
222
- }
223
- function iceServersFromEnv(env = process.env) {
224
- if (env.RECH_ICE_SERVERS) {
225
- try {
226
- return JSON.parse(env.RECH_ICE_SERVERS);
227
- } catch {
228
- throw new Error("RECH_ICE_SERVERS must be a JSON array of {urls, username?, credential?}");
229
- }
230
- }
231
- return DEFAULT_STUN;
232
- }
233
- var turnCache = null;
234
- async function hostIceServers(log, env = process.env, extra = []) {
235
- const base = [...iceServersFromEnv(env), ...extra];
236
- const { CF_TURN_KEY_ID: keyId, CF_TURN_API_TOKEN: apiToken } = env;
237
- if (!keyId || !apiToken)
238
- return base;
239
- if (turnCache && turnCache.exp > Date.now())
240
- return [...base, ...turnCache.servers];
241
- try {
242
- const r = await fetch(`https://rtc.live.cloudflare.com/v1/turn/keys/${keyId}/credentials/generate-ice-servers`, {
243
- method: "POST",
244
- headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
245
- body: JSON.stringify({ ttl: 3600 }),
246
- signal: AbortSignal.timeout(5000)
247
- });
248
- if (!r.ok)
249
- throw new Error(`HTTP ${r.status}`);
250
- const body = await r.json();
251
- const servers = [body.iceServers].flat();
252
- turnCache = { servers, exp: Date.now() + 3000000 };
253
- return [...base, ...servers];
254
- } catch (error) {
255
- log(`Cloudflare TURN credentials unavailable, using STUN only: ${error}`);
256
- return base;
257
- }
258
- }
259
187
  function signalSocketUrl(signal, room) {
260
188
  const u = new URL(signal);
261
189
  u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
@@ -295,6 +223,8 @@ class SealedChannel {
295
223
  this.keysSet();
296
224
  }
297
225
  confirm() {
226
+ if (this.closed)
227
+ return Promise.resolve(false);
298
228
  const done = new Promise((resolve) => {
299
229
  this.confirmWaiters.push(resolve);
300
230
  setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
@@ -307,7 +237,7 @@ class SealedChannel {
307
237
  if (this.closed || !this.keys || this.dc.readyState !== "open")
308
238
  return;
309
239
  const frame = await seal(this.keys.mine, this.send, flags, this.keys.th, pack(obj));
310
- while (this.dc.bufferedAmount > 4194304 && this.dc.readyState === "open")
240
+ while (this.dc.bufferedAmount > 4 * 1024 * 1024 && this.dc.readyState === "open")
311
241
  await new Promise((r) => setTimeout(r, 10));
312
242
  try {
313
243
  this.dc.send(new Uint8Array(frame));
@@ -412,6 +342,83 @@ class SignalSocket {
412
342
  }
413
343
  }
414
344
 
345
+ // webrtc.ts
346
+ var CONNECT_TIMEOUT_MS = 25000;
347
+ var MAX_PEERS_PER_ROOM = 8;
348
+ var DEFAULT_STUN = [{ urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }];
349
+ var WEBRTC_RUNTIME_DIR = join(homedir(), ".rechrome", "webrtc");
350
+ var rtcModule;
351
+ function loadRTC(log = (m) => console.error(m)) {
352
+ return rtcModule ??= (async () => {
353
+ try {
354
+ return await import("node-datachannel/polyfill");
355
+ } catch {}
356
+ const entry = () => {
357
+ try {
358
+ return Bun.resolveSync("node-datachannel/polyfill", WEBRTC_RUNTIME_DIR);
359
+ } catch {
360
+ return null;
361
+ }
362
+ };
363
+ let path = entry();
364
+ if (path)
365
+ try {
366
+ return await import(path);
367
+ } catch {
368
+ path = null;
369
+ }
370
+ log(`[rech] one-time setup: installing WebRTC support (node-datachannel) into ${WEBRTC_RUNTIME_DIR}`);
371
+ mkdirSync(WEBRTC_RUNTIME_DIR, { recursive: true });
372
+ writeFileSync(join(WEBRTC_RUNTIME_DIR, "package.json"), JSON.stringify({
373
+ private: true,
374
+ dependencies: { "node-datachannel": "^0.32.3" },
375
+ trustedDependencies: ["node-datachannel"]
376
+ }, null, 2));
377
+ const proc = Bun.spawn([process.execPath, "install"], { cwd: WEBRTC_RUNTIME_DIR, stdout: "ignore", stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1" } });
378
+ const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
379
+ path = entry();
380
+ if (status !== 0 || !path)
381
+ throw new Error(`Could not install WebRTC support (node-datachannel): ${stderr.trim().slice(-400) || `bun install exited ${status}`}`);
382
+ return import(path);
383
+ })();
384
+ }
385
+ function iceServersFromEnv(env = process.env) {
386
+ if (env.RECH_ICE_SERVERS) {
387
+ try {
388
+ return JSON.parse(env.RECH_ICE_SERVERS);
389
+ } catch {
390
+ throw new Error("RECH_ICE_SERVERS must be a JSON array of {urls, username?, credential?}");
391
+ }
392
+ }
393
+ return DEFAULT_STUN;
394
+ }
395
+ var turnCache = null;
396
+ async function hostIceServers(log, env = process.env, extra = []) {
397
+ const base = [...iceServersFromEnv(env), ...extra];
398
+ const { CF_TURN_KEY_ID: keyId, CF_TURN_API_TOKEN: apiToken } = env;
399
+ if (!keyId || !apiToken)
400
+ return base;
401
+ if (turnCache && turnCache.exp > Date.now())
402
+ return [...base, ...turnCache.servers];
403
+ try {
404
+ const r = await fetch(`https://rtc.live.cloudflare.com/v1/turn/keys/${keyId}/credentials/generate-ice-servers`, {
405
+ method: "POST",
406
+ headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
407
+ body: JSON.stringify({ ttl: 3600 }),
408
+ signal: AbortSignal.timeout(5000)
409
+ });
410
+ if (!r.ok)
411
+ throw new Error(`HTTP ${r.status}`);
412
+ const body = await r.json();
413
+ const servers = [body.iceServers].flat();
414
+ turnCache = { servers, exp: Date.now() + 3000000 };
415
+ return [...base, ...servers];
416
+ } catch (error) {
417
+ log(`Cloudflare TURN credentials unavailable, using STUN only: ${error}`);
418
+ return base;
419
+ }
420
+ }
421
+
415
422
  class WebrtcHost {
416
423
  handler;
417
424
  log;
@@ -632,7 +639,7 @@ class WebrtcClient {
632
639
  sock = new SignalSocket(signalSocketUrl(this.signal, roomOf(token)), { role: "client", auth: roomAuthOf(token) }, async (m) => {
633
640
  try {
634
641
  if (m.type === "welcome" && m.hostPresent === false)
635
- fail("the host is offline (its rech daemon isn't connected to the signal server)");
642
+ fail("nobody is answering this link: the host's rech daemon may be down, or the link was revoked. Ask the host to check, or for a fresh link");
636
643
  else if (m.type === "host-leave" && !this.connected)
637
644
  fail("the host left before the connection finished");
638
645
  else if (m.type === "offer" && typeof m.sdp === "string" && !this.pc) {
@@ -681,7 +688,7 @@ class WebrtcClient {
681
688
  }
682
689
  }, (code, reason) => {
683
690
  if (!this.connected)
684
- fail(code === 4404 ? "the host is offline (its rech daemon isn't connected to the signal server)" : code === 1008 ? `the signal server refused this link (${reason || "forbidden"})` : `signal server closed the connection (${reason || code})`);
691
+ fail(code === 4404 ? "nobody is answering this link: the host's rech daemon may be down, or the link was revoked. Ask the host to check, or for a fresh link" : code === 1008 ? `the signal server refused this link (${reason || "forbidden"})` : `signal server closed the connection (${reason || code})`);
685
692
  });
686
693
  sock.open().catch((e) => fail(e.message));
687
694
  });
package/webrtc.ts CHANGED
@@ -14,11 +14,11 @@ import { join } from "path";
14
14
  import { homedir } from "os";
15
15
  import { randomBytes } from "crypto";
16
16
  import { roomAuthOf, roomOf } from "./secret.ts";
17
- import { CONFIRM_TIMEOUT_MS, FLAG_CONFIRM, MAX_CHUNK, deriveDirKeys, newRecvState, newSendState, open, pack, randomHex, seal, transcriptHash, unpack, type RecvState, type SendState } from "./e2e.ts";
17
+ import { MAX_CHUNK, randomHex, transcriptHash } from "./e2e.ts";
18
+ import { SealedChannel, SignalSocket, signalSocketUrl } from "./rtc-channel.ts";
19
+
20
+ export { SIGNAL_PROTO, signalSocketUrl } from "./rtc-channel.ts";
18
21
 
19
- export const SIGNAL_PROTO = "rech-signal-1";
20
- const PING = '{"type":"ping"}';
21
- const KEEPALIVE_MS = 25_000;
22
22
  const CONNECT_TIMEOUT_MS = 25_000;
23
23
  const MAX_PEERS_PER_ROOM = 8;
24
24
  const DEFAULT_STUN = [{ urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }];
@@ -83,128 +83,6 @@ export async function hostIceServers(log: Log, env = process.env, extra: IceServ
83
83
  }
84
84
  }
85
85
 
86
- export function signalSocketUrl(signal: string, room: string): string {
87
- const u = new URL(signal);
88
- u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
89
- u.pathname = `${u.pathname.replace(/\/+$/, "")}/${room}`;
90
- u.search = ""; u.hash = "";
91
- return u.toString();
92
- }
93
-
94
- // ---- sealed channel (shared by both ends) -------------------------------------------------
95
- type Role = "host" | "client";
96
-
97
- /** A DataChannel with the e2e seal/open and confirmation handshake on top. */
98
- class SealedChannel {
99
- private send = newSendState() as SendState;
100
- private recv = newRecvState() as RecvState;
101
- private sendChain = Promise.resolve();
102
- private recvChain = Promise.resolve();
103
- private keys?: { mine: CryptoKey; theirs: CryptoKey; th: Uint8Array };
104
- // Frames can arrive the moment the channel opens, before this side has derived its keys
105
- // (an async HKDF): hold them until then rather than dropping the connection.
106
- private keysSet!: () => void;
107
- private keysReady = new Promise<void>(r => (this.keysSet = r));
108
- confirmed = false;
109
- private confirmWaiters: Array<(ok: boolean) => void> = [];
110
- onFrame: (frame: any) => void = () => {};
111
- onClose: () => void = () => {};
112
- private closed = false;
113
-
114
- constructor(private dc: any, private role: Role) {
115
- dc.binaryType = "arraybuffer";
116
- dc.onmessage = (e: { data: ArrayBuffer | Uint8Array | string }) => {
117
- this.recvChain = this.recvChain.then(() => this.receive(e.data)).catch(() => this.close());
118
- };
119
- dc.onclose = () => this.close();
120
- }
121
-
122
- async setKeys(token: string, th: Uint8Array): Promise<void> {
123
- const { h2c, c2h } = await deriveDirKeys(token, th);
124
- this.keys = this.role === "host" ? { mine: h2c, theirs: c2h, th } : { mine: c2h, theirs: h2c, th };
125
- this.keysSet();
126
- }
127
-
128
- /** Send our confirm frame (counter 0) and wait for the peer's; false on timeout or failure. */
129
- confirm(): Promise<boolean> {
130
- const done = new Promise<boolean>(resolve => {
131
- this.confirmWaiters.push(resolve);
132
- setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
133
- });
134
- void this.sendFrame({ t: "confirm", nonce: randomHex(16), proto: "rech-e2e-1" }, FLAG_CONFIRM);
135
- return done;
136
- }
137
-
138
- sendFrame(obj: unknown, flags = 0): Promise<void> {
139
- this.sendChain = this.sendChain.then(async () => {
140
- if (this.closed || !this.keys || this.dc.readyState !== "open") return;
141
- const frame = await seal(this.keys.mine, this.send, flags, this.keys.th, pack(obj));
142
- // A DataChannel queues without bound; wait out a large backlog (screenshots) instead.
143
- while (this.dc.bufferedAmount > 4 * 1024 * 1024 && this.dc.readyState === "open") await new Promise(r => setTimeout(r, 10));
144
- try { this.dc.send(new Uint8Array(frame)); } catch { this.close(); }
145
- }).catch(() => this.close());
146
- return this.sendChain;
147
- }
148
-
149
- private async receive(data: ArrayBuffer | Uint8Array | string): Promise<void> {
150
- if (this.closed) return;
151
- if (typeof data === "string") return this.close(); // plaintext is never accepted
152
- await Promise.race([this.keysReady, new Promise(r => setTimeout(r, CONFIRM_TIMEOUT_MS))]);
153
- if (!this.keys) return this.close();
154
- const { plaintext } = await open(this.keys.theirs, data, this.keys.th, this.recv);
155
- const frame = unpack(plaintext);
156
- if (!this.confirmed) {
157
- // The first frame must be the peer's confirm: decrypting it proves the same secret and the
158
- // same DTLS transcript (no relay in the middle); the transcript makes it unreplayable.
159
- if (frame?.t !== "confirm") return this.close();
160
- this.confirmed = true;
161
- for (const w of this.confirmWaiters.splice(0)) w(true);
162
- return;
163
- }
164
- if (frame?.t === "confirm") return;
165
- this.onFrame(frame);
166
- }
167
-
168
- get open(): boolean { return !this.closed && this.dc.readyState === "open"; }
169
-
170
- close(): void {
171
- if (this.closed) return;
172
- this.closed = true;
173
- for (const w of this.confirmWaiters.splice(0)) w(false);
174
- try { this.dc.close(); } catch {}
175
- this.onClose();
176
- }
177
- }
178
-
179
- // Wire frames inside the sealed channel:
180
- // client → host : {t:"req", id, method, path, ct?, body?} | {t:"abort", id}
181
- // host → client : {t:"res", id, status, ct} | {t:"data", id, b64} | {t:"end", id, error?}
182
-
183
- // ---- signal socket ---------------------------------------------------------------------
184
- class SignalSocket {
185
- ws?: WebSocket;
186
- private timer?: ReturnType<typeof setInterval>;
187
- constructor(private url: string, private hello: { role: Role; auth: string }, private onMessage: (m: any) => void, private onClose: (code: number, reason: string) => void) {}
188
- open(): Promise<void> {
189
- return new Promise((resolve, reject) => {
190
- const ws = new WebSocket(this.url, [SIGNAL_PROTO]);
191
- this.ws = ws;
192
- let opened = false;
193
- ws.onopen = () => {
194
- opened = true;
195
- ws.send(JSON.stringify({ type: "hello", ...this.hello }));
196
- this.timer = setInterval(() => { try { ws.send(PING); } catch {} }, KEEPALIVE_MS);
197
- resolve();
198
- };
199
- ws.onmessage = e => { try { const m = JSON.parse(String(e.data)); if (m?.type !== "pong") this.onMessage(m); } catch {} };
200
- ws.onclose = e => { clearInterval(this.timer); if (!opened) reject(new Error(`signal server ${new URL(this.url).host}: ${e.reason || `closed (${e.code})`}`)); this.onClose(e.code, e.reason); };
201
- ws.onerror = () => {};
202
- });
203
- }
204
- send(obj: unknown): void { try { this.ws?.send(JSON.stringify(obj)); } catch {} }
205
- close(): void { clearInterval(this.timer); try { this.ws?.close(); } catch {} }
206
- }
207
-
208
86
  // ---- host ------------------------------------------------------------------------------
209
87
  // listener: the WebRTC listener this room belongs to (a share's `via` entry); ice: its STUN/TURN.
210
88
  export type HostCredential = { token: string; signal: string; label: string; listener?: string; ice?: IceServer[] };
@@ -372,7 +250,7 @@ export class WebrtcClient {
372
250
  const timer = setTimeout(() => fail("timed out connecting to the host over WebRTC (no direct path; the host may be behind a strict NAT: ask them to enable TURN, or use a Tailscale/LAN share)"), CONNECT_TIMEOUT_MS);
373
251
  sock = new SignalSocket(signalSocketUrl(this.signal, roomOf(token)), { role: "client", auth: roomAuthOf(token) }, async m => {
374
252
  try {
375
- if (m.type === "welcome" && m.hostPresent === false) fail("the host is offline (its rech daemon isn't connected to the signal server)");
253
+ if (m.type === "welcome" && m.hostPresent === false) fail("nobody is answering this link: the host's rech daemon may be down, or the link was revoked. Ask the host to check, or for a fresh link");
376
254
  else if (m.type === "host-leave" && !this.connected) fail("the host left before the connection finished");
377
255
  else if (m.type === "offer" && typeof m.sdp === "string" && !this.pc) {
378
256
  const pc = new RTCPeerConnection({ iceServers: Array.isArray(m.ice) ? m.ice : iceServersFromEnv() });
@@ -404,7 +282,7 @@ export class WebrtcClient {
404
282
  await this.pc.addIceCandidate(m.candidate).catch(() => {});
405
283
  }
406
284
  } catch (error) { fail(String(error)); }
407
- }, (code, reason) => { if (!this.connected) fail(code === 4404 ? "the host is offline (its rech daemon isn't connected to the signal server)" : code === 1008 ? `the signal server refused this link (${reason || "forbidden"})` : `signal server closed the connection (${reason || code})`); });
285
+ }, (code, reason) => { if (!this.connected) fail(code === 4404 ? "nobody is answering this link: the host's rech daemon may be down, or the link was revoked. Ask the host to check, or for a fresh link" : code === 1008 ? `the signal server refused this link (${reason || "forbidden"})` : `signal server closed the connection (${reason || code})`); });
408
286
  sock.open().catch(e => fail(e.message));
409
287
  });
410
288
  try { await result; }