@securecoms/agent-bridge 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +132 -26
- package/dist/bridge-agent.d.ts +16 -0
- package/dist/index.js +132 -26
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1371,6 +1371,24 @@ var MlsOperationQueue = class {
|
|
|
1371
1371
|
}
|
|
1372
1372
|
};
|
|
1373
1373
|
|
|
1374
|
+
// ../connect-sdk-ts/dist/groupkey/receive-classifier.js
|
|
1375
|
+
function classifyFailedRow(i) {
|
|
1376
|
+
if (i.messageType === "welcome")
|
|
1377
|
+
return "defer";
|
|
1378
|
+
if (i.messageType !== "commit" && i.messageType !== "application")
|
|
1379
|
+
return "defer";
|
|
1380
|
+
if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
|
|
1381
|
+
const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
|
|
1382
|
+
if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
|
|
1383
|
+
return "defer";
|
|
1384
|
+
const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
|
|
1385
|
+
return dead ? "permanentlySkipped" : "defer";
|
|
1386
|
+
}
|
|
1387
|
+
if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
|
|
1388
|
+
return "retiredDrained";
|
|
1389
|
+
return "defer";
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1374
1392
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1375
1393
|
var LABEL = "agentvault-connect-v1";
|
|
1376
1394
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
@@ -1487,6 +1505,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1487
1505
|
* the past-epoch {@link permanentlySkipped} drain. Read via
|
|
1488
1506
|
* {@link quarantineDrainedCount}. */
|
|
1489
1507
|
quarantineDrained = 0;
|
|
1508
|
+
/** Cumulative count of rows ack-drained because their group_id is RETIRED for
|
|
1509
|
+
* the channel (superseded fork-loser traffic). Distinct from the same-group
|
|
1510
|
+
* past-epoch {@link permanentlySkipped} drain. Read via
|
|
1511
|
+
* {@link retiredDrainedCount}. */
|
|
1512
|
+
retiredDrained = 0;
|
|
1490
1513
|
constructor(o) {
|
|
1491
1514
|
this.o = o;
|
|
1492
1515
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
@@ -1644,6 +1667,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1644
1667
|
quarantineDrainedCount() {
|
|
1645
1668
|
return this.quarantineDrained;
|
|
1646
1669
|
}
|
|
1670
|
+
/** See {@link retiredDrained}. */
|
|
1671
|
+
retiredDrainedCount() {
|
|
1672
|
+
return this.retiredDrained;
|
|
1673
|
+
}
|
|
1647
1674
|
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1648
1675
|
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1649
1676
|
* not enough on its own, because a warm manager keeps decrypting until the
|
|
@@ -1712,6 +1739,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1712
1739
|
if (cached)
|
|
1713
1740
|
return cached;
|
|
1714
1741
|
const saved = await this.o.store.load(channelId);
|
|
1742
|
+
const again = this.managers.get(channelId);
|
|
1743
|
+
if (again)
|
|
1744
|
+
return again;
|
|
1715
1745
|
if (!saved)
|
|
1716
1746
|
throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
|
|
1717
1747
|
const mgr = new MLSGroupManager(saved.ciphersuite);
|
|
@@ -2271,9 +2301,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2271
2301
|
* should delegate to it.
|
|
2272
2302
|
*
|
|
2273
2303
|
* Returns null rather than throwing so every caller must decide explicitly what
|
|
2274
|
-
* an unknown own-leaf means.
|
|
2275
|
-
*
|
|
2276
|
-
*
|
|
2304
|
+
* an unknown own-leaf means.
|
|
2305
|
+
*
|
|
2306
|
+
* ⚠️ The one caller today does NOT fail closed on null. In
|
|
2307
|
+
* {@link removeDeviceFromLiveGroupUnlocked} the branch is
|
|
2308
|
+
* `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
|
|
2309
|
+
* identities at all, so a null return sends an OWN-identity removal down the
|
|
2310
|
+
* positional `leaves[pos]` path, which is exactly the add-order guess the
|
|
2311
|
+
* self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
|
|
2312
|
+
* permutes that mapping after any Remove). Null is therefore currently
|
|
2313
|
+
* indistinguishable from "foreign identity", and only the cardinality guard
|
|
2314
|
+
* stands between it and evicting the wrong device of the same human.
|
|
2315
|
+
* Correcting that is Phase 1b, not this comment.
|
|
2277
2316
|
*/
|
|
2278
2317
|
ownLeafIndex(mgr) {
|
|
2279
2318
|
const idx = mgr.state?.privatePath?.leafIndex;
|
|
@@ -2590,13 +2629,27 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2590
2629
|
}
|
|
2591
2630
|
}
|
|
2592
2631
|
/**
|
|
2593
|
-
* Return the number of leaves the given identity holds in
|
|
2594
|
-
* `channelId`.
|
|
2632
|
+
* Return the number of leaves the given identity holds in THIS DEVICE'S view
|
|
2633
|
+
* of the ratchet tree for `channelId`. Reads the cached manager, else loads
|
|
2634
|
+
* one from the store; it makes no backend call, so the answer is only as
|
|
2635
|
+
* current as the commits this device has already processed. A device that is
|
|
2636
|
+
* behind, forked, or holding a superseded group answers from that state, not
|
|
2637
|
+
* from the server's.
|
|
2638
|
+
*
|
|
2639
|
+
* ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
|
|
2640
|
+
* all — it does not report 0 for "we have not joined yet". Callers that may
|
|
2641
|
+
* run before {@link ensureSession} must handle the throw; see
|
|
2642
|
+
* {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
|
|
2595
2643
|
*
|
|
2596
2644
|
* Primarily used by the gate (S20) to assert that a peer-committer 409-retry
|
|
2597
|
-
* does NOT create a duplicate leaf for an already-added device.
|
|
2598
|
-
*
|
|
2599
|
-
*
|
|
2645
|
+
* does NOT create a duplicate leaf for an already-added device. In THAT
|
|
2646
|
+
* scenario each identity owns exactly one device, so 1 is healthy and > 1
|
|
2647
|
+
* means the S3 guard failed to suppress the re-Add. That reading does NOT
|
|
2648
|
+
* generalise: leaf credentials encode the IDENTITY, not the device, so a
|
|
2649
|
+
* multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
|
|
2650
|
+
* premise {@link removeDeviceFromLiveGroupUnlocked}'s
|
|
2651
|
+
* `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
|
|
2652
|
+
* count against the identity's expected device count, not against 1.
|
|
2600
2653
|
*/
|
|
2601
2654
|
async leafCountForIdentity(channelId, identityId) {
|
|
2602
2655
|
const mgr = await this.manager(channelId);
|
|
@@ -2757,16 +2810,45 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2757
2810
|
}
|
|
2758
2811
|
async receiveOnce(deviceId) {
|
|
2759
2812
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2760
|
-
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
|
|
2761
|
-
console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
|
|
2762
|
-
}
|
|
2763
2813
|
const appEntries = [];
|
|
2764
2814
|
const handshakeAcked = [];
|
|
2765
2815
|
const supersededChannelIds = [];
|
|
2766
|
-
const
|
|
2767
|
-
rows.
|
|
2768
|
-
|
|
2816
|
+
const welcomes = rows.filter((r) => r.message_type === "welcome");
|
|
2817
|
+
const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
|
|
2818
|
+
const ordered = [...welcomes, ...nonWelcomes];
|
|
2819
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
|
|
2820
|
+
console.warn(`[trace] receiveOnce pulled ${ordered.length}: ` + ordered.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
|
|
2821
|
+
}
|
|
2822
|
+
const classCtx = /* @__PURE__ */ new Map();
|
|
2823
|
+
const classCtxFor = async (cid) => {
|
|
2824
|
+
let ctx = classCtx.get(cid);
|
|
2825
|
+
if (!ctx) {
|
|
2826
|
+
let heldGroupId = null;
|
|
2827
|
+
let snapshotEpoch;
|
|
2828
|
+
let retired = null;
|
|
2829
|
+
try {
|
|
2830
|
+
const mgr = await this.manager(cid);
|
|
2831
|
+
heldGroupId = new TextDecoder().decode(mgr.groupId);
|
|
2832
|
+
snapshotEpoch = Number(mgr.epoch);
|
|
2833
|
+
} catch {
|
|
2834
|
+
heldGroupId = null;
|
|
2835
|
+
snapshotEpoch = void 0;
|
|
2836
|
+
}
|
|
2837
|
+
try {
|
|
2838
|
+
retired = await this.o.store.loadRetiredGroupIds(cid);
|
|
2839
|
+
} catch {
|
|
2840
|
+
retired = null;
|
|
2841
|
+
}
|
|
2842
|
+
ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
|
|
2843
|
+
classCtx.set(cid, ctx);
|
|
2844
|
+
}
|
|
2845
|
+
return ctx;
|
|
2846
|
+
};
|
|
2847
|
+
for (const row of ordered) {
|
|
2769
2848
|
try {
|
|
2849
|
+
if (row.message_type !== "welcome" && row.connect_channel_id) {
|
|
2850
|
+
await classCtxFor(row.connect_channel_id);
|
|
2851
|
+
}
|
|
2770
2852
|
if (row.message_type === "welcome") {
|
|
2771
2853
|
const wcid = row.connect_channel_id;
|
|
2772
2854
|
if (wcid) {
|
|
@@ -2803,15 +2885,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2803
2885
|
const cid = row.connect_channel_id;
|
|
2804
2886
|
if (!cid)
|
|
2805
2887
|
continue;
|
|
2888
|
+
let advancedEpoch;
|
|
2806
2889
|
await this.locks.run(cid, async () => {
|
|
2807
2890
|
const mgr = await this.manager(cid);
|
|
2808
2891
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
2809
2892
|
await this.o.store.save(cid, mgr.exportState());
|
|
2893
|
+
advancedEpoch = Number(mgr.epoch);
|
|
2810
2894
|
});
|
|
2811
2895
|
this.recordDecryptSuccess(cid);
|
|
2896
|
+
if (advancedEpoch !== void 0) {
|
|
2897
|
+
const ctx = classCtx.get(cid);
|
|
2898
|
+
if (ctx)
|
|
2899
|
+
ctx.liveCommitEpoch = advancedEpoch;
|
|
2900
|
+
}
|
|
2812
2901
|
handshakeAcked.push(row.queue_id);
|
|
2813
2902
|
} else {
|
|
2814
2903
|
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
2904
|
+
if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
|
|
2905
|
+
throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
|
|
2906
|
+
}
|
|
2815
2907
|
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
2816
2908
|
const aad = await buildAssociatedData({
|
|
2817
2909
|
message_id: envelope.message_id,
|
|
@@ -2839,18 +2931,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2839
2931
|
this.recordDecryptError(row, err);
|
|
2840
2932
|
const cid = row.connect_channel_id;
|
|
2841
2933
|
let drained = false;
|
|
2842
|
-
if (cid &&
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2934
|
+
if (cid && row.message_type !== "welcome") {
|
|
2935
|
+
const ctx = classCtx.get(cid);
|
|
2936
|
+
const verdict = classifyFailedRow({
|
|
2937
|
+
messageType: row.message_type,
|
|
2938
|
+
rowGroupId: row.group_id,
|
|
2939
|
+
rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
|
|
2940
|
+
heldGroupId: ctx?.heldGroupId ?? null,
|
|
2941
|
+
retiredGroupIds: ctx?.retired ?? null,
|
|
2942
|
+
snapshotEpoch: ctx?.snapshotEpoch,
|
|
2943
|
+
liveCommitEpoch: ctx?.liveCommitEpoch
|
|
2944
|
+
});
|
|
2945
|
+
if (verdict === "permanentlySkipped") {
|
|
2851
2946
|
handshakeAcked.push(row.queue_id);
|
|
2852
2947
|
this.permanentlySkipped++;
|
|
2853
2948
|
drained = true;
|
|
2949
|
+
} else if (verdict === "retiredDrained") {
|
|
2950
|
+
handshakeAcked.push(row.queue_id);
|
|
2951
|
+
this.retiredDrained++;
|
|
2952
|
+
drained = true;
|
|
2854
2953
|
}
|
|
2855
2954
|
}
|
|
2856
2955
|
if (!drained && cid) {
|
|
@@ -2858,8 +2957,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2858
2957
|
if (e) {
|
|
2859
2958
|
e.wedgedRun += 1;
|
|
2860
2959
|
if (e.wedgedRun >= this.quarantineThreshold) {
|
|
2861
|
-
|
|
2862
|
-
|
|
2960
|
+
if (row.message_type !== "welcome") {
|
|
2961
|
+
handshakeAcked.push(row.queue_id);
|
|
2962
|
+
this.quarantineDrained++;
|
|
2963
|
+
}
|
|
2863
2964
|
this.quarantineChannel(cid);
|
|
2864
2965
|
}
|
|
2865
2966
|
}
|
|
@@ -6549,6 +6650,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6549
6650
|
filtered_room: this.filteredRoomMessages,
|
|
6550
6651
|
rate_limited: this.rateLimitedSends,
|
|
6551
6652
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
6653
|
+
quarantine_drained: this.provider.quarantineDrainedCount(),
|
|
6654
|
+
retired_drained: this.provider.retiredDrainedCount(),
|
|
6552
6655
|
failed_attachments: this.failedAttachments,
|
|
6553
6656
|
delivery_pings: this.pingsReceived,
|
|
6554
6657
|
agent_sessions: this.authSessions,
|
|
@@ -6594,8 +6697,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6594
6697
|
filteredRoomMessages: this.filteredRoomMessages,
|
|
6595
6698
|
rateLimitedSends: this.rateLimitedSends,
|
|
6596
6699
|
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
6700
|
+
quarantineDrained: this.provider.quarantineDrainedCount(),
|
|
6701
|
+
retiredDrained: this.provider.retiredDrainedCount(),
|
|
6597
6702
|
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
6598
|
-
failedAttachments: this.failedAttachments
|
|
6703
|
+
failedAttachments: this.failedAttachments,
|
|
6704
|
+
build: BRIDGE_VERSION
|
|
6599
6705
|
};
|
|
6600
6706
|
}
|
|
6601
6707
|
async close() {
|
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -851,6 +851,12 @@ export declare class BridgeAgent {
|
|
|
851
851
|
rate_limited: number;
|
|
852
852
|
/** Cumulative inbound rows acked-and-dropped because they were provably past-epoch undecryptable (forward-secrecy secret gone). */
|
|
853
853
|
permanently_skipped: number;
|
|
854
|
+
/** SDK-6: cumulative rows ack-drained via QUARANTINE, distinct from the
|
|
855
|
+
* same-group past-epoch permanently_skipped drain. */
|
|
856
|
+
quarantine_drained: number;
|
|
857
|
+
/** SDK-6: cumulative rows ack-drained because their group_id is RETIRED for
|
|
858
|
+
* the channel (superseded fork-loser traffic). */
|
|
859
|
+
retired_drained: number;
|
|
854
860
|
/** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
|
|
855
861
|
failed_attachments: number;
|
|
856
862
|
/** 2026-07-03 latency triage: delivery pings received on the WS stream.
|
|
@@ -940,9 +946,19 @@ export declare class BridgeAgent {
|
|
|
940
946
|
filteredRoomMessages: number;
|
|
941
947
|
rateLimitedSends: number;
|
|
942
948
|
permanentlySkipped: number;
|
|
949
|
+
/** SDK-6: cumulative rows ack-drained via QUARANTINE, distinct from the
|
|
950
|
+
* same-group past-epoch permanentlySkipped drain. */
|
|
951
|
+
quarantineDrained: number;
|
|
952
|
+
/** SDK-6: cumulative rows ack-drained because their group_id is RETIRED for
|
|
953
|
+
* the channel (superseded fork-loser traffic). */
|
|
954
|
+
retiredDrained: number;
|
|
943
955
|
welcomeRequestsSent: number;
|
|
944
956
|
/** Track 5: cumulative artifact_share failures (download/decrypt/digest). */
|
|
945
957
|
failedAttachments: number;
|
|
958
|
+
/** SDK-6: bridge build id, so a /v1/metrics reading captured across a daemon
|
|
959
|
+
* restart can be attributed to a version — counters are process-lifetime
|
|
960
|
+
* and zero on restart (spec §5.5). */
|
|
961
|
+
build: string;
|
|
946
962
|
};
|
|
947
963
|
close(): Promise<void>;
|
|
948
964
|
}
|
package/dist/index.js
CHANGED
|
@@ -1268,6 +1268,24 @@ var MlsOperationQueue = class {
|
|
|
1268
1268
|
}
|
|
1269
1269
|
};
|
|
1270
1270
|
|
|
1271
|
+
// ../connect-sdk-ts/dist/groupkey/receive-classifier.js
|
|
1272
|
+
function classifyFailedRow(i) {
|
|
1273
|
+
if (i.messageType === "welcome")
|
|
1274
|
+
return "defer";
|
|
1275
|
+
if (i.messageType !== "commit" && i.messageType !== "application")
|
|
1276
|
+
return "defer";
|
|
1277
|
+
if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
|
|
1278
|
+
const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
|
|
1279
|
+
if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
|
|
1280
|
+
return "defer";
|
|
1281
|
+
const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
|
|
1282
|
+
return dead ? "permanentlySkipped" : "defer";
|
|
1283
|
+
}
|
|
1284
|
+
if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
|
|
1285
|
+
return "retiredDrained";
|
|
1286
|
+
return "defer";
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1271
1289
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1272
1290
|
var LABEL = "agentvault-connect-v1";
|
|
1273
1291
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
@@ -1384,6 +1402,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1384
1402
|
* the past-epoch {@link permanentlySkipped} drain. Read via
|
|
1385
1403
|
* {@link quarantineDrainedCount}. */
|
|
1386
1404
|
quarantineDrained = 0;
|
|
1405
|
+
/** Cumulative count of rows ack-drained because their group_id is RETIRED for
|
|
1406
|
+
* the channel (superseded fork-loser traffic). Distinct from the same-group
|
|
1407
|
+
* past-epoch {@link permanentlySkipped} drain. Read via
|
|
1408
|
+
* {@link retiredDrainedCount}. */
|
|
1409
|
+
retiredDrained = 0;
|
|
1387
1410
|
constructor(o) {
|
|
1388
1411
|
this.o = o;
|
|
1389
1412
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
@@ -1541,6 +1564,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1541
1564
|
quarantineDrainedCount() {
|
|
1542
1565
|
return this.quarantineDrained;
|
|
1543
1566
|
}
|
|
1567
|
+
/** See {@link retiredDrained}. */
|
|
1568
|
+
retiredDrainedCount() {
|
|
1569
|
+
return this.retiredDrained;
|
|
1570
|
+
}
|
|
1544
1571
|
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1545
1572
|
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1546
1573
|
* not enough on its own, because a warm manager keeps decrypting until the
|
|
@@ -1609,6 +1636,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
1609
1636
|
if (cached)
|
|
1610
1637
|
return cached;
|
|
1611
1638
|
const saved = await this.o.store.load(channelId);
|
|
1639
|
+
const again = this.managers.get(channelId);
|
|
1640
|
+
if (again)
|
|
1641
|
+
return again;
|
|
1612
1642
|
if (!saved)
|
|
1613
1643
|
throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
|
|
1614
1644
|
const mgr = new MLSGroupManager(saved.ciphersuite);
|
|
@@ -2168,9 +2198,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2168
2198
|
* should delegate to it.
|
|
2169
2199
|
*
|
|
2170
2200
|
* Returns null rather than throwing so every caller must decide explicitly what
|
|
2171
|
-
* an unknown own-leaf means.
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2201
|
+
* an unknown own-leaf means.
|
|
2202
|
+
*
|
|
2203
|
+
* ⚠️ The one caller today does NOT fail closed on null. In
|
|
2204
|
+
* {@link removeDeviceFromLiveGroupUnlocked} the branch is
|
|
2205
|
+
* `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
|
|
2206
|
+
* identities at all, so a null return sends an OWN-identity removal down the
|
|
2207
|
+
* positional `leaves[pos]` path, which is exactly the add-order guess the
|
|
2208
|
+
* self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
|
|
2209
|
+
* permutes that mapping after any Remove). Null is therefore currently
|
|
2210
|
+
* indistinguishable from "foreign identity", and only the cardinality guard
|
|
2211
|
+
* stands between it and evicting the wrong device of the same human.
|
|
2212
|
+
* Correcting that is Phase 1b, not this comment.
|
|
2174
2213
|
*/
|
|
2175
2214
|
ownLeafIndex(mgr) {
|
|
2176
2215
|
const idx = mgr.state?.privatePath?.leafIndex;
|
|
@@ -2487,13 +2526,27 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2487
2526
|
}
|
|
2488
2527
|
}
|
|
2489
2528
|
/**
|
|
2490
|
-
* Return the number of leaves the given identity holds in
|
|
2491
|
-
* `channelId`.
|
|
2529
|
+
* Return the number of leaves the given identity holds in THIS DEVICE'S view
|
|
2530
|
+
* of the ratchet tree for `channelId`. Reads the cached manager, else loads
|
|
2531
|
+
* one from the store; it makes no backend call, so the answer is only as
|
|
2532
|
+
* current as the commits this device has already processed. A device that is
|
|
2533
|
+
* behind, forked, or holding a superseded group answers from that state, not
|
|
2534
|
+
* from the server's.
|
|
2535
|
+
*
|
|
2536
|
+
* ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
|
|
2537
|
+
* all — it does not report 0 for "we have not joined yet". Callers that may
|
|
2538
|
+
* run before {@link ensureSession} must handle the throw; see
|
|
2539
|
+
* {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
|
|
2492
2540
|
*
|
|
2493
2541
|
* Primarily used by the gate (S20) to assert that a peer-committer 409-retry
|
|
2494
|
-
* does NOT create a duplicate leaf for an already-added device.
|
|
2495
|
-
*
|
|
2496
|
-
*
|
|
2542
|
+
* does NOT create a duplicate leaf for an already-added device. In THAT
|
|
2543
|
+
* scenario each identity owns exactly one device, so 1 is healthy and > 1
|
|
2544
|
+
* means the S3 guard failed to suppress the re-Add. That reading does NOT
|
|
2545
|
+
* generalise: leaf credentials encode the IDENTITY, not the device, so a
|
|
2546
|
+
* multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
|
|
2547
|
+
* premise {@link removeDeviceFromLiveGroupUnlocked}'s
|
|
2548
|
+
* `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
|
|
2549
|
+
* count against the identity's expected device count, not against 1.
|
|
2497
2550
|
*/
|
|
2498
2551
|
async leafCountForIdentity(channelId, identityId) {
|
|
2499
2552
|
const mgr = await this.manager(channelId);
|
|
@@ -2654,16 +2707,45 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2654
2707
|
}
|
|
2655
2708
|
async receiveOnce(deviceId) {
|
|
2656
2709
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2657
|
-
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
|
|
2658
|
-
console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
|
|
2659
|
-
}
|
|
2660
2710
|
const appEntries = [];
|
|
2661
2711
|
const handshakeAcked = [];
|
|
2662
2712
|
const supersededChannelIds = [];
|
|
2663
|
-
const
|
|
2664
|
-
rows.
|
|
2665
|
-
|
|
2713
|
+
const welcomes = rows.filter((r) => r.message_type === "welcome");
|
|
2714
|
+
const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
|
|
2715
|
+
const ordered = [...welcomes, ...nonWelcomes];
|
|
2716
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
|
|
2717
|
+
console.warn(`[trace] receiveOnce pulled ${ordered.length}: ` + ordered.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
|
|
2718
|
+
}
|
|
2719
|
+
const classCtx = /* @__PURE__ */ new Map();
|
|
2720
|
+
const classCtxFor = async (cid) => {
|
|
2721
|
+
let ctx = classCtx.get(cid);
|
|
2722
|
+
if (!ctx) {
|
|
2723
|
+
let heldGroupId = null;
|
|
2724
|
+
let snapshotEpoch;
|
|
2725
|
+
let retired = null;
|
|
2726
|
+
try {
|
|
2727
|
+
const mgr = await this.manager(cid);
|
|
2728
|
+
heldGroupId = new TextDecoder().decode(mgr.groupId);
|
|
2729
|
+
snapshotEpoch = Number(mgr.epoch);
|
|
2730
|
+
} catch {
|
|
2731
|
+
heldGroupId = null;
|
|
2732
|
+
snapshotEpoch = void 0;
|
|
2733
|
+
}
|
|
2734
|
+
try {
|
|
2735
|
+
retired = await this.o.store.loadRetiredGroupIds(cid);
|
|
2736
|
+
} catch {
|
|
2737
|
+
retired = null;
|
|
2738
|
+
}
|
|
2739
|
+
ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
|
|
2740
|
+
classCtx.set(cid, ctx);
|
|
2741
|
+
}
|
|
2742
|
+
return ctx;
|
|
2743
|
+
};
|
|
2744
|
+
for (const row of ordered) {
|
|
2666
2745
|
try {
|
|
2746
|
+
if (row.message_type !== "welcome" && row.connect_channel_id) {
|
|
2747
|
+
await classCtxFor(row.connect_channel_id);
|
|
2748
|
+
}
|
|
2667
2749
|
if (row.message_type === "welcome") {
|
|
2668
2750
|
const wcid = row.connect_channel_id;
|
|
2669
2751
|
if (wcid) {
|
|
@@ -2700,15 +2782,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2700
2782
|
const cid = row.connect_channel_id;
|
|
2701
2783
|
if (!cid)
|
|
2702
2784
|
continue;
|
|
2785
|
+
let advancedEpoch;
|
|
2703
2786
|
await this.locks.run(cid, async () => {
|
|
2704
2787
|
const mgr = await this.manager(cid);
|
|
2705
2788
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
2706
2789
|
await this.o.store.save(cid, mgr.exportState());
|
|
2790
|
+
advancedEpoch = Number(mgr.epoch);
|
|
2707
2791
|
});
|
|
2708
2792
|
this.recordDecryptSuccess(cid);
|
|
2793
|
+
if (advancedEpoch !== void 0) {
|
|
2794
|
+
const ctx = classCtx.get(cid);
|
|
2795
|
+
if (ctx)
|
|
2796
|
+
ctx.liveCommitEpoch = advancedEpoch;
|
|
2797
|
+
}
|
|
2709
2798
|
handshakeAcked.push(row.queue_id);
|
|
2710
2799
|
} else {
|
|
2711
2800
|
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
2801
|
+
if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
|
|
2802
|
+
throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
|
|
2803
|
+
}
|
|
2712
2804
|
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
2713
2805
|
const aad = await buildAssociatedData({
|
|
2714
2806
|
message_id: envelope.message_id,
|
|
@@ -2736,18 +2828,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2736
2828
|
this.recordDecryptError(row, err);
|
|
2737
2829
|
const cid = row.connect_channel_id;
|
|
2738
2830
|
let drained = false;
|
|
2739
|
-
if (cid &&
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2831
|
+
if (cid && row.message_type !== "welcome") {
|
|
2832
|
+
const ctx = classCtx.get(cid);
|
|
2833
|
+
const verdict = classifyFailedRow({
|
|
2834
|
+
messageType: row.message_type,
|
|
2835
|
+
rowGroupId: row.group_id,
|
|
2836
|
+
rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
|
|
2837
|
+
heldGroupId: ctx?.heldGroupId ?? null,
|
|
2838
|
+
retiredGroupIds: ctx?.retired ?? null,
|
|
2839
|
+
snapshotEpoch: ctx?.snapshotEpoch,
|
|
2840
|
+
liveCommitEpoch: ctx?.liveCommitEpoch
|
|
2841
|
+
});
|
|
2842
|
+
if (verdict === "permanentlySkipped") {
|
|
2748
2843
|
handshakeAcked.push(row.queue_id);
|
|
2749
2844
|
this.permanentlySkipped++;
|
|
2750
2845
|
drained = true;
|
|
2846
|
+
} else if (verdict === "retiredDrained") {
|
|
2847
|
+
handshakeAcked.push(row.queue_id);
|
|
2848
|
+
this.retiredDrained++;
|
|
2849
|
+
drained = true;
|
|
2751
2850
|
}
|
|
2752
2851
|
}
|
|
2753
2852
|
if (!drained && cid) {
|
|
@@ -2755,8 +2854,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2755
2854
|
if (e) {
|
|
2756
2855
|
e.wedgedRun += 1;
|
|
2757
2856
|
if (e.wedgedRun >= this.quarantineThreshold) {
|
|
2758
|
-
|
|
2759
|
-
|
|
2857
|
+
if (row.message_type !== "welcome") {
|
|
2858
|
+
handshakeAcked.push(row.queue_id);
|
|
2859
|
+
this.quarantineDrained++;
|
|
2860
|
+
}
|
|
2760
2861
|
this.quarantineChannel(cid);
|
|
2761
2862
|
}
|
|
2762
2863
|
}
|
|
@@ -6405,6 +6506,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6405
6506
|
filtered_room: this.filteredRoomMessages,
|
|
6406
6507
|
rate_limited: this.rateLimitedSends,
|
|
6407
6508
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
6509
|
+
quarantine_drained: this.provider.quarantineDrainedCount(),
|
|
6510
|
+
retired_drained: this.provider.retiredDrainedCount(),
|
|
6408
6511
|
failed_attachments: this.failedAttachments,
|
|
6409
6512
|
delivery_pings: this.pingsReceived,
|
|
6410
6513
|
agent_sessions: this.authSessions,
|
|
@@ -6450,8 +6553,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6450
6553
|
filteredRoomMessages: this.filteredRoomMessages,
|
|
6451
6554
|
rateLimitedSends: this.rateLimitedSends,
|
|
6452
6555
|
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
6556
|
+
quarantineDrained: this.provider.quarantineDrainedCount(),
|
|
6557
|
+
retiredDrained: this.provider.retiredDrainedCount(),
|
|
6453
6558
|
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
6454
|
-
failedAttachments: this.failedAttachments
|
|
6559
|
+
failedAttachments: this.failedAttachments,
|
|
6560
|
+
build: BRIDGE_VERSION
|
|
6455
6561
|
};
|
|
6456
6562
|
}
|
|
6457
6563
|
async close() {
|