rechrome 1.47.0 → 1.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rtc-channel.js ADDED
@@ -0,0 +1,303 @@
1
+ // @bun
2
+ // token.ts
3
+ var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
4
+ var B58 = "[1-9A-HJ-NP-Za-km-z]";
5
+ var BODY_LEN = 22;
6
+ var SUM_LEN = 6;
7
+ var SECRET_BYTES = 16;
8
+ var TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
9
+ var EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
10
+ function unbase58(text, length) {
11
+ let n = 0n;
12
+ for (const c of text) {
13
+ const i = ALPHABET.indexOf(c);
14
+ if (i < 0)
15
+ return null;
16
+ n = n * 58n + BigInt(i);
17
+ }
18
+ const out = new Uint8Array(length);
19
+ for (let i = length - 1;i >= 0; i--) {
20
+ out[i] = Number(n & 0xffn);
21
+ n >>= 8n;
22
+ }
23
+ return n === 0n ? out : null;
24
+ }
25
+ var CRC_TABLE = (() => {
26
+ const t = new Uint32Array(256);
27
+ for (let i = 0;i < 256; i++) {
28
+ let c = i;
29
+ for (let k = 0;k < 8; k++)
30
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
31
+ t[i] = c >>> 0;
32
+ }
33
+ return t;
34
+ })();
35
+ function crc32(bytes) {
36
+ let c = 4294967295;
37
+ for (const b of bytes)
38
+ c = CRC_TABLE[(c ^ b) & 255] ^ c >>> 8;
39
+ return (c ^ 4294967295) >>> 0;
40
+ }
41
+ var u32 = (n) => new Uint8Array([n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255]);
42
+ function parseToken(token) {
43
+ const m = EXACT.exec(token.trim());
44
+ if (!m)
45
+ return { ok: false, reason: "format" };
46
+ const secret = unbase58(m[1], SECRET_BYTES), sum = unbase58(m[2], 4);
47
+ if (!secret || !sum)
48
+ return { ok: false, reason: "format" };
49
+ const expected = u32(crc32(secret));
50
+ return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
51
+ }
52
+
53
+ // e2e.ts
54
+ var PROTO = "rech-e2e-1";
55
+ var CONFIRM_TIMEOUT_MS = 5000;
56
+ var FLAG_CONFIRM = 1;
57
+ var VER = 1;
58
+ var HEADER_LEN = 14;
59
+ var NONCE_LEN = 12;
60
+ var TAG_LEN = 16;
61
+ var COUNTER_MAX = (1n << 64n) - 1n;
62
+ var subtle = globalThis.crypto.subtle;
63
+ var enc = new TextEncoder;
64
+ var dec = new TextDecoder;
65
+ var concat = (...parts) => {
66
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
67
+ let o = 0;
68
+ for (const p of parts) {
69
+ out.set(p, o);
70
+ o += p.length;
71
+ }
72
+ return out;
73
+ };
74
+ function ikmOf(token) {
75
+ const parsed = parseToken(token);
76
+ if (!parsed.ok)
77
+ throw new Error("e2e: invalid share secret");
78
+ return parsed.secret;
79
+ }
80
+ async function hkdfKey(ikm, salt, info) {
81
+ const base = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
82
+ const bits = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) }, base, 256);
83
+ return subtle.importKey("raw", bits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
84
+ }
85
+ async function deriveDirKeys(token, transcriptHash) {
86
+ const ikm = ikmOf(token);
87
+ return {
88
+ h2c: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/host->client`),
89
+ c2h: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/client->host`)
90
+ };
91
+ }
92
+ var newSendState = () => ({ ctr: 0n });
93
+ var newRecvState = () => ({ last: -1n });
94
+ function nonceOf(ctr) {
95
+ const n = new Uint8Array(NONCE_LEN);
96
+ new DataView(n.buffer).setBigUint64(4, ctr, false);
97
+ return n;
98
+ }
99
+ async function seal(key, state, flags, th, plaintext) {
100
+ const ctr = state.ctr;
101
+ if (ctr >= COUNTER_MAX)
102
+ throw new Error("e2e: nonce counter overflow");
103
+ state.ctr = ctr + 1n;
104
+ const nonce = nonceOf(ctr);
105
+ const header = new Uint8Array(HEADER_LEN);
106
+ header[0] = VER;
107
+ header[1] = flags & 255;
108
+ header.set(nonce, 2);
109
+ const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: concat(header, th), tagLength: 128 }, key, plaintext));
110
+ return concat(header, sealed).buffer;
111
+ }
112
+ async function open(key, frame, th, state) {
113
+ const buf = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
114
+ if (buf.length < HEADER_LEN + TAG_LEN)
115
+ throw new Error("e2e: short frame");
116
+ if (buf[0] !== VER)
117
+ throw new Error("e2e: bad version");
118
+ const header = buf.subarray(0, HEADER_LEN), nonce = buf.subarray(2, HEADER_LEN);
119
+ const view = new DataView(nonce.buffer, nonce.byteOffset, NONCE_LEN);
120
+ if (view.getUint32(0, false) !== 0)
121
+ throw new Error("e2e: bad epoch");
122
+ const ctr = view.getBigUint64(4, false);
123
+ const plain = await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: concat(header, th), tagLength: 128 }, key, buf.subarray(HEADER_LEN));
124
+ if (state.last === -1n && ctr !== 0n)
125
+ throw new Error("e2e: first frame must be counter-0");
126
+ if (ctr <= state.last)
127
+ throw new Error("e2e: replay/reorder");
128
+ state.last = ctr;
129
+ return { flags: header[1], plaintext: new Uint8Array(plain) };
130
+ }
131
+ var pack = (obj) => enc.encode(JSON.stringify(obj));
132
+ var unpack = (bytes) => JSON.parse(dec.decode(bytes));
133
+ function randomHex(n) {
134
+ return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, "0")).join("");
135
+ }
136
+
137
+ // rtc-channel.ts
138
+ var SIGNAL_PROTO = "rech-signal-1";
139
+ var PING = '{"type":"ping"}';
140
+ var KEEPALIVE_MS = 25000;
141
+ function signalSocketUrl(signal, room) {
142
+ const u = new URL(signal);
143
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
144
+ u.pathname = `${u.pathname.replace(/\/+$/, "")}/${room}`;
145
+ u.search = "";
146
+ u.hash = "";
147
+ return u.toString();
148
+ }
149
+
150
+ class SealedChannel {
151
+ dc;
152
+ role;
153
+ send = newSendState();
154
+ recv = newRecvState();
155
+ sendChain = Promise.resolve();
156
+ recvChain = Promise.resolve();
157
+ keys;
158
+ keysSet;
159
+ keysReady = new Promise((r) => this.keysSet = r);
160
+ confirmed = false;
161
+ confirmWaiters = [];
162
+ onFrame = () => {};
163
+ onClose = () => {};
164
+ closed = false;
165
+ constructor(dc, role) {
166
+ this.dc = dc;
167
+ this.role = role;
168
+ dc.binaryType = "arraybuffer";
169
+ dc.onmessage = (e) => {
170
+ this.recvChain = this.recvChain.then(() => this.receive(e.data)).catch(() => this.close());
171
+ };
172
+ dc.onclose = () => this.close();
173
+ }
174
+ async setKeys(token, th) {
175
+ const { h2c, c2h } = await deriveDirKeys(token, th);
176
+ this.keys = this.role === "host" ? { mine: h2c, theirs: c2h, th } : { mine: c2h, theirs: h2c, th };
177
+ this.keysSet();
178
+ }
179
+ confirm() {
180
+ if (this.closed)
181
+ return Promise.resolve(false);
182
+ const done = new Promise((resolve) => {
183
+ this.confirmWaiters.push(resolve);
184
+ setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
185
+ });
186
+ this.sendFrame({ t: "confirm", nonce: randomHex(16), proto: "rech-e2e-1" }, FLAG_CONFIRM);
187
+ return done;
188
+ }
189
+ sendFrame(obj, flags = 0) {
190
+ this.sendChain = this.sendChain.then(async () => {
191
+ if (this.closed || !this.keys || this.dc.readyState !== "open")
192
+ return;
193
+ const frame = await seal(this.keys.mine, this.send, flags, this.keys.th, pack(obj));
194
+ while (this.dc.bufferedAmount > 4 * 1024 * 1024 && this.dc.readyState === "open")
195
+ await new Promise((r) => setTimeout(r, 10));
196
+ try {
197
+ this.dc.send(new Uint8Array(frame));
198
+ } catch {
199
+ this.close();
200
+ }
201
+ }).catch(() => this.close());
202
+ return this.sendChain;
203
+ }
204
+ async receive(data) {
205
+ if (this.closed)
206
+ return;
207
+ if (typeof data === "string")
208
+ return this.close();
209
+ await Promise.race([this.keysReady, new Promise((r) => setTimeout(r, CONFIRM_TIMEOUT_MS))]);
210
+ if (!this.keys)
211
+ return this.close();
212
+ const { plaintext } = await open(this.keys.theirs, data, this.keys.th, this.recv);
213
+ const frame = unpack(plaintext);
214
+ if (!this.confirmed) {
215
+ if (frame?.t !== "confirm")
216
+ return this.close();
217
+ this.confirmed = true;
218
+ for (const w of this.confirmWaiters.splice(0))
219
+ w(true);
220
+ return;
221
+ }
222
+ if (frame?.t === "confirm")
223
+ return;
224
+ this.onFrame(frame);
225
+ }
226
+ get open() {
227
+ return !this.closed && this.dc.readyState === "open";
228
+ }
229
+ close() {
230
+ if (this.closed)
231
+ return;
232
+ this.closed = true;
233
+ for (const w of this.confirmWaiters.splice(0))
234
+ w(false);
235
+ try {
236
+ this.dc.close();
237
+ } catch {}
238
+ this.onClose();
239
+ }
240
+ }
241
+
242
+ class SignalSocket {
243
+ url;
244
+ hello;
245
+ onMessage;
246
+ onClose;
247
+ ws;
248
+ timer;
249
+ constructor(url, hello, onMessage, onClose) {
250
+ this.url = url;
251
+ this.hello = hello;
252
+ this.onMessage = onMessage;
253
+ this.onClose = onClose;
254
+ }
255
+ open() {
256
+ return new Promise((resolve, reject) => {
257
+ const ws = new WebSocket(this.url, [SIGNAL_PROTO]);
258
+ this.ws = ws;
259
+ let opened = false;
260
+ ws.onopen = () => {
261
+ opened = true;
262
+ ws.send(JSON.stringify({ type: "hello", ...this.hello }));
263
+ this.timer = setInterval(() => {
264
+ try {
265
+ ws.send(PING);
266
+ } catch {}
267
+ }, KEEPALIVE_MS);
268
+ resolve();
269
+ };
270
+ ws.onmessage = (e) => {
271
+ try {
272
+ const m = JSON.parse(String(e.data));
273
+ if (m?.type !== "pong")
274
+ this.onMessage(m);
275
+ } catch {}
276
+ };
277
+ ws.onclose = (e) => {
278
+ clearInterval(this.timer);
279
+ if (!opened)
280
+ reject(new Error(`signal server ${new URL(this.url).host}: ${e.reason || `closed (${e.code})`}`));
281
+ this.onClose(e.code, e.reason);
282
+ };
283
+ ws.onerror = () => {};
284
+ });
285
+ }
286
+ send(obj) {
287
+ try {
288
+ this.ws?.send(JSON.stringify(obj));
289
+ } catch {}
290
+ }
291
+ close() {
292
+ clearInterval(this.timer);
293
+ try {
294
+ this.ws?.close();
295
+ } catch {}
296
+ }
297
+ }
298
+ export {
299
+ SIGNAL_PROTO,
300
+ SealedChannel,
301
+ SignalSocket,
302
+ signalSocketUrl
303
+ };
package/rtc-channel.ts ADDED
@@ -0,0 +1,130 @@
1
+ // What both ends of a WebRTC connection share, with no Node imports: the invite page
2
+ // (public/invite.js) runs this too. See webrtc.ts for the overall design.
3
+ import { CONFIRM_TIMEOUT_MS, FLAG_CONFIRM, deriveDirKeys, newRecvState, newSendState, open, pack, randomHex, seal, unpack, type RecvState, type SendState } from "./e2e.ts";
4
+
5
+ export const SIGNAL_PROTO = "rech-signal-1";
6
+ const PING = '{"type":"ping"}';
7
+ const KEEPALIVE_MS = 25_000;
8
+
9
+ export function signalSocketUrl(signal: string, room: string): string {
10
+ const u = new URL(signal);
11
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
12
+ u.pathname = `${u.pathname.replace(/\/+$/, "")}/${room}`;
13
+ u.search = ""; u.hash = "";
14
+ return u.toString();
15
+ }
16
+
17
+ // ---- sealed channel (shared by both ends) -------------------------------------------------
18
+ export type Role = "host" | "client";
19
+
20
+ /** A DataChannel with the e2e seal/open and confirmation handshake on top. */
21
+ export class SealedChannel {
22
+ private send = newSendState() as SendState;
23
+ private recv = newRecvState() as RecvState;
24
+ private sendChain = Promise.resolve();
25
+ private recvChain = Promise.resolve();
26
+ private keys?: { mine: CryptoKey; theirs: CryptoKey; th: Uint8Array };
27
+ // Frames can arrive the moment the channel opens, before this side has derived its keys
28
+ // (an async HKDF): hold them until then rather than dropping the connection.
29
+ private keysSet!: () => void;
30
+ private keysReady = new Promise<void>(r => (this.keysSet = r));
31
+ confirmed = false;
32
+ private confirmWaiters: Array<(ok: boolean) => void> = [];
33
+ onFrame: (frame: any) => void = () => {};
34
+ onClose: () => void = () => {};
35
+ private closed = false;
36
+
37
+ constructor(private dc: any, private role: Role) {
38
+ dc.binaryType = "arraybuffer";
39
+ dc.onmessage = (e: { data: ArrayBuffer | Uint8Array | string }) => {
40
+ this.recvChain = this.recvChain.then(() => this.receive(e.data)).catch(() => this.close());
41
+ };
42
+ dc.onclose = () => this.close();
43
+ }
44
+
45
+ async setKeys(token: string, th: Uint8Array): Promise<void> {
46
+ const { h2c, c2h } = await deriveDirKeys(token, th);
47
+ this.keys = this.role === "host" ? { mine: h2c, theirs: c2h, th } : { mine: c2h, theirs: h2c, th };
48
+ this.keysSet();
49
+ }
50
+
51
+ /** Send our confirm frame (counter 0) and wait for the peer's; false on timeout or failure. */
52
+ confirm(): Promise<boolean> {
53
+ if (this.closed) return Promise.resolve(false);
54
+ const done = new Promise<boolean>(resolve => {
55
+ this.confirmWaiters.push(resolve);
56
+ setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
57
+ });
58
+ void this.sendFrame({ t: "confirm", nonce: randomHex(16), proto: "rech-e2e-1" }, FLAG_CONFIRM);
59
+ return done;
60
+ }
61
+
62
+ sendFrame(obj: unknown, flags = 0): Promise<void> {
63
+ this.sendChain = this.sendChain.then(async () => {
64
+ if (this.closed || !this.keys || this.dc.readyState !== "open") return;
65
+ const frame = await seal(this.keys.mine, this.send, flags, this.keys.th, pack(obj));
66
+ // A DataChannel queues without bound; wait out a large backlog (screenshots) instead.
67
+ while (this.dc.bufferedAmount > 4 * 1024 * 1024 && this.dc.readyState === "open") await new Promise(r => setTimeout(r, 10));
68
+ try { this.dc.send(new Uint8Array(frame)); } catch { this.close(); }
69
+ }).catch(() => this.close());
70
+ return this.sendChain;
71
+ }
72
+
73
+ private async receive(data: ArrayBuffer | Uint8Array | string): Promise<void> {
74
+ if (this.closed) return;
75
+ if (typeof data === "string") return this.close(); // plaintext is never accepted
76
+ await Promise.race([this.keysReady, new Promise(r => setTimeout(r, CONFIRM_TIMEOUT_MS))]);
77
+ if (!this.keys) return this.close();
78
+ const { plaintext } = await open(this.keys.theirs, data, this.keys.th, this.recv);
79
+ const frame = unpack(plaintext);
80
+ if (!this.confirmed) {
81
+ // The first frame must be the peer's confirm: decrypting it proves the same secret and the
82
+ // same DTLS transcript (no relay in the middle); the transcript makes it unreplayable.
83
+ if (frame?.t !== "confirm") return this.close();
84
+ this.confirmed = true;
85
+ for (const w of this.confirmWaiters.splice(0)) w(true);
86
+ return;
87
+ }
88
+ if (frame?.t === "confirm") return;
89
+ this.onFrame(frame);
90
+ }
91
+
92
+ get open(): boolean { return !this.closed && this.dc.readyState === "open"; }
93
+
94
+ close(): void {
95
+ if (this.closed) return;
96
+ this.closed = true;
97
+ for (const w of this.confirmWaiters.splice(0)) w(false);
98
+ try { this.dc.close(); } catch {}
99
+ this.onClose();
100
+ }
101
+ }
102
+
103
+ // Wire frames inside the sealed channel:
104
+ // client → host : {t:"req", id, method, path, ct?, body?} | {t:"abort", id}
105
+ // host → client : {t:"res", id, status, ct} | {t:"data", id, b64} | {t:"end", id, error?}
106
+
107
+ // ---- signal socket ---------------------------------------------------------------------
108
+ export class SignalSocket {
109
+ ws?: WebSocket;
110
+ private timer?: ReturnType<typeof setInterval>;
111
+ constructor(private url: string, private hello: { role: Role; auth: string }, private onMessage: (m: any) => void, private onClose: (code: number, reason: string) => void) {}
112
+ open(): Promise<void> {
113
+ return new Promise((resolve, reject) => {
114
+ const ws = new WebSocket(this.url, [SIGNAL_PROTO]);
115
+ this.ws = ws;
116
+ let opened = false;
117
+ ws.onopen = () => {
118
+ opened = true;
119
+ ws.send(JSON.stringify({ type: "hello", ...this.hello }));
120
+ this.timer = setInterval(() => { try { ws.send(PING); } catch {} }, KEEPALIVE_MS);
121
+ resolve();
122
+ };
123
+ ws.onmessage = e => { try { const m = JSON.parse(String(e.data)); if (m?.type !== "pong") this.onMessage(m); } catch {} };
124
+ ws.onclose = e => { clearInterval(this.timer); if (!opened) reject(new Error(`signal server ${new URL(this.url).host}: ${e.reason || `closed (${e.code})`}`)); this.onClose(e.code, e.reason); };
125
+ ws.onerror = () => {};
126
+ });
127
+ }
128
+ send(obj: unknown): void { try { this.ws?.send(JSON.stringify(obj)); } catch {} }
129
+ close(): void { clearInterval(this.timer); try { this.ws?.close(); } catch {} }
130
+ }
package/secret.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // @bun
2
2
  // secret.ts
