agent-yes 1.120.0 → 1.121.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.
@@ -1,5 +1,5 @@
1
1
  import { n as logger, t as addTransport } from "./logger-B9h0djqx.js";
2
- import { r as getInstalledPackage } from "./versionChecker-CYZtJKMG.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-CAtpgnoQ.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-BvaUOzCV.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CJxsoGdb.js";
5
5
  import { t as PidStore } from "./pidStore-B5vBu8Px.js";
@@ -1715,4 +1715,4 @@ function sleep(ms) {
1715
1715
 
1716
1716
  //#endregion
1717
1717
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1718
- //# sourceMappingURL=ts-C78N0K4F.js.map
1718
+ //# sourceMappingURL=ts-D91dm1E0.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.120.0";
10
+ var version = "1.121.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -221,4 +221,4 @@ async function displayVersion() {
221
221
 
222
222
  //#endregion
223
223
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
224
- //# sourceMappingURL=versionChecker-CYZtJKMG.js.map
224
+ //# sourceMappingURL=versionChecker-CAtpgnoQ.js.map
@@ -0,0 +1,299 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>End-to-end encryption for agent-yes share links</title>
7
+ <meta
8
+ name="description"
9
+ content="How agent-yes share links stay private even if the signaling relay is hacked: an HKDF key split, AES-256-GCM with per-connection keys, and a fail-closed handshake."
10
+ />
11
+ <style>
12
+ :root {
13
+ --bg: #0d1117;
14
+ --fg: #e6edf3;
15
+ --muted: #8b949e;
16
+ --accent: #58a6ff;
17
+ --green: #3fb950;
18
+ --red: #f85149;
19
+ --card: #161b22;
20
+ --border: #30363d;
21
+ }
22
+ @media (prefers-color-scheme: light) {
23
+ :root {
24
+ --bg: #ffffff;
25
+ --fg: #1f2328;
26
+ --muted: #59636e;
27
+ --accent: #0969da;
28
+ --green: #1a7f37;
29
+ --red: #cf222e;
30
+ --card: #f6f8fa;
31
+ --border: #d0d7de;
32
+ }
33
+ }
34
+ * {
35
+ box-sizing: border-box;
36
+ }
37
+ body {
38
+ background: var(--bg);
39
+ color: var(--fg);
40
+ font:
41
+ 16px/1.65 -apple-system,
42
+ BlinkMacSystemFont,
43
+ "Segoe UI",
44
+ Helvetica,
45
+ Arial,
46
+ sans-serif;
47
+ margin: 0;
48
+ padding: 0 20px;
49
+ }
50
+ main {
51
+ max-width: 760px;
52
+ margin: 0 auto;
53
+ padding: 56px 0 96px;
54
+ }
55
+ .tag {
56
+ background: var(--accent);
57
+ color: #fff;
58
+ padding: 2px 8px;
59
+ border-radius: 6px;
60
+ font-size: 0.8em;
61
+ letter-spacing: 0.02em;
62
+ }
63
+ h1 {
64
+ font-size: 2em;
65
+ line-height: 1.2;
66
+ margin: 18px 0 8px;
67
+ }
68
+ h2 {
69
+ font-size: 1.3em;
70
+ margin: 40px 0 10px;
71
+ border-top: 1px solid var(--border);
72
+ padding-top: 28px;
73
+ }
74
+ .sub {
75
+ color: var(--muted);
76
+ font-size: 1.05em;
77
+ }
78
+ a {
79
+ color: var(--accent);
80
+ }
81
+ code {
82
+ background: var(--card);
83
+ border: 1px solid var(--border);
84
+ border-radius: 5px;
85
+ padding: 1px 5px;
86
+ font-size: 0.88em;
87
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
88
+ }
89
+ pre {
90
+ background: var(--card);
91
+ border: 1px solid var(--border);
92
+ border-radius: 10px;
93
+ padding: 14px 16px;
94
+ overflow-x: auto;
95
+ font-size: 0.86em;
96
+ line-height: 1.5;
97
+ }
98
+ pre code {
99
+ background: none;
100
+ border: 0;
101
+ padding: 0;
102
+ }
103
+ .note {
104
+ border-left: 3px solid var(--accent);
105
+ background: var(--card);
106
+ padding: 10px 16px;
107
+ border-radius: 0 8px 8px 0;
108
+ margin: 18px 0;
109
+ }
110
+ .ok {
111
+ color: var(--green);
112
+ }
113
+ .no {
114
+ color: var(--red);
115
+ }
116
+ footer {
117
+ color: var(--muted);
118
+ font-size: 0.9em;
119
+ margin-top: 48px;
120
+ border-top: 1px solid var(--border);
121
+ padding-top: 20px;
122
+ }
123
+ table {
124
+ border-collapse: collapse;
125
+ width: 100%;
126
+ margin: 16px 0;
127
+ font-size: 0.92em;
128
+ }
129
+ th,
130
+ td {
131
+ border: 1px solid var(--border);
132
+ padding: 8px 10px;
133
+ text-align: left;
134
+ vertical-align: top;
135
+ }
136
+ th {
137
+ background: var(--card);
138
+ }
139
+ </style>
140
+ </head>
141
+ <body>
142
+ <main>
143
+ <p><span class="tag">agent-yes</span> · engineering</p>
144
+ <h1>End-to-end encryption for share links: even a hacked relay can't read your terminal</h1>
145
+ <p class="sub">
146
+ agent-yes share links now run an end-to-end-encrypted protocol. The signaling server that
147
+ introduces your browser to your machine never sees a key — so even if it is fully
148
+ compromised, it cannot read your terminal, type into your agents, or spawn new ones.
149
+ </p>
150
+
151
+ <h2>What a share link does</h2>
152
+ <p>
153
+ When you run <code>ay serve --webrtc</code>, your machine connects to a tiny Cloudflare
154
+ signaling server and waits for a browser to open the printed link on
155
+ <code>agent-yes.com</code>. The signaling server is only a <em>matchmaker</em>: it relays
156
+ the WebRTC handshake (SDP + ICE) so the two sides can find each other, then your terminal
157
+ traffic flows <strong>peer-to-peer</strong> over an encrypted WebRTC DataChannel — it never
158
+ passes through the server. The room link looks like:
159
+ </p>
160
+ <pre><code>https://agent-yes.com/#&lt;room&gt;:e1.&lt;secret&gt;</code></pre>
161
+
162
+ <h2>The threat: "what if the matchmaker is hacked?"</h2>
163
+ <p>
164
+ Previously, the link's token did double duty — it was both the value the server used to
165
+ match peers <em>and</em> the only shared secret. That meant the server
166
+ <em>saw the secret</em>. A compromised signaling server could therefore impersonate a
167
+ browser or man-in-the-middle the connection, and a breach would expose <em>every</em> user's
168
+ token at once. The bar we want instead:
169
+ </p>
170
+ <div class="note">
171
+ A fully compromised signaling server (or an active network attacker who can rewrite the
172
+ relayed handshake) may slow you down or learn metadata — but must
173
+ <strong>never</strong> read terminal I/O, inject input, spawn agents, or recover any key.
174
+ </div>
175
+
176
+ <h2>One secret, two derived keys (the HKDF split)</h2>
177
+ <p>
178
+ The link still carries one 32-byte secret <code>S</code>. We run it through HKDF-SHA256 to
179
+ derive two unrelated values:
180
+ </p>
181
+ <pre><code>authToken = HKDF(S, "ay/ay-e2e-1/auth") → the ONLY value the server sees
182
+ e2e keys = HKDF(S, "ay/ay-e2e-1/key/…") → never leave your machine or browser</code></pre>
183
+ <p>
184
+ HKDF is one-way, so from <code>authToken</code> the server cannot recover <code>S</code> or
185
+ the encryption keys. The actual terminal frames are sealed with AES-256-GCM under keys the
186
+ server has never seen. Because the link is pasted directly between the two endpoints, both
187
+ sides already share <code>S</code> without the server ever being told it.
188
+ </p>
189
+
190
+ <h2>The subtle part nobody gets right: nonce reuse</h2>
191
+ <p>
192
+ AES-GCM is catastrophic if a <code>(key, nonce)</code> pair ever repeats — it leaks
193
+ plaintext <em>and</em> lets an attacker forge messages. The naive design (one key per room
194
+ plus a per-connection counter) reuses nonces across reconnects, host restarts, and multiple
195
+ browsers in the same room. Our fix: derive the encryption keys
196
+ <strong>per connection</strong> by folding the live DTLS handshake fingerprint into HKDF as
197
+ the salt. Every session and every peer therefore gets fresh keys, so a counter that restarts
198
+ at 0 is always paired with a never-before-used key. We also use
199
+ <strong>directional keys</strong>
200
+ (host→client and client→host are different keys), so the two senders never share a nonce
201
+ space.
202
+ </p>
203
+
204
+ <h2>Binding to the real connection (anti-MITM), done right</h2>
205
+ <p>
206
+ We hash both peers' DTLS fingerprints — plus the handshake role and ICE ufrag — from the
207
+ negotiated session, and use that hash as <strong>both</strong> the HKDF salt for the keys
208
+ <strong>and</strong> the authenticated data on every frame. On top of that, the channel runs
209
+ a mandatory, bidirectional <strong>key-confirmation handshake</strong> (each side sends a
210
+ fresh challenge nonce and must see it echoed back) that has to complete in
211
+ <em>both</em> directions before a single byte of terminal I/O is processed. If a relay sat
212
+ in the middle, the fingerprints differ, the keys differ, confirmation fails, and the
213
+ connection <span class="no">closes</span> — there is no "connected but unverified" window
214
+ and no silent fallback.
215
+ </p>
216
+
217
+ <h2>Replay, reordering, and forged aborts</h2>
218
+ <p>
219
+ The DataChannel is reliable and ordered, and we add a mandatory monotonic frame counter,
220
+ 128-bit random request ids, and per-stream sequence numbers. So a captured frame can't be
221
+ replayed to re-run a command, a stale "abort" can't cancel a fresh request, and a truncated
222
+ stream can't masquerade as a complete response.
223
+ </p>
224
+
225
+ <h2>Fail closed, always</h2>
226
+ <p>
227
+ Encrypted links carry a version marker (<code>#room:e1.&lt;secret&gt;</code>). The grammar
228
+ is strict: anything that looks like a marker but isn't exactly <code>e1.</code> + 64 hex is
229
+ rejected, never quietly treated as a legacy link. Legacy plaintext is hard-disabled in the
230
+ client, the host refuses to bridge a plaintext channel for an encrypted room, and on any
231
+ version skew, missing marker, bad tag, fingerprint mismatch, or confirmation timeout we
232
+ <span class="no">refuse</span> rather than downgrade.
233
+ </p>
234
+
235
+ <h2>One implementation, no dependencies</h2>
236
+ <p>
237
+ The crypto is a single WebCrypto module shared by both ends — the browser imports it, and
238
+ the host (running on Bun, which ships WebCrypto) bundles the exact same file. One
239
+ implementation means the two sides can't drift apart, and there are no new dependencies. A
240
+ test suite pins the key derivation, the frame format, and every fail-closed path.
241
+ </p>
242
+
243
+ <h2>What a hacked server gets — and doesn't</h2>
244
+ <table>
245
+ <tr>
246
+ <th></th>
247
+ <th>Before</th>
248
+ <th>Now</th>
249
+ </tr>
250
+ <tr>
251
+ <td>Read your terminal I/O</td>
252
+ <td class="no">possible (sees the secret)</td>
253
+ <td class="ok">no — keys never reach the server</td>
254
+ </tr>
255
+ <tr>
256
+ <td>Inject input / spawn agents</td>
257
+ <td class="no">possible</td>
258
+ <td class="ok">no — frames it forges fail decryption</td>
259
+ </tr>
260
+ <tr>
261
+ <td>Harvest every user's secret in a breach</td>
262
+ <td class="no">yes (stores raw tokens)</td>
263
+ <td class="ok">no — only one-way authTokens</td>
264
+ </tr>
265
+ <tr>
266
+ <td>DoS / see metadata (rooms, timing, IPs)</td>
267
+ <td class="no">yes</td>
268
+ <td class="no">still yes</td>
269
+ </tr>
270
+ </table>
271
+
272
+ <h2>What changed for you</h2>
273
+ <p>
274
+ Old share links rotate to a fresh encrypted room automatically on upgrade — re-open the new
275
+ printed link (or <code>rm ~/.agent-yes/.share-room</code> to force a rotation). The
276
+ signaling server got one small additive change (it pins a protocol version) but still never
277
+ sees a key.
278
+ </p>
279
+
280
+ <h2>Honest limitations</h2>
281
+ <p>
282
+ End-to-end encryption protects the <em>channel</em>, not the <em>capability</em>: anyone you
283
+ hand the full link to gets control, so treat it like an SSH key. The server can still deny
284
+ service and observe metadata (which rooms exist, connection timing, IPs via ICE). The
285
+ channel binding uses DTLS fingerprints from the SDP rather than a true RFC 5705 keying
286
+ exporter (a limitation of the host's WebRTC stack today), and the secret still sits in your
287
+ browser's local storage until it ages out. Forward secrecy / rekeying, and the separate
288
+ codehost transport, are future work.
289
+ </p>
290
+
291
+ <footer>
292
+ Curious how it's built? The whole thing is about 200 lines:
293
+ <code>lab/ui/e2e.js</code> (the shared crypto), <code>ts/share.ts</code> (host), and the
294
+ console client in <code>lab/ui/index.html</code>. ·
295
+ <a href="https://agent-yes.com/">← back to the console</a>
296
+ </footer>
297
+ </main>
298
+ </body>
299
+ </html>
@@ -0,0 +1,47 @@
1
+ // Types for the shared WebCrypto e2e module (lab/ui/e2e.js), so the host
2
+ // (ts/share.ts, lab/ui/share-host.ts) can import it under TypeScript.
3
+
4
+ export const V: number;
5
+ export const PROTO: string;
6
+ export const MARKER: string;
7
+ export const MAX_CHUNK: number;
8
+ export const CONFIRM_TIMEOUT_MS: number;
9
+ export const ALLOW_LEGACY_PLAINTEXT: boolean;
10
+ export const FLAG_CONFIRM: number;
11
+
12
+ export interface SendState {
13
+ sendCtr: bigint;
14
+ }
15
+ export interface RecvState {
16
+ lastSeen: bigint;
17
+ }
18
+ export interface OpenResult {
19
+ counter: bigint;
20
+ flags: number;
21
+ plaintext: Uint8Array;
22
+ }
23
+
24
+ export function validateS(s: string): string;
25
+ export function deriveAuthToken(s: string, room: string, sighost: string): Promise<string>;
26
+ export function deriveDirKeys(
27
+ s: string,
28
+ transcriptHash: Uint8Array,
29
+ ): Promise<{ keyH2C: CryptoKey; keyC2H: CryptoKey }>;
30
+ export function computeTranscriptHash(offerSdp: string, answerSdp: string): Promise<Uint8Array>;
31
+ export function seal(
32
+ key: CryptoKey,
33
+ sendState: SendState,
34
+ flags: number,
35
+ transcriptHash: Uint8Array,
36
+ plaintext: Uint8Array,
37
+ ): Promise<ArrayBuffer>;
38
+ export function open(
39
+ key: CryptoKey,
40
+ frame: ArrayBuffer | Uint8Array,
41
+ transcriptHash: Uint8Array,
42
+ recvState: RecvState,
43
+ ): Promise<OpenResult>;
44
+ export function packEnvelope(obj: unknown): Uint8Array;
45
+ export function unpackEnvelope(bytes: Uint8Array): any;
46
+ export function parseSecret(token: string): { s: string; v2: boolean };
47
+ export function randomHex(n: number): string;
package/lab/ui/e2e.js ADDED
@@ -0,0 +1,245 @@
1
+ // agent-yes end-to-end encryption for the WebRTC share DataChannel (protocol
2
+ // "ay-e2e-1", URL marker "e1.").
3
+ //
4
+ // ONE implementation, shared by both ends so they can never diverge:
5
+ // - the browser console (lab/ui/index.html) imports it over HTTP as ./e2e.js
6
+ // - the host (ts/share.ts, lab/ui/share-host.ts) bundles it via a relative
7
+ // import — Bun ships WebCrypto, so the same code runs on both ends
8
+ // - the test suite (tests/e2e-crypto.test.ts) imports it under Node's WebCrypto
9
+ //
10
+ // Threat model: a fully compromised signaling Durable Object (lab/ui/cf/worker.ts)
11
+ // — or an active MITM on the WebRTC media path — may DoS and observe metadata, but
12
+ // MUST NOT read terminal I/O, inject input, spawn agents, or recover the secret S
13
+ // or any AES key. The signaling server only ever sees `authToken = HKDF(S,…)`,
14
+ // which is one-way; the AES keys never leave the endpoints.
15
+ //
16
+ // See agent-yes.com/blog/e2ee-share-links for the design writeup.
17
+
18
+ export const V = 1;
19
+ export const PROTO = `ay-e2e-${V}`; // "ay-e2e-1"
20
+ export const MARKER = `e${V}.`; // "e1."
21
+ const INFO_AUTH = `ay/${PROTO}/auth`;
22
+ const INFO_H2C = `ay/${PROTO}/key/host->client`;
23
+ const INFO_C2H = `ay/${PROTO}/key/client->host`;
24
+ export const MAX_CHUNK = 12_000; // bytes of plaintext per sealed frame, << SCTP max
25
+ export const CONFIRM_TIMEOUT_MS = 5_000; // bidirectional key-confirmation deadline
26
+ export const ALLOW_LEGACY_PLAINTEXT = false; // NEVER silently downgrade to plaintext
27
+
28
+ const VER = 0x01; // frame version byte
29
+ export const FLAG_CONFIRM = 0x01; // FLAGS bit: key-confirmation frame
30
+ const HEADER_LEN = 14; // VER(1) + FLAGS(1) + NONCE(12)
31
+ const NONCE_LEN = 12;
32
+ const TAG_LEN = 16; // AES-GCM tag, appended to ciphertext (WebCrypto convention)
33
+ const COUNTER_MAX = (1n << 64n) - 1n;
34
+
35
+ // Startup self-check: the single version source must be internally consistent, so
36
+ // a future bump can't leave the marker, info strings, and PROTO disagreeing.
37
+ if (PROTO !== `ay-e2e-${V}` || MARKER !== `e${V}.` || !INFO_AUTH.startsWith(`ay/${PROTO}/`)) {
38
+ throw new Error("e2e: version constants disagree");
39
+ }
40
+
41
+ const subtle = globalThis.crypto.subtle;
42
+ const enc = new TextEncoder();
43
+ const dec = new TextDecoder();
44
+ const HEX64 = /^[0-9a-f]{64}$/;
45
+
46
+ // ---- small byte helpers ----------------------------------------------------
47
+ function concatBytes(...arrs) {
48
+ let len = 0;
49
+ for (const a of arrs) len += a.length;
50
+ const out = new Uint8Array(len);
51
+ let o = 0;
52
+ for (const a of arrs) {
53
+ out.set(a, o);
54
+ o += a.length;
55
+ }
56
+ return out;
57
+ }
58
+ function hexToBytes(hex) {
59
+ const out = new Uint8Array(hex.length / 2);
60
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
61
+ return out;
62
+ }
63
+ function bytesToHex(b) {
64
+ let s = "";
65
+ for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
66
+ return s;
67
+ }
68
+ async function sha256(bytes) {
69
+ return new Uint8Array(await subtle.digest("SHA-256", bytes));
70
+ }
71
+ async function hkdf32(ikm, salt, info) {
72
+ const base = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
73
+ const bits = await subtle.deriveBits(
74
+ { name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) },
75
+ base,
76
+ 256,
77
+ );
78
+ return new Uint8Array(bits);
79
+ }
80
+
81
+ // ---- secret validation + key derivation -----------------------------------
82
+
83
+ // Reject anything that isn't a full-entropy 64-hex secret BEFORE it reaches HKDF.
84
+ // Fail-closed, and the error never echoes the input (no token in logs).
85
+ export function validateS(s) {
86
+ if (typeof s !== "string" || !HEX64.test(s)) throw new Error("invalid share token");
87
+ return s;
88
+ }
89
+ function ikmFromS(s) {
90
+ return hexToBytes(validateS(s)); // IKM is the 32 raw bytes, never the 64 ASCII chars
91
+ }
92
+
93
+ // The ONLY value the signaling server sees. Salted with the room+sighost context
94
+ // (one-way) so it can't be used to link the same S across rooms, and so a hacked
95
+ // server learns nothing about S or the AES keys.
96
+ export async function deriveAuthToken(s, room, sighost) {
97
+ const salt = await sha256(enc.encode(`${room}\n${sighost}`));
98
+ return bytesToHex(await hkdf32(ikmFromS(s), salt, INFO_AUTH));
99
+ }
100
+
101
+ async function importAesKey(raw) {
102
+ return subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
103
+ }
104
+
105
+ // The two directional AES-256-GCM keys, derived AFTER the DTLS handshake so the
106
+ // per-connection transcriptHash is the HKDF salt: every session/peer therefore
107
+ // gets fresh keys, which is what makes a counter that restarts at 0 always safe
108
+ // (no cross-session (key,nonce) reuse). Directional keys also mean the two senders
109
+ // never share a nonce space. HOST encrypts keyH2C / decrypts keyC2H; CLIENT the
110
+ // mirror. These keys never leave the machine.
111
+ export async function deriveDirKeys(s, transcriptHash) {
112
+ const ikm = ikmFromS(s);
113
+ const h2c = await hkdf32(ikm, transcriptHash, INFO_H2C);
114
+ const c2h = await hkdf32(ikm, transcriptHash, INFO_C2H);
115
+ return { keyH2C: await importAesKey(h2c), keyC2H: await importAesKey(c2h) };
116
+ }
117
+
118
+ // ---- transcript hash (channel binding) ------------------------------------
119
+
120
+ function allFingerprints(sdp) {
121
+ const out = [];
122
+ const re = /^a=fingerprint:(.*)$/gim;
123
+ let m;
124
+ while ((m = re.exec(sdp))) out.push(m[1].trim().toLowerCase());
125
+ return out;
126
+ }
127
+ function firstAttr(sdp, name) {
128
+ const m = new RegExp(`^a=${name}:(.*)$`, "im").exec(sdp);
129
+ return m ? m[1].trim().toLowerCase() : "";
130
+ }
131
+
132
+ // Bind the session to the negotiated DTLS handshake by hashing both peers'
133
+ // fingerprints (session- and media-level), DTLS setup role, and ICE ufrag. Used
134
+ // as BOTH the HKDF salt for the directional keys AND the AEAD AAD on every frame,
135
+ // so a relay that can't reproduce the exact transcript can neither derive the keys
136
+ // nor forge a frame. Host passes offer=local/answer=remote; client passes
137
+ // offer=remote/answer=local — both compute the identical string. Fail-closed if a
138
+ // side has no fingerprint or offers a non-sha-256 (downgrade) fingerprint.
139
+ export async function computeTranscriptHash(offerSdp, answerSdp) {
140
+ const offerFps = allFingerprints(offerSdp).sort();
141
+ const answerFps = allFingerprints(answerSdp).sort();
142
+ if (!offerFps.length || !answerFps.length) throw new Error("e2e: missing DTLS fingerprint");
143
+ for (const fp of offerFps.concat(answerFps)) {
144
+ if (!fp.startsWith("sha-256")) throw new Error("e2e: non-sha-256 DTLS fingerprint");
145
+ }
146
+ const input =
147
+ `${PROTO}\n` +
148
+ `offer=${offerFps.join(",")};setup=${firstAttr(offerSdp, "setup")};ufrag=${firstAttr(offerSdp, "ice-ufrag")}\n` +
149
+ `answer=${answerFps.join(",")};setup=${firstAttr(answerSdp, "setup")};ufrag=${firstAttr(answerSdp, "ice-ufrag")}`;
150
+ return await sha256(enc.encode(input));
151
+ }
152
+
153
+ // ---- AEAD frame seal / open -----------------------------------------------
154
+ // Wire frame: VER(1) | FLAGS(1) | NONCE(12) | CIPHERTEXT | TAG(16)
155
+ // NONCE = [4-byte BE epoch = 0] | [8-byte BE monotonic per-direction counter]
156
+ // AAD = header(14) | transcriptHash(32) (on every frame)
157
+
158
+ function nonceFromCounter(ctr) {
159
+ const n = new Uint8Array(NONCE_LEN); // bytes 0..3 epoch stay 0 in v2
160
+ new DataView(n.buffer).setBigUint64(4, ctr, false);
161
+ return n;
162
+ }
163
+
164
+ // sendState: { sendCtr: bigint }. The counter is captured-and-incremented
165
+ // synchronously BEFORE the await, so concurrent seals can never reuse a nonce.
166
+ export async function seal(key, sendState, flags, transcriptHash, plaintext) {
167
+ const ctr = sendState.sendCtr;
168
+ if (ctr >= COUNTER_MAX) throw new Error("e2e: nonce counter overflow");
169
+ sendState.sendCtr = ctr + 1n;
170
+ const nonce = nonceFromCounter(ctr);
171
+ const header = new Uint8Array(HEADER_LEN);
172
+ header[0] = VER;
173
+ header[1] = flags & 0xff;
174
+ header.set(nonce, 2);
175
+ const aad = concatBytes(header, transcriptHash);
176
+ const sealed = new Uint8Array(
177
+ await subtle.encrypt(
178
+ { name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 },
179
+ key,
180
+ plaintext,
181
+ ),
182
+ );
183
+ return concatBytes(header, sealed).buffer; // ArrayBuffer, ready for dc.send
184
+ }
185
+
186
+ // recvState: { lastSeen: bigint } (init -1n). Throws (fail-closed) on bad
187
+ // version/epoch, auth/AAD failure, or replay/reorder (counter <= lastSeen). The
188
+ // caller MUST close the channel on any throw — never fall through to JSON.parse.
189
+ export async function open(key, frame, transcriptHash, recvState) {
190
+ const buf = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
191
+ if (buf.length < HEADER_LEN + TAG_LEN) throw new Error("e2e: short frame");
192
+ if (buf[0] !== VER) throw new Error("e2e: bad version");
193
+ const header = buf.subarray(0, HEADER_LEN);
194
+ const nonce = buf.subarray(2, HEADER_LEN);
195
+ const ndv = new DataView(nonce.buffer, nonce.byteOffset, NONCE_LEN);
196
+ if (ndv.getUint32(0, false) !== 0) throw new Error("e2e: bad epoch");
197
+ const ctr = ndv.getBigUint64(4, false);
198
+ const sealed = buf.subarray(HEADER_LEN);
199
+ const aad = concatBytes(header, transcriptHash);
200
+ const ptBuf = await subtle.decrypt(
201
+ { name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 },
202
+ key,
203
+ sealed,
204
+ ); // throws on auth/AAD failure
205
+ // The first accepted frame of a session MUST be counter-0 (the confirmation
206
+ // frame). Anything else means a skipped/forged opening frame — fail closed,
207
+ // so a counter can't jump ahead and strand the real opening frames as "replay".
208
+ if (recvState.lastSeen === -1n && ctr !== 0n)
209
+ throw new Error("e2e: first frame must be counter-0");
210
+ if (ctr <= recvState.lastSeen) throw new Error("e2e: replay/reorder");
211
+ recvState.lastSeen = ctr;
212
+ return { counter: ctr, flags: header[1], plaintext: new Uint8Array(ptBuf) };
213
+ }
214
+
215
+ // ---- envelope (the {t:…} JSON), sealed as UTF-8 bytes ---------------------
216
+ export function packEnvelope(obj) {
217
+ return enc.encode(JSON.stringify(obj));
218
+ }
219
+ export function unpackEnvelope(bytes) {
220
+ return JSON.parse(dec.decode(bytes));
221
+ }
222
+
223
+ // ---- URL secret marker grammar (strict, fail-closed) ----------------------
224
+ // Parses the secret slot of a share link. Returns { s, v2 }:
225
+ // "e1.<64hex>" -> { s, v2:true } (v2 encrypted link)
226
+ // "<64hex>" / other custom token -> { s, v2:false } (legacy; gated by caller)
227
+ // A token that LOOKS like a version marker but isn't exactly "e1.<64hex>" is
228
+ // rejected outright — it must never silently fall back to a legacy/plaintext path.
229
+ export function parseSecret(token) {
230
+ const mk = /^e(\d+)\.(.*)$/.exec(token);
231
+ if (mk) {
232
+ if (mk[1] !== String(V)) throw new Error("update required");
233
+ if (!HEX64.test(mk[2])) throw new Error("malformed encrypted link");
234
+ return { s: mk[2], v2: true };
235
+ }
236
+ if (/^e\d/i.test(token)) throw new Error("malformed encrypted link");
237
+ return { s: token, v2: false };
238
+ }
239
+
240
+ // Random hex string of n bytes — confirmation challenge nonces, request ids.
241
+ export function randomHex(n) {
242
+ const b = new Uint8Array(n);
243
+ globalThis.crypto.getRandomValues(b);
244
+ return bytesToHex(b);
245
+ }