rechrome 1.36.1 → 1.37.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 +69 -3
- package/e2e.js +164 -0
- package/e2e.ts +106 -0
- package/package.json +19 -5
- package/rechrome.js +458 -28
- package/rechrome.ts +458 -28
- package/secret.js +116 -0
- package/secret.ts +113 -0
- package/serve.js +150 -29
- package/serve.ts +150 -29
- package/shares.js +349 -0
- package/shares.ts +257 -0
- package/signal-relay.js +112 -0
- package/signal-relay.ts +99 -0
- package/webrtc.js +879 -0
- package/webrtc.ts +535 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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) || !
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
};
|