3
3
  import { hkdfSync, randomBytes, timingSafeEqual } from "crypto";
4
+
5
+ // token.ts
4
6
  var TOKEN_PREFIX = "rech1_";
5
7
  var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
6
8
  var B58 = "[1-9A-HJ-NP-Za-km-z]";
@@ -59,9 +61,6 @@ function formatToken(secret) {
59
61
  throw new Error(`a share secret is ${SECRET_BYTES} bytes`);
60
62
  return `${TOKEN_PREFIX}${base58(secret, BODY_LEN)}${base58(u32(crc32(secret)), SUM_LEN)}`;
61
63
  }
62
- function newToken() {
63
- return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
64
- }
65
64
  function parseToken(token) {
66
65
  const m = EXACT.exec(token.trim());
67
66
  if (!m)
@@ -78,17 +77,22 @@ function isToken(text) {
78
77
  function looksLikeToken(text) {
79
78
  return EXACT.test(text.trim());
80
79
  }
81
- function secretOf(token) {
82
- const parsed = parseToken(token);
80
+
81
+ // secret.ts
82
+ function newToken() {
83
+ return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
84
+ }
85
+ function secretOf(token2) {
86
+ const parsed = parseToken(token2);
83
87
  if (!parsed.ok)
84
88
  throw new Error(parsed.reason === "checksum" ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped." : "Not a rech1_ link.");
85
89
  return parsed.secret;
86
90
  }
87
- var derive = (token, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token), new Uint8Array(0), `rech/${label}`, bytes));
88
- var roomOf = (token) => base58(derive(token, "room", 12), 17);
89
- var roomAuthOf = (token) => Buffer.from(derive(token, "auth", 32)).toString("hex");
90
- var bearerOf = (token) => `rb_${base58(derive(token, "bearer", 24), 33)}`;
91
- var credentialIdOf = (token) => base58(derive(token, "id", 6), 9);
91
+ var derive = (token2, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token2), new Uint8Array(0), `rech/${label}`, bytes));
92
+ var roomOf = (token2) => base58(derive(token2, "room", 12), 17);
93
+ var roomAuthOf = (token2) => Buffer.from(derive(token2, "auth", 32)).toString("hex");
94
+ var bearerOf = (token2) => `rb_${base58(derive(token2, "bearer", 24), 33)}`;
95
+ var credentialIdOf = (token2) => base58(derive(token2, "id", 6), 9);
92
96
  function safeEqual(a, b) {
93
97
  const x = Buffer.from(a), y = Buffer.from(b);
94
98
  return x.length === y.length && timingSafeEqual(x, y);
@@ -97,6 +101,7 @@ function redactSecrets(text) {
97
101
  return text.replace(TOKEN_PATTERN, (t) => `${t.slice(0, TOKEN_PREFIX.length + 4)}\u2026`).replace(/([#&?]key=)[^&\s"]*/g, "$1\u2026");
98
102
  }
99
103
  export {
104
+ SECRET_BYTES,
100
105
  TOKEN_PATTERN,
101
106
  TOKEN_PREFIX,
102
107
  base58,
package/secret.ts CHANGED
@@ -6,82 +6,14 @@
6
6
  // Everything a party needs is derived one-way from the secret with HKDF, so a signal server or
7
7
  // HTTP proxy never holds anything that would let it derive another value or the secret itself.
8
8
  import { hkdfSync, randomBytes, timingSafeEqual } from "crypto";
9
+ import { SECRET_BYTES, TOKEN_PATTERN, TOKEN_PREFIX, base58, formatToken, parseToken } from "./token.ts";
9
10
 
10
- export const TOKEN_PREFIX = "rech1_";
11
- const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
12
- const B58 = "[1-9A-HJ-NP-Za-km-z]";
13
- const BODY_LEN = 22, SUM_LEN = 6, SECRET_BYTES = 16;
14
- /** Matches a token anywhere in text (logs, files); for scanners and redaction. */
15
- export const TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
16
- const EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
17
-
18
- export function base58(bytes: Uint8Array, width: number): string {
19
- let n = 0n;
20
- for (const b of bytes) n = (n << 8n) | BigInt(b);
21
- let s = "";
22
- while (n > 0n) { s = ALPHABET[Number(n % 58n)] + s; n /= 58n; }
23
- if (s.length > width) throw new Error("base58 value wider than its field");
24
- return s.padStart(width, "1");
25
- }
26
-
27
- export function unbase58(text: string, length: number): Uint8Array | null {
28
- let n = 0n;
29
- for (const c of text) {
30
- const i = ALPHABET.indexOf(c);
31
- if (i < 0) return null;
32
- n = n * 58n + BigInt(i);
33
- }
34
- const out = new Uint8Array(length);
35
- for (let i = length - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
36
- return n === 0n ? out : null; // leftover bits: not a fixed-width encoding of `length` bytes
37
- }
38
-
39
- const CRC_TABLE = (() => {
40
- const t = new Uint32Array(256);
41
- for (let i = 0; i < 256; i++) {
42
- let c = i;
43
- for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
44
- t[i] = c >>> 0;
45
- }
46
- return t;
47
- })();
48
- export function crc32(bytes: Uint8Array): number {
49
- let c = 0xffffffff;
50
- for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
51
- return (c ^ 0xffffffff) >>> 0;
52
- }
53
- const u32 = (n: number) => new Uint8Array([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]);
54
-
55
- export function formatToken(secret: Uint8Array): string {
56
- if (secret.length !== SECRET_BYTES) throw new Error(`a share secret is ${SECRET_BYTES} bytes`);
57
- return `${TOKEN_PREFIX}${base58(secret, BODY_LEN)}${base58(u32(crc32(secret)), SUM_LEN)}`;
58
- }
11
+ export * from "./token.ts";
59
12
 
60
13
  export function newToken(): string {
61
14
  return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
62
15
  }
63
16
 
64
- export type ParsedToken = { ok: true; secret: Uint8Array } | { ok: false; reason: "format" | "checksum" };
65
-
66
- /** Parse a token, telling a non-token apart from a damaged one (a truncated paste, a typo). */
67
- export function parseToken(token: string): ParsedToken {
68
- const m = EXACT.exec(token.trim());
69
- if (!m) return { ok: false, reason: "format" };
70
- const secret = unbase58(m[1], SECRET_BYTES), sum = unbase58(m[2], 4);
71
- if (!secret || !sum) return { ok: false, reason: "format" };
72
- const expected = u32(crc32(secret));
73
- return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
74
- }
75
-
76
- export function isToken(text: string): boolean {
77
- return parseToken(text).ok;
78
- }
79
-
80
- /** Looks like a token (prefix and shape) whether or not its checksum holds. */
81
- export function looksLikeToken(text: string): boolean {
82
- return EXACT.test(text.trim());
83
- }
84
-
85
17
  function secretOf(token: string): Uint8Array {
86
18
  const parsed = parseToken(token);
87
19
  if (!parsed.ok) throw new Error(parsed.reason === "checksum"