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
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",
|
|
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.
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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.
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
1155
|
+
const id = randomHex(16);
|
|
1013
1156
|
this.streams.set(id, onRaw);
|
|
1014
|
-
this.
|
|
1157
|
+
this._dcSend(0, { t: "req", id, method: "GET", path });
|
|
1015
1158
|
return () => {
|
|
1016
1159
|
this.streams.delete(id);
|
|
1017
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|