@kubun/plugin-p2p 0.11.0 → 0.12.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/lib/context/group.js +1073 -746
- package/lib/context/hub.js +2 -1
- package/lib/context/join.js +50 -15
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +234 -1
- package/lib/context/types.d.ts +56 -20
- package/lib/context/types.js +23 -3
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +42 -117
- package/lib/groups/admin-roster.js +53 -168
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast.d.ts +199 -81
- package/lib/groups/broadcast.js +443 -368
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +32 -64
- package/lib/groups/circle-projection.js +95 -181
- package/lib/groups/circle-reducers.d.ts +71 -110
- package/lib/groups/circle-reducers.js +57 -127
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +14 -1
- package/lib/groups/group-anchor.d.ts +18 -57
- package/lib/groups/group-anchor.js +15 -100
- package/lib/groups/group-crypto.d.ts +36 -13
- package/lib/groups/group-crypto.js +124 -50
- package/lib/groups/group-handle-registry.d.ts +106 -36
- package/lib/groups/group-handle-registry.js +351 -61
- package/lib/groups/group-handlers.d.ts +5 -4
- package/lib/groups/group-handlers.js +155 -29
- package/lib/groups/group-health-monitor.d.ts +1 -1
- package/lib/groups/group-health-monitor.js +1 -1
- package/lib/groups/group-mls.d.ts +35 -4
- package/lib/groups/group-mls.js +594 -62
- package/lib/groups/group-peer-manager.d.ts +212 -19
- package/lib/groups/group-peer-manager.js +1061 -69
- package/lib/groups/group-protocols.d.ts +485 -28
- package/lib/groups/group-protocols.js +289 -15
- package/lib/groups/invite-payload.d.ts +48 -0
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +60 -0
- package/lib/groups/join-utils.js +262 -0
- package/lib/groups/ledger-affected-events.d.ts +11 -6
- package/lib/groups/ledger-affected-events.js +15 -26
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +202 -19
- package/lib/groups/manager.js +623 -106
- package/lib/groups/mls-codec.d.ts +27 -6
- package/lib/groups/mls-codec.js +30 -15
- package/lib/groups/mls-encryptor.js +9 -6
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +12 -4
- package/lib/groups/mls-receive-errors.d.ts +0 -2
- package/lib/groups/mls-receive-errors.js +3 -6
- package/lib/groups/mls-state.d.ts +5 -3
- package/lib/groups/mls-state.js +27 -4
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +4 -3
- package/lib/groups/store-received-grant.js +80 -35
- package/lib/groups/store-received-revocation.d.ts +16 -13
- package/lib/groups/store-received-revocation.js +23 -22
- package/lib/hub/http-client.d.ts +10 -27
- package/lib/hub/http-client.js +2 -19
- package/lib/hub/hub-like.d.ts +57 -6
- package/lib/hub/hub-like.js +539 -52
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +51 -94
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +71 -14
- package/lib/hub/wiring.js +80 -23
- package/lib/index.d.ts +48 -13
- package/lib/index.js +257 -86
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +213 -0
- package/lib/protocol.js +268 -0
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +805 -131
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +57 -3
- package/lib/sync/authorize.js +45 -4
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +2 -3
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +55 -39
- package/lib/sync/errors.d.ts +9 -0
- package/lib/sync/errors.js +10 -0
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +13 -19
- package/lib/sync/handlers.js +54 -14
- package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
- package/lib/sync/hub-tunnel-sync-listener.js +126 -37
- package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
- package/lib/sync/hub-tunnel-sync-provider.js +68 -19
- package/lib/sync/merkle-channel.d.ts +9 -1
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/peer-registry.d.ts +14 -10
- package/lib/sync/peer-registry.js +0 -25
- package/lib/sync/sync-client.d.ts +11 -1
- package/lib/sync/sync-client.js +72 -10
- package/lib/sync/sync-manager.d.ts +22 -4
- package/lib/sync/sync-manager.js +39 -10
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +699 -28
- package/lib/types.js +0 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +37 -3
- package/package.json +59 -52
- package/lib/groups/ledger-entry.d.ts +0 -37
- package/lib/groups/ledger-entry.js +0 -56
- package/lib/groups/ledger-fold.d.ts +0 -56
- package/lib/groups/ledger-fold.js +0 -53
- package/lib/hub/did-observing-transport.d.ts +0 -75
- package/lib/hub/did-observing-transport.js +0 -126
- package/lib/hub/errors.d.ts +0 -24
- package/lib/hub/errors.js +0 -33
package/lib/groups/group-mls.js
CHANGED
|
@@ -1,22 +1,99 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
1
2
|
import { HLC } from '@kubun/hlc';
|
|
2
3
|
import { getKubunLogger } from '@kubun/logger';
|
|
3
4
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
4
|
-
import {
|
|
5
|
+
import { CommitRejectedError, createRecoveryRequest, joinGroupExternal, MissingLedgerEntriesError, openSealedGroupInfo, openSealedLedger, readGroupAnchor, readMessageEpoch, sealGroupInfo, sealLedger } from '@kumiai/mls';
|
|
5
6
|
import { fromB64 } from '@sozai/codec';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { toISO } from '../context/types.js';
|
|
8
|
+
import { bootstrapGroupLedger } from './broadcast.js';
|
|
9
|
+
import { readRecoverySecret } from './group-anchor.js';
|
|
10
|
+
import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
|
|
11
|
+
import { entriesRetainedByCommit, foldCommittedLedgerEntries } from './ledger-commit-fold.js';
|
|
12
|
+
import { DecryptError } from './mls-receive-errors.js';
|
|
13
|
+
import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
|
|
14
|
+
/**
|
|
15
|
+
* How long a minted recovery request's private key is retained.
|
|
16
|
+
*
|
|
17
|
+
* Bounds a gather that never completes rather than expressing a security
|
|
18
|
+
* property: the key opens replies to ONE request id, and a reply arriving after
|
|
19
|
+
* this is simply unopenable. Matches `@kumiai/mls-rpc`'s own retention, so a
|
|
20
|
+
* kubun peer and an upstream one give up on the same gather at the same time.
|
|
21
|
+
*/ const RECOVERY_REQUEST_TTL_MS = 120_000;
|
|
8
22
|
/**
|
|
9
23
|
* Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
|
|
10
24
|
* {@link GroupMLS} lifecycle port: apply received Commits to advance the epoch,
|
|
11
|
-
*
|
|
12
|
-
* recovery seed. group-rpc owns the transport +
|
|
13
|
-
* non-rotating handshake topic, run these methods,
|
|
14
|
-
* epoch advances); this adapter owns the MLS state,
|
|
25
|
+
* read the roster, seal this handle's ledger for another member's gather, and
|
|
26
|
+
* expose the epoch-independent recovery seed. group-rpc owns the transport +
|
|
27
|
+
* orchestration (subscribe the non-rotating handshake topic, run these methods,
|
|
28
|
+
* resync the app lane when the epoch advances); this adapter owns the MLS state,
|
|
29
|
+
* storage, and atomicity.
|
|
30
|
+
*
|
|
31
|
+
* The recovery members that throw below do so for reasons that live in KUBUN,
|
|
32
|
+
* not upstream: `@kumiai/mls` ships every primitive they would need. An earlier
|
|
33
|
+
* revision of this file justified the same throws by upstream gaps, and a later
|
|
34
|
+
* upstream release quietly made those justifications false. So a reader deciding
|
|
35
|
+
* whether one of these can now be wired must re-check the KUBUN-side statement
|
|
36
|
+
* in each message — not assume the blocker moved when a dependency did.
|
|
15
37
|
*/ export function createGroupMLS(params) {
|
|
16
|
-
const { registry, stores, hlc, groupID } = params;
|
|
38
|
+
const { registry, stores, identity, hlc, groupID } = params;
|
|
17
39
|
const logger = params.logger ?? getKubunLogger('plugin-p2p:group-mls');
|
|
40
|
+
// The private half of each outstanding recovery request, in process memory.
|
|
41
|
+
//
|
|
42
|
+
// Deliberately NOT a store row. The gather this key serves is a live
|
|
43
|
+
// rendezvous the lane holds open; a restart loses the gather too, so a
|
|
44
|
+
// surviving key would have no reply left to open and would only be a raw
|
|
45
|
+
// private key sitting in SQLite at rest, where kubun's at-rest encryption is
|
|
46
|
+
// not yet wired. Losing it on restart costs a retry and nothing else.
|
|
47
|
+
const pending = new Map();
|
|
48
|
+
// The lane has no release hook: a gather that times out drops its request id
|
|
49
|
+
// without telling this port, so eviction on reply-open alone would leak one
|
|
50
|
+
// key per timed-out gather for the life of the process. Swept on mint, which
|
|
51
|
+
// is the only moment the map grows.
|
|
52
|
+
const sweep = ()=>{
|
|
53
|
+
const cutoff = Date.now() - RECOVERY_REQUEST_TTL_MS;
|
|
54
|
+
for (const [id, held] of pending){
|
|
55
|
+
if (held.mintedAt < cutoff) {
|
|
56
|
+
pending.delete(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
18
60
|
return {
|
|
19
61
|
/**
|
|
62
|
+
* The DIDs this handle's ratchet tree currently holds a leaf for. Read-only.
|
|
63
|
+
*
|
|
64
|
+
* The lane diffs this around {@link GroupMLS.processCommit} to detect a
|
|
65
|
+
* Remove. Compare DIDs rather than count leaves, or a Commit carrying both an
|
|
66
|
+
* Add and a Remove reads as no change. Sourced from the ratchet TREE, not the
|
|
67
|
+
* store's roster projection — that is the authority the lane must agree with.
|
|
68
|
+
*/ async rosterDIDs () {
|
|
69
|
+
return await registry.readHandle(groupID, (handle)=>handle.listMembers().map((member)=>member.id));
|
|
70
|
+
},
|
|
71
|
+
/**
|
|
72
|
+
* Read what a Commit says about itself — its framed epoch, its
|
|
73
|
+
* MLS-authenticated committer, and whether it is an external commit —
|
|
74
|
+
* WITHOUT advancing state. `null` for bytes that are not a Commit.
|
|
75
|
+
*
|
|
76
|
+
* The committer is recovered from the Commit's OWN bytes, never from the
|
|
77
|
+
* frame's transport sender — that is the hub's word.
|
|
78
|
+
*
|
|
79
|
+
* `external` must be passed through even though nothing here reads it: a
|
|
80
|
+
* rejoin moves no DID and no occupied leaf index, so the lane's anchor
|
|
81
|
+
* rotation is the only signal it has, and dropping the flag disables it
|
|
82
|
+
* silently.
|
|
83
|
+
*/ async readCommitHeader (commit) {
|
|
84
|
+
return await registry.readHandle(groupID, async (handle)=>{
|
|
85
|
+
const header = await handle.readCommitHeader(commit);
|
|
86
|
+
if (header == null) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
epoch: Number(header.epoch),
|
|
91
|
+
committerDID: header.committerDID,
|
|
92
|
+
external: header.external
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
/**
|
|
20
97
|
* Apply a received MLS handshake Commit: advance the local ratchet and write
|
|
21
98
|
* the membership delta derived by diffing the roster before and after.
|
|
22
99
|
*
|
|
@@ -27,11 +104,19 @@ import { CommitOutOfOrderError, DecryptError } from './mls-receive-errors.js';
|
|
|
27
104
|
* holds the per-group lock across the whole transaction, so no concurrent
|
|
28
105
|
* same-group op can interleave.
|
|
29
106
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* -
|
|
107
|
+
* A frame this peer is not in a position to apply resolves `advanced:false`,
|
|
108
|
+
* NEVER a throw — a throw would leave the lane's cursor put and re-read the
|
|
109
|
+
* frame forever (a late joiner meeting its own add-commit, the first frame it
|
|
110
|
+
* reads, would wedge on it). All of these are `advanced:false`:
|
|
111
|
+
* - not a Commit, or a Commit framed at another epoch (past OR future);
|
|
112
|
+
* - a Commit this device authored (a member can never apply its own commit);
|
|
113
|
+
* - a Commit the group's control policy refuses.
|
|
114
|
+
*
|
|
115
|
+
* The ONE deliberate throw is a Commit this peer SHOULD apply but cannot,
|
|
116
|
+
* because the ledger entries it names will not resolve from the Commit's own
|
|
117
|
+
* frame — {@link MissingLedgerEntriesError}, which the lane recognizes as
|
|
118
|
+
* poison and steps over. A corrupt/forged frame is ack-and-skipped
|
|
119
|
+
* (`advanced:false`); a transient store fault propagates so the lane retries.
|
|
35
120
|
*/ async processCommit (commit, context) {
|
|
36
121
|
// The transport sender is auxiliary (logging), NOT the MLS committer and
|
|
37
122
|
// never an authorization boundary — the Commit authenticates its committer
|
|
@@ -40,12 +125,78 @@ import { CommitOutOfOrderError, DecryptError } from './mls-receive-errors.js';
|
|
|
40
125
|
groupID,
|
|
41
126
|
senderDID: context.senderDID
|
|
42
127
|
});
|
|
43
|
-
|
|
128
|
+
// Resolve the commit's sealed ledger bodies BEFORE taking the apply lock.
|
|
129
|
+
// The frame resolver decrypts via the cached handle (`readHandle`), which
|
|
130
|
+
// re-enters the per-group mutex; that mutex is held for the whole apply
|
|
131
|
+
// transaction below, so resolving inside it would self-deadlock. The frame
|
|
132
|
+
// resolver is id-agnostic — it answers with every token in the blob — so a
|
|
133
|
+
// single drain with `[]` yields the whole frame's bodies. The unwrap is
|
|
134
|
+
// read-only and its result is re-verified (content-addressed + signature
|
|
135
|
+
// checked) by `processMessage`, so moving where it runs changes nothing
|
|
136
|
+
// durable or trusted; a blob this peer cannot open drains to `[]`, keeping
|
|
137
|
+
// the missing-entries poison contract intact. When the context carries no
|
|
138
|
+
// resolver, pass none so the store-based default resolves from the store.
|
|
139
|
+
let resolveOverride;
|
|
140
|
+
if (context.resolveLedgerEntries != null) {
|
|
141
|
+
const resolvedEntries = await context.resolveLedgerEntries([]);
|
|
142
|
+
resolveOverride = {
|
|
143
|
+
resolveLedgerEntries: async ()=>resolvedEntries
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// The entries an accepted commit surfaces, collected for this call only.
|
|
147
|
+
// The sink is synchronous by contract, so it cannot await a store write:
|
|
148
|
+
// it only collects, and the fold runs after `processMessage` returns,
|
|
149
|
+
// inside the same transaction as the ratchet advance and the roster delta.
|
|
150
|
+
const surfacedEntries = [];
|
|
151
|
+
// Descriptors the fold produced, emitted only AFTER the transaction
|
|
152
|
+
// commits: an event announcing a row a rollback erased would be a lie.
|
|
153
|
+
let affected = [];
|
|
154
|
+
// The membership deltas this commit enacted, announced under the same
|
|
155
|
+
// past-commit rule as `affected`. This device did NOT author the commit
|
|
156
|
+
// (it declines its own below), so nothing else on this device announces
|
|
157
|
+
// them: the author's own device emits these from the producer path, which
|
|
158
|
+
// no fold ever visits, and the two therefore cannot both fire.
|
|
159
|
+
let joined = [];
|
|
160
|
+
let left = [];
|
|
161
|
+
let roleChanged = [];
|
|
162
|
+
// Set when the commit removed THIS device. Emitted last: the teardown it
|
|
163
|
+
// triggers disposes the listeners the membership events ask to reconcile.
|
|
164
|
+
let selfRemoved = null;
|
|
165
|
+
const advance = await registry.withHandleReplacingInTransaction(groupID, stores, async (handle, tx)=>{
|
|
44
166
|
// The Commit's cleartext wire-header epoch is the sender's pre-commit
|
|
45
|
-
// epoch — the epoch a receiver must be at to process it.
|
|
167
|
+
// epoch — the epoch a receiver must be at to process it. A frame at
|
|
168
|
+
// any other epoch (past = already applied, future = a gap this peer
|
|
169
|
+
// has not reached, e.g. a late joiner's own add-commit) is not this
|
|
170
|
+
// member's to apply and is never opened — the blob riding it stays
|
|
171
|
+
// sealed because its entries are never resolved.
|
|
46
172
|
const messageEpoch = readMessageEpoch(commit);
|
|
47
|
-
|
|
48
|
-
|
|
173
|
+
if (messageEpoch == null || messageEpoch !== handle.epoch) {
|
|
174
|
+
return {
|
|
175
|
+
result: {
|
|
176
|
+
advanced: false
|
|
177
|
+
},
|
|
178
|
+
updated: handle
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// The committer comes from the Commit's own authenticated bytes, not
|
|
182
|
+
// the frame sender. A member can never apply the frame that is its
|
|
183
|
+
// OWN commit — MLS merges a pending commit, it does not process one —
|
|
184
|
+
// so a device meeting its own commit in the log declines it.
|
|
185
|
+
const header = await handle.readCommitHeader(commit);
|
|
186
|
+
// `null` means "these bytes are not a Commit" and nothing else —
|
|
187
|
+
// upstream is explicit that it never means "a Commit I could not
|
|
188
|
+
// read". So this is also the content-type gate for the channel: a
|
|
189
|
+
// by-reference Proposal broadcast on the commit lane by any member
|
|
190
|
+
// stops here, before `processMessage` can store it in the pending
|
|
191
|
+
// set, where it would be absorbed into the next commit an innocent
|
|
192
|
+
// member authors and get that member's commit refused by every peer.
|
|
193
|
+
// Pinned in this position by `commit-ingest-rejects-proposals.test.ts`,
|
|
194
|
+
// because nothing else would fail if it moved.
|
|
195
|
+
if (header == null) {
|
|
196
|
+
logger.warn('commit ingest dropped a non-Commit message', {
|
|
197
|
+
groupID,
|
|
198
|
+
senderDID: context.senderDID
|
|
199
|
+
});
|
|
49
200
|
return {
|
|
50
201
|
result: {
|
|
51
202
|
advanced: false
|
|
@@ -53,19 +204,52 @@ import { CommitOutOfOrderError, DecryptError } from './mls-receive-errors.js';
|
|
|
53
204
|
updated: handle
|
|
54
205
|
};
|
|
55
206
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
207
|
+
if (header.committerDID === handle.credential.id) {
|
|
208
|
+
return {
|
|
209
|
+
result: {
|
|
210
|
+
advanced: false
|
|
211
|
+
},
|
|
212
|
+
updated: handle
|
|
213
|
+
};
|
|
58
214
|
}
|
|
215
|
+
const priorEpoch = handle.epoch;
|
|
59
216
|
const before = handle.listMembers();
|
|
217
|
+
// The ledger log's length BEFORE the commit is applied. The handle
|
|
218
|
+
// appends what an accepted commit enacted to the end of this log, in
|
|
219
|
+
// enactment order, so this index is the only way to tell this
|
|
220
|
+
// commit's entries from the ones the handle already held — the log
|
|
221
|
+
// records repeats, so a token diff would miss a re-enactment.
|
|
222
|
+
const ledgerLengthBeforeCommit = handle.ledger.length;
|
|
60
223
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
224
|
+
// No commit policy is passed: the handle carries `@kumiai/mls`'s
|
|
225
|
+
// default one, which folds the roster from the signed control ledger
|
|
226
|
+
// and refuses a commit whose author lacks the permission for it.
|
|
227
|
+
// Passing a policy here would REPLACE that gate rather than add to it.
|
|
228
|
+
await handle.processMessage(commit);
|
|
64
229
|
} catch (error) {
|
|
230
|
+
// The commit enacts ledger entries this peer does not hold, and they
|
|
231
|
+
// will not resolve from the frame that carries them. This is the one
|
|
232
|
+
// deliberate throw: the lane treats it as poison, stepping over the
|
|
233
|
+
// frame rather than re-reading it. Let it propagate untouched — its
|
|
234
|
+
// NAME is what `isMissingLedgerEntries` matches.
|
|
235
|
+
if (error instanceof MissingLedgerEntriesError) {
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
// Well-formed but refused: the group's control policy does not accept
|
|
239
|
+
// a commit from this committer. A refusal is a judgment, not a
|
|
240
|
+
// failure — there is nothing to retry, so ack-and-skip.
|
|
241
|
+
if (error instanceof CommitRejectedError) {
|
|
242
|
+
return {
|
|
243
|
+
result: {
|
|
244
|
+
advanced: false
|
|
245
|
+
},
|
|
246
|
+
updated: handle
|
|
247
|
+
};
|
|
248
|
+
}
|
|
65
249
|
// A Commit that fails the authenticated MLS check at the matching
|
|
66
250
|
// epoch is corrupt/forged — unrecoverable, so ack-and-skip rather
|
|
67
|
-
// than redeliver.
|
|
68
|
-
//
|
|
251
|
+
// than redeliver.
|
|
252
|
+
// biome-ignore lint/style/useErrorCause: DecryptError forwards its second argument as the cause
|
|
69
253
|
throw new DecryptError('Failed to process MLS commit', error);
|
|
70
254
|
}
|
|
71
255
|
const after = handle.listMembers();
|
|
@@ -78,35 +262,155 @@ import { CommitOutOfOrderError, DecryptError } from './mls-receive-errors.js';
|
|
|
78
262
|
// deadlock single-connection SQLite against the tx's write lock).
|
|
79
263
|
const localHLC = HLC.serialize(hlc.now());
|
|
80
264
|
const p2pStore = await getP2PStore(tx);
|
|
81
|
-
|
|
82
|
-
|
|
265
|
+
// One read of the pre-commit rows serves both announcements and the
|
|
266
|
+
// role comparison below: a removal has to report the role its target
|
|
267
|
+
// held while they were still a member, and the tombstone is about to
|
|
268
|
+
// put that row out of reach.
|
|
269
|
+
const membersBefore = await p2pStore.listGroupMembers(groupID);
|
|
270
|
+
const rowByDID = new Map(membersBefore.map((member)=>[
|
|
271
|
+
normalizeDID(member.member_did),
|
|
272
|
+
member
|
|
273
|
+
]));
|
|
274
|
+
// One statement tombstones the whole removed set, so a commit that
|
|
275
|
+
// drops several members cannot land half its removals.
|
|
276
|
+
await p2pStore.markMembersRemoved(groupID, removed, localHLC);
|
|
277
|
+
// The ex-member's advertisement goes with its membership, in the
|
|
278
|
+
// same transaction: the row is a route MLS would refuse, and a
|
|
279
|
+
// projection still offering it makes the UI name a device the group
|
|
280
|
+
// no longer contains.
|
|
281
|
+
await p2pStore.deletePeerAnnouncements({
|
|
282
|
+
groupID,
|
|
283
|
+
peerDIDs: removed
|
|
284
|
+
});
|
|
285
|
+
// A removal naming THIS device says it has left, and nothing else
|
|
286
|
+
// ever said it: `setGroupStatus(id, 'left')` was written only by
|
|
287
|
+
// `markGroupLeft`, on the voluntary path.
|
|
288
|
+
//
|
|
289
|
+
// Marked here because this is the one moment the eviction is
|
|
290
|
+
// AUTHENTICATED — the commit naming this device was processed under
|
|
291
|
+
// the epoch it still held. Anything downstream (frames that stop
|
|
292
|
+
// opening, a quiet hub) is indistinguishable from an outage.
|
|
293
|
+
//
|
|
294
|
+
// The rows stay: they project what this device saw, and erasing
|
|
295
|
+
// local history on someone else's say-so is worse.
|
|
296
|
+
if (removed.some((memberDID)=>normalizeDID(memberDID) === normalizeDID(identity.id))) {
|
|
297
|
+
await p2pStore.setGroupStatus(groupID, 'left');
|
|
298
|
+
// The status is what a LATER read sees; this tells what is already
|
|
299
|
+
// running. `groupLeft` has one consumer (`hub/wiring.ts`) and it is
|
|
300
|
+
// the entire teardown: the group leaves the peer manager's
|
|
301
|
+
// in-memory `joined` set, its tunnel listeners go, its hub peers
|
|
302
|
+
// are disposed. Presence and sync read that set, never the status
|
|
303
|
+
// column, so writing the row alone left the device announcing
|
|
304
|
+
// itself and encrypting into a group MLS refused.
|
|
305
|
+
//
|
|
306
|
+
// Emitted from here and from `leaveGroup`, so both ways out produce
|
|
307
|
+
// the same teardown. Unlike `leaveGroup` this does NOT drop the MLS
|
|
308
|
+
// state: re-admission compares the Welcome's epoch against the
|
|
309
|
+
// persisted one, and a device that discarded it cannot be let back.
|
|
310
|
+
const row = await p2pStore.getGroup(groupID);
|
|
311
|
+
selfRemoved = row == null ? null : {
|
|
312
|
+
id: row.id,
|
|
313
|
+
groupID: row.id,
|
|
314
|
+
name: row.name,
|
|
315
|
+
description: row.description,
|
|
316
|
+
createdBy: row.created_by,
|
|
317
|
+
createdAt: toISO(row.created_at)
|
|
318
|
+
};
|
|
83
319
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
320
|
+
left = removed.map((memberDID)=>rowByDID.get(normalizeDID(memberDID))).filter((row)=>row != null).map((row)=>({
|
|
321
|
+
groupID,
|
|
322
|
+
memberDID: row.member_did,
|
|
323
|
+
role: row.role,
|
|
324
|
+
createdAt: toISO(row.created_at)
|
|
325
|
+
}));
|
|
326
|
+
// Roles come from the handle's own post-commit roster, not from the
|
|
327
|
+
// ratchet (which carries no kubun role) and not from a separately
|
|
328
|
+
// delivered ledger token. The roster folds the genesis anchor plus
|
|
329
|
+
// exactly the entries the authenticated commit chain enacted, in
|
|
330
|
+
// epoch order, so a receiver reads the same authority its commit
|
|
331
|
+
// policy enforces. Recovering a role change missed while offline
|
|
332
|
+
// additionally requires the device to apply the commit that carried
|
|
333
|
+
// it, which the seed pull does not currently do.
|
|
334
|
+
const roster = handle.roster;
|
|
335
|
+
const addedWithRoles = added.map((memberDID)=>({
|
|
336
|
+
memberDID,
|
|
337
|
+
role: roleFromRoster(roster, memberDID)
|
|
338
|
+
}));
|
|
339
|
+
// One multi-row insert for the whole added set.
|
|
340
|
+
await p2pStore.addGroupMembers(addedWithRoles.map(({ memberDID, role })=>({
|
|
91
341
|
group_id: groupID,
|
|
92
342
|
member_did: memberDID,
|
|
93
|
-
role
|
|
343
|
+
role,
|
|
94
344
|
hlc: localHLC
|
|
95
|
-
});
|
|
96
|
-
|
|
345
|
+
})));
|
|
346
|
+
// The rows were created by the write above, so their creation instant
|
|
347
|
+
// is now; reading them back would cost a query to learn what is
|
|
348
|
+
// already known.
|
|
349
|
+
const addedAt = new Date().toISOString();
|
|
350
|
+
joined = addedWithRoles.map(({ memberDID, role })=>({
|
|
351
|
+
groupID,
|
|
352
|
+
memberDID,
|
|
353
|
+
role,
|
|
354
|
+
createdAt: addedAt
|
|
355
|
+
}));
|
|
356
|
+
// A commit can also promote or demote a member it neither adds nor
|
|
357
|
+
// removes, so every member that survived it is reconciled too. The
|
|
358
|
+
// rows read above are the pre-commit ones, which is exactly what the
|
|
359
|
+
// roster must be compared against to tell a real change from a
|
|
360
|
+
// re-assertion of the role a member already held.
|
|
361
|
+
roleChanged = await mirrorRosterRoles({
|
|
362
|
+
store: p2pStore,
|
|
363
|
+
groupID,
|
|
364
|
+
roster,
|
|
365
|
+
memberDIDs: after.filter((member)=>beforeIDs.has(member.id)).map((member)=>member.id),
|
|
366
|
+
members: membersBefore
|
|
367
|
+
});
|
|
368
|
+
// A landed commit's surfaced entries fold through the same projection
|
|
369
|
+
// ingest a broadcast entry does, on the transaction's own store — the
|
|
370
|
+
// tx holds the write lock, so a bare store read here would deadlock
|
|
371
|
+
// single-connection SQLite.
|
|
372
|
+
//
|
|
373
|
+
// A role entry is NOT among the surfaced ones: the MLS library
|
|
374
|
+
// interprets it itself, folding it into the roster mirrored onto the
|
|
375
|
+
// rows above rather than handing it on. It is in the handle's ledger
|
|
376
|
+
// regardless — which is where the admin roster folds it from — but
|
|
377
|
+
// its HLC reaches this device by no other path, so it is recovered
|
|
378
|
+
// from the log and passed through the same ingest for its clock.
|
|
379
|
+
affected = await foldCommittedLedgerEntries({
|
|
380
|
+
handle,
|
|
381
|
+
entries: surfacedEntries,
|
|
382
|
+
retained: entriesRetainedByCommit(handle, ledgerLengthBeforeCommit),
|
|
383
|
+
p2pStore,
|
|
384
|
+
groupID,
|
|
385
|
+
hlc,
|
|
386
|
+
logger
|
|
387
|
+
});
|
|
97
388
|
return {
|
|
98
389
|
result: {
|
|
99
|
-
advanced: handle.epoch >
|
|
390
|
+
advanced: handle.epoch > priorEpoch
|
|
100
391
|
},
|
|
101
392
|
updated: handle
|
|
102
393
|
};
|
|
394
|
+
}, // Per-call handle wiring, scoped to the working handle this call
|
|
395
|
+
// restores. `resolveLedgerEntries` is pre-resolved above so the commit
|
|
396
|
+
// pre-pass resolves entries from the frame (via a plain non-locking
|
|
397
|
+
// resolver) rather than re-entering the per-group mutex the transaction
|
|
398
|
+
// holds; undefined falls back to the store-based default resolver.
|
|
399
|
+
// `onLedgerEntries` collects what the accepted commit surfaces.
|
|
400
|
+
{
|
|
401
|
+
...resolveOverride != null ? {
|
|
402
|
+
resolveLedgerEntries: resolveOverride.resolveLedgerEntries
|
|
403
|
+
} : {},
|
|
404
|
+
onLedgerEntries: (entries)=>{
|
|
405
|
+
surfacedEntries.push(...entries);
|
|
406
|
+
}
|
|
103
407
|
}).catch((error)=>{
|
|
104
408
|
// A corrupt/forged Commit threw inside the transaction, so it rolled back
|
|
105
409
|
// (DB untouched). Ack-and-skip it: report no epoch advance so group-rpc
|
|
106
410
|
// acks and the hub stops redelivering — group-rpc re-fetches any Commit
|
|
107
411
|
// whose `processCommit` throws, so letting a corrupt frame escape here
|
|
108
|
-
// would poison-redeliver forever.
|
|
109
|
-
//
|
|
412
|
+
// would poison-redeliver forever. MissingLedgerEntriesError (poison) and
|
|
413
|
+
// transient store faults are re-thrown so the lane classifies them.
|
|
110
414
|
if (error instanceof DecryptError) {
|
|
111
415
|
logger.warn('dropping corrupt MLS commit (ack-and-skip)', {
|
|
112
416
|
groupID,
|
|
@@ -118,35 +422,263 @@ import { CommitOutOfOrderError, DecryptError } from './mls-receive-errors.js';
|
|
|
118
422
|
}
|
|
119
423
|
throw error;
|
|
120
424
|
});
|
|
425
|
+
// Only now, past commit: the rows the events announce are durable. A
|
|
426
|
+
// rollback (or an ack-and-skip) folded nothing, so there is nothing to
|
|
427
|
+
// announce. Emitting must not turn a landed commit into a failed one, so a
|
|
428
|
+
// faulting subscriber is logged, not propagated.
|
|
429
|
+
const emitter = params.emitter;
|
|
430
|
+
if (emitter != null) {
|
|
431
|
+
for (const ledger of affected){
|
|
432
|
+
try {
|
|
433
|
+
await emitLedgerAffectedEvents(emitter, {
|
|
434
|
+
applied: true,
|
|
435
|
+
affected: {
|
|
436
|
+
kind: 'ledger',
|
|
437
|
+
ledger
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
} catch (error) {
|
|
441
|
+
logger.warn('committed ledger entry event emit failed', {
|
|
442
|
+
groupID,
|
|
443
|
+
error
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const [eventName, members] of [
|
|
448
|
+
[
|
|
449
|
+
'groupMemberJoined',
|
|
450
|
+
joined
|
|
451
|
+
],
|
|
452
|
+
[
|
|
453
|
+
'groupMemberLeft',
|
|
454
|
+
left
|
|
455
|
+
],
|
|
456
|
+
[
|
|
457
|
+
'groupMemberRoleChanged',
|
|
458
|
+
roleChanged
|
|
459
|
+
]
|
|
460
|
+
]){
|
|
461
|
+
for (const member of members){
|
|
462
|
+
try {
|
|
463
|
+
await emitter.emit(eventName, member);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
logger.warn('group membership event emit failed', {
|
|
466
|
+
groupID,
|
|
467
|
+
eventName,
|
|
468
|
+
error
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// After `groupMemberLeft`: that reconciles the group's tunnel listeners,
|
|
474
|
+
// this disposes them. Reversed, the reconcile rebuilds them.
|
|
475
|
+
if (selfRemoved != null) {
|
|
476
|
+
try {
|
|
477
|
+
await emitter.emit('groupLeft', selfRemoved);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
logger.warn('groupLeft emit failed', {
|
|
480
|
+
groupID,
|
|
481
|
+
error
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return advance;
|
|
121
487
|
},
|
|
122
488
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
489
|
+
* Mint the signed rendezvous request this peer publishes to ask the group
|
|
490
|
+
* for its state, retaining the private half of the request's ephemeral HPKE
|
|
491
|
+
* keypair so the replies can be opened.
|
|
492
|
+
*
|
|
493
|
+
* The request token is signed by this member's identity key. The private half
|
|
494
|
+
* is held in `pending` under `requestID`, in process memory only, and evicted
|
|
495
|
+
* by `sweep()` on a TTL — the lane has no release hook, so a timed-out gather
|
|
496
|
+
* drops its request id silently and an evict-on-reply-open port would leak a
|
|
497
|
+
* key per timeout.
|
|
498
|
+
*/ async createRecoveryRequest (requestID) {
|
|
499
|
+
sweep();
|
|
500
|
+
// Inside the registry lock like every other handle read: the primitive is
|
|
501
|
+
// read-only, but the handle it reads may not be observed mid-replacement.
|
|
502
|
+
const { request, ephemeralPrivateKey } = await registry.readHandle(groupID, (handle)=>createRecoveryRequest({
|
|
503
|
+
group: handle,
|
|
504
|
+
identity,
|
|
505
|
+
requestID
|
|
506
|
+
}));
|
|
507
|
+
pending.set(requestID, {
|
|
508
|
+
ephemeralPrivateKey,
|
|
509
|
+
mintedAt: Date.now()
|
|
510
|
+
});
|
|
511
|
+
return new TextEncoder().encode(request);
|
|
512
|
+
},
|
|
513
|
+
/**
|
|
514
|
+
* Answer another member's GroupInfo request: seal current GroupInfo to the
|
|
515
|
+
* ephemeral key in the signed request and vouch for it with a membership
|
|
516
|
+
* attestation.
|
|
517
|
+
*
|
|
518
|
+
* The seal is HPKE base mode and authenticates NOBODY, so the reply must carry
|
|
519
|
+
* a membership attestation signed by this member's DID key — that is what lets
|
|
520
|
+
* the requester tell it from an observer's forgery. Confidentiality against
|
|
521
|
+
* the relaying hub is intact regardless: the seal targets a per-request
|
|
522
|
+
* ephemeral public key whose private half the hub never sees.
|
|
523
|
+
*/ async sealGroupInfo (request) {
|
|
524
|
+
// Throws for a request this member refuses — a requester holding no leaf
|
|
525
|
+
// in THIS handle's tree gets nothing from a responder that has applied
|
|
526
|
+
// their removal — and group-rpc reads the throw as "stay silent".
|
|
527
|
+
// Roster-intrinsic, so there is no check to write here.
|
|
528
|
+
return await registry.readHandle(groupID, (handle)=>sealGroupInfo({
|
|
529
|
+
group: handle,
|
|
530
|
+
identity,
|
|
531
|
+
request: new TextDecoder().decode(request)
|
|
532
|
+
}));
|
|
533
|
+
},
|
|
534
|
+
/**
|
|
535
|
+
* Open a sealed GroupInfo reply and build the external commit that rejoins
|
|
536
|
+
* this peer, adopted only once the head accepts it.
|
|
537
|
+
*
|
|
538
|
+
* Wired now: it consumes what {@link GroupMLS.createRecoveryRequest}
|
|
539
|
+
* retains — the ephemeral private key held under `requestID` in `pending` —
|
|
540
|
+
* and builds the external commit under the signing `identity`
|
|
541
|
+
* {@link CreateGroupMLSParams} now carries.
|
|
542
|
+
*
|
|
543
|
+
* The rejoin Commit itself has a home: the lane publishes
|
|
544
|
+
* `PendingRecovery.commit` to the commit topic under a compare-and-set at
|
|
545
|
+
* the head, so this peer does not have to fan it out and cannot fork the
|
|
546
|
+
* group by applying it locally. That is not what blocks this member.
|
|
547
|
+
*/ async applyRecovery (sealed, requestID) {
|
|
548
|
+
const held = pending.get(requestID);
|
|
549
|
+
if (held == null) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const rejoined = await registry.readHandle(groupID, async (handle)=>{
|
|
553
|
+
let groupInfo;
|
|
554
|
+
try {
|
|
555
|
+
groupInfo = await openSealedGroupInfo({
|
|
556
|
+
group: handle,
|
|
557
|
+
sealed,
|
|
558
|
+
requestID,
|
|
559
|
+
ephemeralPrivateKey: held.ephemeralPrivateKey
|
|
560
|
+
});
|
|
561
|
+
} catch {
|
|
562
|
+
// Bytes this peer cannot open OR cannot trust. A forgery that merely
|
|
563
|
+
// decrypts still fails the membership attestation, and the two are
|
|
564
|
+
// indistinguishable to the caller on purpose: neither is a reply.
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
return await joinGroupExternal({
|
|
568
|
+
identity,
|
|
569
|
+
groupInfo,
|
|
570
|
+
credential: handle.credential,
|
|
571
|
+
resync: true
|
|
130
572
|
});
|
|
131
|
-
return groupInfo;
|
|
132
573
|
});
|
|
574
|
+
if (rejoined == null) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
commit: rejoined.commitMessage,
|
|
579
|
+
// Adopted ONLY once the head accepts the commit. The lane publishes it
|
|
580
|
+
// under a compare-and-set, and a peer that adopted first would sit on a
|
|
581
|
+
// branch of its own the moment it lost that race.
|
|
582
|
+
onAccepted: async ()=>{
|
|
583
|
+
pending.delete(requestID);
|
|
584
|
+
await registry.replaceHandle(groupID, rejoined.group, {
|
|
585
|
+
stores
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
/**
|
|
591
|
+
* Whether the entries this handle holds fold to the head its own GroupContext
|
|
592
|
+
* attests to. Purely local, read-only: the completeness gate every lane
|
|
593
|
+
* operation runs before it pulls or commits. False means the handle rejoined
|
|
594
|
+
* with an empty ledger against a live head and must bootstrap before it can be
|
|
595
|
+
* trusted to judge a commit.
|
|
596
|
+
*/ async isLedgerComplete () {
|
|
597
|
+
return await registry.readHandle(groupID, (handle)=>handle.isLedgerComplete());
|
|
598
|
+
},
|
|
599
|
+
/**
|
|
600
|
+
* The whole ordered ledger this handle holds, as signed tokens. Purely local,
|
|
601
|
+
* read-only. Order is load-bearing: the head is a chain digest, so a permuted
|
|
602
|
+
* list folds to a different head.
|
|
603
|
+
*/ async getLedger () {
|
|
604
|
+
return await registry.readHandle(groupID, (handle)=>handle.getLedger());
|
|
605
|
+
},
|
|
606
|
+
/**
|
|
607
|
+
* Answer another member's ledger gather: seal this handle's whole ordered
|
|
608
|
+
* ledger to the ephemeral key inside the requester's signed request.
|
|
609
|
+
*
|
|
610
|
+
* Authorization is roster-intrinsic: the primitive refuses a requester with no
|
|
611
|
+
* leaf in THIS handle's tree, which matters because the ledger is the group's
|
|
612
|
+
* entire authority state travelling on a public, secretless topic. A throw
|
|
613
|
+
* means "stay silent" to group-rpc.
|
|
614
|
+
*
|
|
615
|
+
* The payload is read from the handle and never handed in, so this cannot be
|
|
616
|
+
* made to seal another group's state.
|
|
617
|
+
*/ async sealLedger (request) {
|
|
618
|
+
return await registry.readHandle(groupID, (handle)=>sealLedger({
|
|
619
|
+
group: handle,
|
|
620
|
+
request: new TextDecoder().decode(request)
|
|
621
|
+
}));
|
|
133
622
|
},
|
|
134
623
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
624
|
+
* Open a sealed gather reply with the key minted for `requestID`.
|
|
625
|
+
*
|
|
626
|
+
* Wired now: it reads the ephemeral private key
|
|
627
|
+
* {@link GroupMLS.createRecoveryRequest} retains under that id in `pending`.
|
|
628
|
+
* It moves with that member, not with {@link GroupMLS.sealLedger} — the
|
|
629
|
+
* responder side needs only the handle and the request, so both are now live
|
|
630
|
+
* on the same custody the requester side holds.
|
|
631
|
+
*/ async openSealedLedger (sealed, requestID) {
|
|
632
|
+
const held = pending.get(requestID);
|
|
633
|
+
if (held == null) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
return await registry.readHandle(groupID, async (handle)=>{
|
|
637
|
+
try {
|
|
638
|
+
// The key is NOT consumed here: every responder to a gather answers,
|
|
639
|
+
// and the requester must still be able to open the next reply after
|
|
640
|
+
// discarding one it could not use.
|
|
641
|
+
return await openSealedLedger({
|
|
642
|
+
group: handle,
|
|
643
|
+
sealed,
|
|
644
|
+
requestID,
|
|
645
|
+
ephemeralPrivateKey: held.ephemeralPrivateKey
|
|
646
|
+
});
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
},
|
|
652
|
+
/**
|
|
653
|
+
* Install a gathered ledger, replacing what this handle holds once the list
|
|
654
|
+
* reproduces the head the handle's own GroupContext authenticates.
|
|
655
|
+
*
|
|
656
|
+
* The install runs FIRST: a list that does not reproduce the authenticated head
|
|
657
|
+
* throws before a durable row is touched, so a withholding responder is
|
|
658
|
+
* rejected whole rather than merged. Only then does kubun's mirror follow.
|
|
137
659
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
660
|
+
* Deliberately NOT in a transaction — one spanning this would hold kubun's
|
|
661
|
+
* single SQLite connection across an unbounded network wait. Safe because
|
|
662
|
+
* nothing is written until the head check passes, and this REPLACES a ledger
|
|
663
|
+
* rather than appending, so the next bootstrap heals a stale mirror.
|
|
664
|
+
*/ async bootstrapLedger (tokens) {
|
|
665
|
+
const p2pStore = await getP2PStore(stores);
|
|
666
|
+
await bootstrapGroupLedger({
|
|
667
|
+
p2pStore,
|
|
668
|
+
// Only the projection rebuild is transactional — never the install
|
|
669
|
+
// above it, which waits on the network.
|
|
670
|
+
withProjectionTransaction: (run)=>stores.withTransaction(async (tx)=>await run(await getP2PStore(tx))),
|
|
671
|
+
hlc,
|
|
672
|
+
getGroupAnchor: (id)=>registry.readHandle(id, async (handle)=>readGroupAnchor(handle)),
|
|
673
|
+
getGroupLedger: (id)=>registry.readHandle(id, async (handle)=>handle.ledger),
|
|
674
|
+
logger,
|
|
675
|
+
installGroupLedger: async (id, gathered)=>await registry.withHandle(id, async (handle)=>{
|
|
676
|
+
await handle.bootstrapLedger(gathered);
|
|
677
|
+
// Read under the same hold as the install: this is the roster that
|
|
678
|
+
// install folded, not whatever a later operation may have left.
|
|
679
|
+
return handle.roster;
|
|
680
|
+
})
|
|
681
|
+
}, tokens, groupID);
|
|
150
682
|
},
|
|
151
683
|
/**
|
|
152
684
|
* The epoch-independent secret for the non-rotating handshake/recovery topic,
|
|
@@ -158,7 +690,7 @@ import { CommitOutOfOrderError, DecryptError } from './mls-receive-errors.js';
|
|
|
158
690
|
if (anchor == null) {
|
|
159
691
|
throw new Error(`group ${groupID} has no genesis anchor; cannot derive recovery secret`);
|
|
160
692
|
}
|
|
161
|
-
return fromB64(anchor
|
|
693
|
+
return fromB64(readRecoverySecret(anchor));
|
|
162
694
|
}
|
|
163
695
|
};
|
|
164
696
|
}
|