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.
package/lab/ui/index.html CHANGED
@@ -843,6 +843,21 @@
843
843
  hasIdent,
844
844
  deviceCount,
845
845
  } from "./console-logic.js";
846
+ import {
847
+ MARKER as E2E_MARKER,
848
+ ALLOW_LEGACY_PLAINTEXT,
849
+ FLAG_CONFIRM,
850
+ CONFIRM_TIMEOUT_MS,
851
+ deriveAuthToken,
852
+ deriveDirKeys,
853
+ computeTranscriptHash,
854
+ seal as e2eSeal,
855
+ open as e2eOpen,
856
+ packEnvelope,
857
+ unpackEnvelope,
858
+ parseSecret,
859
+ randomHex,
860
+ } from "./e2e.js";
846
861
 
847
862
  let entries = [];
848
863
  let sel = null; // selected agent's composite key (room#pid)
@@ -908,18 +923,46 @@
908
923
  // envelope in lab/ui/share-host.ts: {t:"req"|"abort"} out, {t:"res"|"data"|"end"} in.
909
924
  class RTCClient {
910
925
  constructor(host, room, token) {
926
+ let resolveKeys;
927
+ const keysReady = new Promise((r) => (resolveKeys = r));
911
928
  Object.assign(this, {
912
929
  host,
913
930
  room,
914
931
  token,
915
932
  dc: null,
916
- nextId: 1,
917
933
  calls: new Map(),
918
934
  streams: new Map(),
919
935
  onstate: () => {},
936
+ // e2e (v2) per-connection state
937
+ _s: null,
938
+ _v2: false,
939
+ _send: { sendCtr: 0n },
940
+ _recvState: { lastSeen: -1n },
941
+ _tHash: null,
942
+ _keyH2C: null, // host->client: client decrypts with this
943
+ _keyC2H: null, // client->host: client encrypts with this
944
+ _keysReady: keysReady,
945
+ _resolveKeys: resolveKeys,
946
+ _myNonce: randomHex(16),
947
+ _confirmedIn: false,
948
+ _confirmedOut: false,
949
+ _confirmed: false,
950
+ _confirmTimer: null,
951
+ _recvChain: Promise.resolve(), // serialize decrypts (ordered replay check)
952
+ _sendChain: Promise.resolve(), // serialize seals (wire order == counter order)
920
953
  });
921
954
  }
922
- connect() {
955
+ async connect() {
956
+ // Parse the secret marker (fail-closed on malformed) and split into the
957
+ // server-visible authToken vs the end-to-end keys the server never sees.
958
+ const { s, v2 } = parseSecret(this.token);
959
+ this._s = s;
960
+ this._v2 = v2;
961
+ if (!v2 && !ALLOW_LEGACY_PLAINTEXT)
962
+ throw new Error(
963
+ "this link uses the old unencrypted protocol — ask the host to upgrade",
964
+ );
965
+ const authToken = v2 ? await deriveAuthToken(s, this.room, this.host) : this.token;
923
966
  return new Promise((resolve, reject) => {
924
967
  const ws = new WebSocket(`wss://${this.host}/${this.room}`, [SUB]);
925
968
  this.ws = ws; // kept so close() can drop the signaling registration too
@@ -931,11 +974,22 @@
931
974
  reject(e);
932
975
  }
933
976
  };
977
+ // Resolve ONLY after the mutual key-confirmation completes (see _dcRecv),
978
+ // never on bare dc.onopen — there must be no "connected but unverified" window.
979
+ const done = () => {
980
+ if (!settled) {
981
+ settled = true;
982
+ this.onstate("open");
983
+ resolve();
984
+ }
985
+ };
934
986
  ws.onopen = () =>
935
- ws.send(JSON.stringify({ type: "hello", role: "client", token: this.token }));
987
+ ws.send(JSON.stringify({ type: "hello", role: "client", v: 2, token: authToken }));
936
988
  ws.onmessage = async (ev) => {
937
989
  const m = JSON.parse(ev.data);
938
990
  if (m.type === "welcome") {
991
+ if (this._v2 && m.v !== 2)
992
+ return fail(new Error("host is running an old agent-yes — ask it to upgrade"));
939
993
  pc = new RTCPeerConnection({
940
994
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
941
995
  });
@@ -947,18 +1001,44 @@
947
1001
  pc.onconnectionstatechange = () => this.onstate(pc.connectionState);
948
1002
  pc.ondatachannel = (e) => {
949
1003
  this.dc = e.channel;
950
- this.dc.onopen = () => {
951
- settled = true;
952
- this.onstate("open");
953
- resolve();
1004
+ this.dc.binaryType = "arraybuffer";
1005
+ this.dc.onopen = async () => {
1006
+ try {
1007
+ await this._keysReady;
1008
+ // Open the bidirectional confirmation handshake.
1009
+ this._dcSend(FLAG_CONFIRM, { t: "confirm", nonce: this._myNonce });
1010
+ this._confirmTimer = setTimeout(() => {
1011
+ if (!this._confirmed) fail(new Error("key confirmation timed out"));
1012
+ }, CONFIRM_TIMEOUT_MS);
1013
+ } catch (err) {
1014
+ fail(err);
1015
+ }
1016
+ };
1017
+ this.dc.onmessage = (ev2) => {
1018
+ this._recvChain = this._recvChain
1019
+ .then(() => this._dcRecv(ev2.data, done))
1020
+ .catch(() => {});
954
1021
  };
955
- this.dc.onmessage = (ev2) => this._recv(JSON.parse(ev2.data));
956
1022
  this.dc.onclose = () => this.onstate("closed");
957
1023
  };
958
1024
  } else if (m.type === "offer") {
959
1025
  await pc.setRemoteDescription({ type: "offer", sdp: m.sdp });
960
1026
  await pc.setLocalDescription(await pc.createAnswer());
961
1027
  ws.send(JSON.stringify({ type: "answer", sdp: pc.localDescription.sdp }));
1028
+ // Derive per-connection keys now both descriptions are stable, before
1029
+ // the DataChannel opens. Client: remote=host offer, local=our answer.
1030
+ try {
1031
+ this._tHash = await computeTranscriptHash(
1032
+ pc.remoteDescription.sdp,
1033
+ pc.localDescription.sdp,
1034
+ );
1035
+ const { keyH2C, keyC2H } = await deriveDirKeys(this._s, this._tHash);
1036
+ this._keyH2C = keyH2C;
1037
+ this._keyC2H = keyC2H;
1038
+ this._resolveKeys();
1039
+ } catch (err) {
1040
+ fail(err);
1041
+ }
962
1042
  } else if (m.type === "candidate") {
963
1043
  await pc.addIceCandidate(m.candidate).catch(() => {});
964
1044
  }
@@ -968,28 +1048,93 @@
968
1048
  setTimeout(() => fail(new Error("connect timeout")), 8000);
969
1049
  });
970
1050
  }
1051
+ // Seal an envelope and send it, serialized so wire order == counter order.
1052
+ _dcSend(flags, obj) {
1053
+ this._sendChain = this._sendChain.then(async () => {
1054
+ if (!this.dc || this.dc.readyState !== "open" || !this._keyC2H || !this._tHash) return;
1055
+ let frame;
1056
+ try {
1057
+ frame = await e2eSeal(
1058
+ this._keyC2H,
1059
+ this._send,
1060
+ flags,
1061
+ this._tHash,
1062
+ packEnvelope(obj),
1063
+ );
1064
+ } catch {
1065
+ this.close();
1066
+ return;
1067
+ }
1068
+ try {
1069
+ this.dc.send(frame);
1070
+ } catch {}
1071
+ });
1072
+ return this._sendChain;
1073
+ }
1074
+ // Decrypt + route one frame. Fail-closed: any failure, replay, string
1075
+ // frame, or pre-confirmation app frame closes the connection.
1076
+ async _dcRecv(data, done) {
1077
+ if (!this.dc) return;
1078
+ if (typeof data === "string" || !this._keyH2C || !this._tHash) return this.close();
1079
+ let env;
1080
+ try {
1081
+ const { plaintext } = await e2eOpen(this._keyH2C, data, this._tHash, this._recvState);
1082
+ env = unpackEnvelope(plaintext);
1083
+ } catch {
1084
+ return this.close();
1085
+ }
1086
+ if (!this._confirmed) {
1087
+ if (!env || env.t !== "confirm") return this.close();
1088
+ if (typeof env.nonce === "string" && !this._confirmedOut) {
1089
+ // Send (and flush) our echo BEFORE marking confirmed-out, so connect()
1090
+ // can't resolve and let a req() race ahead of the echo on the wire.
1091
+ await this._dcSend(FLAG_CONFIRM, {
1092
+ t: "confirm",
1093
+ nonce: this._myNonce,
1094
+ echo: env.nonce,
1095
+ });
1096
+ this._confirmedOut = true;
1097
+ }
1098
+ if (env.echo && env.echo === this._myNonce) this._confirmedIn = true;
1099
+ if (this._confirmedIn && this._confirmedOut) {
1100
+ this._confirmed = true;
1101
+ if (this._confirmTimer) clearTimeout(this._confirmTimer);
1102
+ done(); // connect() resolves only now — the channel is mutually verified
1103
+ }
1104
+ return;
1105
+ }
1106
+ if (!env || env.t === "confirm") return; // stray confirm after handshake
1107
+ this._recv(env);
1108
+ }
971
1109
  _recv(r) {
1110
+ // Frames are authenticated + replay-checked before they reach here, so an
1111
+ // id we don't know is a late/cancelled response from the legitimate host,
1112
+ // not an attack — drop it rather than tear down the channel.
972
1113
  const call = this.calls.get(r.id),
973
1114
  stream = this.streams.get(r.id);
974
1115
  if (r.t === "res") {
1116
+ if (call) call.status = r.status;
1117
+ } else if (r.t === "data") {
975
1118
  if (call) {
976
- call.status = r.status;
1119
+ call.body += r.chunk;
1120
+ call.dataCount = (call.dataCount || 0) + 1;
977
1121
  }
978
- } else if (r.t === "data") {
979
- if (call) call.body += r.chunk;
980
1122
  if (stream) stream(r.chunk);
981
1123
  } else if (r.t === "end") {
982
1124
  if (call) {
983
1125
  clearTimeout(call.timer);
984
1126
  this.calls.delete(r.id);
985
- r.error
986
- ? call.reject(new Error(r.error))
987
- : call.resolve({ status: call.status, text: call.body });
1127
+ // end.seq is the count of data frames the host sent; a mismatch means
1128
+ // the stream was truncated, so don't resolve it as complete.
1129
+ if (typeof r.seq === "number" && r.seq !== (call.dataCount || 0))
1130
+ call.reject(new Error("truncated response"));
1131
+ else if (r.error) call.reject(new Error(r.error));
1132
+ else call.resolve({ status: call.status, text: call.body });
988
1133
  }
989
1134
  }
990
1135
  }
991
1136
  req(method, path, body) {
992
- const id = this.nextId++;
1137
+ const id = randomHex(16);
993
1138
  return new Promise((resolve, reject) => {
994
1139
  // Without a deadline a request over a silently-dead DataChannel (host
995
1140
  // gone, ICE not yet timed out) never settles, so the caller — and the
@@ -998,31 +1143,28 @@
998
1143
  const timer = setTimeout(() => {
999
1144
  if (this.calls.delete(id)) reject(new Error("request timed out"));
1000
1145
  }, 12000);
1001
- this.calls.set(id, { status: 0, body: "", resolve, reject, timer });
1002
- try {
1003
- this.dc.send(JSON.stringify({ t: "req", id, method, path, body }));
1004
- } catch (e) {
1146
+ this.calls.set(id, { status: 0, body: "", dataCount: 0, resolve, reject, timer });
1147
+ this._dcSend(0, { t: "req", id, method, path, body }).catch((e) => {
1005
1148
  clearTimeout(timer);
1006
1149
  this.calls.delete(id);
1007
1150
  reject(e); // channel already torn down
1008
- }
1151
+ });
1009
1152
  });
1010
1153
  }
1011
1154
  subscribe(path, onRaw) {
1012
- const id = this.nextId++;
1155
+ const id = randomHex(16);
1013
1156
  this.streams.set(id, onRaw);
1014
- this.dc.send(JSON.stringify({ t: "req", id, method: "GET", path }));
1157
+ this._dcSend(0, { t: "req", id, method: "GET", path });
1015
1158
  return () => {
1016
1159
  this.streams.delete(id);
1017
- try {
1018
- this.dc.send(JSON.stringify({ t: "abort", id }));
1019
- } catch {}
1160
+ this._dcSend(0, { t: "abort", id });
1020
1161
  };
1021
1162
  }
1022
1163
  // Tear down BOTH wires. Closing only the pc leaves the signaling socket
1023
1164
  // open and this client registered in the room, so each reconnect would
1024
1165
  // leak another peer on the host. onstate is detached by the caller first.
1025
1166
  close() {
1167
+ if (this._confirmTimer) clearTimeout(this._confirmTimer);
1026
1168
  try {
1027
1169
  this.ws?.close();
1028
1170
  } catch {}
@@ -1763,9 +1905,20 @@
1763
1905
 
1764
1906
  // ---- rooms: localStorage cache + a manager you open by clicking the badge ----
1765
1907
  const ROOMS_KEY = "ay.rooms";
1908
+ const ROOM_TTL_MS = 90 * 24 * 60 * 60 * 1000; // evict stale rooms to bound how long a secret lingers in localStorage
1766
1909
  const loadRooms = () => {
1767
1910
  try {
1768
- return JSON.parse(localStorage.getItem(ROOMS_KEY) || "{}");
1911
+ const r = JSON.parse(localStorage.getItem(ROOMS_KEY) || "{}");
1912
+ const now = Date.now();
1913
+ let changed = false;
1914
+ for (const k of Object.keys(r)) {
1915
+ if (r[k]?.ts && now - r[k].ts > ROOM_TTL_MS) {
1916
+ delete r[k];
1917
+ changed = true;
1918
+ }
1919
+ }
1920
+ if (changed) localStorage.setItem(ROOMS_KEY, JSON.stringify(r));
1921
+ return r;
1769
1922
  } catch {
1770
1923
  return {};
1771
1924
  }
@@ -2277,6 +2430,7 @@
2277
2430
  const aidLike =
2278
2431
  full &&
2279
2432
  !full[3] &&
2433
+ !/^e\d/i.test(full[2]) && // an e<N>. version marker is always a secret, never an agent id
2280
2434
  /^\d{1,7}$/.test(full[2]) &&
2281
2435
  (full[1] === LOCAL || !!loadRooms()[full[1]]);
2282
2436
  if (full && !aidLike) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.120.0",
3
+ "version": "1.121.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -56,8 +56,11 @@
56
56
  "!dist/**/*.map",
57
57
  "dist/**/*.js",
58
58
  "lab/ui/console-logic.js",
59
+ "lab/ui/e2e.d.ts",
60
+ "lab/ui/e2e.js",
59
61
  "lab/ui/index.html",
60
- "lab/ui/room-client.js"
62
+ "lab/ui/room-client.js",
63
+ "lab/ui/blog/**"
61
64
  ],
62
65
  "type": "module",
63
66
  "module": "ts/index.ts",
@@ -72,6 +75,7 @@
72
75
  },
73
76
  "scripts": {
74
77
  "build": "tsdown",
78
+ "check:e2e": "bun scripts/check-e2e.ts",
75
79
  "cf": "bun scripts/cf.ts",
76
80
  "build:rs": "cargo install --path rs --features swarm",
77
81
  "postbuild": "bun ./ts/postbuild.ts",
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bun
2
+ // CI guard: the WebRTC DataChannel must ALWAYS be end-to-end encrypted. This
3
+ // fails the build if a plaintext-fallback pattern reappears in any of the three
4
+ // DataChannel handlers — a sealed channel that silently falls back to
5
+ // JSON.stringify/JSON.parse would defeat the whole protocol (see lab/ui/e2e.js).
6
+ //
7
+ // Note: signaling (the WebSocket) is plaintext by design — JSON.parse(ev.data)
8
+ // in a ws.onmessage handler is fine. We only forbid the DataChannel patterns.
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ const root = path.join(import.meta.dir, "..");
13
+ const FILES = ["ts/share.ts", "lab/ui/share-host.ts", "lab/ui/index.html"];
14
+
15
+ // Literal substrings that only ever appear on a plaintext DataChannel path.
16
+ const FORBIDDEN = [
17
+ "dc.send(JSON.stringify(",
18
+ "this.dc.send(JSON.stringify(",
19
+ "JSON.parse(ev2.data)", // browser dc.onmessage
20
+ "onReq(dc, aborts, JSON.parse", // host dc.onmessage (old form)
21
+ ];
22
+
23
+ let violations = 0;
24
+ for (const rel of FILES) {
25
+ const src = readFileSync(path.join(root, rel), "utf8");
26
+ for (const pat of FORBIDDEN) {
27
+ if (src.includes(pat)) {
28
+ console.error(`✗ ${rel}: forbidden plaintext-DataChannel pattern \`${pat}\``);
29
+ violations++;
30
+ }
31
+ }
32
+ }
33
+
34
+ if (violations) {
35
+ console.error(
36
+ `\n${violations} plaintext-fallback pattern(s) found — DataChannel frames MUST go through seal()/open() in lab/ui/e2e.js.`,
37
+ );
38
+ process.exit(1);
39
+ }
40
+ console.log("✓ e2e: no plaintext-DataChannel fallback in", FILES.join(", "));
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ V,
4
+ PROTO,
5
+ MARKER,
6
+ FLAG_CONFIRM,
7
+ validateS,
8
+ deriveAuthToken,
9
+ deriveDirKeys,
10
+ computeTranscriptHash,
11
+ seal,
12
+ open,
13
+ packEnvelope,
14
+ unpackEnvelope,
15
+ parseSecret,
16
+ randomHex,
17
+ } from "../lab/ui/e2e.js";
18
+
19
+ const S = "a".repeat(64); // a valid 64-hex secret for tests
20
+ const S2 = "b".repeat(64);
21
+ const TH = new Uint8Array(32).fill(7); // a fake transcript hash
22
+ const TH2 = new Uint8Array(32).fill(9);
23
+
24
+ // Minimal but realistic SDP fragments carrying the lines the binding reads.
25
+ function sdp(fp: string, setup: string, ufrag: string): string {
26
+ return [
27
+ "v=0",
28
+ "o=- 1 1 IN IP4 0.0.0.0",
29
+ "s=-",
30
+ "t=0 0",
31
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel",
32
+ `a=ice-ufrag:${ufrag}`,
33
+ `a=setup:${setup}`,
34
+ `a=fingerprint:${fp}`,
35
+ ].join("\r\n");
36
+ }
37
+ const FP_A = "sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99";
38
+ const FP_B = "sha-256 11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00";
39
+
40
+ describe("e2e version constants", () => {
41
+ it("are internally consistent", () => {
42
+ expect(V).toBe(1);
43
+ expect(PROTO).toBe("ay-e2e-1");
44
+ expect(MARKER).toBe("e1.");
45
+ expect(FLAG_CONFIRM).toBe(0x01);
46
+ });
47
+ });
48
+
49
+ describe("validateS", () => {
50
+ it("accepts a 64-hex secret", () => {
51
+ expect(validateS(S)).toBe(S);
52
+ });
53
+ it("rejects bad input without echoing it", () => {
54
+ for (const bad of ["", "xyz", S.toUpperCase(), "e1." + S, S + "0", 123 as unknown as string]) {
55
+ try {
56
+ validateS(bad);
57
+ throw new Error("should have thrown for " + bad);
58
+ } catch (e) {
59
+ expect((e as Error).message).toBe("invalid share token");
60
+ expect((e as Error).message).not.toContain(String(bad).slice(0, 8) || "∅");
61
+ }
62
+ }
63
+ });
64
+ });
65
+
66
+ describe("deriveAuthToken", () => {
67
+ it("is deterministic and 64-hex", async () => {
68
+ const a = await deriveAuthToken(S, "room1", "s.agent-yes.com");
69
+ const b = await deriveAuthToken(S, "room1", "s.agent-yes.com");
70
+ expect(a).toBe(b);
71
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
72
+ });
73
+ it("is not equal to S (one-way) and binds room + sighost", async () => {
74
+ const base = await deriveAuthToken(S, "room1", "s.agent-yes.com");
75
+ expect(base).not.toBe(S);
76
+ expect(await deriveAuthToken(S, "room2", "s.agent-yes.com")).not.toBe(base);
77
+ expect(await deriveAuthToken(S, "room1", "other.host")).not.toBe(base);
78
+ expect(await deriveAuthToken(S2, "room1", "s.agent-yes.com")).not.toBe(base);
79
+ });
80
+ });
81
+
82
+ describe("seal / open round trip", () => {
83
+ it("round-trips a sealed envelope", async () => {
84
+ const { keyH2C } = await deriveDirKeys(S, TH);
85
+ const send = { sendCtr: 0n };
86
+ const recv = { lastSeen: -1n };
87
+ const frame = await seal(keyH2C, send, 0, TH, packEnvelope({ t: "req", id: "x", path: "/p" }));
88
+ expect(frame).toBeInstanceOf(ArrayBuffer);
89
+ expect(new Uint8Array(frame)[0]).toBe(0x01); // VER
90
+ const { plaintext, counter, flags } = await open(keyH2C, frame, TH, recv);
91
+ expect(counter).toBe(0n);
92
+ expect(flags).toBe(0);
93
+ expect(unpackEnvelope(plaintext)).toEqual({ t: "req", id: "x", path: "/p" });
94
+ });
95
+
96
+ it("increments the counter and never repeats a nonce, even concurrently", async () => {
97
+ const { keyH2C } = await deriveDirKeys(S, TH);
98
+ const send = { sendCtr: 0n };
99
+ const [f0, f1] = await Promise.all([
100
+ seal(keyH2C, send, 0, TH, packEnvelope({ n: 0 })),
101
+ seal(keyH2C, send, 0, TH, packEnvelope({ n: 1 })),
102
+ ]);
103
+ const nonce = (f: ArrayBuffer) => new Uint8Array(f).slice(2, 14).join(",");
104
+ expect(nonce(f0)).not.toBe(nonce(f1));
105
+ expect(send.sendCtr).toBe(2n);
106
+ });
107
+
108
+ it("carries the confirmation FLAG through", async () => {
109
+ const { keyC2H } = await deriveDirKeys(S, TH);
110
+ const frame = await seal(
111
+ keyC2H,
112
+ { sendCtr: 0n },
113
+ FLAG_CONFIRM,
114
+ TH,
115
+ packEnvelope({ t: "confirm" }),
116
+ );
117
+ const { flags } = await open(keyC2H, frame, TH, { lastSeen: -1n });
118
+ expect(flags & FLAG_CONFIRM).toBe(FLAG_CONFIRM);
119
+ });
120
+ });
121
+
122
+ describe("open is fail-closed", () => {
123
+ it("rejects a tampered ciphertext bit", async () => {
124
+ const { keyH2C } = await deriveDirKeys(S, TH);
125
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
126
+ const bytes = new Uint8Array(frame);
127
+ const last = bytes.length - 1;
128
+ bytes[last] = (bytes[last] ?? 0) ^ 0x01; // flip a tag bit
129
+ await expect(open(keyH2C, bytes, TH, { lastSeen: -1n })).rejects.toThrow();
130
+ });
131
+ it("rejects a tampered header (VER/FLAGS/NONCE are authenticated)", async () => {
132
+ const { keyH2C } = await deriveDirKeys(S, TH);
133
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
134
+ const bytes = new Uint8Array(frame);
135
+ bytes[1] = (bytes[1] ?? 0) ^ 0x02; // flip a FLAGS bit
136
+ await expect(open(keyH2C, bytes, TH, { lastSeen: -1n })).rejects.toThrow();
137
+ });
138
+ it("rejects a bad version byte", async () => {
139
+ const { keyH2C } = await deriveDirKeys(S, TH);
140
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
141
+ const bytes = new Uint8Array(frame);
142
+ bytes[0] = 0x02;
143
+ await expect(open(keyH2C, bytes, TH, { lastSeen: -1n })).rejects.toThrow();
144
+ });
145
+ it("rejects a frame bound to a different transcript (per-frame binding)", async () => {
146
+ const { keyH2C } = await deriveDirKeys(S, TH);
147
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
148
+ await expect(open(keyH2C, frame, TH2, { lastSeen: -1n })).rejects.toThrow();
149
+ });
150
+ it("rejects the wrong direction key", async () => {
151
+ const { keyH2C, keyC2H } = await deriveDirKeys(S, TH);
152
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
153
+ await expect(open(keyC2H, frame, TH, { lastSeen: -1n })).rejects.toThrow();
154
+ });
155
+ it("rejects a first frame whose counter is not 0", async () => {
156
+ const { keyH2C } = await deriveDirKeys(S, TH);
157
+ const frame = await seal(keyH2C, { sendCtr: 5n }, 0, TH, packEnvelope({ t: "confirm" }));
158
+ await expect(open(keyH2C, frame, TH, { lastSeen: -1n })).rejects.toThrow(/counter-0/);
159
+ });
160
+ it("rejects a frame from a different session (per-session keys)", async () => {
161
+ const a = await deriveDirKeys(S, TH);
162
+ const b = await deriveDirKeys(S, TH2);
163
+ const frame = await seal(a.keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
164
+ // different key AND different AAD → fails
165
+ await expect(open(b.keyH2C, frame, TH2, { lastSeen: -1n })).rejects.toThrow();
166
+ });
167
+ });
168
+
169
+ describe("anti-replay (mandatory)", () => {
170
+ it("rejects a replayed frame and an out-of-order frame", async () => {
171
+ const { keyH2C } = await deriveDirKeys(S, TH);
172
+ const send = { sendCtr: 0n };
173
+ const recv = { lastSeen: -1n };
174
+ const f0 = await seal(keyH2C, send, 0, TH, packEnvelope({ n: 0 }));
175
+ const f1 = await seal(keyH2C, send, 0, TH, packEnvelope({ n: 1 }));
176
+ await open(keyH2C, f0, TH, recv); // counter 0 ok
177
+ await open(keyH2C, f1, TH, recv); // counter 1 ok
178
+ await expect(open(keyH2C, f1, TH, recv)).rejects.toThrow(/replay/); // replay counter 1
179
+ await expect(open(keyH2C, f0, TH, recv)).rejects.toThrow(/replay/); // reorder back to 0
180
+ });
181
+ });
182
+
183
+ describe("computeTranscriptHash", () => {
184
+ it("matches across ends with offer/answer swapped", async () => {
185
+ const local = sdp(FP_A, "actpass", "ufragLOCAL");
186
+ const remote = sdp(FP_B, "active", "ufragREMOTE");
187
+ // host: offer=local, answer=remote ; client: offer=remote(as seen), answer=local
188
+ const host = await computeTranscriptHash(local, remote);
189
+ const client = await computeTranscriptHash(local, remote);
190
+ expect(Buffer.from(host).toString("hex")).toBe(Buffer.from(client).toString("hex"));
191
+ // a different fingerprint changes the hash
192
+ const other = await computeTranscriptHash(
193
+ sdp(FP_A, "actpass", "ufragLOCAL"),
194
+ sdp(FP_A, "active", "x"),
195
+ );
196
+ expect(Buffer.from(other).toString("hex")).not.toBe(Buffer.from(host).toString("hex"));
197
+ });
198
+ it("fails closed on a missing fingerprint", async () => {
199
+ const noFp = "v=0\r\na=setup:active\r\na=ice-ufrag:u";
200
+ await expect(computeTranscriptHash(sdp(FP_A, "actpass", "u"), noFp)).rejects.toThrow(
201
+ /fingerprint/,
202
+ );
203
+ });
204
+ it("fails closed on a non-sha-256 fingerprint (downgrade)", async () => {
205
+ const sha1 = sdp("sha-1 AA:BB:CC", "active", "u");
206
+ await expect(computeTranscriptHash(sdp(FP_A, "actpass", "u"), sha1)).rejects.toThrow(/sha-256/);
207
+ });
208
+ });
209
+
210
+ describe("parseSecret marker grammar (strict, fail-closed)", () => {
211
+ it("parses v2 encrypted links", () => {
212
+ expect(parseSecret("e1." + S)).toEqual({ s: S, v2: true });
213
+ });
214
+ it("treats a bare 64-hex token and a pid as legacy (caller gates)", () => {
215
+ expect(parseSecret(S)).toEqual({ s: S, v2: false });
216
+ expect(parseSecret("12345")).toEqual({ s: "12345", v2: false });
217
+ });
218
+ it("rejects an unknown version marker (update required)", () => {
219
+ expect(() => parseSecret("e2." + S)).toThrow(/update required/);
220
+ expect(() => parseSecret("e10." + S)).toThrow(/update required/);
221
+ });
222
+ it("rejects marker-shaped-but-malformed tokens (never legacy)", () => {
223
+ expect(() => parseSecret("e1." + "z".repeat(64))).toThrow(/malformed/);
224
+ expect(() => parseSecret("e1." + "a".repeat(63))).toThrow(/malformed/);
225
+ expect(() => parseSecret("e1" + S)).toThrow(/malformed/); // marker-like, no dot
226
+ expect(() => parseSecret("E1." + S)).toThrow(/malformed/); // wrong case is not v2
227
+ });
228
+ });
229
+
230
+ describe("randomHex", () => {
231
+ it("returns n bytes of hex and varies", () => {
232
+ expect(randomHex(16)).toMatch(/^[0-9a-f]{32}$/);
233
+ expect(randomHex(16)).not.toBe(randomHex(16));
234
+ });
235
+ });