@wacando/agent 2.1.0 → 2.2.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/STATUS.md +17 -0
- package/bin/wacando-agent.mjs +48 -3
- package/docs/AGENT_TOOLS.md +82 -1
- package/docs/WACANDO_PACKAGES.md +10 -4
- package/package.json +1 -1
- package/packages/mcp/agent-tools.mjs +15 -0
- package/packages/mcp/server.mjs +1 -1
- package/packages/onboarding/version.mjs +1 -1
- package/packages/runtime/conversations.mjs +41 -8
- package/packages/runtime/files.mjs +626 -0
- package/packages/runtime/runtime.mjs +21 -3
- package/packages/wacando/chunks/{chunk-GASYW3SG.mjs → chunk-53E7SSTF.mjs} +3 -2
- package/packages/wacando/chunks/{chunk-OI3RF7TS.mjs → chunk-PHGOH4AU.mjs} +2 -2
- package/packages/wacando/chunks/chunk-PZDQXGA6.mjs +74 -0
- package/packages/wacando/omemo-wire.mjs +3 -3
- package/packages/wacando/omemo.mjs +568 -11
- package/packages/wacando/xmpp.mjs +41 -3
- package/plugins/wacando/.codex-plugin/plugin.json +1 -1
- package/plugins/wacando/skills/wacando/SKILL.md +1 -0
- package/plugins/wacando-claude/.claude-plugin/plugin.json +1 -1
- package/plugins/wacando-claude/skills/open/SKILL.md +1 -0
- package/plugins/wacando-cursor/.cursor-plugin/plugin.json +1 -1
- package/plugins/wacando-cursor/skills/wacando/SKILL.md +1 -0
- package/provenance.json +20 -17
- package/packages/wacando/chunks/chunk-EIGFLZ2Y.mjs +0 -23
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Embedded from lbframe/wacando@
|
|
1
|
+
// Embedded from lbframe/wacando@618922ecaf2213da18949a8b27d343c8767b64c5 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
|
|
2
2
|
import {
|
|
3
3
|
authenticatedMessageOrigin,
|
|
4
4
|
pepFetchItems,
|
|
5
5
|
pepPublish
|
|
6
|
-
} from "./chunks/chunk-
|
|
6
|
+
} from "./chunks/chunk-53E7SSTF.mjs";
|
|
7
7
|
import {
|
|
8
8
|
BODY_MARKER,
|
|
9
9
|
GCM_IV_BYTES,
|
|
@@ -37,8 +37,13 @@ import {
|
|
|
37
37
|
signerJid,
|
|
38
38
|
signerRole,
|
|
39
39
|
utf8Encode
|
|
40
|
-
} from "./chunks/chunk-
|
|
41
|
-
import
|
|
40
|
+
} from "./chunks/chunk-PHGOH4AU.mjs";
|
|
41
|
+
import {
|
|
42
|
+
ALLOWED_MEDIA_TYPES,
|
|
43
|
+
MAX_FILE_BYTES,
|
|
44
|
+
MIN_FILE_BYTES,
|
|
45
|
+
looksLikePlaintext
|
|
46
|
+
} from "./chunks/chunk-PZDQXGA6.mjs";
|
|
42
47
|
|
|
43
48
|
// packages/omemo/src/libsignal.ts
|
|
44
49
|
import { jid as parseJid2 } from "@xmpp/jid";
|
|
@@ -1076,6 +1081,527 @@ var OmemoTranscriptDecryptor = class {
|
|
|
1076
1081
|
}
|
|
1077
1082
|
};
|
|
1078
1083
|
|
|
1084
|
+
// packages/omemo/src/file-core.ts
|
|
1085
|
+
var FILE_KEY_BYTES = 32;
|
|
1086
|
+
var FILE_IV_BYTES = 12;
|
|
1087
|
+
var FILE_TAG_BYTES = MIN_FILE_BYTES;
|
|
1088
|
+
var MAX_FILE_CIPHERTEXT_BYTES = MAX_FILE_BYTES;
|
|
1089
|
+
var MAX_FILE_PLAINTEXT_BYTES = MAX_FILE_CIPHERTEXT_BYTES - FILE_TAG_BYTES;
|
|
1090
|
+
var PLAINTEXT_GUARD_ATTEMPTS = 4;
|
|
1091
|
+
var FileCryptoError = class extends OmemoError {
|
|
1092
|
+
constructor(reason, message) {
|
|
1093
|
+
super(message);
|
|
1094
|
+
this.reason = reason;
|
|
1095
|
+
this.name = "FileCryptoError";
|
|
1096
|
+
}
|
|
1097
|
+
reason;
|
|
1098
|
+
};
|
|
1099
|
+
function subtle() {
|
|
1100
|
+
const s = globalThis.crypto?.subtle;
|
|
1101
|
+
if (!s) throw new FileCryptoError("unavailable", "WebCrypto is not available");
|
|
1102
|
+
return s;
|
|
1103
|
+
}
|
|
1104
|
+
function own(bytes) {
|
|
1105
|
+
return new Uint8Array(bytes);
|
|
1106
|
+
}
|
|
1107
|
+
var HEX = /^[0-9a-f]*$/;
|
|
1108
|
+
function toHex(bytes) {
|
|
1109
|
+
let out = "";
|
|
1110
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
1113
|
+
function fromHex(hex) {
|
|
1114
|
+
const h = hex.toLowerCase();
|
|
1115
|
+
if (h.length % 2 !== 0 || !HEX.test(h))
|
|
1116
|
+
throw new FileCryptoError("invalid_descriptor", "invalid hex");
|
|
1117
|
+
const out = new Uint8Array(h.length / 2);
|
|
1118
|
+
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
1119
|
+
return out;
|
|
1120
|
+
}
|
|
1121
|
+
async function fileSha256Hex(bytes) {
|
|
1122
|
+
return toHex(new Uint8Array(await subtle().digest("SHA-256", own(bytes))));
|
|
1123
|
+
}
|
|
1124
|
+
function checkKeyIv(key2, iv) {
|
|
1125
|
+
if (key2.length !== FILE_KEY_BYTES)
|
|
1126
|
+
throw new FileCryptoError("invalid_descriptor", "file key must be 32 bytes");
|
|
1127
|
+
if (iv.length !== FILE_IV_BYTES)
|
|
1128
|
+
throw new FileCryptoError("invalid_descriptor", "file IV must be 12 bytes");
|
|
1129
|
+
}
|
|
1130
|
+
function importKey(key2, usage) {
|
|
1131
|
+
return subtle().importKey("raw", own(key2), { name: "AES-GCM" }, false, [usage]);
|
|
1132
|
+
}
|
|
1133
|
+
async function aesGcmSeal(key2, iv, plaintext) {
|
|
1134
|
+
checkKeyIv(key2, iv);
|
|
1135
|
+
const out = await subtle().encrypt(
|
|
1136
|
+
{ name: "AES-GCM", iv: own(iv), tagLength: FILE_TAG_BYTES * 8 },
|
|
1137
|
+
await importKey(key2, "encrypt"),
|
|
1138
|
+
own(plaintext)
|
|
1139
|
+
);
|
|
1140
|
+
return new Uint8Array(out);
|
|
1141
|
+
}
|
|
1142
|
+
async function aesGcmOpen(key2, iv, sealed) {
|
|
1143
|
+
checkKeyIv(key2, iv);
|
|
1144
|
+
if (sealed.length < FILE_TAG_BYTES)
|
|
1145
|
+
throw new FileCryptoError("authentication_failed", "ciphertext shorter than its tag");
|
|
1146
|
+
let out;
|
|
1147
|
+
try {
|
|
1148
|
+
out = await subtle().decrypt(
|
|
1149
|
+
{ name: "AES-GCM", iv: own(iv), tagLength: FILE_TAG_BYTES * 8 },
|
|
1150
|
+
await importKey(key2, "decrypt"),
|
|
1151
|
+
own(sealed)
|
|
1152
|
+
);
|
|
1153
|
+
} catch {
|
|
1154
|
+
throw new FileCryptoError("authentication_failed", "file authentication failed");
|
|
1155
|
+
}
|
|
1156
|
+
return new Uint8Array(out);
|
|
1157
|
+
}
|
|
1158
|
+
var DEFAULT_SEAL_DEPS = { random: randomBytes, guard: looksLikePlaintext };
|
|
1159
|
+
async function sealFresh(plaintext, deps = DEFAULT_SEAL_DEPS) {
|
|
1160
|
+
for (let attempt = 0; attempt < PLAINTEXT_GUARD_ATTEMPTS; attempt++) {
|
|
1161
|
+
const key2 = deps.random(FILE_KEY_BYTES);
|
|
1162
|
+
const iv = deps.random(FILE_IV_BYTES);
|
|
1163
|
+
const ciphertext = await aesGcmSeal(key2, iv, plaintext);
|
|
1164
|
+
if (!deps.guard(ciphertext)) return { ciphertext, key: key2, iv };
|
|
1165
|
+
}
|
|
1166
|
+
throw new FileCryptoError("unavailable", "could not produce a depositable ciphertext");
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// packages/omemo/src/file-crypto.ts
|
|
1170
|
+
var FILE_MEDIA_TYPES = ALLOWED_MEDIA_TYPES;
|
|
1171
|
+
var MAX_FILE_NAME_BYTES = 200;
|
|
1172
|
+
var FILE_DOWNLOAD_TIMEOUT_MS = 6e4;
|
|
1173
|
+
var NAME_INVISIBLE = new RegExp(
|
|
1174
|
+
"[\\u0000-\\u001f\\u007f-\\u009f\\u00ad\\u061c\\u115f\\u1160\\u180e\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0]",
|
|
1175
|
+
"g"
|
|
1176
|
+
);
|
|
1177
|
+
var NAME_RESERVED = /[<>:"|?*]/g;
|
|
1178
|
+
var WINDOWS_DEVICE = /^(con|prn|aux|nul|conin\$|conout\$|clock\$|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])\s*(\..*)?$/i;
|
|
1179
|
+
function utf8Length(s) {
|
|
1180
|
+
return new TextEncoder().encode(s).length;
|
|
1181
|
+
}
|
|
1182
|
+
function cutUtf8(s, max) {
|
|
1183
|
+
let out = "";
|
|
1184
|
+
for (const ch of s) {
|
|
1185
|
+
if (utf8Length(out + ch) > max) break;
|
|
1186
|
+
out += ch;
|
|
1187
|
+
}
|
|
1188
|
+
return out;
|
|
1189
|
+
}
|
|
1190
|
+
function sanitizeFileName(raw) {
|
|
1191
|
+
let name = raw.normalize("NFC").replace(NAME_INVISIBLE, "");
|
|
1192
|
+
name = name.split(/[/\\]/).pop() ?? "";
|
|
1193
|
+
name = name.replace(NAME_RESERVED, "_").trim();
|
|
1194
|
+
name = name.replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
|
|
1195
|
+
if (WINDOWS_DEVICE.test(name)) name = `_${name}`;
|
|
1196
|
+
if (utf8Length(name) > MAX_FILE_NAME_BYTES) {
|
|
1197
|
+
const ext = fileExtension(name);
|
|
1198
|
+
const keep = ext && utf8Length(ext) < 32 ? ext : "";
|
|
1199
|
+
name = cutUtf8(
|
|
1200
|
+
name.slice(0, name.length - keep.length),
|
|
1201
|
+
MAX_FILE_NAME_BYTES - utf8Length(keep)
|
|
1202
|
+
);
|
|
1203
|
+
name = name.replace(/[.\s]+$/, "") + keep;
|
|
1204
|
+
}
|
|
1205
|
+
return name === "" ? "file" : name;
|
|
1206
|
+
}
|
|
1207
|
+
function fileExtension(name) {
|
|
1208
|
+
const lower = name.toLowerCase();
|
|
1209
|
+
if (lower.endsWith(".tar.gz")) return ".tar.gz";
|
|
1210
|
+
const dot = lower.lastIndexOf(".");
|
|
1211
|
+
return dot > 0 ? lower.slice(dot) : "";
|
|
1212
|
+
}
|
|
1213
|
+
var EXTENSION_TYPES = {
|
|
1214
|
+
"": "text/plain",
|
|
1215
|
+
".txt": "text/plain",
|
|
1216
|
+
".text": "text/plain",
|
|
1217
|
+
".log": "text/plain",
|
|
1218
|
+
".csv": "text/plain",
|
|
1219
|
+
".tsv": "text/plain",
|
|
1220
|
+
".json": "text/plain",
|
|
1221
|
+
".yaml": "text/plain",
|
|
1222
|
+
".yml": "text/plain",
|
|
1223
|
+
".md": "text/markdown",
|
|
1224
|
+
".markdown": "text/markdown",
|
|
1225
|
+
".pdf": "application/pdf",
|
|
1226
|
+
".zip": "application/zip",
|
|
1227
|
+
".tar.gz": "application/gzip",
|
|
1228
|
+
".tgz": "application/gzip"
|
|
1229
|
+
};
|
|
1230
|
+
function startsWith(bytes, sig) {
|
|
1231
|
+
return bytes.length >= sig.length && sig.every((b, i) => bytes[i] === b);
|
|
1232
|
+
}
|
|
1233
|
+
function isGzipContent(bytes) {
|
|
1234
|
+
if (bytes.length < 10 || !startsWith(bytes, [31, 139, 8])) return false;
|
|
1235
|
+
const flg = bytes[3];
|
|
1236
|
+
const os = bytes[9];
|
|
1237
|
+
return (flg & 224) === 0 && (os <= 13 || os === 255);
|
|
1238
|
+
}
|
|
1239
|
+
function isText(bytes) {
|
|
1240
|
+
let text;
|
|
1241
|
+
try {
|
|
1242
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1243
|
+
} catch {
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
return !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text);
|
|
1247
|
+
}
|
|
1248
|
+
function sniffContent(bytes) {
|
|
1249
|
+
if (startsWith(bytes, [37, 80, 68, 70, 45])) return "application/pdf";
|
|
1250
|
+
if (startsWith(bytes, [80, 75, 3, 4]) || startsWith(bytes, [80, 75, 5, 6]))
|
|
1251
|
+
return "application/zip";
|
|
1252
|
+
if (isGzipContent(bytes)) return "application/gzip";
|
|
1253
|
+
return isText(bytes) ? "text" : null;
|
|
1254
|
+
}
|
|
1255
|
+
function detectFileType(name, bytes) {
|
|
1256
|
+
const expected = EXTENSION_TYPES[fileExtension(name)];
|
|
1257
|
+
if (!expected) throw new FileCryptoError("type_not_allowed", "file extension not allowed");
|
|
1258
|
+
const sniffed = sniffContent(bytes);
|
|
1259
|
+
if (sniffed === null) throw new FileCryptoError("type_not_allowed", "file content not allowed");
|
|
1260
|
+
const actual = sniffed === "text" && (expected === "text/plain" || expected === "text/markdown") ? expected : sniffed;
|
|
1261
|
+
if (actual !== expected)
|
|
1262
|
+
throw new FileCryptoError("type_mismatch", "file content does not match its extension");
|
|
1263
|
+
return expected;
|
|
1264
|
+
}
|
|
1265
|
+
async function encryptFile(input) {
|
|
1266
|
+
if (input.bytes.length > MAX_FILE_PLAINTEXT_BYTES)
|
|
1267
|
+
throw new FileCryptoError(
|
|
1268
|
+
"too_large",
|
|
1269
|
+
`files are limited to ${MAX_FILE_PLAINTEXT_BYTES} bytes`
|
|
1270
|
+
);
|
|
1271
|
+
const name = sanitizeFileName(input.name);
|
|
1272
|
+
const mediaType = detectFileType(name, input.bytes);
|
|
1273
|
+
if (input.mediaType !== void 0 && input.mediaType !== mediaType)
|
|
1274
|
+
throw new FileCryptoError("type_mismatch", "declared type does not match the file");
|
|
1275
|
+
const plaintextSha256 = await fileSha256Hex(input.bytes);
|
|
1276
|
+
const { ciphertext, key: key2, iv } = await sealFresh(input.bytes);
|
|
1277
|
+
return {
|
|
1278
|
+
ciphertext,
|
|
1279
|
+
approval: {
|
|
1280
|
+
ciphertextSha256: await fileSha256Hex(ciphertext),
|
|
1281
|
+
sizeBytes: ciphertext.length,
|
|
1282
|
+
mediaType
|
|
1283
|
+
},
|
|
1284
|
+
name,
|
|
1285
|
+
mediaType,
|
|
1286
|
+
plaintextSize: input.bytes.length,
|
|
1287
|
+
plaintextSha256,
|
|
1288
|
+
key: key2,
|
|
1289
|
+
iv
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
async function decryptFile(ciphertext, share, opts = {}) {
|
|
1293
|
+
const { key: key2, iv } = aesgcmSecrets(share.url);
|
|
1294
|
+
return openChecked(ciphertext, share, key2, iv, opts);
|
|
1295
|
+
}
|
|
1296
|
+
async function openChecked(ciphertext, facts, key2, iv, opts) {
|
|
1297
|
+
if (ciphertext.length > MAX_FILE_CIPHERTEXT_BYTES)
|
|
1298
|
+
throw new FileCryptoError("too_large", "ciphertext over the file limit");
|
|
1299
|
+
if (opts.approvedCiphertextSha256 !== void 0 && opts.approvedCiphertextSha256 !== facts.ciphertextSha256)
|
|
1300
|
+
throw new FileCryptoError("integrity", "file share differs from the approved file");
|
|
1301
|
+
if (ciphertext.length !== facts.ciphertextSize || await fileSha256Hex(ciphertext) !== facts.ciphertextSha256)
|
|
1302
|
+
throw new FileCryptoError("integrity", "ciphertext does not match the file share");
|
|
1303
|
+
const bytes = await aesGcmOpen(key2, iv, ciphertext);
|
|
1304
|
+
if (bytes.length !== facts.size || await fileSha256Hex(bytes) !== facts.sha256)
|
|
1305
|
+
throw new FileCryptoError("integrity", "plaintext does not match the file share");
|
|
1306
|
+
const name = sanitizeFileName(facts.name);
|
|
1307
|
+
const mediaType = detectFileType(name, bytes);
|
|
1308
|
+
if (mediaType !== facts.mediaType)
|
|
1309
|
+
throw new FileCryptoError("type_mismatch", "file content does not match its declared type");
|
|
1310
|
+
return { bytes, name, mediaType };
|
|
1311
|
+
}
|
|
1312
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1313
|
+
var IPV4_LITERAL = /^\d+(\.\d+){0,3}$/;
|
|
1314
|
+
var UPLOAD_PATH = /^\/v1\/uploads\/fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9_-]{43}$/;
|
|
1315
|
+
function pinnedOrigin(pin) {
|
|
1316
|
+
const bad = (what) => new FileCryptoError("invalid_descriptor", `upload origin: ${what}`);
|
|
1317
|
+
if (!pin || typeof pin.origin !== "string") throw bad("missing");
|
|
1318
|
+
let url;
|
|
1319
|
+
try {
|
|
1320
|
+
url = new URL(pin.origin);
|
|
1321
|
+
} catch {
|
|
1322
|
+
throw bad("invalid");
|
|
1323
|
+
}
|
|
1324
|
+
if (url.origin !== pin.origin.replace(/\/$/, "")) throw bad("must be a bare origin");
|
|
1325
|
+
const host = url.hostname.toLowerCase();
|
|
1326
|
+
const loopback = LOOPBACK_HOSTS.has(host) || host.endsWith(".localhost");
|
|
1327
|
+
if (loopback) {
|
|
1328
|
+
if (!pin.allowLoopbackHttp) throw bad("loopback only in explicit test mode");
|
|
1329
|
+
} else {
|
|
1330
|
+
if (url.protocol !== "https:") throw bad("must be https");
|
|
1331
|
+
if (IPV4_LITERAL.test(host) || host.startsWith("[")) throw bad("no IP literal");
|
|
1332
|
+
}
|
|
1333
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw bad("must be http(s)");
|
|
1334
|
+
return url;
|
|
1335
|
+
}
|
|
1336
|
+
function aesgcmSecrets(raw) {
|
|
1337
|
+
if (typeof raw !== "string" || !raw.startsWith("aesgcm://"))
|
|
1338
|
+
throw new FileCryptoError("invalid_descriptor", "not an aesgcm URL");
|
|
1339
|
+
const hash = raw.indexOf("#");
|
|
1340
|
+
const fragment = hash < 0 ? "" : raw.slice(hash + 1).toLowerCase();
|
|
1341
|
+
if (!/^[0-9a-f]{88}$/.test(fragment))
|
|
1342
|
+
throw new FileCryptoError(
|
|
1343
|
+
"invalid_descriptor",
|
|
1344
|
+
"aesgcm URL needs a 12-byte IV and a 32-byte key"
|
|
1345
|
+
);
|
|
1346
|
+
return {
|
|
1347
|
+
iv: fromHex(fragment.slice(0, FILE_IV_BYTES * 2)),
|
|
1348
|
+
key: fromHex(fragment.slice(FILE_IV_BYTES * 2))
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
function aesgcmUrl(getUrl, key2, iv) {
|
|
1352
|
+
checkKeyIv(key2, iv);
|
|
1353
|
+
let url;
|
|
1354
|
+
try {
|
|
1355
|
+
url = new URL(getUrl);
|
|
1356
|
+
} catch {
|
|
1357
|
+
throw new FileCryptoError("invalid_descriptor", "invalid download URL");
|
|
1358
|
+
}
|
|
1359
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
1360
|
+
throw new FileCryptoError("invalid_descriptor", "download URL must be http(s)");
|
|
1361
|
+
if (url.hash !== "" || url.search !== "" || url.username !== "" || url.password !== "")
|
|
1362
|
+
throw new FileCryptoError(
|
|
1363
|
+
"invalid_descriptor",
|
|
1364
|
+
"download URL must not carry a query, fragment or credentials"
|
|
1365
|
+
);
|
|
1366
|
+
if (!UPLOAD_PATH.test(url.pathname))
|
|
1367
|
+
throw new FileCryptoError("invalid_descriptor", "not a file download URL");
|
|
1368
|
+
return `aesgcm://${url.host}${url.pathname}#${toHex(iv)}${toHex(key2)}`;
|
|
1369
|
+
}
|
|
1370
|
+
function parseAesgcmUrl(raw, pin) {
|
|
1371
|
+
const origin = pinnedOrigin(pin);
|
|
1372
|
+
const { key: key2, iv } = aesgcmSecrets(raw);
|
|
1373
|
+
const rest = raw.slice("aesgcm://".length, raw.indexOf("#"));
|
|
1374
|
+
const slash = rest.indexOf("/");
|
|
1375
|
+
const host = slash < 0 ? rest : rest.slice(0, slash);
|
|
1376
|
+
const path = slash < 0 ? "" : rest.slice(slash);
|
|
1377
|
+
if (host.toLowerCase() !== origin.host.toLowerCase())
|
|
1378
|
+
throw new FileCryptoError("invalid_descriptor", "file is not on the expected upload origin");
|
|
1379
|
+
if (!UPLOAD_PATH.test(path))
|
|
1380
|
+
throw new FileCryptoError("invalid_descriptor", "not a file download path");
|
|
1381
|
+
return { fetchUrl: `${origin.origin}${path}`, key: key2, iv };
|
|
1382
|
+
}
|
|
1383
|
+
var FILE_SHARE_VERSION = 1;
|
|
1384
|
+
var SHARE_KEYS = [
|
|
1385
|
+
"wacandoFile",
|
|
1386
|
+
"url",
|
|
1387
|
+
"name",
|
|
1388
|
+
"mediaType",
|
|
1389
|
+
"size",
|
|
1390
|
+
"sha256",
|
|
1391
|
+
"ciphertextSize",
|
|
1392
|
+
"ciphertextSha256"
|
|
1393
|
+
];
|
|
1394
|
+
var SHA256 = /^[0-9a-f]{64}$/;
|
|
1395
|
+
function fileShareFor(file, getUrl) {
|
|
1396
|
+
return {
|
|
1397
|
+
url: aesgcmUrl(getUrl, file.key, file.iv),
|
|
1398
|
+
name: file.name,
|
|
1399
|
+
mediaType: file.mediaType,
|
|
1400
|
+
size: file.plaintextSize,
|
|
1401
|
+
sha256: file.plaintextSha256,
|
|
1402
|
+
ciphertextSize: file.approval.sizeBytes,
|
|
1403
|
+
ciphertextSha256: file.approval.ciphertextSha256
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
function checkShape(v) {
|
|
1407
|
+
const bad = (what) => new FileCryptoError("invalid_descriptor", `file share: ${what}`);
|
|
1408
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) throw bad("not an object");
|
|
1409
|
+
const o = v;
|
|
1410
|
+
const keys = Object.keys(o);
|
|
1411
|
+
if (keys.length !== SHARE_KEYS.length || !SHARE_KEYS.every((k) => keys.includes(k)))
|
|
1412
|
+
throw bad("unexpected fields");
|
|
1413
|
+
if (o.wacandoFile !== FILE_SHARE_VERSION) throw bad("unsupported version");
|
|
1414
|
+
if (typeof o.url !== "string") throw bad("url");
|
|
1415
|
+
aesgcmSecrets(o.url);
|
|
1416
|
+
if (typeof o.name !== "string" || o.name.length === 0 || utf8Length(o.name) > 1024)
|
|
1417
|
+
throw bad("name");
|
|
1418
|
+
if (!ALLOWED_MEDIA_TYPES.includes(o.mediaType)) throw bad("media type");
|
|
1419
|
+
const int = (x, min, max) => typeof x === "number" && Number.isSafeInteger(x) && x >= min && x <= max;
|
|
1420
|
+
if (!int(o.size, 0, MAX_FILE_PLAINTEXT_BYTES)) throw bad("size");
|
|
1421
|
+
if (!int(o.ciphertextSize, FILE_TAG_BYTES, MAX_FILE_CIPHERTEXT_BYTES))
|
|
1422
|
+
throw bad("ciphertext size");
|
|
1423
|
+
if (o.ciphertextSize !== o.size + FILE_TAG_BYTES) throw bad("sizes disagree");
|
|
1424
|
+
if (typeof o.sha256 !== "string" || !SHA256.test(o.sha256)) throw bad("sha256");
|
|
1425
|
+
if (typeof o.ciphertextSha256 !== "string" || !SHA256.test(o.ciphertextSha256))
|
|
1426
|
+
throw bad("ciphertext sha256");
|
|
1427
|
+
return {
|
|
1428
|
+
url: o.url,
|
|
1429
|
+
name: o.name,
|
|
1430
|
+
mediaType: o.mediaType,
|
|
1431
|
+
size: o.size,
|
|
1432
|
+
sha256: o.sha256,
|
|
1433
|
+
ciphertextSize: o.ciphertextSize,
|
|
1434
|
+
ciphertextSha256: o.ciphertextSha256
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
function formatFileShare(share) {
|
|
1438
|
+
const checked = checkShape({ wacandoFile: FILE_SHARE_VERSION, ...share });
|
|
1439
|
+
return JSON.stringify({ wacandoFile: FILE_SHARE_VERSION, ...checked });
|
|
1440
|
+
}
|
|
1441
|
+
function isFileShareBody(body) {
|
|
1442
|
+
return body.startsWith('{"wacandoFile":');
|
|
1443
|
+
}
|
|
1444
|
+
function parseFileShare(body, pin) {
|
|
1445
|
+
let v;
|
|
1446
|
+
try {
|
|
1447
|
+
v = JSON.parse(body);
|
|
1448
|
+
} catch {
|
|
1449
|
+
throw new FileCryptoError("invalid_descriptor", "file share is not JSON");
|
|
1450
|
+
}
|
|
1451
|
+
const share = checkShape(v);
|
|
1452
|
+
parseAesgcmUrl(share.url, pin);
|
|
1453
|
+
return share;
|
|
1454
|
+
}
|
|
1455
|
+
var FILE_REVIEW_VERSION = 1;
|
|
1456
|
+
var REVIEW_KEYS = [
|
|
1457
|
+
"wacandoFileReview",
|
|
1458
|
+
"fileId",
|
|
1459
|
+
"name",
|
|
1460
|
+
"mediaType",
|
|
1461
|
+
"size",
|
|
1462
|
+
"sha256",
|
|
1463
|
+
"ciphertextSize",
|
|
1464
|
+
"ciphertextSha256",
|
|
1465
|
+
"secret"
|
|
1466
|
+
];
|
|
1467
|
+
var FILE_ID = /^fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
1468
|
+
function fileReviewFor(file, fileId) {
|
|
1469
|
+
checkKeyIv(file.key, file.iv);
|
|
1470
|
+
return checkReview({
|
|
1471
|
+
wacandoFileReview: FILE_REVIEW_VERSION,
|
|
1472
|
+
fileId,
|
|
1473
|
+
name: file.name,
|
|
1474
|
+
mediaType: file.mediaType,
|
|
1475
|
+
size: file.plaintextSize,
|
|
1476
|
+
sha256: file.plaintextSha256,
|
|
1477
|
+
ciphertextSize: file.approval.sizeBytes,
|
|
1478
|
+
ciphertextSha256: file.approval.ciphertextSha256,
|
|
1479
|
+
secret: `${toHex(file.iv)}${toHex(file.key)}`
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
function checkReview(v) {
|
|
1483
|
+
const bad = (what) => new FileCryptoError("invalid_descriptor", `file review: ${what}`);
|
|
1484
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) throw bad("not an object");
|
|
1485
|
+
const o = v;
|
|
1486
|
+
const keys = Object.keys(o);
|
|
1487
|
+
if (keys.length !== REVIEW_KEYS.length || !REVIEW_KEYS.every((k) => keys.includes(k)))
|
|
1488
|
+
throw bad("unexpected fields");
|
|
1489
|
+
if (o.wacandoFileReview !== FILE_REVIEW_VERSION) throw bad("unsupported version");
|
|
1490
|
+
if (typeof o.fileId !== "string" || !FILE_ID.test(o.fileId)) throw bad("file id");
|
|
1491
|
+
if (typeof o.secret !== "string" || !/^[0-9a-f]{88}$/.test(o.secret)) throw bad("secret");
|
|
1492
|
+
const share = checkShape({
|
|
1493
|
+
wacandoFile: FILE_SHARE_VERSION,
|
|
1494
|
+
url: `aesgcm://review.invalid/#${o.secret}`,
|
|
1495
|
+
name: o.name,
|
|
1496
|
+
mediaType: o.mediaType,
|
|
1497
|
+
size: o.size,
|
|
1498
|
+
sha256: o.sha256,
|
|
1499
|
+
ciphertextSize: o.ciphertextSize,
|
|
1500
|
+
ciphertextSha256: o.ciphertextSha256
|
|
1501
|
+
});
|
|
1502
|
+
return {
|
|
1503
|
+
fileId: o.fileId,
|
|
1504
|
+
name: share.name,
|
|
1505
|
+
mediaType: share.mediaType,
|
|
1506
|
+
size: share.size,
|
|
1507
|
+
sha256: share.sha256,
|
|
1508
|
+
ciphertextSize: share.ciphertextSize,
|
|
1509
|
+
ciphertextSha256: share.ciphertextSha256,
|
|
1510
|
+
secret: o.secret
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
function formatFileReview(review) {
|
|
1514
|
+
const checked = checkReview({ wacandoFileReview: FILE_REVIEW_VERSION, ...review });
|
|
1515
|
+
return JSON.stringify({ wacandoFileReview: FILE_REVIEW_VERSION, ...checked });
|
|
1516
|
+
}
|
|
1517
|
+
function isFileReviewBody(body) {
|
|
1518
|
+
return body.startsWith('{"wacandoFileReview":');
|
|
1519
|
+
}
|
|
1520
|
+
function parseFileReview(body) {
|
|
1521
|
+
let v;
|
|
1522
|
+
try {
|
|
1523
|
+
v = JSON.parse(body);
|
|
1524
|
+
} catch {
|
|
1525
|
+
throw new FileCryptoError("invalid_descriptor", "file review is not JSON");
|
|
1526
|
+
}
|
|
1527
|
+
return checkReview(v);
|
|
1528
|
+
}
|
|
1529
|
+
async function decryptFileReview(ciphertext, review, opts = {}) {
|
|
1530
|
+
const checked = checkReview({ wacandoFileReview: FILE_REVIEW_VERSION, ...review });
|
|
1531
|
+
const iv = fromHex(checked.secret.slice(0, FILE_IV_BYTES * 2));
|
|
1532
|
+
const key2 = fromHex(checked.secret.slice(FILE_IV_BYTES * 2));
|
|
1533
|
+
return openChecked(ciphertext, checked, key2, iv, opts);
|
|
1534
|
+
}
|
|
1535
|
+
function fileIdOfShare(share) {
|
|
1536
|
+
const match = /^aesgcm:\/\/[^/#]+\/v1\/uploads\/(fil_[0-9a-f-]{36})\/[A-Za-z0-9_-]{43}#/.exec(
|
|
1537
|
+
share.url
|
|
1538
|
+
);
|
|
1539
|
+
return match?.[1] && FILE_ID.test(match[1]) ? match[1] : null;
|
|
1540
|
+
}
|
|
1541
|
+
async function downloadFile(share, opts) {
|
|
1542
|
+
const { fetchUrl } = parseAesgcmUrl(share.url, opts.pin);
|
|
1543
|
+
const limit = share.ciphertextSize;
|
|
1544
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
1545
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? FILE_DOWNLOAD_TIMEOUT_MS);
|
|
1546
|
+
const failed = (what) => new FileCryptoError("download_failed", `download ${what}`);
|
|
1547
|
+
let res;
|
|
1548
|
+
try {
|
|
1549
|
+
res = await doFetch(fetchUrl, {
|
|
1550
|
+
method: "GET",
|
|
1551
|
+
headers: opts.bearer ? { authorization: `Bearer ${opts.bearer}` } : {},
|
|
1552
|
+
redirect: "error",
|
|
1553
|
+
credentials: "omit",
|
|
1554
|
+
referrerPolicy: "no-referrer",
|
|
1555
|
+
signal
|
|
1556
|
+
});
|
|
1557
|
+
} catch {
|
|
1558
|
+
throw failed(signal.aborted ? "timed out" : "failed (network or redirect)");
|
|
1559
|
+
}
|
|
1560
|
+
if (res.redirected || res.type === "opaqueredirect") {
|
|
1561
|
+
await res.body?.cancel().catch(() => {
|
|
1562
|
+
});
|
|
1563
|
+
throw failed("redirected");
|
|
1564
|
+
}
|
|
1565
|
+
if (res.status !== 200) {
|
|
1566
|
+
await res.body?.cancel().catch(() => {
|
|
1567
|
+
});
|
|
1568
|
+
throw failed(`refused with HTTP ${res.status}`);
|
|
1569
|
+
}
|
|
1570
|
+
const declared = res.headers.get("content-length");
|
|
1571
|
+
if (declared !== null && !(Number(declared) <= limit)) {
|
|
1572
|
+
await res.body?.cancel().catch(() => {
|
|
1573
|
+
});
|
|
1574
|
+
throw new FileCryptoError("too_large", "download larger than the announced file");
|
|
1575
|
+
}
|
|
1576
|
+
if (!res.body) throw failed("has no body");
|
|
1577
|
+
const reader = res.body.getReader();
|
|
1578
|
+
const chunks = [];
|
|
1579
|
+
let total = 0;
|
|
1580
|
+
try {
|
|
1581
|
+
for (; ; ) {
|
|
1582
|
+
const { done, value } = await reader.read();
|
|
1583
|
+
if (done) break;
|
|
1584
|
+
total += value.length;
|
|
1585
|
+
if (total > limit) {
|
|
1586
|
+
await reader.cancel().catch(() => {
|
|
1587
|
+
});
|
|
1588
|
+
throw new FileCryptoError("too_large", "download larger than the announced file");
|
|
1589
|
+
}
|
|
1590
|
+
chunks.push(value);
|
|
1591
|
+
}
|
|
1592
|
+
} catch (e) {
|
|
1593
|
+
if (e instanceof FileCryptoError) throw e;
|
|
1594
|
+
throw failed(signal.aborted ? "timed out" : "interrupted");
|
|
1595
|
+
}
|
|
1596
|
+
const out = new Uint8Array(total);
|
|
1597
|
+
let at = 0;
|
|
1598
|
+
for (const c of chunks) {
|
|
1599
|
+
out.set(c, at);
|
|
1600
|
+
at += c.length;
|
|
1601
|
+
}
|
|
1602
|
+
return out;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1079
1605
|
// packages/omemo/src/recovery.ts
|
|
1080
1606
|
import {
|
|
1081
1607
|
CiphertextMessageType as CiphertextMessageType2,
|
|
@@ -1983,14 +2509,14 @@ var SessionRecovery = class _SessionRecovery {
|
|
|
1983
2509
|
if (this.inflight.has(pair)) throw new Refused("resource-constraint", "busy");
|
|
1984
2510
|
if (!findPendingSend(this.ledger, pair, r.roomJid, r.stanzaId, r.keyDigest, r.consumedPreKeyId))
|
|
1985
2511
|
throw new Refused("not-acceptable", "stale");
|
|
1986
|
-
const
|
|
1987
|
-
if (
|
|
2512
|
+
const own2 = activeAttempt(this.ledger, pair);
|
|
2513
|
+
if (own2) {
|
|
1988
2514
|
const weInitiate = { jid: this.o.provider.bareJid, device: this.state.deviceId };
|
|
1989
2515
|
const theyInitiate = { jid: r.bJid, device: r.bDevice };
|
|
1990
|
-
if (
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
delete
|
|
2516
|
+
if (own2.role === "B" && own2.state === "offered" && compareInitiators(weInitiate, theyInitiate) < 0) {
|
|
2517
|
+
own2.state = "failed";
|
|
2518
|
+
own2.reason = "stale";
|
|
2519
|
+
delete own2.cached;
|
|
1994
2520
|
this.persist();
|
|
1995
2521
|
} else throw new Refused("not-acceptable", "fenced");
|
|
1996
2522
|
}
|
|
@@ -2462,7 +2988,18 @@ function loadNativeOmemoProvider(jid, options = {}) {
|
|
|
2462
2988
|
export {
|
|
2463
2989
|
BODY_MARKER,
|
|
2464
2990
|
DeviceState,
|
|
2991
|
+
FILE_DOWNLOAD_TIMEOUT_MS,
|
|
2992
|
+
FILE_IV_BYTES,
|
|
2993
|
+
FILE_KEY_BYTES,
|
|
2994
|
+
FILE_MEDIA_TYPES,
|
|
2995
|
+
FILE_REVIEW_VERSION,
|
|
2996
|
+
FILE_SHARE_VERSION,
|
|
2997
|
+
FILE_TAG_BYTES,
|
|
2998
|
+
FileCryptoError,
|
|
2465
2999
|
LibsignalNodeBackend,
|
|
3000
|
+
MAX_FILE_CIPHERTEXT_BYTES,
|
|
3001
|
+
MAX_FILE_NAME_BYTES,
|
|
3002
|
+
MAX_FILE_PLAINTEXT_BYTES,
|
|
2466
3003
|
NS_DEVICELIST,
|
|
2467
3004
|
NS_EME,
|
|
2468
3005
|
NS_HINTS,
|
|
@@ -2478,11 +3015,31 @@ export {
|
|
|
2478
3015
|
RECOVERY_LIMITS,
|
|
2479
3016
|
SessionRecovery,
|
|
2480
3017
|
UnsupportedSignalFormatError,
|
|
3018
|
+
aesgcmUrl,
|
|
2481
3019
|
bundleNode,
|
|
3020
|
+
decryptFile,
|
|
3021
|
+
decryptFileReview,
|
|
2482
3022
|
defaultStateBaseDir,
|
|
3023
|
+
detectFileType,
|
|
2483
3024
|
deviceListEl,
|
|
3025
|
+
downloadFile,
|
|
3026
|
+
encryptFile,
|
|
3027
|
+
fileExtension,
|
|
3028
|
+
fileIdOfShare,
|
|
3029
|
+
fileReviewFor,
|
|
3030
|
+
fileSha256Hex,
|
|
3031
|
+
fileShareFor,
|
|
3032
|
+
formatFileReview,
|
|
3033
|
+
formatFileShare,
|
|
3034
|
+
isFileReviewBody,
|
|
3035
|
+
isFileShareBody,
|
|
2484
3036
|
jidStateDirName,
|
|
2485
3037
|
loadNativeOmemoProvider,
|
|
3038
|
+
parseAesgcmUrl,
|
|
2486
3039
|
parseDeviceList,
|
|
2487
|
-
parseEncrypted
|
|
3040
|
+
parseEncrypted,
|
|
3041
|
+
parseFileReview,
|
|
3042
|
+
parseFileShare,
|
|
3043
|
+
sanitizeFileName,
|
|
3044
|
+
sniffContent
|
|
2488
3045
|
};
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
// Embedded from lbframe/wacando@
|
|
1
|
+
// Embedded from lbframe/wacando@618922ecaf2213da18949a8b27d343c8767b64c5 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
|
|
2
2
|
import {
|
|
3
3
|
MucJoinError,
|
|
4
4
|
MucOriginTracker,
|
|
5
5
|
authenticatedMessageOrigin,
|
|
6
|
+
defaultTimeoutMs,
|
|
6
7
|
forwardedStanza,
|
|
7
8
|
joinRoom,
|
|
8
9
|
queryMam,
|
|
9
10
|
refuseSubscriptionRequests,
|
|
10
11
|
unwrapPolicyDelivery,
|
|
11
12
|
waitForStanza
|
|
12
|
-
} from "./chunks/chunk-
|
|
13
|
-
import "./chunks/chunk-
|
|
13
|
+
} from "./chunks/chunk-53E7SSTF.mjs";
|
|
14
|
+
import "./chunks/chunk-PZDQXGA6.mjs";
|
|
14
15
|
|
|
15
16
|
// packages/xmpp/src/index.ts
|
|
16
17
|
import { default as default2 } from "@xmpp/xml";
|
|
@@ -34,6 +35,41 @@ async function announceAvailable(entity, timeoutMs) {
|
|
|
34
35
|
}
|
|
35
36
|
await echoed;
|
|
36
37
|
}
|
|
38
|
+
|
|
39
|
+
// packages/xmpp/src/upload.ts
|
|
40
|
+
import xml2 from "@xmpp/xml";
|
|
41
|
+
var NS_HTTP_UPLOAD = "urn:xmpp:http:upload:0";
|
|
42
|
+
function uploadServiceJid(domain) {
|
|
43
|
+
return `upload.${domain}`;
|
|
44
|
+
}
|
|
45
|
+
function uploadSlotRequest(req) {
|
|
46
|
+
return xml2("request", {
|
|
47
|
+
xmlns: NS_HTTP_UPLOAD,
|
|
48
|
+
filename: req.filename,
|
|
49
|
+
size: String(req.size),
|
|
50
|
+
"content-type": req.contentType ?? "application/octet-stream"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
var ALLOWED_PUT_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "expires"]);
|
|
54
|
+
function parseUploadSlot(slot) {
|
|
55
|
+
if (!slot || slot.name !== "slot" || slot.attrs.xmlns !== NS_HTTP_UPLOAD)
|
|
56
|
+
throw new Error("upload service returned no slot");
|
|
57
|
+
const put = slot.getChild("put")?.attrs.url;
|
|
58
|
+
const get = slot.getChild("get")?.attrs.url;
|
|
59
|
+
if (typeof put !== "string" || typeof get !== "string")
|
|
60
|
+
throw new Error("upload slot is missing its put/get URL");
|
|
61
|
+
const putHeaders = {};
|
|
62
|
+
for (const header of slot.getChild("put")?.getChildren("header") ?? []) {
|
|
63
|
+
const name = String(header.attrs.name ?? "").toLowerCase();
|
|
64
|
+
if (ALLOWED_PUT_HEADERS.has(name)) putHeaders[name] = header.text().replace(/[\r\n]/g, "");
|
|
65
|
+
}
|
|
66
|
+
return { put, get, putHeaders };
|
|
67
|
+
}
|
|
68
|
+
async function requestUploadSlot(entity, service, req, timeoutMs = defaultTimeoutMs()) {
|
|
69
|
+
const iq = xml2("iq", { type: "get", to: service }, uploadSlotRequest(req));
|
|
70
|
+
const result = await entity.iqCaller.request(iq, timeoutMs);
|
|
71
|
+
return parseUploadSlot(result.getChild("slot", NS_HTTP_UPLOAD));
|
|
72
|
+
}
|
|
37
73
|
export {
|
|
38
74
|
MucJoinError,
|
|
39
75
|
MucOriginTracker,
|
|
@@ -43,6 +79,8 @@ export {
|
|
|
43
79
|
joinRoom,
|
|
44
80
|
queryMam,
|
|
45
81
|
refuseSubscriptionRequests,
|
|
82
|
+
requestUploadSlot,
|
|
46
83
|
unwrapPolicyDelivery,
|
|
84
|
+
uploadServiceJid,
|
|
47
85
|
default2 as xml
|
|
48
86
|
};
|