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/README.md
CHANGED
|
@@ -77,9 +77,71 @@ directory outside git. rechrome reads `RECHROME_URL` from the nearest `.rechrome
|
|
|
77
77
|
When editing a file by hand, quote the URL (`RECHROME_URL="…#key=…"`): unquoted, a `#` starts a
|
|
78
78
|
comment for some .env loaders. rech quotes it when it saves one.
|
|
79
79
|
|
|
80
|
-
### 4.
|
|
80
|
+
### 4. Share with another machine
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
On the host (the machine with Chrome), make an invite:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
rech share new you@example.com --name alice
|
|
86
|
+
# Share "alice": you@example.com
|
|
87
|
+
# Invite for one device (unused, it expires …; secret: send it privately):
|
|
88
|
+
# https://rechrome.pages.dev/#rech1_7fQxC9tZp2mVb8wLrN4hdK3kT9aQ
|
|
89
|
+
# or in a terminal: rech connect rech1_7fQxC9tZp2mVb8wLrN4hdK3kT9aQ
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
On the other machine, inside the project that should use it:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
rech connect 'https://rechrome.pages.dev/#rech1_…'
|
|
96
|
+
rech open https://example.com
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
By default the two machines meet over **WebRTC**: neither needs an open port, a VPN or a
|
|
100
|
+
domain. A small signal server introduces them, and then traffic flows peer to peer, end-to-end
|
|
101
|
+
encrypted with keys the signal server never sees. The first `rech` command after connecting starts
|
|
102
|
+
a small local bridge that keeps the connection open between commands.
|
|
103
|
+
|
|
104
|
+
**The link is an invite, not a key.** `rech connect` redeems it once: this machine gets its own
|
|
105
|
+
device credential (saved to `.rechrome/.env.local`) and the invite stops working, so a link that
|
|
106
|
+
leaks after it was used is harmless, and if someone else used it first you'll know. Unused invites
|
|
107
|
+
expire after 24 hours, and WebRTC shares after 30 days unless renewed.
|
|
108
|
+
|
|
109
|
+
| On the host | |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `rech share new <profile..> [--name n]` | Invite link (`--via webrtc` by default) |
|
|
112
|
+
| `--via lan` · `--via tailscale` · `--via <IP or listener>` | Direct HTTP instead of (or as well as) WebRTC |
|
|
113
|
+
| `--devices 3` · `--approve` | Let 3 devices redeem it · make each new device wait for `rech share approve` |
|
|
114
|
+
| `--expires 7d` · `--invite-ttl 1h` · `--signal <url>` | Lifetimes · your [own signal server](docs/signal-selfhost.md) |
|
|
115
|
+
| `rech share ls` · `rech share devices <share> [--rm <device>]` | Shares, devices, expiry · drop one device |
|
|
116
|
+
| `rech share allow\|deny <share> <profile..>` | Change the profiles; the link and devices stay |
|
|
117
|
+
| `rech share rotate <share>` | A fresh invite; the old one stops working |
|
|
118
|
+
| `rech share renew <share> --expires 30d` · `revoke` · `rm` | Extend · cut it off (closes its sessions) · delete |
|
|
119
|
+
|
|
120
|
+
| On the other machine | |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `rech profile` | The profiles this share gives you |
|
|
123
|
+
| `rech --profile <name> open <url>` | Pick one when the share has several |
|
|
124
|
+
| `rech -s=<name> open <url>` · `rech session ls` · `rech session close <name>` | Separate browser sessions; list and close them |
|
|
125
|
+
| `rech status` | Transport, share, device, expiry |
|
|
126
|
+
|
|
127
|
+
Each device gets its own browser sessions, apart from every other device and link. A share allows
|
|
128
|
+
navigation, tabs, snapshots, screenshots and basic interactions, but not `eval`/`run-code` or
|
|
129
|
+
filesystem commands (see [Remote access](#remote-access)).
|
|
130
|
+
|
|
131
|
+
**WebRTC needs** `node-datachannel`, installed on first use into `~/.rechrome/webrtc`. When a
|
|
132
|
+
direct path can't be found (some corporate networks, symmetric NAT), give the host TURN: set
|
|
133
|
+
`CF_TURN_KEY_ID` and `CF_TURN_API_TOKEN` (a [Cloudflare TURN](https://developers.cloudflare.com/realtime/turn/)
|
|
134
|
+
key; its free tier is 1,000 GB/month) in the daemon's environment, or share `--via tailscale`.
|
|
135
|
+
|
|
136
|
+
**Keep links out of git.** `rech status`, `rech connect` and `rech share --save` warn when the file
|
|
137
|
+
holding `RECHROME_URL` is committed, staged, not git-ignored, or readable by other users. Links
|
|
138
|
+
use a `rech1_` prefix and a checksum, so secret scanners can match them:
|
|
139
|
+
`\brech1_[1-9A-HJ-NP-Za-km-z]{28}\b`.
|
|
140
|
+
|
|
141
|
+
#### Listener links (reverse proxies)
|
|
142
|
+
|
|
143
|
+
Before invites, a profile was shared as a listener's link carrying its key (`#key=…`); these
|
|
144
|
+
keep working. The daemon only listens on this machine until you expose a profile. One way is a
|
|
83
145
|
**scoped listener behind a reverse proxy** — shown with Tailscale Serve, but any proxy that
|
|
84
146
|
forwards to `127.0.0.1:<port>` works. The commands are identical in bash, PowerShell and
|
|
85
147
|
`cmd.exe`.
|
|
@@ -130,8 +192,10 @@ profile to your LAN address directly (plain HTTP); share the result of `rech sha
|
|
|
130
192
|
|
|
131
193
|
### 5. Manage access
|
|
132
194
|
|
|
195
|
+
Invite shares are managed with `rech share` (above). Listener links:
|
|
196
|
+
|
|
133
197
|
```bash
|
|
134
|
-
rech share ls #
|
|
198
|
+
rech share ls # shares, then listener × profile, local and public URLs (keys hidden)
|
|
135
199
|
rech share you@example.com # print one URL again (add --save to use it in this project)
|
|
136
200
|
rech listener allow share teammate@example.com
|
|
137
201
|
rech listener deny share teammate@example.com
|
|
@@ -366,6 +430,8 @@ roll up to their parent), or the current directory outside git. So two worktrees
|
|
|
366
430
|
get separate sessions, and `git checkout` keeps you in the same one. `RECH_IDENTITY=branch`
|
|
367
431
|
restores the older `<remote>/tree/<branch>` keying and `RECH_IDENTITY=cwd` keys on the exact
|
|
368
432
|
directory. Pass `-s=<name>` for a named sub-session, or `--isolate` for a throwaway one.
|
|
433
|
+
A share device's sessions are additionally keyed by its share and device, so they never meet
|
|
434
|
+
another device's or a listener link's; `rech session ls` lists the ones this link drove.
|
|
369
435
|
|
|
370
436
|
## Development
|
|
371
437
|
|
package/e2e.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// secret.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 MAX_CHUNK = 12000;
|
|
56
|
+
var CONFIRM_TIMEOUT_MS = 5000;
|
|
57
|
+
var FLAG_CONFIRM = 1;
|
|
58
|
+
var VER = 1;
|
|
59
|
+
var HEADER_LEN = 14;
|
|
60
|
+
var NONCE_LEN = 12;
|
|
61
|
+
var TAG_LEN = 16;
|
|
62
|
+
var COUNTER_MAX = (1n << 64n) - 1n;
|
|
63
|
+
var subtle = globalThis.crypto.subtle;
|
|
64
|
+
var enc = new TextEncoder;
|
|
65
|
+
var dec = new TextDecoder;
|
|
66
|
+
var concat = (...parts) => {
|
|
67
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
68
|
+
let o = 0;
|
|
69
|
+
for (const p of parts) {
|
|
70
|
+
out.set(p, o);
|
|
71
|
+
o += p.length;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
var sha256 = async (bytes) => new Uint8Array(await subtle.digest("SHA-256", bytes));
|
|
76
|
+
function ikmOf(token) {
|
|
77
|
+
const parsed = parseToken(token);
|
|
78
|
+
if (!parsed.ok)
|
|
79
|
+
throw new Error("e2e: invalid share secret");
|
|
80
|
+
return parsed.secret;
|
|
81
|
+
}
|
|
82
|
+
async function hkdfKey(ikm, salt, info) {
|
|
83
|
+
const base = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
84
|
+
const bits = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) }, base, 256);
|
|
85
|
+
return subtle.importKey("raw", bits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
86
|
+
}
|
|
87
|
+
async function deriveDirKeys(token, transcriptHash) {
|
|
88
|
+
const ikm = ikmOf(token);
|
|
89
|
+
return {
|
|
90
|
+
h2c: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/host->client`),
|
|
91
|
+
c2h: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/client->host`)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
var fingerprints = (sdp) => [...sdp.matchAll(/^a=fingerprint:(.*)$/gim)].map((m) => m[1].trim().toLowerCase()).sort();
|
|
95
|
+
var firstAttr = (sdp, name) => (new RegExp(`^a=${name}:(.*)$`, "im").exec(sdp)?.[1] ?? "").trim().toLowerCase();
|
|
96
|
+
async function transcriptHash(offerSdp, answerSdp) {
|
|
97
|
+
const offer = fingerprints(offerSdp), answer = fingerprints(answerSdp);
|
|
98
|
+
if (!offer.length || !answer.length)
|
|
99
|
+
throw new Error("e2e: missing DTLS fingerprint");
|
|
100
|
+
if ([...offer, ...answer].some((fp) => !fp.startsWith("sha-256")))
|
|
101
|
+
throw new Error("e2e: non-sha-256 DTLS fingerprint");
|
|
102
|
+
return sha256(enc.encode(`${PROTO}
|
|
103
|
+
` + `offer=${offer.join(",")};setup=${firstAttr(offerSdp, "setup")};ufrag=${firstAttr(offerSdp, "ice-ufrag")}
|
|
104
|
+
` + `answer=${answer.join(",")};setup=${firstAttr(answerSdp, "setup")};ufrag=${firstAttr(answerSdp, "ice-ufrag")}`));
|
|
105
|
+
}
|
|
106
|
+
var newSendState = () => ({ ctr: 0n });
|
|
107
|
+
var newRecvState = () => ({ last: -1n });
|
|
108
|
+
function nonceOf(ctr) {
|
|
109
|
+
const n = new Uint8Array(NONCE_LEN);
|
|
110
|
+
new DataView(n.buffer).setBigUint64(4, ctr, false);
|
|
111
|
+
return n;
|
|
112
|
+
}
|
|
113
|
+
async function seal(key, state, flags, th, plaintext) {
|
|
114
|
+
const ctr = state.ctr;
|
|
115
|
+
if (ctr >= COUNTER_MAX)
|
|
116
|
+
throw new Error("e2e: nonce counter overflow");
|
|
117
|
+
state.ctr = ctr + 1n;
|
|
118
|
+
const nonce = nonceOf(ctr);
|
|
119
|
+
const header = new Uint8Array(HEADER_LEN);
|
|
120
|
+
header[0] = VER;
|
|
121
|
+
header[1] = flags & 255;
|
|
122
|
+
header.set(nonce, 2);
|
|
123
|
+
const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: concat(header, th), tagLength: 128 }, key, plaintext));
|
|
124
|
+
return concat(header, sealed).buffer;
|
|
125
|
+
}
|
|
126
|
+
async function open(key, frame, th, state) {
|
|
127
|
+
const buf = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
|
|
128
|
+
if (buf.length < HEADER_LEN + TAG_LEN)
|
|
129
|
+
throw new Error("e2e: short frame");
|
|
130
|
+
if (buf[0] !== VER)
|
|
131
|
+
throw new Error("e2e: bad version");
|
|
132
|
+
const header = buf.subarray(0, HEADER_LEN), nonce = buf.subarray(2, HEADER_LEN);
|
|
133
|
+
const view = new DataView(nonce.buffer, nonce.byteOffset, NONCE_LEN);
|
|
134
|
+
if (view.getUint32(0, false) !== 0)
|
|
135
|
+
throw new Error("e2e: bad epoch");
|
|
136
|
+
const ctr = view.getBigUint64(4, false);
|
|
137
|
+
const plain = await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: concat(header, th), tagLength: 128 }, key, buf.subarray(HEADER_LEN));
|
|
138
|
+
if (state.last === -1n && ctr !== 0n)
|
|
139
|
+
throw new Error("e2e: first frame must be counter-0");
|
|
140
|
+
if (ctr <= state.last)
|
|
141
|
+
throw new Error("e2e: replay/reorder");
|
|
142
|
+
state.last = ctr;
|
|
143
|
+
return { flags: header[1], plaintext: new Uint8Array(plain) };
|
|
144
|
+
}
|
|
145
|
+
var pack = (obj) => enc.encode(JSON.stringify(obj));
|
|
146
|
+
var unpack = (bytes) => JSON.parse(dec.decode(bytes));
|
|
147
|
+
function randomHex(n) {
|
|
148
|
+
return Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(n))).toString("hex");
|
|
149
|
+
}
|
|
150
|
+
export {
|
|
151
|
+
CONFIRM_TIMEOUT_MS,
|
|
152
|
+
FLAG_CONFIRM,
|
|
153
|
+
MAX_CHUNK,
|
|
154
|
+
PROTO,
|
|
155
|
+
deriveDirKeys,
|
|
156
|
+
newRecvState,
|
|
157
|
+
newSendState,
|
|
158
|
+
open,
|
|
159
|
+
pack,
|
|
160
|
+
randomHex,
|
|
161
|
+
seal,
|
|
162
|
+
transcriptHash,
|
|
163
|
+
unpack
|
|
164
|
+
};
|
package/e2e.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// End-to-end encryption for the WebRTC DataChannel ("rech-e2e-1"). Ported from agent-yes's
|
|
2
|
+
// lab/ui/e2e.js (MIT, same author) with rech's own labels and rech1_ secrets.
|
|
3
|
+
//
|
|
4
|
+
// Threat model: a fully compromised signal server, or an active MITM on the media path, may
|
|
5
|
+
// drop connections and see metadata, but must not read or inject traffic or learn the secret.
|
|
6
|
+
// The signal server only ever sees roomOf/roomAuthOf (one-way HKDF outputs, see secret.ts).
|
|
7
|
+
// The AES keys are HKDF(secret, transcriptHash) where the transcript binds both DTLS
|
|
8
|
+
// fingerprints: a relay that swaps in its own fingerprints derives different keys, and the
|
|
9
|
+
// mandatory key-confirmation round fails closed.
|
|
10
|
+
import { parseToken } from "./secret.ts";
|
|
11
|
+
|
|
12
|
+
export const PROTO = "rech-e2e-1";
|
|
13
|
+
export const MAX_CHUNK = 12_000; // plaintext bytes per sealed frame, well under SCTP limits
|
|
14
|
+
export const CONFIRM_TIMEOUT_MS = 5_000;
|
|
15
|
+
export const FLAG_CONFIRM = 0x01;
|
|
16
|
+
const VER = 0x01, HEADER_LEN = 14, NONCE_LEN = 12, TAG_LEN = 16;
|
|
17
|
+
const COUNTER_MAX = (1n << 64n) - 1n;
|
|
18
|
+
const subtle = globalThis.crypto.subtle;
|
|
19
|
+
const enc = new TextEncoder(), dec = new TextDecoder();
|
|
20
|
+
|
|
21
|
+
const concat = (...parts: Uint8Array[]) => {
|
|
22
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
23
|
+
let o = 0;
|
|
24
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
const sha256 = async (bytes: Uint8Array) => new Uint8Array(await subtle.digest("SHA-256", bytes as BufferSource));
|
|
28
|
+
|
|
29
|
+
function ikmOf(token: string): Uint8Array {
|
|
30
|
+
const parsed = parseToken(token);
|
|
31
|
+
if (!parsed.ok) throw new Error("e2e: invalid share secret");
|
|
32
|
+
return parsed.secret;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function hkdfKey(ikm: Uint8Array, salt: Uint8Array, info: string): Promise<CryptoKey> {
|
|
36
|
+
const base = await subtle.importKey("raw", ikm as BufferSource, "HKDF", false, ["deriveBits"]);
|
|
37
|
+
const bits = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: enc.encode(info) }, base, 256);
|
|
38
|
+
return subtle.importKey("raw", bits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Directional keys, fresh per connection (the transcript is the salt). Host seals h2c, client c2h. */
|
|
42
|
+
export async function deriveDirKeys(token: string, transcriptHash: Uint8Array): Promise<{ h2c: CryptoKey; c2h: CryptoKey }> {
|
|
43
|
+
const ikm = ikmOf(token);
|
|
44
|
+
return {
|
|
45
|
+
h2c: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/host->client`),
|
|
46
|
+
c2h: await hkdfKey(ikm, transcriptHash, `rech/${PROTO}/key/client->host`),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fingerprints = (sdp: string) => [...sdp.matchAll(/^a=fingerprint:(.*)$/gim)].map(m => m[1].trim().toLowerCase()).sort();
|
|
51
|
+
const firstAttr = (sdp: string, name: string) => (new RegExp(`^a=${name}:(.*)$`, "im").exec(sdp)?.[1] ?? "").trim().toLowerCase();
|
|
52
|
+
|
|
53
|
+
/** Bind the session to the negotiated DTLS handshake. Both sides compute it from (offer, answer). */
|
|
54
|
+
export async function transcriptHash(offerSdp: string, answerSdp: string): Promise<Uint8Array> {
|
|
55
|
+
const offer = fingerprints(offerSdp), answer = fingerprints(answerSdp);
|
|
56
|
+
if (!offer.length || !answer.length) throw new Error("e2e: missing DTLS fingerprint");
|
|
57
|
+
if ([...offer, ...answer].some(fp => !fp.startsWith("sha-256"))) throw new Error("e2e: non-sha-256 DTLS fingerprint");
|
|
58
|
+
return sha256(enc.encode(`${PROTO}\n`
|
|
59
|
+
+ `offer=${offer.join(",")};setup=${firstAttr(offerSdp, "setup")};ufrag=${firstAttr(offerSdp, "ice-ufrag")}\n`
|
|
60
|
+
+ `answer=${answer.join(",")};setup=${firstAttr(answerSdp, "setup")};ufrag=${firstAttr(answerSdp, "ice-ufrag")}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type SendState = { ctr: bigint };
|
|
64
|
+
export type RecvState = { last: bigint };
|
|
65
|
+
export const newSendState = (): SendState => ({ ctr: 0n });
|
|
66
|
+
export const newRecvState = (): RecvState => ({ last: -1n });
|
|
67
|
+
|
|
68
|
+
function nonceOf(ctr: bigint): Uint8Array {
|
|
69
|
+
const n = new Uint8Array(NONCE_LEN);
|
|
70
|
+
new DataView(n.buffer).setBigUint64(4, ctr, false);
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Wire frame: VER | FLAGS | NONCE(12) | CIPHERTEXT+TAG; AAD = header | transcriptHash. */
|
|
75
|
+
export async function seal(key: CryptoKey, state: SendState, flags: number, th: Uint8Array, plaintext: Uint8Array): Promise<ArrayBuffer> {
|
|
76
|
+
const ctr = state.ctr;
|
|
77
|
+
if (ctr >= COUNTER_MAX) throw new Error("e2e: nonce counter overflow");
|
|
78
|
+
state.ctr = ctr + 1n; // claimed before the await: concurrent seals never share a nonce
|
|
79
|
+
const nonce = nonceOf(ctr);
|
|
80
|
+
const header = new Uint8Array(HEADER_LEN);
|
|
81
|
+
header[0] = VER; header[1] = flags & 0xff; header.set(nonce, 2);
|
|
82
|
+
const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: nonce as BufferSource, additionalData: concat(header, th) as BufferSource, tagLength: 128 }, key, plaintext as BufferSource));
|
|
83
|
+
return concat(header, sealed).buffer as ArrayBuffer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Throws on any tampering, replay or reordering; the caller must then close the channel. */
|
|
87
|
+
export async function open(key: CryptoKey, frame: ArrayBuffer | Uint8Array, th: Uint8Array, state: RecvState): Promise<{ flags: number; plaintext: Uint8Array }> {
|
|
88
|
+
const buf = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
|
|
89
|
+
if (buf.length < HEADER_LEN + TAG_LEN) throw new Error("e2e: short frame");
|
|
90
|
+
if (buf[0] !== VER) throw new Error("e2e: bad version");
|
|
91
|
+
const header = buf.subarray(0, HEADER_LEN), nonce = buf.subarray(2, HEADER_LEN);
|
|
92
|
+
const view = new DataView(nonce.buffer, nonce.byteOffset, NONCE_LEN);
|
|
93
|
+
if (view.getUint32(0, false) !== 0) throw new Error("e2e: bad epoch");
|
|
94
|
+
const ctr = view.getBigUint64(4, false);
|
|
95
|
+
const plain = await subtle.decrypt({ name: "AES-GCM", iv: nonce as BufferSource, additionalData: concat(header, th) as BufferSource, tagLength: 128 }, key, buf.subarray(HEADER_LEN) as BufferSource);
|
|
96
|
+
if (state.last === -1n && ctr !== 0n) throw new Error("e2e: first frame must be counter-0");
|
|
97
|
+
if (ctr <= state.last) throw new Error("e2e: replay/reorder");
|
|
98
|
+
state.last = ctr;
|
|
99
|
+
return { flags: header[1], plaintext: new Uint8Array(plain) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const pack = (obj: unknown) => enc.encode(JSON.stringify(obj));
|
|
103
|
+
export const unpack = (bytes: Uint8Array) => JSON.parse(dec.decode(bytes));
|
|
104
|
+
export function randomHex(n: number): string {
|
|
105
|
+
return Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(n))).toString("hex");
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/rechrome.git"
|
|
@@ -27,7 +27,17 @@
|
|
|
27
27
|
"serve.ts",
|
|
28
28
|
"vendor",
|
|
29
29
|
"scoped-upload.ts",
|
|
30
|
-
"scoped-upload.js"
|
|
30
|
+
"scoped-upload.js",
|
|
31
|
+
"secret.ts",
|
|
32
|
+
"secret.js",
|
|
33
|
+
"shares.ts",
|
|
34
|
+
"shares.js",
|
|
35
|
+
"e2e.ts",
|
|
36
|
+
"e2e.js",
|
|
37
|
+
"webrtc.ts",
|
|
38
|
+
"webrtc.js",
|
|
39
|
+
"signal-relay.ts",
|
|
40
|
+
"signal-relay.js"
|
|
31
41
|
],
|
|
32
42
|
"type": "module",
|
|
33
43
|
"scripts": {
|
|
@@ -36,11 +46,12 @@
|
|
|
36
46
|
"vendor-cli": "bash scripts/vendor-cli.sh",
|
|
37
47
|
"prepare": "bash scripts/vendor-cli.sh --prepare",
|
|
38
48
|
"check-pack": "bash scripts/check-pack.sh",
|
|
39
|
-
"prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bash scripts/check-pack.sh"
|
|
49
|
+
"prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bash scripts/check-pack.sh"
|
|
40
50
|
},
|
|
41
51
|
"devDependencies": {
|
|
42
52
|
"@types/bun": "latest",
|
|
43
|
-
"@types/yargs": "^17.0.35"
|
|
53
|
+
"@types/yargs": "^17.0.35",
|
|
54
|
+
"node-datachannel": "^0.32.3"
|
|
44
55
|
},
|
|
45
56
|
"release": {
|
|
46
57
|
"branches": [
|
|
@@ -49,5 +60,8 @@
|
|
|
49
60
|
},
|
|
50
61
|
"dependencies": {
|
|
51
62
|
"yargs": "^18.2.0"
|
|
52
|
-
}
|
|
63
|
+
},
|
|
64
|
+
"trustedDependencies": [
|
|
65
|
+
"node-datachannel"
|
|
66
|
+
]
|
|
53
67
|
}
|