rechrome 1.36.0 → 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/README.md +74 -3
- package/e2e.js +164 -0
- package/e2e.ts +106 -0
- package/package.json +19 -5
- package/rechrome.js +537 -34
- package/rechrome.ts +537 -34
- package/secret.js +116 -0
- package/secret.ts +113 -0
- package/serve.js +259 -51
- package/serve.ts +259 -51
- 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/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
|
+
};
|
package/shares.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Shares: who may connect, to which profiles, over which transports.
|
|
2
|
+
//
|
|
3
|
+
// A listener is a transport (an address); a share is a grant on one or more transports. Each
|
|
4
|
+
// share has an invite (a rech1_ token) that is used up as devices redeem it: a redeemed device
|
|
5
|
+
// gets its own rech1_ credential, so a link that leaks after it was used no longer works, and
|
|
6
|
+
// the host can see, approve and drop each device. Legacy listener keys (#key=…) stay valid as
|
|
7
|
+
// they are; shares are an additional way in.
|
|
8
|
+
import { randomBytes } from "crypto";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
12
|
+
import { bearerOf, credentialIdOf, newToken, safeEqual } from "./secret.ts";
|
|
13
|
+
|
|
14
|
+
export const WEBRTC = "webrtc";
|
|
15
|
+
export const DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
|
|
16
|
+
export const SHARES_FILE = join(homedir(), ".rechrome", "shares.json");
|
|
17
|
+
|
|
18
|
+
export type Device = { id: string; name: string; token: string; addedAt: number; lastSeen?: number; approved: boolean };
|
|
19
|
+
export type Share = {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
invite: string; // rech1_ token; replaced by rotate
|
|
23
|
+
inviteExpiresAt?: number; // unused invites stop working after this
|
|
24
|
+
inviteTtlMs?: number; // how long each new invite stays usable (rotate reuses it)
|
|
25
|
+
profiles: string[];
|
|
26
|
+
via: string[]; // listener names, and/or "webrtc"
|
|
27
|
+
signal?: string; // signal server origin, for webrtc
|
|
28
|
+
createdAt: number;
|
|
29
|
+
expiresAt?: number;
|
|
30
|
+
maxDevices: number;
|
|
31
|
+
approve: boolean; // each new device waits for `rech share approve`
|
|
32
|
+
devices: Device[];
|
|
33
|
+
revokedAt?: number;
|
|
34
|
+
};
|
|
35
|
+
export type ShareConfig = { version: 1; shares: Share[] };
|
|
36
|
+
|
|
37
|
+
const LABEL = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
38
|
+
|
|
39
|
+
export function validateShares(value: unknown): ShareConfig {
|
|
40
|
+
const config = value as ShareConfig;
|
|
41
|
+
if (config?.version !== 1 || !Array.isArray(config.shares)) throw new Error("shares.json requires version 1 and a shares list");
|
|
42
|
+
const labels = new Set<string>();
|
|
43
|
+
for (const s of config.shares) {
|
|
44
|
+
if (!s || typeof s.id !== "string" || !LABEL.test(s.label) || typeof s.invite !== "string" || !Array.isArray(s.profiles)
|
|
45
|
+
|| !Array.isArray(s.via) || !s.via.length || !Array.isArray(s.devices) || !Number.isInteger(s.maxDevices) || s.maxDevices < 1)
|
|
46
|
+
throw new Error(`shares.json: malformed share ${JSON.stringify(s?.label ?? s?.id ?? "?")}`);
|
|
47
|
+
if (labels.has(s.label)) throw new Error(`shares.json: duplicate share name "${s.label}"`);
|
|
48
|
+
labels.add(s.label);
|
|
49
|
+
}
|
|
50
|
+
return config;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readShares(path = SHARES_FILE): Promise<ShareConfig> {
|
|
54
|
+
let raw: string;
|
|
55
|
+
try { raw = await readFile(path, "utf8"); }
|
|
56
|
+
catch (error: any) { if (error.code === "ENOENT") return { version: 1, shares: [] }; throw error; }
|
|
57
|
+
return validateShares(JSON.parse(raw));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function writeShares(config: ShareConfig, path = SHARES_FILE): Promise<void> {
|
|
61
|
+
validateShares(config);
|
|
62
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
63
|
+
const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
64
|
+
await writeFile(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
65
|
+
await rename(temp, path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read-modify-write under a lock directory, so the daemon (redeeming invites, noting device
|
|
70
|
+
* use) and the CLI (allow, revoke, approve) can't drop each other's changes.
|
|
71
|
+
*/
|
|
72
|
+
export async function updateShares<T>(fn: (config: ShareConfig) => T | Promise<T>, path = SHARES_FILE): Promise<T> {
|
|
73
|
+
const lock = `${path}.lock`;
|
|
74
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
75
|
+
for (;;) {
|
|
76
|
+
try { await mkdir(lock); break; }
|
|
77
|
+
catch (error: any) {
|
|
78
|
+
if (error.code !== "EEXIST") throw error;
|
|
79
|
+
// Break a lock only when its owner is gone (crashed) or it is far past any real write.
|
|
80
|
+
if (await staleLock(lock)) { await rm(lock, { recursive: true, force: true }); continue; }
|
|
81
|
+
await new Promise(r => setTimeout(r, 25 + Math.random() * 50));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
await writeFile(join(lock, "owner"), JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
85
|
+
try {
|
|
86
|
+
const config = await readShares(path);
|
|
87
|
+
const result = await fn(config);
|
|
88
|
+
await writeShares(config, path);
|
|
89
|
+
return result;
|
|
90
|
+
} finally { await rm(lock, { recursive: true, force: true }); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function staleLock(lock: string): Promise<boolean> {
|
|
94
|
+
try {
|
|
95
|
+
const { pid, at } = JSON.parse(await readFile(join(lock, "owner"), "utf8"));
|
|
96
|
+
if (Date.now() - at > 30_000) return true;
|
|
97
|
+
try { process.kill(pid, 0); return false; } catch (error: any) { return error.code === "ESRCH"; }
|
|
98
|
+
} catch {
|
|
99
|
+
// No owner file yet: the holder is between mkdir and writing it; stale only if old.
|
|
100
|
+
const st = await stat(lock).catch(() => null);
|
|
101
|
+
return !!st && Date.now() - st.mtimeMs > 30_000;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function findShare(config: ShareConfig, name: string): Share {
|
|
106
|
+
const share = config.shares.find(s => s.label === name || s.id === name);
|
|
107
|
+
if (!share) throw new Error(`No share named "${name}". See: rech share ls`);
|
|
108
|
+
return share;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type NewShareOptions = {
|
|
112
|
+
label: string; profiles: string[]; via: string[]; signal?: string;
|
|
113
|
+
expiresAt?: number; inviteTtlMs?: number; maxDevices?: number; approve?: boolean; now?: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function createShare(config: ShareConfig, opts: NewShareOptions): Share {
|
|
117
|
+
const now = opts.now ?? Date.now();
|
|
118
|
+
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}"`);
|
|
119
|
+
if (config.shares.some(s => s.label === opts.label)) throw new Error(`A share named "${opts.label}" already exists. Pick another --name, or: rech share rotate ${opts.label}`);
|
|
120
|
+
if (!opts.profiles.length) throw new Error("A share needs at least one profile.");
|
|
121
|
+
if (!opts.via.length) throw new Error("A share needs at least one transport (--via).");
|
|
122
|
+
const share: Share = {
|
|
123
|
+
id: randomBytes(4).toString("hex"),
|
|
124
|
+
label: opts.label,
|
|
125
|
+
invite: newToken(),
|
|
126
|
+
inviteExpiresAt: opts.inviteTtlMs ? now + opts.inviteTtlMs : undefined,
|
|
127
|
+
inviteTtlMs: opts.inviteTtlMs,
|
|
128
|
+
profiles: [...new Set(opts.profiles)],
|
|
129
|
+
via: [...new Set(opts.via)],
|
|
130
|
+
signal: opts.via.includes(WEBRTC) ? normalizeSignal(opts.signal ?? DEFAULT_SIGNAL) : undefined,
|
|
131
|
+
createdAt: now,
|
|
132
|
+
expiresAt: opts.expiresAt,
|
|
133
|
+
maxDevices: opts.maxDevices ?? 1,
|
|
134
|
+
approve: !!opts.approve,
|
|
135
|
+
devices: [],
|
|
136
|
+
};
|
|
137
|
+
config.shares.push(share);
|
|
138
|
+
return share;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function normalizeSignal(value: string): string {
|
|
142
|
+
let url: URL;
|
|
143
|
+
try { url = new URL(value); } catch { throw new Error(`Signal server must be an http(s) URL, e.g. https://signal.example.com; got "${value}"`); }
|
|
144
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
145
|
+
throw new Error("Signal server must be a plain http(s) URL, without credentials, query or fragment");
|
|
146
|
+
if (!url.pathname.endsWith("/")) url.pathname += "/";
|
|
147
|
+
return url.toString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The link to send: the transport's origin plus the invite in the #fragment. */
|
|
151
|
+
export function inviteLink(share: Share, origin: string): string {
|
|
152
|
+
const base = origin.endsWith("/") ? origin : `${origin}/`;
|
|
153
|
+
return `${base}#${share.invite}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type Credential = { share: Share; kind: "invite"; token: string } | { share: Share; kind: "device"; token: string; device: Device };
|
|
157
|
+
|
|
158
|
+
const bearerCache = new Map<string, string>();
|
|
159
|
+
const cachedBearer = (token: string) => {
|
|
160
|
+
let b = bearerCache.get(token);
|
|
161
|
+
if (!b) { b = bearerOf(token); bearerCache.set(token, b); }
|
|
162
|
+
return b;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** The share credential an HTTP bearer belongs to, if any. */
|
|
166
|
+
export function credentialForBearer(config: ShareConfig, bearer: string | null | undefined): Credential | null {
|
|
167
|
+
if (!bearer) return null;
|
|
168
|
+
for (const share of config.shares) {
|
|
169
|
+
if (safeEqual(cachedBearer(share.invite), bearer)) return { share, kind: "invite", token: share.invite };
|
|
170
|
+
for (const device of share.devices)
|
|
171
|
+
if (safeEqual(cachedBearer(device.token), bearer)) return { share, kind: "device", token: device.token, device };
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type Denial = { status: number; message: string; pending?: true };
|
|
177
|
+
|
|
178
|
+
const day = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
|
179
|
+
|
|
180
|
+
/** Why a share as a whole can't be used right now (revoked, expired), or null. */
|
|
181
|
+
export function shareProblem(share: Share, now = Date.now()): string | null {
|
|
182
|
+
if (share.revokedAt) return `Share "${share.label}" was revoked by its host.`;
|
|
183
|
+
if (share.expiresAt && now >= share.expiresAt) return `Share "${share.label}" expired on ${day(share.expiresAt)}. Ask the host to renew it: rech share renew ${share.label}`;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Why an unused invite can't be redeemed right now, or null. */
|
|
188
|
+
export function inviteProblem(share: Share, now = Date.now()): string | null {
|
|
189
|
+
if (share.inviteExpiresAt && now >= share.inviteExpiresAt) return `This invite expired unused. Ask the host for a fresh one: rech share rotate ${share.label}`;
|
|
190
|
+
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.`;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether a credential may make this request. Invites may only ping and redeem; a device may
|
|
196
|
+
* do everything its share allows once approved. `via` is the transport the request came in on.
|
|
197
|
+
*/
|
|
198
|
+
export function authorizeCredential(cred: Credential, via: string, route: string, now = Date.now()): Denial | null {
|
|
199
|
+
const problem = shareProblem(cred.share, now);
|
|
200
|
+
if (problem) return { status: 403, message: problem };
|
|
201
|
+
if (!cred.share.via.includes(via)) return { status: 403, message: `Share "${cred.share.label}" is not offered on this connection.` };
|
|
202
|
+
if (cred.kind === "invite") {
|
|
203
|
+
if (route === "ping") return null;
|
|
204
|
+
const inv = inviteProblem(cred.share, now);
|
|
205
|
+
if (inv) return { status: 403, message: inv };
|
|
206
|
+
if (route === "redeem") return null;
|
|
207
|
+
return { status: 403, message: "This link is an invite: run `rech connect '<link>'` once to register this device." };
|
|
208
|
+
}
|
|
209
|
+
if (!cred.device.approved && route !== "ping")
|
|
210
|
+
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}` };
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Turn one use of an invite into a device credential. */
|
|
215
|
+
export function redeemInvite(config: ShareConfig, shareId: string, name: string, now = Date.now()): Device {
|
|
216
|
+
const share = config.shares.find(s => s.id === shareId);
|
|
217
|
+
if (!share) throw new Error("This share no longer exists.");
|
|
218
|
+
const problem = shareProblem(share, now) ?? inviteProblem(share, now);
|
|
219
|
+
if (problem) throw new Error(problem);
|
|
220
|
+
const token = newToken();
|
|
221
|
+
const device: Device = {
|
|
222
|
+
id: credentialIdOf(token),
|
|
223
|
+
name: (name || "device").replace(/[^\w .@-]/g, "").slice(0, 40) || "device",
|
|
224
|
+
token, addedAt: now, approved: !share.approve,
|
|
225
|
+
};
|
|
226
|
+
share.devices.push(device);
|
|
227
|
+
return device;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function findDevice(share: Share, id: string): Device {
|
|
231
|
+
const device = share.devices.find(d => d.id === id || d.name === id);
|
|
232
|
+
if (!device) throw new Error(`Share "${share.label}" has no device "${id}". See: rech share devices ${share.label}`);
|
|
233
|
+
return device;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Parse "30d", "24h", "90m", "2w"; "never"/"none"/"0" mean no limit (undefined). */
|
|
237
|
+
export function parseDuration(text: string): number | undefined {
|
|
238
|
+
const t = text.trim().toLowerCase();
|
|
239
|
+
if (["never", "none", "0", "off"].includes(t)) return undefined;
|
|
240
|
+
const m = /^(\d+(?:\.\d+)?)\s*(m|min|h|d|w)$/.exec(t);
|
|
241
|
+
if (!m) throw new Error(`Durations look like 90m, 24h, 30d or 2w (or "never"); got "${text}"`);
|
|
242
|
+
const unit = { m: 60_000, min: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[m[2] as "m"];
|
|
243
|
+
return Math.round(Number(m[1]) * unit);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Every credential a WebRTC host should be reachable on: unused invites and devices. */
|
|
247
|
+
export function webrtcCredentials(config: ShareConfig, now = Date.now()): Credential[] {
|
|
248
|
+
const out: Credential[] = [];
|
|
249
|
+
for (const share of config.shares) {
|
|
250
|
+
if (!share.via.includes(WEBRTC) || shareProblem(share, now)) continue;
|
|
251
|
+
// A used-up invite keeps its room while the share lives, so whoever tries it again hears
|
|
252
|
+
// "already used" (a sign the link leaked) rather than "host offline".
|
|
253
|
+
if (!(share.inviteExpiresAt && now >= share.inviteExpiresAt)) out.push({ share, kind: "invite", token: share.invite });
|
|
254
|
+
for (const device of share.devices) out.push({ share, kind: "device", token: device.token, device });
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|