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/README.md +4 -0
- package/e2e.js +2 -2
- package/e2e.ts +2 -2
- package/extension-token.js +37 -1
- package/extension-token.ts +37 -1
- package/fill-secret.js +542 -40
- package/fill-secret.ts +293 -33
- package/listeners.js +9 -5
- package/listeners.ts +10 -4
- package/package.json +9 -2
- package/rechrome.js +96 -21
- package/rechrome.ts +96 -21
- package/record.js +288 -0
- package/record.ts +258 -0
- package/rtc-channel.js +303 -0
- package/rtc-channel.ts +130 -0
- package/secret.js +15 -10
- package/secret.ts +2 -70
- package/serve.js +140 -13
- package/serve.ts +140 -13
- package/shares.js +49 -12
- package/shares.ts +39 -6
- package/token.js +88 -0
- package/token.ts +74 -0
- package/webrtc.js +92 -85
- package/webrtc.ts +6 -128
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
|
|
258
|
-
// "already used" (a sign the link leaked) rather than "host offline".
|
|
259
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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 = (
|
|
79
|
-
var roomOf = (
|
|
80
|
-
var roomAuthOf = (
|
|
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
|
|
180
|
+
return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
178
181
|
}
|
|
179
182
|
|
|
180
|
-
//
|
|
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 >
|
|
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("
|
|
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 ? "
|
|
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 {
|
|
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("
|
|
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 ? "
|
|
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; }
|