@securecoms/agent-bridge 0.11.0 → 0.11.1
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/attachments.d.ts +8 -4
- package/dist/attachments.js +49 -0
- package/dist/auth.js +56 -0
- package/dist/bin.js +873 -95
- package/dist/bin.js.pre-c1-f803007 +5795 -0
- package/dist/bin.js.pre-c3-096a9a3 +5832 -0
- package/dist/bin.js.pre-ffrebuild-b162d4a +5639 -0
- package/dist/bin.js.pre-kpheal-20260708-150924 +5531 -0
- package/dist/bin.js.pre-kppersist-20260708-152341 +5543 -0
- package/dist/bin.js.pre-p0-20260708-102415 +5196 -0
- package/dist/bin.js.pre-p1b-02f47e1 +5639 -0
- package/dist/bin.js.pre-warmfix-20260708-153705 +5613 -0
- package/dist/bin.js.pretrace-113205 +5395 -0
- package/dist/bridge-agent.d.ts +94 -19
- package/dist/bridge-agent.js +831 -0
- package/dist/config.js +74 -0
- package/dist/coordination.js +63 -0
- package/dist/errors.js +25 -0
- package/dist/http-server.js +220 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +874 -95
- package/dist/index.js.pre-p0-20260708-102415 +4879 -0
- package/dist/latency.js +87 -0
- package/dist/rate-limit.js +56 -0
- package/dist/service/config.js +26 -0
- package/dist/service/install.js +60 -0
- package/dist/service/launchd.js +30 -0
- package/dist/service/systemd.js +25 -0
- package/dist/sodium-ready.d.ts +43 -0
- package/dist/vault.d.ts +96 -0
- package/dist/ws-server.js +254 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1113,6 +1113,28 @@ async function agentPausedErrorFromResponse(res) {
|
|
|
1113
1113
|
const err = detail?.error;
|
|
1114
1114
|
return err === "agent_paused" ? new AgentPausedError() : null;
|
|
1115
1115
|
}
|
|
1116
|
+
var MalformedCommitterWorkError = class extends ConnectSdkError {
|
|
1117
|
+
channelId;
|
|
1118
|
+
field;
|
|
1119
|
+
receivedType;
|
|
1120
|
+
constructor(channelId, field, receivedType) {
|
|
1121
|
+
super(`committer-work for channel ${channelId} returned a malformed body: \`${field}\` is ${receivedType}, expected array`);
|
|
1122
|
+
this.channelId = channelId;
|
|
1123
|
+
this.field = field;
|
|
1124
|
+
this.receivedType = receivedType;
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
var MalformedGroupRosterError = class extends ConnectSdkError {
|
|
1128
|
+
endpoint;
|
|
1129
|
+
channelId;
|
|
1130
|
+
receivedType;
|
|
1131
|
+
constructor(endpoint, channelId, receivedType) {
|
|
1132
|
+
super(`${endpoint} for channel ${channelId} returned a malformed group roster: \`member_device_ids\` is ${receivedType}, expected a non-empty array`);
|
|
1133
|
+
this.endpoint = endpoint;
|
|
1134
|
+
this.channelId = channelId;
|
|
1135
|
+
this.receivedType = receivedType;
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1116
1138
|
|
|
1117
1139
|
// ../connect-sdk-ts/dist/envelope-builder.js
|
|
1118
1140
|
async function buildOutboundEnvelope(p) {
|
|
@@ -1221,8 +1243,23 @@ import { hmac } from "@noble/hashes/hmac";
|
|
|
1221
1243
|
import { sha256 as sha2562 } from "@noble/hashes/sha256";
|
|
1222
1244
|
import { bytesToHex } from "@agentvault/crypto";
|
|
1223
1245
|
|
|
1246
|
+
// ../connect-sdk-ts/dist/groupkey/crypto-patch-guard.js
|
|
1247
|
+
import { MLSGroupManager } from "@agentvault/crypto";
|
|
1248
|
+
var CryptoPatchMissingError = class extends Error {
|
|
1249
|
+
constructor() {
|
|
1250
|
+
super("MLSGroupManager.leafIndicesForKeyPackage is missing \u2014 the @agentvault/crypto patch did not apply. Expected patches/@agentvault+crypto+0.4.1.patch to be applied by the root postinstall (`patch-package --error-on-fail --error-on-warn`). Re-run `npm ci` at the repo root, and check that @agentvault/crypto resolved to exactly 0.4.1.");
|
|
1251
|
+
this.name = "CryptoPatchMissingError";
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
function leafIndicesForKeyPackage(mgr, kp) {
|
|
1255
|
+
if (typeof MLSGroupManager.prototype.leafIndicesForKeyPackage !== "function") {
|
|
1256
|
+
throw new CryptoPatchMissingError();
|
|
1257
|
+
}
|
|
1258
|
+
return mgr.leafIndicesForKeyPackage(kp);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1224
1261
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1225
|
-
import { MLSGroupManager, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
|
|
1262
|
+
import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
|
|
1226
1263
|
|
|
1227
1264
|
// ../connect-sdk-ts/dist/groupkey/operation-queue.js
|
|
1228
1265
|
var MlsOperationQueue = class {
|
|
@@ -1286,6 +1323,51 @@ function classifyFailedRow(i) {
|
|
|
1286
1323
|
return "defer";
|
|
1287
1324
|
}
|
|
1288
1325
|
|
|
1326
|
+
// ../connect-sdk-ts/dist/groupkey/admission-rule.js
|
|
1327
|
+
function canAdmit(input) {
|
|
1328
|
+
if (input.targetDeviceId === input.ownDeviceId)
|
|
1329
|
+
return { decision: "refuse", reason: "self" };
|
|
1330
|
+
if (input.boundLeaves.some((b) => b.device === input.targetDeviceId)) {
|
|
1331
|
+
return { decision: "refuse", reason: "already-bound" };
|
|
1332
|
+
}
|
|
1333
|
+
if (input.targetLeaves.length > 0) {
|
|
1334
|
+
if (input.deltaVouches)
|
|
1335
|
+
return { decision: "refuse", reason: "identity-leaf-present" };
|
|
1336
|
+
if (input.targetLeaves.length > 1)
|
|
1337
|
+
return { decision: "refuse", reason: "identity-leaf-present" };
|
|
1338
|
+
const t = input.targetLeaves[0];
|
|
1339
|
+
if (!input.identityLeaves.some((l) => l.leafIndex === t))
|
|
1340
|
+
return { decision: "refuse", reason: "already-bound" };
|
|
1341
|
+
if (input.identityLeaves.some((l) => l.leafIndex !== t && l.leafIndex !== input.ownLeafIndex))
|
|
1342
|
+
return { decision: "refuse", reason: "identity-leaf-present" };
|
|
1343
|
+
return { decision: "already-present" };
|
|
1344
|
+
}
|
|
1345
|
+
if (input.targetIdentityId === null)
|
|
1346
|
+
return { decision: "refuse", reason: "unknown-identity" };
|
|
1347
|
+
if (input.identityLeaves.length === 0)
|
|
1348
|
+
return { decision: "admit" };
|
|
1349
|
+
const allUnbound = input.identityLeaves.every((l) => l.device === null);
|
|
1350
|
+
const ownLeafUnreadable = allUnbound && input.ownLeafIndex === null;
|
|
1351
|
+
const exemptionApplies = allUnbound && input.ownLeafIndex !== null && input.identityLeaves.every((l) => l.leafIndex === input.ownLeafIndex);
|
|
1352
|
+
if (exemptionApplies)
|
|
1353
|
+
return { decision: "admit" };
|
|
1354
|
+
if (input.deltaVouches)
|
|
1355
|
+
return { decision: "admit" };
|
|
1356
|
+
return {
|
|
1357
|
+
decision: "refuse",
|
|
1358
|
+
reason: ownLeafUnreadable ? "unknown-own-leaf" : "identity-leaf-present"
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
function hasMalformedBinding(identityLeaves) {
|
|
1362
|
+
return identityLeaves.some((l) => l.malformed);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// ../connect-sdk-ts/dist/groupkey/kp-expiry.js
|
|
1366
|
+
var SKEW_GUARD_MS = 5 * 60 * 1e3;
|
|
1367
|
+
function isServerExpired(exp, nowMs) {
|
|
1368
|
+
return nowMs - SKEW_GUARD_MS >= exp.getTime();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1289
1371
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1290
1372
|
var LABEL = "agentvault-connect-v1";
|
|
1291
1373
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
@@ -1333,6 +1415,30 @@ var ForgedCredentialError = class extends Error {
|
|
|
1333
1415
|
this.name = "ForgedCredentialError";
|
|
1334
1416
|
}
|
|
1335
1417
|
};
|
|
1418
|
+
var SelfAddRefusedError = class extends Error {
|
|
1419
|
+
deviceId;
|
|
1420
|
+
channelId;
|
|
1421
|
+
constructor(deviceId, channelId) {
|
|
1422
|
+
super(`addMemberToLiveGroup: refusing to Add this committer's OWN device ${deviceId} to channel ${channelId} \u2014 a self-Add mints a second, permanent leaf for a device that already holds one. Terminal; this is never a retryable condition.`);
|
|
1423
|
+
this.deviceId = deviceId;
|
|
1424
|
+
this.channelId = channelId;
|
|
1425
|
+
this.name = "SelfAddRefusedError";
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
var AddRefusedError = class extends Error {
|
|
1429
|
+
reason;
|
|
1430
|
+
deviceId;
|
|
1431
|
+
identityId;
|
|
1432
|
+
identityLeafCount;
|
|
1433
|
+
constructor(reason, deviceId, identityId, identityLeafCount) {
|
|
1434
|
+
super(`addMemberToLiveGroup: refusing Add of device ${deviceId} (${reason}) \u2014 identity ${identityId ?? "<undecodable>"} holds ${identityLeafCount} leaf/leaves in this device's view of the tree. A plain Add here risks an irreversible duplicate leaf; the repair path is Remove+Add, never a plain Add.`);
|
|
1435
|
+
this.reason = reason;
|
|
1436
|
+
this.deviceId = deviceId;
|
|
1437
|
+
this.identityId = identityId;
|
|
1438
|
+
this.identityLeafCount = identityLeafCount;
|
|
1439
|
+
this.name = "AddRefusedError";
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1336
1442
|
var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
1337
1443
|
o;
|
|
1338
1444
|
suite;
|
|
@@ -1604,6 +1710,24 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1604
1710
|
* ONE active leaf. Used by the guest reconcile sweep when an approved guest
|
|
1605
1711
|
* sits in `joining`.
|
|
1606
1712
|
*
|
|
1713
|
+
* ⛔ AS OF SLICE B (ADR-0014 Decision B) THE WORKING DEVICE SWAP IS GONE.
|
|
1714
|
+
* When `priorDeviceId` names a device whose leaf still carries this guest
|
|
1715
|
+
* identity — the case the swap existed for — the Add chokepoint's admission
|
|
1716
|
+
* rule refuses (AddRefusedError, reason `identity-leaf-present`) and this
|
|
1717
|
+
* method THROWS before reaching the eviction below.
|
|
1718
|
+
*
|
|
1719
|
+
* ⚠️ That does NOT make the eviction block unreachable, and an earlier
|
|
1720
|
+
* revision of this note wrongly said it did. Pass a `priorDeviceId` that
|
|
1721
|
+
* differs from `newDeviceId` while the guest identity holds NO leaf and the
|
|
1722
|
+
* rule admits on clause 2's first arm, the Add commits, and the block DOES
|
|
1723
|
+
* run — then throws `LeafRosterMismatchError`, because the identity owns one
|
|
1724
|
+
* leaf where the default `orderedDeviceIds` names two. So the reachable
|
|
1725
|
+
* outcomes for a caller passing `priorDeviceId` are a refusal or a roster
|
|
1726
|
+
* mismatch, never a completed swap. The block is retained because it is still
|
|
1727
|
+
* correct and is what a Remove-then-Add re-enablement (or slice 5's
|
|
1728
|
+
* device-bound leaf) will build on. See the body comment for why the
|
|
1729
|
+
* count-only attestation that used to open that path was retired.
|
|
1730
|
+
*
|
|
1607
1731
|
* Add then evict: we commit the Add first so the new leaf exists before the
|
|
1608
1732
|
* old one is dropped (the guest is never momentarily leaf-less in the group).
|
|
1609
1733
|
* The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
|
|
@@ -1619,16 +1743,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1619
1743
|
* equals newDeviceId (the same device re-admitted).
|
|
1620
1744
|
*/
|
|
1621
1745
|
async admitGuestDevice(channelId, g) {
|
|
1622
|
-
await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
|
|
1623
|
-
|
|
1746
|
+
const outcome = await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
|
|
1747
|
+
expectedIdentityId: g.identityId
|
|
1624
1748
|
});
|
|
1625
|
-
if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
1749
|
+
if (outcome === "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
1626
1750
|
const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
|
|
1627
1751
|
await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
|
|
1628
1752
|
identity: g.identityId,
|
|
1629
1753
|
orderedDeviceIds
|
|
1630
1754
|
});
|
|
1755
|
+
} else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
1756
|
+
console.warn(`admitGuestDevice: channel ${channelId} \u2014 Add of ${g.newDeviceId} was a no-op (${outcome}), so the eviction of prior device ${g.priorDeviceId} was SKIPPED. The prior leaf is intact and no swap occurred; re-drive the swap only after the new device holds its own leaf.`);
|
|
1631
1757
|
}
|
|
1758
|
+
return outcome;
|
|
1632
1759
|
}
|
|
1633
1760
|
/** Load (or reuse) the per-channel manager from the store. Throws if no state. */
|
|
1634
1761
|
async manager(channelId) {
|
|
@@ -1641,7 +1768,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1641
1768
|
return again;
|
|
1642
1769
|
if (!saved)
|
|
1643
1770
|
throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
|
|
1644
|
-
const mgr = new
|
|
1771
|
+
const mgr = new MLSGroupManager2(saved.ciphersuite);
|
|
1645
1772
|
mgr.importState(saved);
|
|
1646
1773
|
this.managers.set(channelId, mgr);
|
|
1647
1774
|
return mgr;
|
|
@@ -1843,11 +1970,21 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1843
1970
|
return w?.connect_channel_id ?? null;
|
|
1844
1971
|
}
|
|
1845
1972
|
/**
|
|
1846
|
-
*
|
|
1847
|
-
* operation that deserializes a persisted KeyPackage. generateKeyPackage
|
|
1848
|
-
*
|
|
1849
|
-
*
|
|
1850
|
-
*
|
|
1973
|
+
* Give the ts-mls libsodium (WASM) backend time to initialize before any
|
|
1974
|
+
* store operation that deserializes a persisted KeyPackage. generateKeyPackage
|
|
1975
|
+
* is pure in-memory (it neither publishes to the backend nor writes the
|
|
1976
|
+
* store), so the throwaway bundle is a side-effect-free warm-up.
|
|
1977
|
+
*
|
|
1978
|
+
* ⛔ IT IS A RACE, NOT A BARRIER, and do not read it as one. Nothing on this
|
|
1979
|
+
* path awaits sodium.ready: MLSGroupManager defaults _provider to
|
|
1980
|
+
* nobleCryptoProvider, and _ensureCiphersuite passes that provider to
|
|
1981
|
+
* getCiphersuiteImpl. What the warm-up actually does is BURN WALL CLOCK while
|
|
1982
|
+
* libsodium's WASM initializes in PARALLEL. The margin is roughly 1.7 ms and
|
|
1983
|
+
* it DRIFTS: libsodium-sumo is reached through a caret and its init timing
|
|
1984
|
+
* sets the margin, so an install can move it with no change in this repo.
|
|
1985
|
+
* See 02f47e15 (PR #87), whose commit body carries the correct diagnosis.
|
|
1986
|
+
*
|
|
1987
|
+
* Without it, the FIRST loadPendingBundle at boot (the republish
|
|
1851
1988
|
* guard, and the join path) runs while WASM is still cold, throws deep in the
|
|
1852
1989
|
* crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
|
|
1853
1990
|
* and misread as "no held bundle", and the daemon mints + republishes a FRESH
|
|
@@ -1858,7 +1995,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1858
1995
|
async ensureCryptoReady() {
|
|
1859
1996
|
if (this.cryptoWarmed)
|
|
1860
1997
|
return;
|
|
1861
|
-
await new
|
|
1998
|
+
await new MLSGroupManager2().generateKeyPackage(this.o.identity);
|
|
1862
1999
|
this.cryptoWarmed = true;
|
|
1863
2000
|
}
|
|
1864
2001
|
async publishKeyPackage() {
|
|
@@ -1909,7 +2046,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1909
2046
|
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1910
2047
|
if (!bundle)
|
|
1911
2048
|
return false;
|
|
1912
|
-
const heldHex = bytesToHex2(
|
|
2049
|
+
const heldHex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
|
|
1913
2050
|
const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
|
|
1914
2051
|
const pubHex = published[this.o.deviceId];
|
|
1915
2052
|
return !!pubHex && pubHex === heldHex;
|
|
@@ -1919,13 +2056,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1919
2056
|
}
|
|
1920
2057
|
async publishKeyPackageUnlocked() {
|
|
1921
2058
|
if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
|
|
1922
|
-
|
|
1923
|
-
|
|
2059
|
+
const kpId = await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
|
|
2060
|
+
const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
|
|
2061
|
+
const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
|
|
2062
|
+
if (!expired) {
|
|
2063
|
+
console.warn("publishKeyPackage: pending bundle exists; skipping republish");
|
|
2064
|
+
return kpId;
|
|
2065
|
+
}
|
|
2066
|
+
console.warn(`publishKeyPackage: server KeyPackage expired for device ${this.o.deviceId.slice(0, 8)}; rotating`);
|
|
2067
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1924
2068
|
}
|
|
1925
2069
|
console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
|
|
1926
|
-
const mgr = new
|
|
2070
|
+
const mgr = new MLSGroupManager2();
|
|
1927
2071
|
const bundle = await mgr.generateKeyPackage(this.o.identity);
|
|
1928
|
-
const hex = bytesToHex2(
|
|
2072
|
+
const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
|
|
1929
2073
|
const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
|
|
1930
2074
|
await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
|
|
1931
2075
|
return id;
|
|
@@ -1953,14 +2097,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1953
2097
|
if (opts.forceReinit) {
|
|
1954
2098
|
const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
|
|
1955
2099
|
const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
|
|
1956
|
-
const mgr = new
|
|
2100
|
+
const mgr = new MLSGroupManager2();
|
|
1957
2101
|
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
1958
2102
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
1959
2103
|
const pkgs = others.map((d) => {
|
|
1960
2104
|
const hex = kps[d];
|
|
1961
2105
|
if (!hex)
|
|
1962
2106
|
throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
|
|
1963
|
-
return
|
|
2107
|
+
return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
1964
2108
|
});
|
|
1965
2109
|
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
1966
2110
|
await this.o.backend.distribute(reinit.group_id, {
|
|
@@ -1979,7 +2123,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1979
2123
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
1980
2124
|
if (init.created === false)
|
|
1981
2125
|
throw new BootstrapRaceLostError();
|
|
1982
|
-
const mgr = new
|
|
2126
|
+
const mgr = new MLSGroupManager2();
|
|
1983
2127
|
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
1984
2128
|
const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
|
|
1985
2129
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
@@ -1987,7 +2131,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1987
2131
|
const hex = kps[d];
|
|
1988
2132
|
if (!hex)
|
|
1989
2133
|
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
1990
|
-
return
|
|
2134
|
+
return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
1991
2135
|
});
|
|
1992
2136
|
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
1993
2137
|
await this.o.backend.distribute(init.group_id, {
|
|
@@ -2005,7 +2149,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2005
2149
|
const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
|
|
2006
2150
|
if (!w)
|
|
2007
2151
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
2008
|
-
const mgr = new
|
|
2152
|
+
const mgr = new MLSGroupManager2();
|
|
2009
2153
|
try {
|
|
2010
2154
|
await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
|
|
2011
2155
|
} catch (err) {
|
|
@@ -2041,36 +2185,66 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2041
2185
|
const hex = kps[newDeviceId];
|
|
2042
2186
|
if (!hex)
|
|
2043
2187
|
throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
|
|
2044
|
-
const pkg =
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2188
|
+
const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
2189
|
+
const cred = pkg.leafNode.credential;
|
|
2190
|
+
const claimedIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
|
|
2191
|
+
if (opts?.expectedIdentityId && claimedIdentity !== opts.expectedIdentityId) {
|
|
2192
|
+
throw new ForgedCredentialError(opts.expectedIdentityId, claimedIdentity ?? "");
|
|
2193
|
+
}
|
|
2194
|
+
const targetLeaves = leafIndicesForKeyPackage(mgr, pkg);
|
|
2195
|
+
let deltaVouches = false;
|
|
2196
|
+
try {
|
|
2197
|
+
deltaVouches = (await this.o.backend.getCommitterWork(channelId)).adds.includes(newDeviceId);
|
|
2198
|
+
} catch (e) {
|
|
2199
|
+
if (targetLeaves.length > 0) {
|
|
2200
|
+
throw new Error(`addMemberToLiveGroup: committer-work unreachable for channel ${channelId} while device ${newDeviceId}'s published KeyPackage already holds ${targetLeaves.length} leaf/leaves here (indices ${targetLeaves.join(",")}) \u2014 refusing to report a no-op success on a delta we could not read (fail closed): ` + (e instanceof Error ? e.message : String(e)));
|
|
2051
2201
|
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2202
|
+
deltaVouches = false;
|
|
2203
|
+
}
|
|
2204
|
+
const identityLeaves = claimedIdentity === null ? [] : mgr.leafIndicesForIdentity(claimedIdentity).map((leafIndex) => ({
|
|
2205
|
+
leafIndex,
|
|
2206
|
+
device: null,
|
|
2207
|
+
// SLICE 5: read the LeafNode extension here.
|
|
2208
|
+
malformed: false
|
|
2209
|
+
// SLICE 5: set from a failed extension decode.
|
|
2210
|
+
}));
|
|
2211
|
+
const ownLeaf = this.ownLeafIndex(mgr);
|
|
2212
|
+
const verdict = canAdmit({
|
|
2213
|
+
targetDeviceId: newDeviceId,
|
|
2214
|
+
targetIdentityId: claimedIdentity,
|
|
2215
|
+
ownDeviceId: this.o.deviceId,
|
|
2216
|
+
ownLeafIndex: ownLeaf,
|
|
2217
|
+
boundLeaves: [],
|
|
2218
|
+
// SLICE 5: the whole-tree bound-leaf scan replaces this literal.
|
|
2219
|
+
identityLeaves,
|
|
2220
|
+
targetLeaves,
|
|
2221
|
+
deltaVouches
|
|
2222
|
+
});
|
|
2223
|
+
if (hasMalformedBinding(identityLeaves)) {
|
|
2224
|
+
console.warn(`addMemberToLiveGroup: channel ${channelId} has a leaf with an unreadable device binding for identity ${claimedIdentity ?? "?"} \u2014 treated as UNBOUND, never as absent`);
|
|
2225
|
+
}
|
|
2226
|
+
if (targetLeaves.length > 0 && deltaVouches) {
|
|
2227
|
+
console.warn(`addMemberToLiveGroup: channel ${channelId} \u2014 the server's add-delta vouches for device ${newDeviceId} while its published KeyPackage is ALREADY a leaf here (indices ${targetLeaves.join(",")}). An honest retry cannot produce this pair: attempt 0's init unions the device into the roster, so the delta can never list it again. Treat as a possible KeyPackage substitution, or as a lost race against a peer's Add that will self-heal on the next sweep pass.`);
|
|
2228
|
+
}
|
|
2229
|
+
if (targetLeaves.length > 1) {
|
|
2230
|
+
console.warn(`addMemberToLiveGroup: channel ${channelId} \u2014 device ${newDeviceId}'s published KeyPackage already occupies ${targetLeaves.length} leaves (indices ${targetLeaves.join(",")}). This is a pre-existing duplicate leaf; nothing on this path can repair it and heal refuses an identity holding more than one leaf.`);
|
|
2231
|
+
}
|
|
2232
|
+
switch (verdict.decision) {
|
|
2233
|
+
case "admit":
|
|
2234
|
+
break;
|
|
2235
|
+
case "already-present":
|
|
2236
|
+
return "already-present";
|
|
2237
|
+
case "refuse":
|
|
2238
|
+
if (verdict.reason === "self") {
|
|
2239
|
+
throw new SelfAddRefusedError(newDeviceId, channelId);
|
|
2063
2240
|
}
|
|
2241
|
+
throw new AddRefusedError(verdict.reason, newDeviceId, claimedIdentity, identityLeaves.length);
|
|
2242
|
+
default: {
|
|
2243
|
+
const _never = verdict;
|
|
2244
|
+
throw new Error(`unhandled admission verdict: ${JSON.stringify(_never)}`);
|
|
2064
2245
|
}
|
|
2065
2246
|
}
|
|
2066
2247
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
|
|
2067
|
-
if (opts?.expectedIdentityId) {
|
|
2068
|
-
const cred = pkg.leafNode.credential;
|
|
2069
|
-
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
2070
|
-
if (claimed !== opts.expectedIdentityId) {
|
|
2071
|
-
throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
2248
|
const priorEpoch = Number(mgr.epoch);
|
|
2075
2249
|
const { commit, welcome } = await mgr.addMembers([pkg]);
|
|
2076
2250
|
const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
@@ -2092,15 +2266,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2092
2266
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
2093
2267
|
} catch {
|
|
2094
2268
|
}
|
|
2095
|
-
try {
|
|
2096
|
-
const work = await this.o.backend.getCommitterWork(channelId);
|
|
2097
|
-
if (!work.adds.includes(newDeviceId))
|
|
2098
|
-
return;
|
|
2099
|
-
} catch {
|
|
2100
|
-
}
|
|
2101
2269
|
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
|
|
2102
2270
|
}
|
|
2103
2271
|
await this.o.store.save(channelId, mgr.exportState());
|
|
2272
|
+
return "committed";
|
|
2104
2273
|
}
|
|
2105
2274
|
/**
|
|
2106
2275
|
* Committer-side: REMOVE a member from a live group (Phase C eviction).
|
|
@@ -2318,6 +2487,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2318
2487
|
* - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
|
|
2319
2488
|
* leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
|
|
2320
2489
|
*
|
|
2490
|
+
* ⛔⛔ LIMITATION — A MULTI-DEVICE MEMBER IS UNHEALABLE BY THIS METHOD, AND THE
|
|
2491
|
+
* FAILURE IS SILENT AND DESTRUCTIVE. READ THIS BEFORE CALLING IT.
|
|
2492
|
+
*
|
|
2493
|
+
* Leaf credentials encode the IDENTITY and not the device, so the leaf
|
|
2494
|
+
* resolution above is only exact when the identity's leaf set is unambiguous.
|
|
2495
|
+
* The `> 1` case refuses. The `=== 1` case DOES NOT, and it is the worse one:
|
|
2496
|
+
* heal device B of an identity whose device A holds the only leaf and A is
|
|
2497
|
+
* evicted while the call reports a clean heal. Every downstream check is
|
|
2498
|
+
* satisfied — the cardinality guard in `removeDeviceFromLiveGroupUnlocked`
|
|
2499
|
+
* compares this call site's single-element `orderedDeviceIds` against a
|
|
2500
|
+
* single-element leaf set, a coincidence of counts, and `leaves[0]` is A's.
|
|
2501
|
+
* `mls.heal-multidevice.test.ts` measures exactly that against real MLS.
|
|
2502
|
+
*
|
|
2503
|
+
* ⛔ NO GUARD IS AVAILABLE HERE, and this is a measured conclusion rather than
|
|
2504
|
+
* an unexplored one. The device-level predicate that would settle it (leaf
|
|
2505
|
+
* indices whose LeafNode signature key equals the fetched KeyPackage's — the
|
|
2506
|
+
* same evidence the Add chokepoint uses) was implemented and it refused EVERY
|
|
2507
|
+
* production heal: the repair contract requires the member to republish first,
|
|
2508
|
+
* `forceRepublishKeyPackage` mints a NEW SIGNING KEY, and a diverged device's
|
|
2509
|
+
* fresh KeyPackage therefore matches no leaf, including its own. A guard built
|
|
2510
|
+
* on it is a blanket disable, not a guard.
|
|
2511
|
+
*
|
|
2512
|
+
* ⚠️ CALLER CONTRACT UNTIL SLICE 5: do not drive this for a member you know to
|
|
2513
|
+
* hold more than one device. The caller is the only layer that has a roster,
|
|
2514
|
+
* and nothing in the tree binds a leaf to a device id, so the refusal has to
|
|
2515
|
+
* live above this function. connect-web's `foreign-identity-heal.ts` declines a
|
|
2516
|
+
* member whose identity holds two or more LEAVES; the residual it cannot see —
|
|
2517
|
+
* two DEVICES sharing one leaf — is the shape described above and is open.
|
|
2518
|
+
* It resolves when the leaf credential carries a device binding (slice 5),
|
|
2519
|
+
* which cannot be retrofitted without re-keying every live group.
|
|
2520
|
+
*
|
|
2321
2521
|
* The Add fetches the device's freshly published KeyPackage from the backend,
|
|
2322
2522
|
* exactly like a normal add — the CALLER is responsible for the member having
|
|
2323
2523
|
* re-published one first (mirrors addMemberToLiveGroup's "no published
|
|
@@ -2334,7 +2534,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2334
2534
|
const hex = kps[memberDeviceId];
|
|
2335
2535
|
if (!hex)
|
|
2336
2536
|
throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
|
|
2337
|
-
const
|
|
2537
|
+
const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
2538
|
+
const cred = pkg.leafNode.credential;
|
|
2338
2539
|
if (cred.credentialType !== "basic") {
|
|
2339
2540
|
throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
|
|
2340
2541
|
}
|
|
@@ -2347,6 +2548,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2347
2548
|
if (leaves.length > 1) {
|
|
2348
2549
|
throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
|
|
2349
2550
|
}
|
|
2551
|
+
if (leaves.length === 1) {
|
|
2552
|
+
console.warn(`[heal] channel ${channelId}: about to Remove the single leaf held by identity ${identity} in order to re-Add device ${memberDeviceId}, WITHOUT being able to prove that leaf is that device's. Leaf credentials encode the identity, not the device. If this member holds more than one device, the leaf being evicted may belong to a SIBLING device that was never diverged, and this call will still report a clean heal. Multi-device members are unhealable until leaf\u2192device attribution exists.`);
|
|
2553
|
+
}
|
|
2350
2554
|
let removeLanded = false;
|
|
2351
2555
|
if (leaves.length === 1) {
|
|
2352
2556
|
await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
|
|
@@ -2357,13 +2561,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2357
2561
|
throw new Error(`healDivergedMember: expected 0 leaves for the Add-only branch but found ${stillZero.length} (a leaf reappeared under the held lock \u2014 refusing to create a duplicate leaf)`);
|
|
2358
2562
|
}
|
|
2359
2563
|
}
|
|
2564
|
+
let outcome;
|
|
2360
2565
|
try {
|
|
2361
|
-
await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
|
|
2566
|
+
outcome = await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
|
|
2362
2567
|
} catch (e) {
|
|
2363
2568
|
if (removeLanded)
|
|
2364
2569
|
throw new HealAddAfterRemoveError(e);
|
|
2365
2570
|
throw e;
|
|
2366
2571
|
}
|
|
2572
|
+
if (outcome !== "committed") {
|
|
2573
|
+
const err = new Error(`healDivergedMember: the re-Add of ${memberDeviceId} on channel ${channelId} committed nothing (outcome "${outcome}") \u2014 refusing to report a clean heal for a device that may hold no leaf at the live epoch.`);
|
|
2574
|
+
if (removeLanded)
|
|
2575
|
+
throw new HealAddAfterRemoveError(err);
|
|
2576
|
+
throw err;
|
|
2577
|
+
}
|
|
2367
2578
|
}));
|
|
2368
2579
|
}
|
|
2369
2580
|
/** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
|
|
@@ -2470,14 +2681,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2470
2681
|
throw new Error("resetSessionSupersede: call publishKeyPackage() first");
|
|
2471
2682
|
const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
|
|
2472
2683
|
const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
2473
|
-
const mgr = new
|
|
2684
|
+
const mgr = new MLSGroupManager2();
|
|
2474
2685
|
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
2475
2686
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
2476
2687
|
const pkgs = others.map((d) => {
|
|
2477
2688
|
const hex = kps[d];
|
|
2478
2689
|
if (!hex)
|
|
2479
2690
|
throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
|
|
2480
|
-
return
|
|
2691
|
+
return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
2481
2692
|
});
|
|
2482
2693
|
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
2483
2694
|
await this.o.backend.distribute(reinit.group_id, {
|
|
@@ -2552,6 +2763,29 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2552
2763
|
const mgr = await this.manager(channelId);
|
|
2553
2764
|
return mgr.leafIndicesForIdentity(identityId).length;
|
|
2554
2765
|
}
|
|
2766
|
+
/**
|
|
2767
|
+
* Cache-only leaf count for `identityId` in `channelId`, or **null** when
|
|
2768
|
+
* there is no in-memory manager (a cold channel).
|
|
2769
|
+
*
|
|
2770
|
+
* The non-throwing sibling of {@link leafCountForIdentity}, with the same
|
|
2771
|
+
* cold-channel semantics as {@link hasLeafForIdentity}. It exists because the
|
|
2772
|
+
* join confirmer needs to know whether a leaf can be ATTRIBUTED to a device,
|
|
2773
|
+
* and a boolean cannot answer that: leaf credentials encode the identity, not
|
|
2774
|
+
* the device, so "the identity holds a leaf" is true off ANY of its devices'
|
|
2775
|
+
* leaves. `null` and any count other than 1 both mean "cannot attribute", and
|
|
2776
|
+
* a caller that clears state on attribution must treat them as such.
|
|
2777
|
+
*
|
|
2778
|
+
* ⚠️ This is still an IDENTITY-level read. It does not name which device
|
|
2779
|
+
* holds which leaf and it cannot until leaves carry a device binding
|
|
2780
|
+
* (ADR-0014 Decision B, slice 5). What it buys today is the ability to REFUSE
|
|
2781
|
+
* to attribute rather than to guess.
|
|
2782
|
+
*/
|
|
2783
|
+
leafCountForIdentityCached(channelId, identityId) {
|
|
2784
|
+
const mgr = this.managers.get(channelId);
|
|
2785
|
+
if (!mgr)
|
|
2786
|
+
return null;
|
|
2787
|
+
return mgr.leafIndicesForIdentity(identityId).length;
|
|
2788
|
+
}
|
|
2555
2789
|
/** Returns true when the live in-memory MLS tree for `channelId` contains at
|
|
2556
2790
|
* least one leaf bearing `identityId` as its credential. Used by the agent
|
|
2557
2791
|
* admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
|
|
@@ -2669,7 +2903,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2669
2903
|
return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
|
|
2670
2904
|
}
|
|
2671
2905
|
return await this.locks.run(channelId, async () => {
|
|
2672
|
-
const mgr = new
|
|
2906
|
+
const mgr = new MLSGroupManager2();
|
|
2673
2907
|
try {
|
|
2674
2908
|
await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
|
|
2675
2909
|
} catch (err) {
|
|
@@ -3413,6 +3647,24 @@ var RestTransport = class {
|
|
|
3413
3647
|
};
|
|
3414
3648
|
|
|
3415
3649
|
// ../connect-sdk-ts/dist/mls/rest-backend.js
|
|
3650
|
+
function describeType(v) {
|
|
3651
|
+
if (v === void 0)
|
|
3652
|
+
return "missing";
|
|
3653
|
+
if (v === null)
|
|
3654
|
+
return "null";
|
|
3655
|
+
if (Array.isArray(v))
|
|
3656
|
+
return "array";
|
|
3657
|
+
return typeof v;
|
|
3658
|
+
}
|
|
3659
|
+
function parseGroupRoster(endpoint, channelId, value) {
|
|
3660
|
+
if (!Array.isArray(value)) {
|
|
3661
|
+
throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
|
|
3662
|
+
}
|
|
3663
|
+
if (value.length === 0) {
|
|
3664
|
+
throw new MalformedGroupRosterError(endpoint, channelId, "empty array");
|
|
3665
|
+
}
|
|
3666
|
+
return value.map(String);
|
|
3667
|
+
}
|
|
3416
3668
|
var RestMlsBackend = class {
|
|
3417
3669
|
opts;
|
|
3418
3670
|
f;
|
|
@@ -3488,7 +3740,10 @@ var RestMlsBackend = class {
|
|
|
3488
3740
|
return {
|
|
3489
3741
|
group_id: String(j.group_id),
|
|
3490
3742
|
current_epoch: Number(j.current_epoch),
|
|
3491
|
-
|
|
3743
|
+
// #230: fail loud on an absent / non-array / empty roster rather than
|
|
3744
|
+
// coerce to `[]` (which pre-empts the consumers' `?? [self]` fallback and
|
|
3745
|
+
// gets POSTed back as a wipe on distribute).
|
|
3746
|
+
member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
|
|
3492
3747
|
// Back-compat: servers that predate the created field return nothing;
|
|
3493
3748
|
// treat absence as true (they always created a fresh group).
|
|
3494
3749
|
created: j.created === void 0 ? true : Boolean(j.created)
|
|
@@ -3513,10 +3768,14 @@ var RestMlsBackend = class {
|
|
|
3513
3768
|
return {
|
|
3514
3769
|
group_id: String(j.group_id),
|
|
3515
3770
|
current_epoch: Number(j.current_epoch),
|
|
3516
|
-
|
|
3771
|
+
// #230: same guard as init. This field has ZERO consumers today
|
|
3772
|
+
// (resetSessionSupersede reads only stale_member_device_ids), so this is
|
|
3773
|
+
// defense in depth: a future consumer cannot inherit the silent-`[]` trap.
|
|
3774
|
+
member_device_ids: parseGroupRoster("reinit", channelId, j.member_device_ids),
|
|
3517
3775
|
// Absent on a backend that predates the dropped-member report; an empty
|
|
3518
3776
|
// list is the correct read of "the server told us nothing", since the old
|
|
3519
|
-
// server also never dropped anyone for an expired KeyPackage.
|
|
3777
|
+
// server also never dropped anyone for an expired KeyPackage. UNCHANGED:
|
|
3778
|
+
// empty here is legitimate, the opposite of member_device_ids (§3.3).
|
|
3520
3779
|
stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
|
|
3521
3780
|
};
|
|
3522
3781
|
}
|
|
@@ -3532,6 +3791,22 @@ var RestMlsBackend = class {
|
|
|
3532
3791
|
return null;
|
|
3533
3792
|
}
|
|
3534
3793
|
}
|
|
3794
|
+
async fetchKeyPackageExpiry(kpId) {
|
|
3795
|
+
try {
|
|
3796
|
+
const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}/expiry`);
|
|
3797
|
+
const raw = j.expires_at;
|
|
3798
|
+
if (typeof raw !== "string")
|
|
3799
|
+
return null;
|
|
3800
|
+
if (!/(?:Z|[+-]\d{2}:?\d{2})$/.test(raw))
|
|
3801
|
+
return null;
|
|
3802
|
+
const ms = Date.parse(raw);
|
|
3803
|
+
if (Number.isNaN(ms))
|
|
3804
|
+
return null;
|
|
3805
|
+
return new Date(ms);
|
|
3806
|
+
} catch {
|
|
3807
|
+
return null;
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3535
3810
|
async distribute(groupId, req) {
|
|
3536
3811
|
await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
|
|
3537
3812
|
...req,
|
|
@@ -3556,10 +3831,14 @@ var RestMlsBackend = class {
|
|
|
3556
3831
|
}
|
|
3557
3832
|
async getCommitterWork(channelId) {
|
|
3558
3833
|
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3834
|
+
const requireList = (field) => {
|
|
3835
|
+
const v = j?.[field];
|
|
3836
|
+
if (!Array.isArray(v)) {
|
|
3837
|
+
throw new MalformedCommitterWorkError(channelId, field, describeType(v));
|
|
3838
|
+
}
|
|
3839
|
+
return v.map(String);
|
|
3562
3840
|
};
|
|
3841
|
+
return { adds: requireList("adds"), removes: requireList("removes") };
|
|
3563
3842
|
}
|
|
3564
3843
|
async acquireCommitLease(channelId) {
|
|
3565
3844
|
const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
|
|
@@ -3590,6 +3869,390 @@ var RestMlsBackend = class {
|
|
|
3590
3869
|
|
|
3591
3870
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
3592
3871
|
import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
|
|
3872
|
+
var FakeConnectBackend = class _FakeConnectBackend {
|
|
3873
|
+
// Key-package store: deviceId → hex-encoded KeyPackage
|
|
3874
|
+
kps = /* @__PURE__ */ new Map();
|
|
3875
|
+
// Server-assigned KeyPackage ids: deviceId → id (last publish wins, mirroring
|
|
3876
|
+
// the real backend's single-row-per-device semantics).
|
|
3877
|
+
kpIds = /* @__PURE__ */ new Map();
|
|
3878
|
+
_kpSeq = 0;
|
|
3879
|
+
/** Server TTL stamps: kp_id → expiry epoch-ms. Keyed by ID, not by device, so a
|
|
3880
|
+
* SUPERSEDED id has no entry at all — mirroring `publish_key_package`, whose
|
|
3881
|
+
* first statement DELETEs every prior row for the device
|
|
3882
|
+
* (`mls_key_package_service.py:26-28`), which makes a superseded id 404. */
|
|
3883
|
+
kpExpiry = /* @__PURE__ */ new Map();
|
|
3884
|
+
/** Injectable clock, so a test can place a row inside or just outside the guard's
|
|
3885
|
+
* skew grace. The fake's stamp, its `fetchKeyPackages` filter, and the provider's
|
|
3886
|
+
* comparison must all read ONE clock or the grace is not expressible. */
|
|
3887
|
+
now = () => Date.now();
|
|
3888
|
+
/** The real server's default KeyPackage TTL. */
|
|
3889
|
+
static TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
3890
|
+
// Per-device delivery queues
|
|
3891
|
+
queues = /* @__PURE__ */ new Map();
|
|
3892
|
+
// Per-channel state (the real backend scopes membership + epoch per group;
|
|
3893
|
+
// keying these by channel keeps a multi-channel test from cross-contaminating).
|
|
3894
|
+
membersByChannel = /* @__PURE__ */ new Map();
|
|
3895
|
+
epochByChannel = /* @__PURE__ */ new Map();
|
|
3896
|
+
groupIdByChannel = /* @__PURE__ */ new Map();
|
|
3897
|
+
channelByGroup = /* @__PURE__ */ new Map();
|
|
3898
|
+
// identity_id → device_id, recorded at registerDevice so ingestApplication can
|
|
3899
|
+
// exclude the sender's own device on fanout (mirrors sendApplication's
|
|
3900
|
+
// senderDeviceId exclusion when only the envelope's sender_id is known).
|
|
3901
|
+
deviceByIdentity = /* @__PURE__ */ new Map();
|
|
3902
|
+
// Owner-attested reset: settable attestation map keyed by NEW groupId, read by
|
|
3903
|
+
// fetchResetAttestation (agent verify path) + written by resetReinitConnectChannelGroup.
|
|
3904
|
+
resetAttestations = /* @__PURE__ */ new Map();
|
|
3905
|
+
// Optional signer id the owner-side resetReinit records on the attestation it
|
|
3906
|
+
// stores (Task 9). Undefined in Task-6 tests, which set resetAttestations directly.
|
|
3907
|
+
ownerIdentityId;
|
|
3908
|
+
// Monotonic counter for generating unique IDs
|
|
3909
|
+
/** Ported-from-gen2 replay support: every distributed commit, in order, so
|
|
3910
|
+
* syncGroup() can serve a real catch-up instead of a stub. */
|
|
3911
|
+
commitLog = /* @__PURE__ */ new Map();
|
|
3912
|
+
/** Gap beyond which syncGroup answers "rejoin" (mirrors REJOIN_THRESHOLD). */
|
|
3913
|
+
rejoinThreshold = 100;
|
|
3914
|
+
_seq = 0;
|
|
3915
|
+
// Dedicated monotonic counter for reinit group ids (see reinitConnectChannelGroup).
|
|
3916
|
+
_reinitSeq = 0;
|
|
3917
|
+
/** Returns (and lazily creates) the delivery queue for a device. */
|
|
3918
|
+
q(deviceId) {
|
|
3919
|
+
let queue = this.queues.get(deviceId);
|
|
3920
|
+
if (!queue) {
|
|
3921
|
+
queue = [];
|
|
3922
|
+
this.queues.set(deviceId, queue);
|
|
3923
|
+
}
|
|
3924
|
+
return queue;
|
|
3925
|
+
}
|
|
3926
|
+
// ── MlsBackend implementation ──────────────────────────────────────────────
|
|
3927
|
+
/** Test stub: a FIXED 32-byte challenge hex. The real backend mints a fresh
|
|
3928
|
+
* single-use nonce; tests that exercise the PoP path sign over this constant
|
|
3929
|
+
* and re-derive the same message to verify. Deterministic so a register spy
|
|
3930
|
+
* can reconstruct and check the proof. */
|
|
3931
|
+
async getRegisterChallenge() {
|
|
3932
|
+
return "ab".repeat(32);
|
|
3933
|
+
}
|
|
3934
|
+
async registerDevice(req) {
|
|
3935
|
+
const deviceId = "fake-device-" + this._seq++;
|
|
3936
|
+
if (req.identity_id)
|
|
3937
|
+
this.deviceByIdentity.set(req.identity_id, deviceId);
|
|
3938
|
+
return { device_id: deviceId };
|
|
3939
|
+
}
|
|
3940
|
+
/** Test seam: record an identity→device link for a provider constructed with a
|
|
3941
|
+
* fixed deviceId (the SDK e2e wires providers directly, bypassing registerDevice).
|
|
3942
|
+
* The real backend resolves removed_identity_id → device(s) from the devices
|
|
3943
|
+
* table FK; this mirrors that resolution so op==="remove" can subtract the
|
|
3944
|
+
* removed identity's device(s) from the roster + delivery, exactly like the
|
|
3945
|
+
* server's purge_device_queue. */
|
|
3946
|
+
linkIdentityDevice(identityId, deviceId) {
|
|
3947
|
+
this.deviceByIdentity.set(identityId, deviceId);
|
|
3948
|
+
}
|
|
3949
|
+
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
3950
|
+
this.kps.set(deviceId, keyPackageHex);
|
|
3951
|
+
const prior = this.kpIds.get(deviceId);
|
|
3952
|
+
if (prior)
|
|
3953
|
+
this.kpExpiry.delete(prior);
|
|
3954
|
+
const id = `kp_${deviceId}_${this._kpSeq = (this._kpSeq ?? 0) + 1}`;
|
|
3955
|
+
this.kpIds.set(deviceId, id);
|
|
3956
|
+
this.kpExpiry.set(id, this.now() + _FakeConnectBackend.TTL_MS);
|
|
3957
|
+
return id;
|
|
3958
|
+
}
|
|
3959
|
+
/** Test seam: move a recorded expiry to an absolute epoch-ms instant. Named
|
|
3960
|
+
* rather than letting a test write `kpExpiry` directly, so the id-keyed shape
|
|
3961
|
+
* stays an implementation detail. A hard-coded past value would make T2
|
|
3962
|
+
* (the fresh-KeyPackage negative control) unbuildable. */
|
|
3963
|
+
setKeyPackageExpiry(kpId, expiresAtMs) {
|
|
3964
|
+
this.kpExpiry.set(kpId, expiresAtMs);
|
|
3965
|
+
}
|
|
3966
|
+
async fetchKeyPackageExpiry(kpId) {
|
|
3967
|
+
const ms = this.kpExpiry.get(kpId);
|
|
3968
|
+
return ms === void 0 ? null : new Date(ms);
|
|
3969
|
+
}
|
|
3970
|
+
async getConnectChannelGroup(channelId) {
|
|
3971
|
+
const groupId = this.groupIdByChannel.get(channelId) ?? null;
|
|
3972
|
+
if (!groupId)
|
|
3973
|
+
return { exists: false, group_id: null, current_epoch: null };
|
|
3974
|
+
return {
|
|
3975
|
+
exists: true,
|
|
3976
|
+
group_id: groupId,
|
|
3977
|
+
current_epoch: this.epochByChannel.get(channelId) ?? 0
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
async initConnectChannelGroup(channelId, memberDeviceIds) {
|
|
3981
|
+
const alreadyExists = this.groupIdByChannel.has(channelId);
|
|
3982
|
+
let groupId = this.groupIdByChannel.get(channelId);
|
|
3983
|
+
if (!groupId) {
|
|
3984
|
+
groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
|
|
3985
|
+
this.groupIdByChannel.set(channelId, groupId);
|
|
3986
|
+
this.channelByGroup.set(groupId, channelId);
|
|
3987
|
+
this.epochByChannel.set(channelId, 0);
|
|
3988
|
+
}
|
|
3989
|
+
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
|
|
3990
|
+
this.membersByChannel.set(channelId, members);
|
|
3991
|
+
return {
|
|
3992
|
+
group_id: groupId,
|
|
3993
|
+
current_epoch: this.epochByChannel.get(channelId) ?? 0,
|
|
3994
|
+
member_device_ids: members,
|
|
3995
|
+
// Real semantics: created=true on first call (group was just created),
|
|
3996
|
+
// created=false on idempotent return (group already existed). Without
|
|
3997
|
+
// this, race tests silently pass wrong (fake is laxer than the server).
|
|
3998
|
+
created: !alreadyExists
|
|
3999
|
+
};
|
|
4000
|
+
}
|
|
4001
|
+
async reinitConnectChannelGroup(channelId, memberDeviceIds) {
|
|
4002
|
+
const groupId = `00000000-0000-0000-0000-${String(1e3 + (this._reinitSeq = (this._reinitSeq ?? 0) + 1)).padStart(12, "0")}`;
|
|
4003
|
+
this.groupIdByChannel.set(channelId, groupId);
|
|
4004
|
+
this.channelByGroup.set(groupId, channelId);
|
|
4005
|
+
this.epochByChannel.set(channelId, 0);
|
|
4006
|
+
const members = [...new Set(memberDeviceIds)];
|
|
4007
|
+
this.membersByChannel.set(channelId, members);
|
|
4008
|
+
return { group_id: groupId, current_epoch: 0, member_device_ids: members };
|
|
4009
|
+
}
|
|
4010
|
+
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
4011
|
+
this.groupIdByChannel.set(channelId, att.newGroupId);
|
|
4012
|
+
this.channelByGroup.set(att.newGroupId, channelId);
|
|
4013
|
+
this.epochByChannel.set(channelId, 0);
|
|
4014
|
+
const members = [...new Set(memberDeviceIds)];
|
|
4015
|
+
this.membersByChannel.set(channelId, members);
|
|
4016
|
+
this.resetAttestations.set(att.newGroupId, {
|
|
4017
|
+
signedContent: new TextDecoder().decode(hexToBytes2(att.signedContentHex)),
|
|
4018
|
+
signatureHex: att.signatureHex,
|
|
4019
|
+
signerIdentityId: this.ownerIdentityId ?? "owner"
|
|
4020
|
+
});
|
|
4021
|
+
return {
|
|
4022
|
+
group_id: att.newGroupId,
|
|
4023
|
+
current_epoch: 0,
|
|
4024
|
+
member_device_ids: members,
|
|
4025
|
+
// The fake honours the roster verbatim, so nothing is ever dropped here.
|
|
4026
|
+
// Tests that need the dropped-member path stub this method directly.
|
|
4027
|
+
stale_member_device_ids: []
|
|
4028
|
+
};
|
|
4029
|
+
}
|
|
4030
|
+
async fetchKeyPackages(deviceIds) {
|
|
4031
|
+
const out = {};
|
|
4032
|
+
for (const id of deviceIds) {
|
|
4033
|
+
const kpId = this.kpIds.get(id);
|
|
4034
|
+
const exp = kpId === void 0 ? void 0 : this.kpExpiry.get(kpId);
|
|
4035
|
+
if (exp !== void 0 && exp <= this.now())
|
|
4036
|
+
continue;
|
|
4037
|
+
out[id] = this.kps.get(id);
|
|
4038
|
+
}
|
|
4039
|
+
return out;
|
|
4040
|
+
}
|
|
4041
|
+
async fetchKeyPackageById(kpId) {
|
|
4042
|
+
for (const [deviceId, id] of this.kpIds) {
|
|
4043
|
+
if (id === kpId) {
|
|
4044
|
+
return this.kps.get(deviceId) ?? null;
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
return null;
|
|
4048
|
+
}
|
|
4049
|
+
async syncGroup(groupId, lastEpoch, _deviceId) {
|
|
4050
|
+
const channelId = this.channelByGroup.get(groupId);
|
|
4051
|
+
const current = channelId ? this.epochByChannel.get(channelId) ?? 0 : 0;
|
|
4052
|
+
if (current - lastEpoch > this.rejoinThreshold)
|
|
4053
|
+
return { action: "rejoin", messages: [] };
|
|
4054
|
+
const log = this.commitLog.get(groupId) ?? [];
|
|
4055
|
+
return {
|
|
4056
|
+
action: "replay",
|
|
4057
|
+
messages: log.filter((c) => c.epoch > lastEpoch).sort((a, b) => a.epoch - b.epoch).map((c) => ({
|
|
4058
|
+
id: c.id,
|
|
4059
|
+
epoch: c.epoch,
|
|
4060
|
+
message_type: "commit",
|
|
4061
|
+
sender_device_id: c.sender_device_id,
|
|
4062
|
+
payload: c.payload
|
|
4063
|
+
}))
|
|
4064
|
+
};
|
|
4065
|
+
}
|
|
4066
|
+
async distribute(groupId, req) {
|
|
4067
|
+
const channelId = this.channelByGroup.get(groupId);
|
|
4068
|
+
if (!channelId)
|
|
4069
|
+
throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
|
|
4070
|
+
if (req.commit && req.expected_epoch != null) {
|
|
4071
|
+
const current = this.epochByChannel.get(channelId) ?? 0;
|
|
4072
|
+
if (req.expected_epoch !== current) {
|
|
4073
|
+
const e = new Error(`connect backend post failed: 409 EPOCH_CONFLICT (expected ${req.expected_epoch}, current ${current})`);
|
|
4074
|
+
e.status = 409;
|
|
4075
|
+
throw e;
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
if (req.commit) {
|
|
4079
|
+
const log = this.commitLog.get(groupId) ?? [];
|
|
4080
|
+
log.push({
|
|
4081
|
+
id: `c${++this._seq}`,
|
|
4082
|
+
epoch: Number(req.commit_epoch ?? 0),
|
|
4083
|
+
payload: String(req.commit),
|
|
4084
|
+
sender_device_id: String(req.device_id ?? "")
|
|
4085
|
+
});
|
|
4086
|
+
this.commitLog.set(groupId, log);
|
|
4087
|
+
}
|
|
4088
|
+
const priorMembers = this.membersByChannel.get(channelId) ?? [];
|
|
4089
|
+
this.epochByChannel.set(channelId, req.commit_epoch ?? this.epochByChannel.get(channelId) ?? 0);
|
|
4090
|
+
let evictedDevice;
|
|
4091
|
+
if (req.op === "remove") {
|
|
4092
|
+
if (!req.removed_identity_id) {
|
|
4093
|
+
throw new Error("removed_identity_id required");
|
|
4094
|
+
}
|
|
4095
|
+
evictedDevice = req.removed_device_id ?? (req.removed_identity_id ? this.deviceByIdentity.get(req.removed_identity_id) : void 0);
|
|
4096
|
+
const remaining = (this.membersByChannel.get(channelId) ?? []).filter((d) => d !== evictedDevice);
|
|
4097
|
+
this.membersByChannel.set(channelId, remaining);
|
|
4098
|
+
if (evictedDevice)
|
|
4099
|
+
this.queues.delete(evictedDevice);
|
|
4100
|
+
} else {
|
|
4101
|
+
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
|
|
4102
|
+
this.membersByChannel.set(channelId, members);
|
|
4103
|
+
}
|
|
4104
|
+
if (req.commit) {
|
|
4105
|
+
for (const dev of priorMembers) {
|
|
4106
|
+
if (dev === req.device_id)
|
|
4107
|
+
continue;
|
|
4108
|
+
if (dev === evictedDevice)
|
|
4109
|
+
continue;
|
|
4110
|
+
this.q(dev).push({
|
|
4111
|
+
queue_id: "q-" + this._seq++,
|
|
4112
|
+
message_id: "m-commit-" + this._seq++,
|
|
4113
|
+
group_id: groupId,
|
|
4114
|
+
message_type: "commit",
|
|
4115
|
+
sender_device_id: req.device_id,
|
|
4116
|
+
epoch: this.epochByChannel.get(channelId) ?? 0,
|
|
4117
|
+
payload: req.commit,
|
|
4118
|
+
connect_channel_id: channelId,
|
|
4119
|
+
created_at: ""
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
for (const w of req.welcomes ?? []) {
|
|
4124
|
+
this.q(w.target_device_id).push({
|
|
4125
|
+
queue_id: "q-" + this._seq++,
|
|
4126
|
+
message_id: "m-welcome-" + this._seq++,
|
|
4127
|
+
group_id: groupId,
|
|
4128
|
+
message_type: "welcome",
|
|
4129
|
+
sender_device_id: req.device_id,
|
|
4130
|
+
epoch: this.epochByChannel.get(channelId) ?? 0,
|
|
4131
|
+
payload: w.payload,
|
|
4132
|
+
connect_channel_id: channelId,
|
|
4133
|
+
created_at: ""
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
async pullDelivery(deviceId) {
|
|
4138
|
+
return this.queues.get(deviceId) ?? [];
|
|
4139
|
+
}
|
|
4140
|
+
async ackDelivery(deviceId, queueIds) {
|
|
4141
|
+
const queue = this.queues.get(deviceId);
|
|
4142
|
+
if (queue) {
|
|
4143
|
+
this.queues.set(deviceId, queue.filter((r) => !queueIds.includes(r.queue_id)));
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
requestWelcomeCalls = [];
|
|
4147
|
+
async requestWelcome(groupId, deviceId, freshKpId) {
|
|
4148
|
+
this.requestWelcomeCalls.push({ groupId, deviceId, freshKpId });
|
|
4149
|
+
}
|
|
4150
|
+
// ADR 0004 C1: committer-work + lease stubs (in-memory for unit tests;
|
|
4151
|
+
// no sweep driver runs against the fake backend, so these are no-ops).
|
|
4152
|
+
async getCommitterWork(_channelId) {
|
|
4153
|
+
return { adds: [], removes: [] };
|
|
4154
|
+
}
|
|
4155
|
+
async acquireCommitLease(_channelId) {
|
|
4156
|
+
return "fake-lease-token";
|
|
4157
|
+
}
|
|
4158
|
+
async releaseCommitLease(_channelId, _token) {
|
|
4159
|
+
}
|
|
4160
|
+
async fetchResetAttestation(groupId) {
|
|
4161
|
+
return this.resetAttestations.get(groupId) ?? null;
|
|
4162
|
+
}
|
|
4163
|
+
// ── E2E helper: seal + fanout (mirrors the Python edge's Task-7 path) ──────
|
|
4164
|
+
async sendApplication(p) {
|
|
4165
|
+
const key = await p.sender.exporterKey(p.enclaveId, p.channelId, p.epoch);
|
|
4166
|
+
const messageId = "m-" + this._seq++;
|
|
4167
|
+
const replayNonce = bytesToBase64(crypto.getRandomValues(new Uint8Array(24)));
|
|
4168
|
+
const policy_binding = {
|
|
4169
|
+
binding_id: "b",
|
|
4170
|
+
version: "1",
|
|
4171
|
+
agent_skill_id: null,
|
|
4172
|
+
delegation_id: null,
|
|
4173
|
+
client_stage5_outcome: "allow",
|
|
4174
|
+
client_stage5_evaluators: []
|
|
4175
|
+
};
|
|
4176
|
+
const aad = await buildAssociatedData({
|
|
4177
|
+
message_id: messageId,
|
|
4178
|
+
channel_id: p.channelId,
|
|
4179
|
+
sender_id: p.senderId,
|
|
4180
|
+
mls_group_epoch: p.epoch,
|
|
4181
|
+
replay_nonce: replayNonce,
|
|
4182
|
+
policy_binding
|
|
4183
|
+
});
|
|
4184
|
+
const sealed = sealPayload({
|
|
4185
|
+
plaintext: new TextEncoder().encode(p.plaintext),
|
|
4186
|
+
key,
|
|
4187
|
+
associatedData: aad,
|
|
4188
|
+
suite: "xchacha20poly1305"
|
|
4189
|
+
});
|
|
4190
|
+
const envelope = {
|
|
4191
|
+
av_version: "1.0",
|
|
4192
|
+
message_id: messageId,
|
|
4193
|
+
channel_id: p.channelId,
|
|
4194
|
+
enclave_id: p.enclaveId,
|
|
4195
|
+
message_type: "text",
|
|
4196
|
+
sender_id: p.senderId,
|
|
4197
|
+
sender_type: "agent",
|
|
4198
|
+
identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
|
|
4199
|
+
trace_context: { traceparent: "" },
|
|
4200
|
+
policy_binding,
|
|
4201
|
+
mls_group_epoch: p.epoch,
|
|
4202
|
+
replay_nonce: replayNonce,
|
|
4203
|
+
payload_encrypted: bytesToBase64(sealed),
|
|
4204
|
+
references: []
|
|
4205
|
+
};
|
|
4206
|
+
const canonical = await canonicalEnvelopeBytes(envelope);
|
|
4207
|
+
const members = this.membersByChannel.get(p.channelId) ?? [];
|
|
4208
|
+
const groupId = this.groupIdByChannel.get(p.channelId) ?? "";
|
|
4209
|
+
for (const dev of members) {
|
|
4210
|
+
if (dev === p.senderDeviceId)
|
|
4211
|
+
continue;
|
|
4212
|
+
this.q(dev).push({
|
|
4213
|
+
queue_id: "q-" + this._seq++,
|
|
4214
|
+
message_id: messageId,
|
|
4215
|
+
group_id: groupId,
|
|
4216
|
+
message_type: "application",
|
|
4217
|
+
sender_device_id: p.senderDeviceId,
|
|
4218
|
+
epoch: p.epoch,
|
|
4219
|
+
payload: bytesToHex3(canonical),
|
|
4220
|
+
connect_channel_id: p.channelId,
|
|
4221
|
+
created_at: ""
|
|
4222
|
+
});
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
// ── E2E helper: ingest an already-built envelope (client-side build/seal) ──
|
|
4226
|
+
//
|
|
4227
|
+
// Mirrors sendApplication's storage + fanout, but takes an AgentVaultEnvelope
|
|
4228
|
+
// the *client* already built, sealed, and signed (via buildOutboundEnvelope) —
|
|
4229
|
+
// exactly what the owner client hands to its injected `publish`. The server
|
|
4230
|
+
// edge stores the canonical envelope bytes (hex) and fans out one delivery row
|
|
4231
|
+
// per active member device except the sender's. We resolve the sender's device
|
|
4232
|
+
// from sender_id (recorded at registerDevice); if unknown, every member gets it.
|
|
4233
|
+
async ingestApplication(envelope) {
|
|
4234
|
+
const channelId = envelope.channel_id;
|
|
4235
|
+
const canonical = await canonicalEnvelopeBytes(envelope);
|
|
4236
|
+
const members = this.membersByChannel.get(channelId) ?? [];
|
|
4237
|
+
const groupId = this.groupIdByChannel.get(channelId) ?? "";
|
|
4238
|
+
const senderDeviceId = this.deviceByIdentity.get(envelope.sender_id);
|
|
4239
|
+
for (const dev of members) {
|
|
4240
|
+
if (dev === senderDeviceId)
|
|
4241
|
+
continue;
|
|
4242
|
+
this.q(dev).push({
|
|
4243
|
+
queue_id: "q-" + this._seq++,
|
|
4244
|
+
message_id: envelope.message_id,
|
|
4245
|
+
group_id: groupId,
|
|
4246
|
+
message_type: "application",
|
|
4247
|
+
sender_device_id: senderDeviceId ?? "",
|
|
4248
|
+
epoch: envelope.mls_group_epoch,
|
|
4249
|
+
payload: bytesToHex3(canonical),
|
|
4250
|
+
connect_channel_id: channelId,
|
|
4251
|
+
created_at: ""
|
|
4252
|
+
});
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
};
|
|
3593
4256
|
|
|
3594
4257
|
// ../connect-sdk-ts/dist/receive/inbox-cipher.js
|
|
3595
4258
|
import { hkdf } from "@noble/hashes/hkdf";
|
|
@@ -3639,7 +4302,7 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
|
|
|
3639
4302
|
|
|
3640
4303
|
// ../connect-mls-store/dist/sqlite-store.js
|
|
3641
4304
|
import { DatabaseSync } from "node:sqlite";
|
|
3642
|
-
import { MLSGroupManager as
|
|
4305
|
+
import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
|
|
3643
4306
|
|
|
3644
4307
|
// ../connect-mls-store/dist/schema.js
|
|
3645
4308
|
var SCHEMA_VERSION = 6;
|
|
@@ -3755,7 +4418,7 @@ var MlsStoreSealError = class extends Error {
|
|
|
3755
4418
|
function encodeBundle(bundle) {
|
|
3756
4419
|
const pp = bundle.privatePackage;
|
|
3757
4420
|
const obj = {
|
|
3758
|
-
pub: bytesToHex4(
|
|
4421
|
+
pub: bytesToHex4(MLSGroupManager3.serializeKeyPackage(bundle.publicPackage)),
|
|
3759
4422
|
initPriv: bytesToHex4(pp.initPrivateKey),
|
|
3760
4423
|
hpkePriv: bytesToHex4(pp.hpkePrivateKey),
|
|
3761
4424
|
sigPriv: bytesToHex4(pp.signaturePrivateKey)
|
|
@@ -3765,7 +4428,7 @@ function encodeBundle(bundle) {
|
|
|
3765
4428
|
function decodeBundle(bytes) {
|
|
3766
4429
|
const obj = JSON.parse(new TextDecoder().decode(bytes));
|
|
3767
4430
|
return {
|
|
3768
|
-
publicPackage:
|
|
4431
|
+
publicPackage: MLSGroupManager3.deserializeKeyPackage(hexToBytes3(obj.pub)),
|
|
3769
4432
|
privatePackage: {
|
|
3770
4433
|
initPrivateKey: hexToBytes3(obj.initPriv),
|
|
3771
4434
|
hpkePrivateKey: hexToBytes3(obj.hpkePriv),
|
|
@@ -4269,9 +4932,10 @@ function mimeForName(name) {
|
|
|
4269
4932
|
return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
|
|
4270
4933
|
}
|
|
4271
4934
|
async function prepareAttachment(filePath, caption, channelId, deps) {
|
|
4935
|
+
await deps.ready();
|
|
4272
4936
|
const bytes = await deps.readFile(filePath);
|
|
4273
4937
|
const name = basename(filePath);
|
|
4274
|
-
const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
|
|
4938
|
+
const { encryptedData, fileKey, fileNonce, digest } = await deps.encryptFile(bytes);
|
|
4275
4939
|
const blobId = await deps.uploadBlob(encryptedData, channelId);
|
|
4276
4940
|
const body = {
|
|
4277
4941
|
v: 1,
|
|
@@ -4288,12 +4952,13 @@ async function prepareAttachment(filePath, caption, channelId, deps) {
|
|
|
4288
4952
|
return body;
|
|
4289
4953
|
}
|
|
4290
4954
|
async function materializeAttachment(body, ctx, deps) {
|
|
4955
|
+
await deps.ready();
|
|
4291
4956
|
const ciphertext = await deps.downloadBlob(body.blobId);
|
|
4292
|
-
const sha2566 = deps.computeFileDigest(ciphertext);
|
|
4957
|
+
const sha2566 = await deps.computeFileDigest(ciphertext);
|
|
4293
4958
|
if (sha2566 !== body.digest) {
|
|
4294
4959
|
throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
|
|
4295
4960
|
}
|
|
4296
|
-
const plain = deps.decryptFile(
|
|
4961
|
+
const plain = await deps.decryptFile(
|
|
4297
4962
|
ciphertext,
|
|
4298
4963
|
deps.base64ToBytes(body.fileKey),
|
|
4299
4964
|
deps.base64ToBytes(body.fileNonce)
|
|
@@ -4306,6 +4971,16 @@ async function materializeAttachment(body, ctx, deps) {
|
|
|
4306
4971
|
return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2566 };
|
|
4307
4972
|
}
|
|
4308
4973
|
|
|
4974
|
+
// src/sodium-ready.ts
|
|
4975
|
+
import { generateEphemeralKeypair } from "@agentvault/crypto";
|
|
4976
|
+
var warming = null;
|
|
4977
|
+
function ensureSodiumReady() {
|
|
4978
|
+
return warming ??= generateEphemeralKeypair().then(() => void 0).catch((e) => {
|
|
4979
|
+
warming = null;
|
|
4980
|
+
throw e;
|
|
4981
|
+
});
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4309
4984
|
// src/memory/hermes-session-map.ts
|
|
4310
4985
|
import { readFileSync } from "node:fs";
|
|
4311
4986
|
import { homedir } from "node:os";
|
|
@@ -5203,6 +5878,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5203
5878
|
}
|
|
5204
5879
|
async start() {
|
|
5205
5880
|
this.startedAt = Date.now();
|
|
5881
|
+
try {
|
|
5882
|
+
await this.ingestOnce();
|
|
5883
|
+
} catch {
|
|
5884
|
+
}
|
|
5206
5885
|
await this.provider.publishKeyPackage();
|
|
5207
5886
|
this.startReceivePump();
|
|
5208
5887
|
if (this.selfHealSweepEnabled) {
|
|
@@ -5406,6 +6085,50 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5406
6085
|
* DEVICE never held a leaf can 403 on the signal call. That's harmless and
|
|
5407
6086
|
* must not abort the sweep for every other channel.
|
|
5408
6087
|
*/
|
|
6088
|
+
/**
|
|
6089
|
+
* One best-effort device-level KeyPackage rotation attempt.
|
|
6090
|
+
*
|
|
6091
|
+
* `publishKeyPackage` is a cheap no-op skip unless the SERVER says the held
|
|
6092
|
+
* KeyPackage is expired, so calling it every cycle costs one read and rotates at
|
|
6093
|
+
* most once per TTL. The catch logs once and continues: it MUST NOT rethrow, MUST
|
|
6094
|
+
* NOT abort the sweep, and MUST NOT retry inside the cycle. A KeyPackage that
|
|
6095
|
+
* cannot be rotated this cycle is retried on the next interval, and the sweep's
|
|
6096
|
+
* per-channel repair work is independent of it.
|
|
6097
|
+
*/
|
|
6098
|
+
/**
|
|
6099
|
+
* Tri-state: "is this device STILL serving a server-expired KeyPackage at the end
|
|
6100
|
+
* of the cycle that just tried to fix it?"
|
|
6101
|
+
*
|
|
6102
|
+
* `false` is the only healthy value, and it requires a POSITIVE, current proof of
|
|
6103
|
+
* non-expiry. `null` is unknown and reads exactly like `true` in `degraded`, so a
|
|
6104
|
+
* daemon that cannot prove itself un-expired stops reporting healthy. That is the
|
|
6105
|
+
* OPPOSITE direction to the rotation guard, which never rotates on unknown.
|
|
6106
|
+
* Conflating the two directions recreates the lie this change removes.
|
|
6107
|
+
*
|
|
6108
|
+
* Initialised `null` and written only by `selfHealSweep`, so a sweep-off daemon
|
|
6109
|
+
* reads `null` and therefore degraded.
|
|
6110
|
+
*/
|
|
6111
|
+
keypackageExpired = null;
|
|
6112
|
+
/** Resolve the flag from the SAME two inputs the guard uses, with every unknown
|
|
6113
|
+
* mapped to `null`. Never throws: a failure to measure is itself unknown. */
|
|
6114
|
+
async resolveKeypackageExpired() {
|
|
6115
|
+
try {
|
|
6116
|
+
const kpId = await this.provider.getLastPublishedKpId();
|
|
6117
|
+
if (kpId === null || kpId === "") return null;
|
|
6118
|
+
const exp = await this.backend.fetchKeyPackageExpiry(kpId);
|
|
6119
|
+
if (exp === null) return null;
|
|
6120
|
+
return isServerExpired(exp, Date.now());
|
|
6121
|
+
} catch {
|
|
6122
|
+
return null;
|
|
6123
|
+
}
|
|
6124
|
+
}
|
|
6125
|
+
async rotateDeviceKeyPackage() {
|
|
6126
|
+
try {
|
|
6127
|
+
await this.provider.publishKeyPackage();
|
|
6128
|
+
} catch (e) {
|
|
6129
|
+
console.error("[bridge] selfHealSweep: device KeyPackage rotation failed (continuing):", e);
|
|
6130
|
+
}
|
|
6131
|
+
}
|
|
5409
6132
|
async selfHealSweep() {
|
|
5410
6133
|
if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
|
|
5411
6134
|
this.providerCapabilityMissing = true;
|
|
@@ -5414,7 +6137,16 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5414
6137
|
);
|
|
5415
6138
|
return;
|
|
5416
6139
|
}
|
|
6140
|
+
if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
|
|
6141
|
+
this.providerCapabilityMissing = true;
|
|
6142
|
+
console.error(
|
|
6143
|
+
"[bridge] selfHealSweep ABORTED: backend is missing fetchKeyPackageExpiry \u2014 the SDK dist is STALE (it predates KeyPackage expiry rotation). Rebuild the SDK dist THEN the bridge dist. KeyPackage rotation and healing are NOT running; observations are reported STALE."
|
|
6144
|
+
);
|
|
6145
|
+
return;
|
|
6146
|
+
}
|
|
5417
6147
|
this.providerCapabilityMissing = false;
|
|
6148
|
+
await this.rotateDeviceKeyPackage();
|
|
6149
|
+
this.keypackageExpired = await this.resolveKeypackageExpired();
|
|
5418
6150
|
const enclaves = await this.enclavesToCover();
|
|
5419
6151
|
this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
|
|
5420
6152
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -5662,12 +6394,24 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5662
6394
|
}
|
|
5663
6395
|
sendDeps() {
|
|
5664
6396
|
return {
|
|
6397
|
+
ready: () => ensureSodiumReady(),
|
|
5665
6398
|
uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
|
|
5666
6399
|
encryptFile,
|
|
5667
6400
|
bytesToBase64: bytesToBase642,
|
|
5668
6401
|
readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
|
|
5669
6402
|
};
|
|
5670
6403
|
}
|
|
6404
|
+
receiveDeps() {
|
|
6405
|
+
return {
|
|
6406
|
+
ready: () => ensureSodiumReady(),
|
|
6407
|
+
downloadBlob: this.downloadBlobImpl,
|
|
6408
|
+
decryptFile,
|
|
6409
|
+
computeFileDigest,
|
|
6410
|
+
base64ToBytes: base64ToBytes2,
|
|
6411
|
+
mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
|
|
6412
|
+
writeFile: (p, data) => fsp.writeFile(p, data)
|
|
6413
|
+
};
|
|
6414
|
+
}
|
|
5671
6415
|
async sendAttachment(channelId, filePath, caption) {
|
|
5672
6416
|
this.enforceRateFloor("attachment", channelId);
|
|
5673
6417
|
const joined = await this.reconcileJoin(channelId);
|
|
@@ -5723,8 +6467,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5723
6467
|
/**
|
|
5724
6468
|
* List channels in one enclave (default: the daemon's own home enclave).
|
|
5725
6469
|
* Task 3: called per-*known* enclave by refreshChannelMeta so a
|
|
5726
|
-
* cross-enclave room's is_dm gets resolved
|
|
5727
|
-
*
|
|
6470
|
+
* cross-enclave room's is_dm gets resolved. ⚠️ The old "same-tenant only; a
|
|
6471
|
+
* cross-tenant enclave 404s under the agent's bearer" caveat here is refuted
|
|
6472
|
+
* — see the CROSS-TENANT LISTING note on enclavesToCover. This route admits
|
|
6473
|
+
* any enclave the caller holds a live membership row in, partner tenants
|
|
6474
|
+
* included.
|
|
5728
6475
|
*/
|
|
5729
6476
|
async listChannels(enclaveId = this.cfg.enclaveId) {
|
|
5730
6477
|
const url = `${this.cfg.apiBase}/api/v1/enclaves/${enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
@@ -5779,12 +6526,16 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5779
6526
|
* room is never listed under the home-enclave-only call, so its is_dm never
|
|
5780
6527
|
* resolves and the @mention filter fails open for that room.
|
|
5781
6528
|
*
|
|
5782
|
-
* Each enclave is wrapped in its own try/catch
|
|
5783
|
-
*
|
|
5784
|
-
*
|
|
5785
|
-
*
|
|
5786
|
-
*
|
|
5787
|
-
*
|
|
6529
|
+
* Each enclave is wrapped in its own try/catch so one enclave's listing
|
|
6530
|
+
* failure leaves only that enclave's channels unresolved (fail open) without
|
|
6531
|
+
* aborting the other enclaves in the set.
|
|
6532
|
+
*
|
|
6533
|
+
* ⚠️ The old scope caveat here (audit H2/tenant) said
|
|
6534
|
+
* `list_channels_in_enclave` filters on tenant_id and 404s a cross-TENANT
|
|
6535
|
+
* federated-partner enclave. That is refuted — see the CROSS-TENANT LISTING
|
|
6536
|
+
* note on enclavesToCover. A partner enclave with a live membership row
|
|
6537
|
+
* resolves like any other, so the try/catch now covers transport errors,
|
|
6538
|
+
* 5xx, and genuine non-member 404s only.
|
|
5788
6539
|
*/
|
|
5789
6540
|
async refreshChannelMeta() {
|
|
5790
6541
|
const enclaves = await this.enclavesToCover();
|
|
@@ -5812,12 +6563,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5812
6563
|
* failure right after a restart would read as "never succeeded" and slip
|
|
5813
6564
|
* through the expected-gap branch — weaker than the old abort-on-failure).
|
|
5814
6565
|
* A failure of a member of this set is a REGRESSION and degrades health; a
|
|
5815
|
-
* failure of a never-succeeded enclave
|
|
5816
|
-
*
|
|
5817
|
-
*
|
|
6566
|
+
* failure of a never-succeeded enclave is reported but does not degrade.
|
|
6567
|
+
*
|
|
6568
|
+
* ⚠️ The old justification for that asymmetry named a partner enclave's
|
|
6569
|
+
* "tenant-scoped 404, permanent pending backend authz" as the expected gap.
|
|
6570
|
+
* That premise is refuted — see the CROSS-TENANT LISTING note on
|
|
6571
|
+
* enclavesToCover; a partner enclave with a live membership row lists fine.
|
|
6572
|
+
* The asymmetry is kept on its remaining merit: an enclave this process has
|
|
6573
|
+
* never listed may simply be one this agent holds no live membership in, and
|
|
6574
|
+
* pegging health degraded forever on that is noise, not signal. */
|
|
5818
6575
|
enclaveListEverSucceeded = /* @__PURE__ */ new Set();
|
|
5819
6576
|
/** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
|
|
5820
|
-
* a
|
|
6577
|
+
* a persistently-failing enclave must not log 1440 lines a day. (The old
|
|
6578
|
+
* wording named "a permanently-404ing partner enclave" — refuted, see the
|
|
6579
|
+
* CROSS-TENANT LISTING note on enclavesToCover.) */
|
|
5821
6580
|
enclaveListWarned = /* @__PURE__ */ new Set();
|
|
5822
6581
|
static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
|
|
5823
6582
|
enclaveEnumWarned = false;
|
|
@@ -5845,11 +6604,35 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5845
6604
|
* membership changes are picked up within one TTL, which matches the sweep
|
|
5846
6605
|
* cadence this exists to serve.
|
|
5847
6606
|
*
|
|
5848
|
-
*
|
|
5849
|
-
*
|
|
5850
|
-
*
|
|
5851
|
-
*
|
|
5852
|
-
*
|
|
6607
|
+
* ── CROSS-TENANT LISTING: the canonical statement for this file ──────────
|
|
6608
|
+
*
|
|
6609
|
+
* ⚠️ CORRECTED 2026-08-21 (#280 spec F6). This block, and five other comments
|
|
6610
|
+
* in this file, used to state that `list_channels_in_enclave` is tenant-
|
|
6611
|
+
* scoped and 404s a cross-tenant partner enclave under this agent's bearer,
|
|
6612
|
+
* leaving a partner enclave in this set with no channels. That has been FALSE
|
|
6613
|
+
* since #187 (`d7cc685a`, 2026-07-31). Verified by opening the code:
|
|
6614
|
+
*
|
|
6615
|
+
* - `list_channels_in_enclave` calls `_resolve_enclave_membership(...,
|
|
6616
|
+
* host_only=False)` (backend/app/api/v1/connect_channels.py:897-900).
|
|
6617
|
+
* - `host_only=False` OMITS the `ConnectEnclave.tenant_id == auth.tenant_id`
|
|
6618
|
+
* predicate and runs the membership query under `bypass_rls`, so the
|
|
6619
|
+
* host-owned enclave row is visible to a partner session (same file
|
|
6620
|
+
* :519-520, :531-543). The gate is membership, not tenancy.
|
|
6621
|
+
* - The channel query it then runs (same file :902-919) reads
|
|
6622
|
+
* `connect_channels` joined to `connect_channel_members`, and BOTH tables
|
|
6623
|
+
* are among the eight the schema deliberately leaves without RLS for
|
|
6624
|
+
* having no `tenant_id` column (alembic 0001_connect_initial_schema.py:
|
|
6625
|
+
* 750-755, :778-780). No policy filters those rows by tenant.
|
|
6626
|
+
*
|
|
6627
|
+
* So a partner-tenant agent holding a live `ConnectEnclaveMember` row
|
|
6628
|
+
* (`removed_at IS NULL`) gets 200 with that enclave's real channels. There is
|
|
6629
|
+
* no partner-enclave listing gap left to close, and no backend authz work
|
|
6630
|
+
* pending for it.
|
|
6631
|
+
*
|
|
6632
|
+
* What DOES still reach a per-enclave listing failure: a transport error, a
|
|
6633
|
+
* 5xx, or a genuine 404 for an enclave this agent holds no live membership
|
|
6634
|
+
* in. Those are real outages, so the skip-one, never-abort-all rule stands on
|
|
6635
|
+
* its own merit — it just no longer has a standing partner-enclave case.
|
|
5853
6636
|
*/
|
|
5854
6637
|
async enclavesToCover() {
|
|
5855
6638
|
const now = Date.now();
|
|
@@ -6305,14 +7088,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6305
7088
|
const meta = await materializeAttachment(
|
|
6306
7089
|
parsed,
|
|
6307
7090
|
{ attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
|
|
6308
|
-
|
|
6309
|
-
downloadBlob: this.downloadBlobImpl,
|
|
6310
|
-
decryptFile,
|
|
6311
|
-
computeFileDigest,
|
|
6312
|
-
base64ToBytes: base64ToBytes2,
|
|
6313
|
-
mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
|
|
6314
|
-
writeFile: (p, data) => fsp.writeFile(p, data)
|
|
6315
|
-
}
|
|
7091
|
+
this.receiveDeps()
|
|
6316
7092
|
);
|
|
6317
7093
|
this.buffer.push({
|
|
6318
7094
|
channel_id: m.channelId,
|
|
@@ -6495,8 +7271,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6495
7271
|
sweep_enabled: this.selfHealSweepEnabled,
|
|
6496
7272
|
observations_stale: observationsStale,
|
|
6497
7273
|
diverged_channels: divergedChannels,
|
|
7274
|
+
keypackage_expired: this.keypackageExpired,
|
|
6498
7275
|
// A2: the truthful roll-up gates read instead of `status` (D-3).
|
|
6499
|
-
|
|
7276
|
+
// `!== false` and not `=== true`: unknown must never read healthy.
|
|
7277
|
+
degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost || this.keypackageExpired !== false,
|
|
6500
7278
|
channels: liveChannels,
|
|
6501
7279
|
sweep_enclaves_failed: [...this.sweepEnclavesFailed],
|
|
6502
7280
|
identity_id: this.cfg.identityId,
|
|
@@ -7256,6 +8034,7 @@ export {
|
|
|
7256
8034
|
attachAgentWebSocket,
|
|
7257
8035
|
capabilities,
|
|
7258
8036
|
createHttpServer,
|
|
8037
|
+
ensureSodiumReady,
|
|
7259
8038
|
errorBody,
|
|
7260
8039
|
httpStatus,
|
|
7261
8040
|
isLoopback,
|