rechrome 1.36.1 → 1.37.0

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/serve.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { receiveUpload, readRequestJson } from "./scoped-upload.ts";
2
2
  import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.ts";
3
+ import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.ts";
4
+ import { bearerOf, safeEqual } from "./secret.ts";
5
+ import { WebrtcHost } from "./webrtc.ts";
3
6
  import { file } from "bun";
4
- import { createHash, X509Certificate } from "crypto";
7
+ import { createHash, randomBytes, X509Certificate } from "crypto";
5
8
  import { mkdirSync, lstatSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
6
9
  import { join, resolve, relative, isAbsolute } from "path";
7
10
  import { tmpdir } from "os";
@@ -518,7 +521,7 @@ a { color: var(--accent); }
518
521
  <p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
519
522
  On your computer, inside the project folder that should use it, run these two lines
520
523
  (the same in macOS/Linux terminals, PowerShell and cmd):</p>
521
- <p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
524
+ <p id="nokey" class="warn" hidden>This link is missing its key (the part after # — #key=… or #rech1_…). Ask for the full link from <code>rech share</code>.</p>
522
525
  <div id="cmds"></div>
523
526
  <div id="profiles" hidden><p>Profiles this link can use (pick one to make it the default in the command above):</p><div id="plist"></div></div>
524
527
  <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
@@ -528,13 +531,14 @@ This link contains a secret key: anyone with it can use this browser profile, so
528
531
  (() => {
529
532
  let url = location.href;
530
533
  const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
531
- if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
534
+ const invite = /^#rech1_[1-9A-HJ-NP-Za-km-z]{28}$/.test(location.hash);
535
+ if (!/[#&?]key=/.test(location.hash) && !invite) document.getElementById("nokey").hidden = false;
532
536
  // One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
533
537
  // shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
534
538
  // %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
535
539
  const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
536
540
  const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
537
- const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
541
+ const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…").replace(/(#rech1_[1-9A-HJ-NP-Za-km-z]{4})[1-9A-HJ-NP-Za-km-z]+/, "$1…");
538
542
  const root = document.getElementById("cmds");
539
543
  const row = document.createElement("div"); row.className = "row";
540
544
  const line = document.createElement("div"); line.className = "cmd";
@@ -643,24 +647,23 @@ export async function serve() {
643
647
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
644
648
  const policies = new Map<string, Listener>();
645
649
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
646
- const startServer = (initial: Listener, reusePort = false) => Bun.serve({
647
- hostname: initial.host,
648
- port: initial.port,
649
- // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
650
- // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
651
- // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
652
- reusePort,
653
- tls,
654
- error(err) {
655
- log(`unhandled error: ${err.message}`);
656
- return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
657
- },
658
- async fetch(req, server) {
659
- const listener = policies.get(listenerAddress(initial));
660
- if (!listener) return new Response("Listener removed", { status: 403 });
661
- const key = listener.key;
662
- const listenHost = listener.host;
663
- const scoped = listener.profiles !== "*";
650
+ // Shares (shares.json) are reloaded with the listeners below. sessionOwners remembers which
651
+ // credential drove each session, for `rech session ls` and for closing a revoked share's sessions.
652
+ let shares: ShareConfig = { version: 1, shares: [] };
653
+ const sessionOwners = new Map<string, { owner: string; label: string; profile?: string; name: string; lastUsed: number }>();
654
+ const deviceSeen = new Map<string, number>();
655
+ const noteDeviceUse = (cred: Credential & { kind: "device" }) => {
656
+ const now = Date.now();
657
+ if (now - (deviceSeen.get(cred.device.id) ?? cred.device.lastSeen ?? 0) < 10 * 60_000) return;
658
+ deviceSeen.set(cred.device.id, now);
659
+ void updateShares(config => {
660
+ const device = config.shares.find(s => s.id === cred.share.id)?.devices.find(d => d.id === cred.device.id);
661
+ if (device) device.lastSeen = now;
662
+ }).catch(error => log(`could not record device use: ${error}`));
663
+ };
664
+
665
+ const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
666
+ let listener = initialListener;
664
667
  const reqUrl = new URL(req.url);
665
668
  const prefix = normalizePrefix(listener.prefix);
666
669
  // Accept the path with or without the prefix, so a proxy works whether it keeps the
@@ -671,18 +674,70 @@ export async function serve() {
671
674
  else if (reqUrl.pathname.startsWith(prefix)) reqUrl.pathname = "/" + reqUrl.pathname.slice(prefix.length);
672
675
  }
673
676
 
677
+ // Who is asking: the listener's own key (legacy links, the local management key), or a
678
+ // share credential (an invite or a device, see shares.ts) offered on this transport. A
679
+ // share narrows the listener to the share's own profiles.
680
+ const presented = req.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
681
+ const cred = presented && !safeEqual(presented, listener.key) ? credentialForBearer(shares, presented) : null;
682
+ if (cred) {
683
+ const route = reqUrl.pathname === "/ping" ? "ping" : reqUrl.pathname === "/redeem" ? "redeem" : "use";
684
+ const denial = authorizeCredential(cred, listener.name, route);
685
+ if (denial) return Response.json({ status: 1, stdout: "", stderr: `[rech] ${denial.message}\n`, error: denial.message, pending: denial.pending }, { status: denial.status });
686
+ if (cred.kind === "device") noteDeviceUse(cred);
687
+ listener = { ...listener, key: presented, profiles: cred.share.profiles };
688
+ }
689
+ const key = listener.key;
690
+ const listenHost = listener.host;
691
+ const scoped = listener.profiles !== "*";
692
+ const owner = cred?.kind === "device" ? `share:${cred.share.id}:${cred.device.id}` : `listener:${listener.name}`;
693
+
694
+ if (reqUrl.pathname === "/redeem" && req.method === "POST") {
695
+ if (cred?.kind !== "invite") return Response.json({ error: "Only an invite link can be redeemed." }, { status: 400 });
696
+ const { name } = await req.json().catch(() => ({})) as { name?: string };
697
+ try {
698
+ const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
699
+ shares = await readShares();
700
+ log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
701
+ return Response.json({ token: device.token, device: { id: device.id, name: device.name, approved: device.approved }, share: cred.share.label, profiles: cred.share.profiles });
702
+ } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
703
+ }
704
+
705
+ if (reqUrl.pathname === "/profiles") {
706
+ const denied = authCheck(req, key);
707
+ if (denied) return denied;
708
+ const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
709
+ const cache = await readChromeProfileCache().catch(() => null) ?? {};
710
+ const keys = listener.profiles === "*" ? Object.keys(registry) : listener.profiles;
711
+ return Response.json({ share: cred?.share.label, profiles: keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] })) });
712
+ }
713
+
714
+ if (reqUrl.pathname === "/sessions") {
715
+ const denied = authCheck(req, key);
716
+ if (denied) return denied;
717
+ let live = new Set<string>();
718
+ try { live = new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch {}
719
+ const all = listener.profiles === "*" && !cred;
720
+ const rows = [...sessionOwners.entries()]
721
+ .filter(([, o]) => all || o.owner === owner)
722
+ .map(([session, o]) => ({ session: o.name, profile: o.profile, client: o.label, owner: all ? o.owner : undefined, lastUsed: o.lastUsed, live: live.has(session) }))
723
+ .sort((a, b) => b.lastUsed - a.lastUsed);
724
+ return Response.json({ sessions: rows });
725
+ }
726
+
674
727
  // Serve files from output dir
675
728
  if (reqUrl.pathname.startsWith("/files/")) {
676
729
  const denied = authCheck(req, key);
677
730
  if (denied) return denied;
678
731
  const name = decodeURIComponent(reqUrl.pathname.slice(7));
679
732
  if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
680
- if (!canReadProfileFile(listener, name)) return new Response("Forbidden", { status: 403 });
733
+ const readable = (path: string) => canReadProfileFile(listener, path)
734
+ && (cred?.kind !== "device" || cred.share.profiles.some(p => path.startsWith(`${profileOutputPrefix(p)}d-${cred.device.id}/`)));
735
+ if (!readable(name)) return new Response("Forbidden", { status: 403 });
681
736
  const resolved = resolve(workDir, name);
682
737
  const f = file(resolved);
683
738
  if (!(await f.exists())) return new Response("Not found", { status: 404 });
684
739
  const real = realpathSync(resolved);
685
- if (!isUnderDir(realpathSync(workDir), real) || !canReadProfileFile(listener, relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
740
+ if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
686
741
  return new Response(f);
687
742
  }
688
743
 
@@ -694,7 +749,10 @@ export async function serve() {
694
749
  // clients / `rech status` can see trouble without an active probe.
695
750
  const degraded = consecutiveTimeouts > 0;
696
751
  if (scoped || !reqUrl.searchParams.get("deep"))
697
- return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem });
752
+ return Response.json({
753
+ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem,
754
+ ...(cred ? { share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : {}), expiresAt: cred.share.expiresAt } : {}),
755
+ });
698
756
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
699
757
  // most-recently-used session — never a fresh one, which could spawn a browser window.
700
758
  const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
@@ -754,7 +812,9 @@ export async function serve() {
754
812
  PLAYWRIGHT_MCP_USER_DATA_DIR: entry.userDataDir || "",
755
813
  PLAYWRIGHT_MCP_LOAD_EXTENSION: entry.loadExtension || "",
756
814
  };
757
- body.identity.key = `scoped:${body.identity.key}`;
815
+ // A share's devices never share browser state with each other or with legacy links.
816
+ body.identity.key = cred?.kind === "device" ? `share:${cred.share.id}:${cred.device.id}:${body.identity.key}` : `scoped:${body.identity.key}`;
817
+ if (cred) body.identity.label = cred.share.label;
758
818
  body.env = {}; // Server-owned profile configuration wins over every client override.
759
819
  } catch (error) {
760
820
  if (Array.isArray(body?.args) && body.args.filter((a: unknown) => typeof a !== "string" || !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a))[0] === "upload")
@@ -762,7 +822,9 @@ export async function serve() {
762
822
  return Response.json({ status: 1, stdout: "", stderr: `${error instanceof Error ? error.message : String(error)}\n` }, { status: 403 });
763
823
  }
764
824
  }
765
- const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
825
+ // A share device's files live under its own folder, so other devices and links on the same
826
+ // profile can't fetch them (see the /files check above).
827
+ const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) + (cred?.kind === "device" ? `d-${cred.device.id}/` : "") : "";
766
828
  const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
767
829
  mkdirSync(runWorkDir, { recursive: true });
768
830
  let args: string[];
@@ -771,7 +833,7 @@ export async function serve() {
771
833
  let clientEnv: Record<string, string> = {};
772
834
  if (Array.isArray(body)) {
773
835
  args = body;
774
- const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
836
+ const clientAddr = remoteAddr();
775
837
  sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
776
838
  clientName = clientAddr;
777
839
  log(`session from client IP: ${clientAddr} -> ${sessionId}`);
@@ -792,7 +854,7 @@ export async function serve() {
792
854
  clientName = labelBase || keyBase;
793
855
  log(`session from identity: ${clientName}${id?.profile ? ` profile:${id.profile}` : ""} [${keyBase}] -> ${sessionId}`);
794
856
  } else {
795
- const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
857
+ const clientAddr = remoteAddr();
796
858
  sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
797
859
  clientName = clientAddr;
798
860
  log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
@@ -817,6 +879,8 @@ export async function serve() {
817
879
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
818
880
  // Track --isolate sessions so the idle-TTL reaper can close them later.
819
881
  const nowMs = Date.now();
882
+ sessionOwners.set(namespacedSession, { owner, label: cred ? `${cred.share.label}/${(cred as any).device?.name ?? ""}` : clientName, profile: scopedProfile ?? (body as any)?.identity?.profile, name: clientSession || "default", lastUsed: nowMs });
883
+ if (sessionOwners.size > 512) sessionOwners.delete(sessionOwners.keys().next().value!);
820
884
  if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
821
885
  else noteSession(namespacedSession, nowMs); // for the deep health probe to target
822
886
 
@@ -1075,6 +1139,33 @@ export async function serve() {
1075
1139
  // a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
1076
1140
  files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
1077
1141
  });
1142
+ };
1143
+
1144
+ // WebRTC: the same handler, reached over an encrypted DataChannel. Each request carries the
1145
+ // bearer of the credential whose room it came in on, so share checks apply unchanged.
1146
+ const webrtcListener: Listener = { name: WEBRTC, host: "webrtc", port: 0, key: randomBytes(24).toString("base64url"), profiles: [] };
1147
+ const webrtc = new WebrtcHost(async (req, token) => {
1148
+ const headers = new Headers(req.headers);
1149
+ headers.set("Authorization", `Bearer ${bearerOf(token)}`);
1150
+ return handle(new Request(req, { headers }), webrtcListener, () => "webrtc");
1151
+ }, log);
1152
+
1153
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
1154
+ hostname: initial.host,
1155
+ port: initial.port,
1156
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
1157
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
1158
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
1159
+ reusePort,
1160
+ tls,
1161
+ error(err) {
1162
+ log(`unhandled error: ${err.message}`);
1163
+ return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
1164
+ },
1165
+ async fetch(req, server) {
1166
+ const listener = policies.get(listenerAddress(initial));
1167
+ if (!listener) return new Response("Listener removed", { status: 403 });
1168
+ return handle(req, listener, () => `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`);
1078
1169
  },
1079
1170
  });
1080
1171
 
@@ -1143,12 +1234,42 @@ export async function serve() {
1143
1234
  configured ||= !!config;
1144
1235
  applied = fingerprint;
1145
1236
  };
1237
+ // Shares change independently of listeners (redeem, approve, revoke); a bad shares.json keeps
1238
+ // the previous one, like listeners.json.
1239
+ let appliedShares = "";
1240
+ const reloadShares = async () => {
1241
+ const next = await readShares();
1242
+ // Expiry is time-based: fold "what is live right now" into the fingerprint so an expiring
1243
+ // share or invite closes its rooms and sessions without anyone touching the file.
1244
+ const now = Date.now();
1245
+ const fingerprint = JSON.stringify(next) + JSON.stringify(next.shares.map(s => [!!shareProblem(s, now), !!(s.inviteExpiresAt && now >= s.inviteExpiresAt)]));
1246
+ if (fingerprint === appliedShares) return;
1247
+ const allowed = new Set(next.shares.filter(s => !shareProblem(s, now)).flatMap(s => s.devices.map(d => `share:${s.id}:${d.id}`)));
1248
+ const dropped = [...sessionOwners.entries()].filter(([, o]) => o.owner.startsWith("share:") && !allowed.has(o.owner));
1249
+ shares = next;
1250
+ appliedShares = fingerprint;
1251
+ // A revoked share or a removed device loses its browser sessions too.
1252
+ if (dropped.length) {
1253
+ const [bin, ...binArgs] = splitCommand(resolvePlaywrightCli());
1254
+ for (const [session, o] of dropped) {
1255
+ sessionOwners.delete(session);
1256
+ try { Bun.spawn([bin, ...binArgs, "close", `-s=${session}`], { cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true, env: playwrightCliEnv() }); } catch {}
1257
+ log(`closed session ${session} of ${o.label}: its share was revoked or its device removed`);
1258
+ }
1259
+ }
1260
+ const creds = webrtcCredentials(next);
1261
+ webrtc.sync(creds.map(c => ({ token: c.token, signal: c.share.signal!, label: c.kind === "device" ? `${c.share.label}/${c.device.name}` : `${c.share.label} (invite)` })));
1262
+ if (creds.length || webrtc.size) log(`webrtc: listening for ${creds.length} share credential(s)`);
1263
+ };
1264
+ await reloadShares().catch(error => log(`shares.json rejected: ${error}`));
1146
1265
  await reload(true);
1147
1266
  let reloading = false;
1148
1267
  let lastError = "";
1149
1268
  setInterval(async () => {
1150
1269
  if (reloading) return;
1151
1270
  reloading = true;
1271
+ // Independent: a broken listeners.json must not freeze revocations (and vice versa).
1272
+ try { await reloadShares(); } catch (error) { log(`shares.json reload rejected; previous shares retained: ${error}`); }
1152
1273
  try { await reload(); lastError = ""; }
1153
1274
  catch (error) {
1154
1275
  const message = String(error);
package/shares.js ADDED
@@ -0,0 +1,349 @@
1
+ // @bun
2
+ // shares.ts
3
+ import { randomBytes as randomBytes2 } from "crypto";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
7
+
8
+ // secret.ts
9
+ import { hkdfSync, randomBytes, timingSafeEqual } from "crypto";
10
+ var TOKEN_PREFIX = "rech1_";
11
+ var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
12
+ var B58 = "[1-9A-HJ-NP-Za-km-z]";
13
+ var BODY_LEN = 22;
14
+ var SUM_LEN = 6;
15
+ var SECRET_BYTES = 16;
16
+ var TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
17
+ var EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
18
+ function base58(bytes, width) {
19
+ let n = 0n;
20
+ for (const b of bytes)
21
+ n = n << 8n | BigInt(b);
22
+ let s = "";
23
+ while (n > 0n) {
24
+ s = ALPHABET[Number(n % 58n)] + s;
25
+ n /= 58n;
26
+ }
27
+ if (s.length > width)
28
+ throw new Error("base58 value wider than its field");
29
+ return s.padStart(width, "1");
30
+ }
31
+ function unbase58(text, length) {
32
+ let n = 0n;
33
+ for (const c of text) {
34
+ const i = ALPHABET.indexOf(c);
35
+ if (i < 0)
36
+ return null;
37
+ n = n * 58n + BigInt(i);
38
+ }
39
+ const out = new Uint8Array(length);
40
+ for (let i = length - 1;i >= 0; i--) {
41
+ out[i] = Number(n & 0xffn);
42
+ n >>= 8n;
43
+ }
44
+ return n === 0n ? out : null;
45
+ }
46
+ var CRC_TABLE = (() => {
47
+ const t = new Uint32Array(256);
48
+ for (let i = 0;i < 256; i++) {
49
+ let c = i;
50
+ for (let k = 0;k < 8; k++)
51
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
52
+ t[i] = c >>> 0;
53
+ }
54
+ return t;
55
+ })();
56
+ function crc32(bytes) {
57
+ let c = 4294967295;
58
+ for (const b of bytes)
59
+ c = CRC_TABLE[(c ^ b) & 255] ^ c >>> 8;
60
+ return (c ^ 4294967295) >>> 0;
61
+ }
62
+ var u32 = (n) => new Uint8Array([n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255]);
63
+ function formatToken(secret) {
64
+ if (secret.length !== SECRET_BYTES)
65
+ throw new Error(`a share secret is ${SECRET_BYTES} bytes`);
66
+ return `${TOKEN_PREFIX}${base58(secret, BODY_LEN)}${base58(u32(crc32(secret)), SUM_LEN)}`;
67
+ }
68
+ function newToken() {
69
+ return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
70
+ }
71
+ function parseToken(token) {
72
+ const m = EXACT.exec(token.trim());
73
+ if (!m)
74
+ return { ok: false, reason: "format" };
75
+ const secret = unbase58(m[1], SECRET_BYTES), sum = unbase58(m[2], 4);
76
+ if (!secret || !sum)
77
+ return { ok: false, reason: "format" };
78
+ const expected = u32(crc32(secret));
79
+ return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
80
+ }
81
+ function secretOf(token) {
82
+ const parsed = parseToken(token);
83
+ if (!parsed.ok)
84
+ throw new Error(parsed.reason === "checksum" ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped." : "Not a rech1_ link.");
85
+ return parsed.secret;
86
+ }
87
+ var derive = (token, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token), new Uint8Array(0), `rech/${label}`, bytes));
88
+ var bearerOf = (token) => `rb_${base58(derive(token, "bearer", 24), 33)}`;
89
+ var credentialIdOf = (token) => base58(derive(token, "id", 6), 9);
90
+ function safeEqual(a, b) {
91
+ const x = Buffer.from(a), y = Buffer.from(b);
92
+ return x.length === y.length && timingSafeEqual(x, y);
93
+ }
94
+
95
+ // shares.ts
96
+ var WEBRTC = "webrtc";
97
+ var DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
98
+ var SHARES_FILE = join(homedir(), ".rechrome", "shares.json");
99
+ var LABEL = /^[a-z0-9][a-z0-9_-]{0,39}$/;
100
+ function validateShares(value) {
101
+ const config = value;
102
+ if (config?.version !== 1 || !Array.isArray(config.shares))
103
+ throw new Error("shares.json requires version 1 and a shares list");
104
+ const labels = new Set;
105
+ for (const s of config.shares) {
106
+ if (!s || typeof s.id !== "string" || !LABEL.test(s.label) || typeof s.invite !== "string" || !Array.isArray(s.profiles) || !Array.isArray(s.via) || !s.via.length || !Array.isArray(s.devices) || !Number.isInteger(s.maxDevices) || s.maxDevices < 1)
107
+ throw new Error(`shares.json: malformed share ${JSON.stringify(s?.label ?? s?.id ?? "?")}`);
108
+ if (labels.has(s.label))
109
+ throw new Error(`shares.json: duplicate share name "${s.label}"`);
110
+ labels.add(s.label);
111
+ }
112
+ return config;
113
+ }
114
+ async function readShares(path = SHARES_FILE) {
115
+ let raw;
116
+ try {
117
+ raw = await readFile(path, "utf8");
118
+ } catch (error) {
119
+ if (error.code === "ENOENT")
120
+ return { version: 1, shares: [] };
121
+ throw error;
122
+ }
123
+ return validateShares(JSON.parse(raw));
124
+ }
125
+ async function writeShares(config, path = SHARES_FILE) {
126
+ validateShares(config);
127
+ await mkdir(join(path, ".."), { recursive: true });
128
+ const temp = `${path}.${randomBytes2(6).toString("hex")}.tmp`;
129
+ await writeFile(temp, JSON.stringify(config, null, 2) + `
130
+ `, { mode: 384 });
131
+ await rename(temp, path);
132
+ }
133
+ async function updateShares(fn, path = SHARES_FILE) {
134
+ const lock = `${path}.lock`;
135
+ await mkdir(join(path, ".."), { recursive: true });
136
+ for (;; ) {
137
+ try {
138
+ await mkdir(lock);
139
+ break;
140
+ } catch (error) {
141
+ if (error.code !== "EEXIST")
142
+ throw error;
143
+ if (await staleLock(lock)) {
144
+ await rm(lock, { recursive: true, force: true });
145
+ continue;
146
+ }
147
+ await new Promise((r) => setTimeout(r, 25 + Math.random() * 50));
148
+ }
149
+ }
150
+ await writeFile(join(lock, "owner"), JSON.stringify({ pid: process.pid, at: Date.now() }));
151
+ try {
152
+ const config = await readShares(path);
153
+ const result = await fn(config);
154
+ await writeShares(config, path);
155
+ return result;
156
+ } finally {
157
+ await rm(lock, { recursive: true, force: true });
158
+ }
159
+ }
160
+ async function staleLock(lock) {
161
+ try {
162
+ const { pid, at } = JSON.parse(await readFile(join(lock, "owner"), "utf8"));
163
+ if (Date.now() - at > 30000)
164
+ return true;
165
+ try {
166
+ process.kill(pid, 0);
167
+ return false;
168
+ } catch (error) {
169
+ return error.code === "ESRCH";
170
+ }
171
+ } catch {
172
+ const st = await stat(lock).catch(() => null);
173
+ return !!st && Date.now() - st.mtimeMs > 30000;
174
+ }
175
+ }
176
+ function findShare(config, name) {
177
+ const share = config.shares.find((s) => s.label === name || s.id === name);
178
+ if (!share)
179
+ throw new Error(`No share named "${name}". See: rech share ls`);
180
+ return share;
181
+ }
182
+ function createShare(config, opts) {
183
+ const now = opts.now ?? Date.now();
184
+ if (!LABEL.test(opts.label))
185
+ throw new Error(`Share names use a-z, 0-9, "-" and "_" (up to 40), e.g. alice or ci-bot; got "${opts.label}"`);
186
+ if (config.shares.some((s) => s.label === opts.label))
187
+ throw new Error(`A share named "${opts.label}" already exists. Pick another --name, or: rech share rotate ${opts.label}`);
188
+ if (!opts.profiles.length)
189
+ throw new Error("A share needs at least one profile.");
190
+ if (!opts.via.length)
191
+ throw new Error("A share needs at least one transport (--via).");
192
+ const share = {
193
+ id: randomBytes2(4).toString("hex"),
194
+ label: opts.label,
195
+ invite: newToken(),
196
+ inviteExpiresAt: opts.inviteTtlMs ? now + opts.inviteTtlMs : undefined,
197
+ inviteTtlMs: opts.inviteTtlMs,
198
+ profiles: [...new Set(opts.profiles)],
199
+ via: [...new Set(opts.via)],
200
+ signal: opts.via.includes(WEBRTC) ? normalizeSignal(opts.signal ?? DEFAULT_SIGNAL) : undefined,
201
+ createdAt: now,
202
+ expiresAt: opts.expiresAt,
203
+ maxDevices: opts.maxDevices ?? 1,
204
+ approve: !!opts.approve,
205
+ devices: []
206
+ };
207
+ config.shares.push(share);
208
+ return share;
209
+ }
210
+ function normalizeSignal(value) {
211
+ let url;
212
+ try {
213
+ url = new URL(value);
214
+ } catch {
215
+ throw new Error(`Signal server must be an http(s) URL, e.g. https://signal.example.com; got "${value}"`);
216
+ }
217
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
218
+ throw new Error("Signal server must be a plain http(s) URL, without credentials, query or fragment");
219
+ if (!url.pathname.endsWith("/"))
220
+ url.pathname += "/";
221
+ return url.toString();
222
+ }
223
+ function inviteLink(share, origin) {
224
+ const base = origin.endsWith("/") ? origin : `${origin}/`;
225
+ return `${base}#${share.invite}`;
226
+ }
227
+ var bearerCache = new Map;
228
+ var cachedBearer = (token) => {
229
+ let b = bearerCache.get(token);
230
+ if (!b) {
231
+ b = bearerOf(token);
232
+ bearerCache.set(token, b);
233
+ }
234
+ return b;
235
+ };
236
+ function credentialForBearer(config, bearer) {
237
+ if (!bearer)
238
+ return null;
239
+ for (const share of config.shares) {
240
+ if (safeEqual(cachedBearer(share.invite), bearer))
241
+ return { share, kind: "invite", token: share.invite };
242
+ for (const device of share.devices)
243
+ if (safeEqual(cachedBearer(device.token), bearer))
244
+ return { share, kind: "device", token: device.token, device };
245
+ }
246
+ return null;
247
+ }
248
+ var day = (ms) => new Date(ms).toISOString().slice(0, 10);
249
+ function shareProblem(share, now = Date.now()) {
250
+ if (share.revokedAt)
251
+ return `Share "${share.label}" was revoked by its host.`;
252
+ if (share.expiresAt && now >= share.expiresAt)
253
+ return `Share "${share.label}" expired on ${day(share.expiresAt)}. Ask the host to renew it: rech share renew ${share.label}`;
254
+ return null;
255
+ }
256
+ function inviteProblem(share, now = Date.now()) {
257
+ if (share.inviteExpiresAt && now >= share.inviteExpiresAt)
258
+ return `This invite expired unused. Ask the host for a fresh one: rech share rotate ${share.label}`;
259
+ if (share.devices.length >= share.maxDevices)
260
+ 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.`;
261
+ return null;
262
+ }
263
+ function authorizeCredential(cred, via, route, now = Date.now()) {
264
+ const problem = shareProblem(cred.share, now);
265
+ if (problem)
266
+ return { status: 403, message: problem };
267
+ if (!cred.share.via.includes(via))
268
+ return { status: 403, message: `Share "${cred.share.label}" is not offered on this connection.` };
269
+ if (cred.kind === "invite") {
270
+ if (route === "ping")
271
+ return null;
272
+ const inv = inviteProblem(cred.share, now);
273
+ if (inv)
274
+ return { status: 403, message: inv };
275
+ if (route === "redeem")
276
+ return null;
277
+ return { status: 403, message: "This link is an invite: run `rech connect '<link>'` once to register this device." };
278
+ }
279
+ if (!cred.device.approved && route !== "ping")
280
+ return { status: 403, pending: true, message: `Waiting for the host to approve this device (${cred.device.name}, ${cred.device.id}). On the host: rech share approve ${cred.share.label} ${cred.device.id}` };
281
+ return null;
282
+ }
283
+ function redeemInvite(config, shareId, name, now = Date.now()) {
284
+ const share = config.shares.find((s) => s.id === shareId);
285
+ if (!share)
286
+ throw new Error("This share no longer exists.");
287
+ const problem = shareProblem(share, now) ?? inviteProblem(share, now);
288
+ if (problem)
289
+ throw new Error(problem);
290
+ const token = newToken();
291
+ const device = {
292
+ id: credentialIdOf(token),
293
+ name: (name || "device").replace(/[^\w .@-]/g, "").slice(0, 40) || "device",
294
+ token,
295
+ addedAt: now,
296
+ approved: !share.approve
297
+ };
298
+ share.devices.push(device);
299
+ return device;
300
+ }
301
+ function findDevice(share, id) {
302
+ const device = share.devices.find((d) => d.id === id || d.name === id);
303
+ if (!device)
304
+ throw new Error(`Share "${share.label}" has no device "${id}". See: rech share devices ${share.label}`);
305
+ return device;
306
+ }
307
+ function parseDuration(text) {
308
+ const t = text.trim().toLowerCase();
309
+ if (["never", "none", "0", "off"].includes(t))
310
+ return;
311
+ const m = /^(\d+(?:\.\d+)?)\s*(m|min|h|d|w)$/.exec(t);
312
+ if (!m)
313
+ throw new Error(`Durations look like 90m, 24h, 30d or 2w (or "never"); got "${text}"`);
314
+ const unit = { m: 60000, min: 60000, h: 3600000, d: 86400000, w: 604800000 }[m[2]];
315
+ return Math.round(Number(m[1]) * unit);
316
+ }
317
+ function webrtcCredentials(config, now = Date.now()) {
318
+ const out = [];
319
+ for (const share of config.shares) {
320
+ if (!share.via.includes(WEBRTC) || shareProblem(share, now))
321
+ continue;
322
+ if (!(share.inviteExpiresAt && now >= share.inviteExpiresAt))
323
+ out.push({ share, kind: "invite", token: share.invite });
324
+ for (const device of share.devices)
325
+ out.push({ share, kind: "device", token: device.token, device });
326
+ }
327
+ return out;
328
+ }
329
+ export {
330
+ DEFAULT_SIGNAL,
331
+ SHARES_FILE,
332
+ WEBRTC,
333
+ authorizeCredential,
334
+ createShare,
335
+ credentialForBearer,
336
+ findDevice,
337
+ findShare,
338
+ inviteLink,
339
+ inviteProblem,
340
+ normalizeSignal,
341
+ parseDuration,
342
+ readShares,
343
+ redeemInvite,
344
+ shareProblem,
345
+ updateShares,
346
+ validateShares,
347
+ webrtcCredentials,
348
+ writeShares
349
+ };