tracegist-mcp-bridge 0.3.2 → 1.0.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/CHANGELOG.md +88 -0
- package/bin/lib.mjs +38 -0
- package/bin/live-crypto.mjs +535 -0
- package/bin/tracegist-mcp-bridge.mjs +698 -54
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,94 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `tracegist-mcp-bridge`.
|
|
4
4
|
|
|
5
|
+
## 1.0.0
|
|
6
|
+
|
|
7
|
+
> ⚠️ **Breaking Remote Live Session protocol change** (`FRAME_VERSION` → 3):
|
|
8
|
+
> a 1.0.0 bridge will NOT interoperate with an older extension, and vice
|
|
9
|
+
> versa. It ships **in lockstep** with the TraceGist extension v1.0.0 release —
|
|
10
|
+
> update both together. Includes everything from the (unpublished) 0.5.0 below.
|
|
11
|
+
|
|
12
|
+
Hardens the E2E encryption per the security audit (findings F1–F3) plus a
|
|
13
|
+
per-connection ephemeral-key handshake:
|
|
14
|
+
|
|
15
|
+
- **Per-connection handshake (fixes the CRITICAL reconnect/replay flaw).** Data
|
|
16
|
+
frames are now encrypted under a _connection key_, not the code key. On every
|
|
17
|
+
(re)connection the tester sends a fresh nonce (tagged with a monotonic
|
|
18
|
+
wall-clock epoch); the agent replies with its own nonce; both HKDF a connection
|
|
19
|
+
key from the code secret + both nonces + epoch and reset their counter. Because
|
|
20
|
+
each connection derives a distinct key: (a) a routine single-sided reconnect
|
|
21
|
+
re-handshakes and resynchronizes both sides (no more "post-reconnect frames
|
|
22
|
+
rejected as replays" brick), and (b) a malicious relay replaying a previous
|
|
23
|
+
connection's frames fails to decrypt under the new key. The agent is idempotent
|
|
24
|
+
per epoch and the tester retransmits until it gets a reply (with a bounded
|
|
25
|
+
timeout that ends the session), so a dropped handshake nonce recovers.
|
|
26
|
+
- **Anti-replay / anti-reflection (F1).** Every data frame authenticates a
|
|
27
|
+
per-direction monotonic counter + sender role via AES-GCM AAD; a `ReplayGuard`
|
|
28
|
+
rejects non-increasing counters (in-connection replay/reorder), and the
|
|
29
|
+
per-connection key rejects cross-connection replay.
|
|
30
|
+
- **Slow KDF (F2).** The code secret is PBKDF2-SHA256 (210k iterations); the
|
|
31
|
+
handshake and connection keys are HKDF'd from it. The relay-visible room ID
|
|
32
|
+
stays a fast, domain-separated hash.
|
|
33
|
+
- **Chunk-count cap (F3).** `FrameChunkAssembler` rejects a part count above
|
|
34
|
+
`MAX_CHUNK_TOTAL` (1024).
|
|
35
|
+
|
|
36
|
+
Not included (deferred): **forward secrecy (F5)** — the nonce-mixed key does NOT
|
|
37
|
+
provide it (a compromised code + logged nonces still derives the key); true FS
|
|
38
|
+
needs ephemeral ECDH. Also deferred: raising session-code entropy above 75 bits.
|
|
39
|
+
|
|
40
|
+
## 0.5.0
|
|
41
|
+
|
|
42
|
+
_Never published to npm — these changes first shipped as part of 1.0.0._
|
|
43
|
+
|
|
44
|
+
Remote Live Session agent-experience improvements (all additive — no breaking
|
|
45
|
+
tool-signature changes), from real agent-session feedback.
|
|
46
|
+
|
|
47
|
+
- `watch_live_session` gains an optional `wait_seconds` long-poll parameter (max
|
|
48
|
+
30). The call now blocks until the next event arrives (or the session ends),
|
|
49
|
+
so agents react instantly instead of sleeping-and-re-polling every few seconds.
|
|
50
|
+
- **Tester reconnect grace window.** A transient tester disconnect (service-worker
|
|
51
|
+
suspend, network blip) no longer ends the session immediately; the bridge waits
|
|
52
|
+
`TRACEGIST_PEER_GRACE_MS` (default 15s) for the extension to reconnect and
|
|
53
|
+
re-announce, preserving buffered events and in-flight replies. An explicit
|
|
54
|
+
`session-end` (recording stopped / consent revoked) still ends at once.
|
|
55
|
+
- **Actionable, differentiated errors** for `ask_tester_question` /
|
|
56
|
+
`get_live_screenshot`: distinguishes "tester hasn't joined / left", "connected
|
|
57
|
+
but not recording yet", and "momentarily disconnected, retry" instead of one
|
|
58
|
+
opaque message.
|
|
59
|
+
- **Silent-recording detection.** A voice answer that transcribes to whisper's
|
|
60
|
+
`[BLANK_AUDIO]` (or empty) is now surfaced as an explicit "no speech detected —
|
|
61
|
+
consider re-asking" signal rather than a bogus transcript.
|
|
62
|
+
- Every live event now carries `tsEpoch` (bridge wall-clock at ingestion) — a
|
|
63
|
+
single uniformly-comparable absolute timestamp, since `ts` is source-relative
|
|
64
|
+
for tester events but epoch for bridge control events.
|
|
65
|
+
- `ask_tester_question` message limit raised 200 → 500 chars and reframed to allow
|
|
66
|
+
statements/replies, not only questions.
|
|
67
|
+
|
|
68
|
+
## 0.4.0
|
|
69
|
+
|
|
70
|
+
**Remote Live Sessions** — the live shadowing loop now works across machines.
|
|
71
|
+
A tester anywhere starts a remote session in the TraceGist extension and
|
|
72
|
+
shares a code; this bridge joins through a relay and every live tool works
|
|
73
|
+
exactly as it does locally.
|
|
74
|
+
|
|
75
|
+
- New tool `join_live_session({ code })`: joins the tester's session via a
|
|
76
|
+
relay (`TRACEGIST_RELAY_URL` env var overrides the default). All live-shadow
|
|
77
|
+
traffic is end-to-end encrypted with AES-256-GCM using a key derived from
|
|
78
|
+
the session code — the relay only ever sees ciphertext, and the room ID is
|
|
79
|
+
a hash of the code, so the relay cannot derive the key.
|
|
80
|
+
- New tool `leave_live_session()`: disconnects from the remote session.
|
|
81
|
+
- `live-session://status` now reports the active transport (`local` / `relay`
|
|
82
|
+
/ `none`) and remote connection state.
|
|
83
|
+
- The local localhost WebSocket transport is unchanged and remains the
|
|
84
|
+
default; remote mode is purely additive (semver minor).
|
|
85
|
+
- `TRACEGIST_DISABLE_LIVE` now also disables `join_live_session`.
|
|
86
|
+
- Relay reconnects use the same backoff strategy as the extension; a
|
|
87
|
+
`tester-connected` / `tester-disconnected` live event is pushed when the
|
|
88
|
+
tester's extension joins or drops.
|
|
89
|
+
|
|
90
|
+
The relay itself ships separately as the `tracegist-relay` package
|
|
91
|
+
(self-hostable; plain Node + ws).
|
|
92
|
+
|
|
5
93
|
## 0.3.0
|
|
6
94
|
|
|
7
95
|
**Breaking changes:**
|
package/bin/lib.mjs
CHANGED
|
@@ -212,6 +212,44 @@ export function audioFormatFromMime(mimeType, fallback = "webm") {
|
|
|
212
212
|
return m ? m[1] : fallback;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Interpret a raw transcription string from Whisper/OpenRouter. whisper.cpp
|
|
217
|
+
* emits the literal "[BLANK_AUDIO]" marker (and models can return near-empty
|
|
218
|
+
* text) when a recording has no speech — a silent mic. Strips those markers and
|
|
219
|
+
* reports silence explicitly so the caller can tell the agent to re-ask instead
|
|
220
|
+
* of forwarding a bogus "answer".
|
|
221
|
+
* @param {string | null | undefined} rawText
|
|
222
|
+
* @returns {{ transcription: string } | { silent: true }}
|
|
223
|
+
*/
|
|
224
|
+
export function interpretTranscription(rawText) {
|
|
225
|
+
const cleaned = String(rawText || "")
|
|
226
|
+
.replace(/\[BLANK_?AUDIO\]/gi, "")
|
|
227
|
+
.trim();
|
|
228
|
+
if (!cleaned) return { silent: true };
|
|
229
|
+
return { transcription: cleaned };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Validate a Whisper language code before it is handed to nodejs-whisper.
|
|
234
|
+
*
|
|
235
|
+
* nodejs-whisper interpolates this value UNescaped into a shell command string
|
|
236
|
+
* (`-l <language>`) executed via shelljs.exec, so an unvalidated value is a
|
|
237
|
+
* command-injection sink. Whisper language codes are ISO 639-1/639-2 (2-3
|
|
238
|
+
* ASCII letters) or the literal "auto". Returns the trimmed code, or `undefined`
|
|
239
|
+
* for an empty/absent value. Throws on anything else.
|
|
240
|
+
*/
|
|
241
|
+
export function sanitizeWhisperLanguage(language) {
|
|
242
|
+
const trimmed = typeof language === "string" ? language.trim() : "";
|
|
243
|
+
if (!trimmed) return undefined;
|
|
244
|
+
if (!/^(auto|[a-z]{2,3})$/i.test(trimmed)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Invalid language code: ${JSON.stringify(language)}. ` +
|
|
247
|
+
`Expected an ISO 639 code (e.g. "en", "de", "ja") or "auto".`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return trimmed;
|
|
251
|
+
}
|
|
252
|
+
|
|
215
253
|
/**
|
|
216
254
|
* Parse a boolean-ish env-var value. Treats "0", "false", "no", "off"
|
|
217
255
|
* (case-insensitive) and empty/undefined as falsy; everything else as truthy.
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Live Sessions — session-code derivation and E2E frame crypto.
|
|
3
|
+
*
|
|
4
|
+
* Node-side mirror of the extension's lib/remote-session.ts. The two modules
|
|
5
|
+
* must stay byte-compatible: same code normalization, same room-ID and key
|
|
6
|
+
* derivation, same AES-256-GCM frame format (see docs/live-protocol.md in the
|
|
7
|
+
* TraceGist repo). This package is published standalone, so the primitives
|
|
8
|
+
* are duplicated here rather than imported from the extension codebase.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { webcrypto } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
const subtle = webcrypto.subtle;
|
|
14
|
+
|
|
15
|
+
/** Crockford base32 alphabet — no I, L, O, U to avoid ambiguity. */
|
|
16
|
+
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
17
|
+
const CODE_GROUP_LENGTH = 5;
|
|
18
|
+
const CODE_GROUPS = 3;
|
|
19
|
+
|
|
20
|
+
const ROOM_ID_CONTEXT = "tracegist-room-v1:";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Key derivation: PBKDF2-SHA256, high iteration count, into an HKDF base key.
|
|
24
|
+
* v3 adds the per-connection ephemeral-key handshake (see HandshakeManager).
|
|
25
|
+
* MUST match lib/remote-session.ts.
|
|
26
|
+
*/
|
|
27
|
+
const PBKDF2_SALT = "tracegist-live-v2";
|
|
28
|
+
const PBKDF2_ITERATIONS = 210_000;
|
|
29
|
+
|
|
30
|
+
export const FRAME_VERSION = 3;
|
|
31
|
+
|
|
32
|
+
const HKDF_HANDSHAKE_INFO = "tracegist-hs-key-v3";
|
|
33
|
+
const HKDF_CONNECTION_INFO = "tracegist-conn-v3";
|
|
34
|
+
const HKDF_SALT_CONST = "tracegist-live-v3";
|
|
35
|
+
const HANDSHAKE_NONCE_BYTES = 16;
|
|
36
|
+
|
|
37
|
+
/** AES-GCM IV length in bytes (protocol constant, see docs/live-protocol.md) */
|
|
38
|
+
const GCM_IV_BYTES = 12;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* AAD bound into every DATA frame's AES-GCM tag: version + sender role +
|
|
42
|
+
* per-direction counter. Blocks reflection and replay/reorder. MUST match
|
|
43
|
+
* lib/remote-session.ts byte-for-byte.
|
|
44
|
+
* @param {"tester" | "agent"} senderRole
|
|
45
|
+
* @param {number} counter
|
|
46
|
+
*/
|
|
47
|
+
function frameAad(senderRole, counter) {
|
|
48
|
+
return new TextEncoder().encode(`tracegist-frame-v3|${senderRole}|${counter}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** AAD for a handshake frame: binds protocol tag + sender role. */
|
|
52
|
+
function handshakeAad(senderRole) {
|
|
53
|
+
return new TextEncoder().encode(`tracegist-hs-v3|${senderRole}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rejects frames whose per-direction counter is not strictly increasing (anti
|
|
58
|
+
* replay/reorder). One instance per receive direction; reset per connection.
|
|
59
|
+
*/
|
|
60
|
+
export class ReplayGuard {
|
|
61
|
+
constructor() {
|
|
62
|
+
this.last = -1;
|
|
63
|
+
}
|
|
64
|
+
/** @param {number} counter */
|
|
65
|
+
accept(counter) {
|
|
66
|
+
if (!Number.isInteger(counter) || counter <= this.last) return false;
|
|
67
|
+
this.last = counter;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a user-entered session code: trim, uppercase.
|
|
74
|
+
* @param {string} raw
|
|
75
|
+
* @returns {string | null} normalized code, or null if malformed
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeSessionCode(raw) {
|
|
78
|
+
const code = String(raw).trim().toUpperCase();
|
|
79
|
+
const groupPattern = `[${CODE_ALPHABET}]{${CODE_GROUP_LENGTH}}`;
|
|
80
|
+
const pattern = new RegExp(`^TG(-${groupPattern}){${CODE_GROUPS}}$`);
|
|
81
|
+
return pattern.test(code) ? code : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Derive the relay room ID (hex SHA-256) from a normalized session code.
|
|
86
|
+
* @param {string} code
|
|
87
|
+
* @returns {Promise<string>}
|
|
88
|
+
*/
|
|
89
|
+
export async function deriveRoomId(code) {
|
|
90
|
+
const data = new TextEncoder().encode(ROOM_ID_CONTEXT + code);
|
|
91
|
+
const digest = await subtle.digest("SHA-256", data);
|
|
92
|
+
return Buffer.from(digest).toString("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Derive the long-term "code secret" (HKDF base key) from the session code via
|
|
97
|
+
* PBKDF2. Everything else is HKDF'd from this. MUST match lib/remote-session.ts.
|
|
98
|
+
* @param {string} code
|
|
99
|
+
* @returns {Promise<CryptoKey>}
|
|
100
|
+
*/
|
|
101
|
+
export async function deriveCodeSecret(code) {
|
|
102
|
+
const encoder = new TextEncoder();
|
|
103
|
+
const pbkdf2Key = await subtle.importKey("raw", encoder.encode(code), "PBKDF2", false, [
|
|
104
|
+
"deriveBits",
|
|
105
|
+
]);
|
|
106
|
+
const bits = await subtle.deriveBits(
|
|
107
|
+
{
|
|
108
|
+
name: "PBKDF2",
|
|
109
|
+
hash: "SHA-256",
|
|
110
|
+
salt: encoder.encode(PBKDF2_SALT),
|
|
111
|
+
iterations: PBKDF2_ITERATIONS,
|
|
112
|
+
},
|
|
113
|
+
pbkdf2Key,
|
|
114
|
+
256,
|
|
115
|
+
);
|
|
116
|
+
return subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Derive the handshake key (encrypts the nonce-exchange frames).
|
|
121
|
+
* @param {CryptoKey} codeSecret
|
|
122
|
+
* @returns {Promise<CryptoKey>}
|
|
123
|
+
*/
|
|
124
|
+
export async function deriveHandshakeKey(codeSecret) {
|
|
125
|
+
const encoder = new TextEncoder();
|
|
126
|
+
return subtle.deriveKey(
|
|
127
|
+
{
|
|
128
|
+
name: "HKDF",
|
|
129
|
+
hash: "SHA-256",
|
|
130
|
+
salt: encoder.encode(HKDF_SALT_CONST),
|
|
131
|
+
info: encoder.encode(HKDF_HANDSHAKE_INFO),
|
|
132
|
+
},
|
|
133
|
+
codeSecret,
|
|
134
|
+
{ name: "AES-GCM", length: 256 },
|
|
135
|
+
false,
|
|
136
|
+
["encrypt", "decrypt"],
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Derive the per-connection DATA key from the code secret + both handshake
|
|
142
|
+
* nonces + epoch (nonces concatenated tester-then-agent). MUST match
|
|
143
|
+
* lib/remote-session.ts.
|
|
144
|
+
* @param {CryptoKey} codeSecret
|
|
145
|
+
* @param {Uint8Array} testerNonce
|
|
146
|
+
* @param {Uint8Array} agentNonce
|
|
147
|
+
* @param {number} epoch
|
|
148
|
+
* @returns {Promise<CryptoKey>}
|
|
149
|
+
*/
|
|
150
|
+
export async function deriveConnectionKey(codeSecret, testerNonce, agentNonce, epoch) {
|
|
151
|
+
const salt = new Uint8Array(testerNonce.length + agentNonce.length);
|
|
152
|
+
salt.set(testerNonce, 0);
|
|
153
|
+
salt.set(agentNonce, testerNonce.length);
|
|
154
|
+
return subtle.deriveKey(
|
|
155
|
+
{
|
|
156
|
+
name: "HKDF",
|
|
157
|
+
hash: "SHA-256",
|
|
158
|
+
salt,
|
|
159
|
+
info: new TextEncoder().encode(`${HKDF_CONNECTION_INFO}|${epoch}`),
|
|
160
|
+
},
|
|
161
|
+
codeSecret,
|
|
162
|
+
{ name: "AES-GCM", length: 256 },
|
|
163
|
+
false,
|
|
164
|
+
["encrypt", "decrypt"],
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Generate a fresh handshake nonce (raw bytes). */
|
|
169
|
+
export function generateHandshakeNonce() {
|
|
170
|
+
return webcrypto.getRandomValues(new Uint8Array(HANDSHAKE_NONCE_BYTES));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Encrypt a live-shadow message into a frame payload. `senderRole` is this
|
|
175
|
+
* endpoint's own role, `counter` its per-direction monotonic sequence; both are
|
|
176
|
+
* authenticated via AAD.
|
|
177
|
+
* @param {CryptoKey} key
|
|
178
|
+
* @param {object} message
|
|
179
|
+
* @param {"tester" | "agent"} senderRole
|
|
180
|
+
* @param {number} counter
|
|
181
|
+
* @returns {Promise<{v: number, iv: string, ct: string, c: number}>}
|
|
182
|
+
*/
|
|
183
|
+
export async function encryptFrame(key, message, senderRole, counter) {
|
|
184
|
+
// Counter must be a non-negative safe integer (see lib/remote-session.ts).
|
|
185
|
+
if (!Number.isSafeInteger(counter) || counter < 0) {
|
|
186
|
+
throw new Error("Frame counter exhausted — a new session/key is required");
|
|
187
|
+
}
|
|
188
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
|
|
189
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(message));
|
|
190
|
+
const additionalData = frameAad(senderRole, counter);
|
|
191
|
+
const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, plaintext);
|
|
192
|
+
return {
|
|
193
|
+
v: FRAME_VERSION,
|
|
194
|
+
iv: Buffer.from(iv).toString("base64"),
|
|
195
|
+
ct: Buffer.from(ct).toString("base64"),
|
|
196
|
+
c: counter,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Decrypt a frame payload back into a message object. `senderRole` is the role
|
|
202
|
+
* the receiver EXPECTS (the peer's role); the AAD is rebuilt from it and the
|
|
203
|
+
* frame counter, so a reflected or counter-tampered frame fails to authenticate.
|
|
204
|
+
* Returns null for unknown versions, malformed payloads, or failed auth.
|
|
205
|
+
* Anti-replay is enforced separately by ReplayGuard.
|
|
206
|
+
* @param {CryptoKey} key
|
|
207
|
+
* @param {{v: number, iv: string, ct: string, c: number}} payload
|
|
208
|
+
* @param {"tester" | "agent"} senderRole
|
|
209
|
+
* @returns {Promise<object | null>}
|
|
210
|
+
*/
|
|
211
|
+
export async function decryptFrame(key, payload, senderRole) {
|
|
212
|
+
// Counters are non-negative safe integers by construction; reject anything
|
|
213
|
+
// else before it reaches AAD building or the ReplayGuard.
|
|
214
|
+
if (!payload || payload.v !== FRAME_VERSION || !Number.isSafeInteger(payload.c) || payload.c < 0)
|
|
215
|
+
return null;
|
|
216
|
+
try {
|
|
217
|
+
const iv = Buffer.from(payload.iv, "base64");
|
|
218
|
+
const ct = Buffer.from(payload.ct, "base64");
|
|
219
|
+
const additionalData = frameAad(senderRole, payload.c);
|
|
220
|
+
const plaintext = await subtle.decrypt({ name: "AES-GCM", iv, additionalData }, key, ct);
|
|
221
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Encrypt a handshake message under the handshake key (authenticated by role).
|
|
229
|
+
* @param {CryptoKey} handshakeKey
|
|
230
|
+
* @param {"tester" | "agent"} senderRole
|
|
231
|
+
* @param {{epoch: number, nonce: string, role: string}} message
|
|
232
|
+
* @returns {Promise<{hs: 1, iv: string, ct: string}>}
|
|
233
|
+
*/
|
|
234
|
+
export async function encryptHandshake(handshakeKey, senderRole, message) {
|
|
235
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
|
|
236
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(message));
|
|
237
|
+
const additionalData = handshakeAad(senderRole);
|
|
238
|
+
const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData }, handshakeKey, plaintext);
|
|
239
|
+
return {
|
|
240
|
+
hs: 1,
|
|
241
|
+
iv: Buffer.from(iv).toString("base64"),
|
|
242
|
+
ct: Buffer.from(ct).toString("base64"),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Decrypt a handshake frame. `senderRole` is the expected sender (peer) role.
|
|
248
|
+
* @param {CryptoKey} handshakeKey
|
|
249
|
+
* @param {"tester" | "agent"} senderRole
|
|
250
|
+
* @param {{hs: 1, iv: string, ct: string}} payload
|
|
251
|
+
* @returns {Promise<{epoch: number, nonce: string, role: string} | null>}
|
|
252
|
+
*/
|
|
253
|
+
export async function decryptHandshake(handshakeKey, senderRole, payload) {
|
|
254
|
+
if (!payload || payload.hs !== 1) return null;
|
|
255
|
+
try {
|
|
256
|
+
const iv = Buffer.from(payload.iv, "base64");
|
|
257
|
+
const ct = Buffer.from(payload.ct, "base64");
|
|
258
|
+
const additionalData = handshakeAad(senderRole);
|
|
259
|
+
const plaintext = await subtle.decrypt(
|
|
260
|
+
{ name: "AES-GCM", iv, additionalData },
|
|
261
|
+
handshakeKey,
|
|
262
|
+
ct,
|
|
263
|
+
);
|
|
264
|
+
const msg = JSON.parse(new TextDecoder().decode(plaintext));
|
|
265
|
+
if (
|
|
266
|
+
!Number.isSafeInteger(msg.epoch) ||
|
|
267
|
+
msg.epoch < 0 ||
|
|
268
|
+
typeof msg.nonce !== "string" ||
|
|
269
|
+
(msg.role !== "tester" && msg.role !== "agent")
|
|
270
|
+
) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
return msg;
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** True if a relay payload is a handshake frame (vs a data frame). */
|
|
280
|
+
export function isHandshakeFrame(payload) {
|
|
281
|
+
return typeof payload === "object" && payload !== null && payload.hs === 1;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Per-connection ephemeral-key handshake state machine — mirror of
|
|
286
|
+
* lib/remote-session.ts (see that file for the protocol description).
|
|
287
|
+
*/
|
|
288
|
+
export class HandshakeManager {
|
|
289
|
+
/**
|
|
290
|
+
* @param {"tester" | "agent"} role
|
|
291
|
+
* @param {CryptoKey} codeSecret
|
|
292
|
+
* @param {CryptoKey} handshakeKey
|
|
293
|
+
*/
|
|
294
|
+
constructor(role, codeSecret, handshakeKey) {
|
|
295
|
+
this.role = role;
|
|
296
|
+
this.peerRole = role === "tester" ? "agent" : "tester";
|
|
297
|
+
this.codeSecret = codeSecret;
|
|
298
|
+
this.handshakeKey = handshakeKey;
|
|
299
|
+
this.connectionKey = null;
|
|
300
|
+
this.sendCounter = 0;
|
|
301
|
+
this.guard = new ReplayGuard();
|
|
302
|
+
this.pendingEpoch = -1;
|
|
303
|
+
this.pendingNonce = null;
|
|
304
|
+
this.lastEpoch = -1;
|
|
305
|
+
// Highest epoch the tester has established (idempotency guard; see
|
|
306
|
+
// lib/remote-session.ts).
|
|
307
|
+
this.establishedEpoch = -1;
|
|
308
|
+
this.respondedEpoch = -1;
|
|
309
|
+
this.respondedNonce = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @param {"tester" | "agent"} role
|
|
314
|
+
* @param {string} code
|
|
315
|
+
*/
|
|
316
|
+
static async create(role, code) {
|
|
317
|
+
const codeSecret = await deriveCodeSecret(code);
|
|
318
|
+
const handshakeKey = await deriveHandshakeKey(codeSecret);
|
|
319
|
+
return new HandshakeManager(role, codeSecret, handshakeKey);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
get ready() {
|
|
323
|
+
return this.connectionKey !== null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** @param {number} now */
|
|
327
|
+
nextEpoch(now) {
|
|
328
|
+
const e = Math.max(this.lastEpoch + 1, Math.floor(now));
|
|
329
|
+
this.lastEpoch = e;
|
|
330
|
+
return e;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** TESTER: begin a fresh handshake. @param {number} now */
|
|
334
|
+
async startHandshake(now) {
|
|
335
|
+
if (this.role !== "tester") throw new Error("only the tester initiates the handshake");
|
|
336
|
+
this.connectionKey = null;
|
|
337
|
+
this.pendingEpoch = this.nextEpoch(now);
|
|
338
|
+
this.pendingNonce = generateHandshakeNonce();
|
|
339
|
+
return this.encryptPendingHandshake();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
encryptPendingHandshake() {
|
|
343
|
+
return encryptHandshake(this.handshakeKey, "tester", {
|
|
344
|
+
epoch: this.pendingEpoch,
|
|
345
|
+
nonce: Buffer.from(this.pendingNonce).toString("base64"),
|
|
346
|
+
role: "tester",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** TESTER: re-encrypt the in-flight handshake for retransmit (or null). */
|
|
351
|
+
async retransmit() {
|
|
352
|
+
if (this.role !== "tester" || this.connectionKey !== null || !this.pendingNonce) return null;
|
|
353
|
+
return this.encryptPendingHandshake();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Feed an incoming handshake frame. */
|
|
357
|
+
async onHandshakeFrame(payload) {
|
|
358
|
+
const msg = await decryptHandshake(this.handshakeKey, this.peerRole, payload);
|
|
359
|
+
if (!msg || msg.role !== this.peerRole) return { kind: "ignored" };
|
|
360
|
+
|
|
361
|
+
if (this.role === "agent") {
|
|
362
|
+
if (msg.epoch < this.respondedEpoch) return { kind: "ignored" };
|
|
363
|
+
if (msg.epoch === this.respondedEpoch && this.respondedNonce) {
|
|
364
|
+
const resend = await encryptHandshake(this.handshakeKey, "agent", {
|
|
365
|
+
epoch: msg.epoch,
|
|
366
|
+
nonce: Buffer.from(this.respondedNonce).toString("base64"),
|
|
367
|
+
role: "agent",
|
|
368
|
+
});
|
|
369
|
+
return { kind: "response", payload: resend, keyReady: this.connectionKey !== null };
|
|
370
|
+
}
|
|
371
|
+
const testerNonce = Buffer.from(msg.nonce, "base64");
|
|
372
|
+
// A wrong-size nonce would silently derive a mismatched key — fail loud.
|
|
373
|
+
if (testerNonce.length !== HANDSHAKE_NONCE_BYTES) return { kind: "ignored" };
|
|
374
|
+
const agentNonce = generateHandshakeNonce();
|
|
375
|
+
this.connectionKey = await deriveConnectionKey(
|
|
376
|
+
this.codeSecret,
|
|
377
|
+
testerNonce,
|
|
378
|
+
agentNonce,
|
|
379
|
+
msg.epoch,
|
|
380
|
+
);
|
|
381
|
+
this.sendCounter = 0;
|
|
382
|
+
this.guard = new ReplayGuard();
|
|
383
|
+
this.respondedEpoch = msg.epoch;
|
|
384
|
+
this.respondedNonce = agentNonce;
|
|
385
|
+
const response = await encryptHandshake(this.handshakeKey, "agent", {
|
|
386
|
+
epoch: msg.epoch,
|
|
387
|
+
nonce: Buffer.from(agentNonce).toString("base64"),
|
|
388
|
+
role: "agent",
|
|
389
|
+
});
|
|
390
|
+
return { kind: "response", payload: response, keyReady: true };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (msg.epoch !== this.pendingEpoch || !this.pendingNonce) return { kind: "ignored" };
|
|
394
|
+
// Idempotent: a re-delivered/replayed response for an already-established
|
|
395
|
+
// epoch must not re-derive or reset (would rewind the counter + reopen the
|
|
396
|
+
// replay hole). A legitimate reconnect uses a strictly higher epoch.
|
|
397
|
+
if (this.establishedEpoch >= msg.epoch) return { kind: "ignored" };
|
|
398
|
+
const agentNonce = Buffer.from(msg.nonce, "base64");
|
|
399
|
+
// A wrong-size nonce would silently derive a mismatched key — fail loud.
|
|
400
|
+
if (agentNonce.length !== HANDSHAKE_NONCE_BYTES) return { kind: "ignored" };
|
|
401
|
+
this.connectionKey = await deriveConnectionKey(
|
|
402
|
+
this.codeSecret,
|
|
403
|
+
this.pendingNonce,
|
|
404
|
+
agentNonce,
|
|
405
|
+
msg.epoch,
|
|
406
|
+
);
|
|
407
|
+
this.sendCounter = 0;
|
|
408
|
+
this.guard = new ReplayGuard();
|
|
409
|
+
this.establishedEpoch = msg.epoch;
|
|
410
|
+
return { kind: "established" };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Encrypt a data message under the connection key. Throws if not ready. */
|
|
414
|
+
async encryptData(message) {
|
|
415
|
+
if (!this.connectionKey) throw new Error("handshake not complete");
|
|
416
|
+
return encryptFrame(this.connectionKey, message, this.role, this.sendCounter++);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Decrypt + replay-check a data frame. Null if not ready / invalid. */
|
|
420
|
+
async decryptData(payload) {
|
|
421
|
+
if (!this.connectionKey) return null;
|
|
422
|
+
const inner = await decryptFrame(this.connectionKey, payload, this.peerRole);
|
|
423
|
+
if (inner === null) return null;
|
|
424
|
+
if (!this.guard.accept(payload.c)) return null;
|
|
425
|
+
return inner;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Frame chunking — mirror of lib/remote-session.ts (see that file for the
|
|
431
|
+
// size rationale). Hosted relays cap WebSocket messages (Cloudflare: 1 MiB);
|
|
432
|
+
// oversized inner messages are split into `frame-chunk` parts BEFORE
|
|
433
|
+
// encryption so relays only ever see ordinary frames.
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/** Inner messages whose UTF-8 size exceeds this are chunked. */
|
|
437
|
+
export const FRAME_CHUNK_THRESHOLD_BYTES = 700_000;
|
|
438
|
+
/** UTF-8 bytes per chunk. */
|
|
439
|
+
export const FRAME_CHUNK_BYTES = 500_000;
|
|
440
|
+
/** Hard cap on part count — bounds new Array(total) from a peer-supplied total. */
|
|
441
|
+
export const MAX_CHUNK_TOTAL = 1024;
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Split an oversized inner message into `frame-chunk` parts. Returns null
|
|
445
|
+
* when the message fits in a single frame. Operates on UTF-8 bytes so
|
|
446
|
+
* multi-byte characters and surrogate pairs can never be split.
|
|
447
|
+
* @param {object} message
|
|
448
|
+
* @returns {Array<{type: "frame-chunk", id: string, seq: number, total: number, data: string}> | null}
|
|
449
|
+
*/
|
|
450
|
+
export function chunkInnerMessage(message) {
|
|
451
|
+
const bytes = new TextEncoder().encode(JSON.stringify(message));
|
|
452
|
+
if (bytes.length <= FRAME_CHUNK_THRESHOLD_BYTES) return null;
|
|
453
|
+
const total = Math.ceil(bytes.length / FRAME_CHUNK_BYTES);
|
|
454
|
+
const id = webcrypto.randomUUID();
|
|
455
|
+
const parts = [];
|
|
456
|
+
for (let seq = 0; seq < total; seq++) {
|
|
457
|
+
parts.push({
|
|
458
|
+
type: "frame-chunk",
|
|
459
|
+
id,
|
|
460
|
+
seq,
|
|
461
|
+
total,
|
|
462
|
+
data: Buffer.from(
|
|
463
|
+
bytes.subarray(seq * FRAME_CHUNK_BYTES, (seq + 1) * FRAME_CHUNK_BYTES),
|
|
464
|
+
).toString("base64"),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return parts;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Reassembles decrypted `frame-chunk` parts back into the original message.
|
|
472
|
+
* Non-chunk messages pass through unchanged; chunk parts return null until
|
|
473
|
+
* the final part completes the message. A part for a new id discards any
|
|
474
|
+
* incomplete assembly (peer reconnect mid-message).
|
|
475
|
+
*/
|
|
476
|
+
export class FrameChunkAssembler {
|
|
477
|
+
constructor() {
|
|
478
|
+
this.id = null;
|
|
479
|
+
this.parts = [];
|
|
480
|
+
this.received = 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* @param {object} inner - a decrypted inner message
|
|
485
|
+
* @returns {object | null} pass-through message, completed reassembly, or null
|
|
486
|
+
*/
|
|
487
|
+
push(inner) {
|
|
488
|
+
if (inner.type !== "frame-chunk") return inner;
|
|
489
|
+
|
|
490
|
+
const { id, seq, total, data } = inner;
|
|
491
|
+
if (
|
|
492
|
+
typeof id !== "string" ||
|
|
493
|
+
typeof data !== "string" ||
|
|
494
|
+
!Number.isInteger(seq) ||
|
|
495
|
+
!Number.isInteger(total) ||
|
|
496
|
+
total < 1 ||
|
|
497
|
+
total > MAX_CHUNK_TOTAL ||
|
|
498
|
+
seq < 0 ||
|
|
499
|
+
seq >= total
|
|
500
|
+
) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (this.id !== id) {
|
|
505
|
+
this.id = id;
|
|
506
|
+
this.parts = new Array(total);
|
|
507
|
+
this.received = 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (this.parts.length !== total || this.parts[seq] !== undefined) {
|
|
511
|
+
this.reset();
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
this.parts[seq] = data;
|
|
516
|
+
this.received++;
|
|
517
|
+
if (this.received < total) return null;
|
|
518
|
+
|
|
519
|
+
const assembled = this.parts;
|
|
520
|
+
this.reset();
|
|
521
|
+
try {
|
|
522
|
+
const bytes = Buffer.concat(assembled.map((p) => Buffer.from(p, "base64")));
|
|
523
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
524
|
+
} catch {
|
|
525
|
+
// Corrupt part payload — same posture as decryptFrame: drop, don't crash
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
reset() {
|
|
531
|
+
this.id = null;
|
|
532
|
+
this.parts = [];
|
|
533
|
+
this.received = 0;
|
|
534
|
+
}
|
|
535
|
+
}
|