agent-yes 1.119.1 → 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.
- package/dist/SUPPORTED_CLIS-CegJgoEf.js +8 -0
- package/dist/{SUPPORTED_CLIS-DwPmzY8B.js → SUPPORTED_CLIS-O57LGUEG.js} +2 -2
- package/dist/cli.js +3 -3
- package/dist/index.js +2 -2
- package/dist/{serve-Bd-6ZItj.js → serve-D2czcYNC.js} +29 -18
- package/dist/{setup-DiRSdfeu.js → setup-f1FIFcZm.js} +2 -2
- package/dist/share-B6QVr5D1.js +522 -0
- package/dist/{subcommands-BC_0iPGS.js → subcommands-CzpZQHO6.js} +3 -3
- package/dist/{subcommands-BFHJ2AUQ.js → subcommands-DobVXouH.js} +1 -1
- package/dist/{ts-VrgyWwNH.js → ts-D91dm1E0.js} +2 -2
- package/dist/{versionChecker-BjZOppZJ.js → versionChecker-CAtpgnoQ.js} +2 -2
- package/lab/ui/blog/e2ee-share-links/index.html +299 -0
- package/lab/ui/e2e.d.ts +47 -0
- package/lab/ui/e2e.js +245 -0
- package/lab/ui/index.html +180 -26
- package/package.json +6 -2
- package/scripts/check-e2e.ts +40 -0
- package/ts/e2e-crypto.spec.ts +235 -0
- package/ts/serve.ts +57 -21
- package/ts/share.ts +205 -32
- package/dist/SUPPORTED_CLIS-CwM5JV4y.js +0 -8
- package/dist/share-B7J79Wq9.js +0 -254
|
@@ -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/#<room>:e1.<secret></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.<secret></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>
|
package/lab/ui/e2e.d.ts
ADDED
|
@@ -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
|
+
}
|