@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
|
@@ -1,58 +1,75 @@
|
|
|
1
|
+
import { readMessageEpoch } from '@kumiai/mls';
|
|
2
|
+
import { ENTRY_SEAL_LABEL } from '@kumiai/mls-rpc';
|
|
3
|
+
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
1
4
|
import { toB64 } from '@sozai/codec';
|
|
5
|
+
import { createRuntime } from '@sozai/runtime';
|
|
2
6
|
import { mlsExporter } from 'ts-mls';
|
|
3
7
|
import { mlsDecryptFramed, mlsEncryptFramed } from './mls-codec.js';
|
|
4
|
-
// Domain-separation label for the epoch-bound topic-derivation secret. Stable so
|
|
5
|
-
// the exported secret is constant within an epoch and rotates when a commit
|
|
6
|
-
// advances the epoch (MLS re-derives exporterSecret per epoch).
|
|
7
|
-
const TOPIC_SECRET_LABEL = 'kubun/group-rpc/topic';
|
|
8
8
|
// Empty context: the epoch already varies the secret; topic derivation mixes in
|
|
9
|
-
// its own epoch/protocol/scope on top.
|
|
9
|
+
// its own epoch/protocol/scope on top. Byte-identical to the exporter context
|
|
10
|
+
// `@kumiai/mls-rpc` uses for the ledger-entry seal (`new Uint8Array()`), which is
|
|
11
|
+
// what lets a blob sealed here open there.
|
|
10
12
|
const TOPIC_SECRET_CONTEXT = new Uint8Array(0);
|
|
11
|
-
// 32
|
|
12
|
-
// unconstrained.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
return await mlsExporter(handle.state.keySchedule.exporterSecret, TOPIC_SECRET_LABEL, TOPIC_SECRET_CONTEXT, TOPIC_SECRET_LENGTH, handle.context.cipherSuite);
|
|
13
|
+
// Default when a caller names no length. 32 bytes feeds an HKDF-based topic
|
|
14
|
+
// derivation; the length is otherwise unconstrained. It is also XChaCha20-
|
|
15
|
+
// Poly1305's key size, and the same 32 the ledger-entry seal exports at — RFC
|
|
16
|
+
// 9420 §8.5 binds the length into the exporter's KDFLabel, so deriving this from
|
|
17
|
+
// the ciphersuite instead would return 48 on SHA-384 and break every seal.
|
|
18
|
+
const DEFAULT_SECRET_LENGTH = 32;
|
|
19
|
+
// XChaCha20-Poly1305's nonce, carried in the clear ahead of the ciphertext.
|
|
20
|
+
const ENTRY_NONCE_BYTES = 24;
|
|
21
|
+
// Sealed blob format version, first byte, in the clear:
|
|
22
|
+
// [ VERSION(1) | NONCE(24) | CIPHERTEXT ]
|
|
23
|
+
// Unauthenticated by necessity — it is read to decide how to open, so it cannot
|
|
24
|
+
// sit under the seal. It buys diagnosis, not compatibility: no v1 reader can open
|
|
25
|
+
// a v2 blob either way, but the failure reads as "unsupported version" rather
|
|
26
|
+
// than an AEAD refusal indistinguishable from a wrong epoch or a tampered frame.
|
|
27
|
+
const ENTRY_VERSION = 1;
|
|
28
|
+
export function createGroupCrypto(params) {
|
|
29
|
+
const { registry, groupID, initialEpoch, runtime = createRuntime() } = params;
|
|
30
|
+
// Exports the ledger-entry seal key for this epoch. Not routed through
|
|
31
|
+
// `exportSecret`, which refuses this label: the two are the same exporter call,
|
|
32
|
+
// and the point of the refusal is that only the seal reaches it.
|
|
33
|
+
const exportEntryKey = ()=>registry.readHandle(groupID, async (handle)=>{
|
|
34
|
+
return await mlsExporter(handle.state.keySchedule.exporterSecret, ENTRY_SEAL_LABEL, TOPIC_SECRET_CONTEXT, DEFAULT_SECRET_LENGTH, handle.context.cipherSuite);
|
|
34
35
|
});
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const exportSecret = (label, length)=>{
|
|
37
|
+
// Reusing the ledger-entry label would not be an independent export: it is
|
|
38
|
+
// the exact exporter call `sealEntries`/`openEntries` make (same context,
|
|
39
|
+
// same length), so it would hand back the ledger-entry seal key under
|
|
40
|
+
// another name and make every holder of a topic secret a reader of the
|
|
41
|
+
// group's control ledger. Refused here rather than left to the doc.
|
|
42
|
+
if (label === ENTRY_SEAL_LABEL) {
|
|
43
|
+
throw new Error(`exportSecret: label '${label}' is reserved for the ledger-entry seal`);
|
|
44
|
+
}
|
|
45
|
+
return registry.readHandle(groupID, async (handle)=>{
|
|
46
|
+
return await mlsExporter(handle.state.keySchedule.exporterSecret, label, TOPIC_SECRET_CONTEXT, length ?? DEFAULT_SECRET_LENGTH, handle.context.cipherSuite);
|
|
39
47
|
});
|
|
48
|
+
};
|
|
49
|
+
// A NUMBER off the registry, never a handle: the registry publishes a scalar
|
|
50
|
+
// precisely so this synchronous contract can be met without anyone reaching
|
|
51
|
+
// MLS state outside the per-group mutex. `initialEpoch` only covers the window
|
|
52
|
+
// before the registry has observed a handle for this group.
|
|
53
|
+
const epoch = ()=>registry.groupEpoch(groupID) ?? initialEpoch;
|
|
54
|
+
const wrap = (bytes)=>registry.readHandle(groupID, (handle)=>mlsEncryptFramed(handle, bytes));
|
|
40
55
|
// In-flight decrypt cache, keyed by the ciphertext bytes.
|
|
41
56
|
//
|
|
42
|
-
// group-rpc subscribes TWO broadcast transports
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
57
|
+
// group-rpc subscribes TWO broadcast transports per app-protocol topic (the
|
|
58
|
+
// anycast `BroadcastClient` and the `busServer`) and the hub mux fans every
|
|
59
|
+
// frame to both, so `unwrap` runs twice in one synchronous burst. MLS
|
|
60
|
+
// decryption is single-use: `handle.decrypt` consumes a forward-secret ratchet
|
|
61
|
+
// generation, so the second call fails with "Desired gen in the past" and the
|
|
62
|
+
// frame never reaches its handler. Caching the in-flight promise per ciphertext
|
|
63
|
+
// makes `unwrap` idempotent — the first call advances the ratchet, the sibling
|
|
64
|
+
// (calling before it resolves) replays the plaintext. Entries drop once
|
|
65
|
+
// settled, so the map holds only concurrent in-flight decrypts and never an
|
|
66
|
+
// evicted pending one; distinct ciphertexts miss and decrypt normally, so
|
|
67
|
+
// forward secrecy across messages is preserved.
|
|
68
|
+
//
|
|
69
|
+
// The replayed value carries an IDENTITY, not just bytes. That is sound only
|
|
70
|
+
// because the key is the ciphertext: one ciphertext at one epoch has exactly
|
|
71
|
+
// one MLS-authenticated sender, so a replay cannot attribute a frame to
|
|
72
|
+
// anyone but the leaf that sealed it.
|
|
56
73
|
const unwrapCache = new Map();
|
|
57
74
|
const unwrap = (bytes)=>{
|
|
58
75
|
const key = toB64(bytes);
|
|
@@ -61,25 +78,82 @@ const TOPIC_SECRET_LENGTH = 32;
|
|
|
61
78
|
return cached;
|
|
62
79
|
}
|
|
63
80
|
const promise = registry.readHandle(groupID, async (handle)=>{
|
|
64
|
-
const payload = await mlsDecryptFramed(handle, bytes);
|
|
81
|
+
const { payload, senderDID } = await mlsDecryptFramed(handle, bytes);
|
|
65
82
|
return {
|
|
66
|
-
payload
|
|
83
|
+
payload,
|
|
84
|
+
senderDID
|
|
67
85
|
};
|
|
68
86
|
});
|
|
69
87
|
// Drop the entry once it settles (success or failure): the sibling transport
|
|
70
88
|
// has already taken the in-flight promise, and a genuine failure must not be
|
|
71
89
|
// cached so a later valid frame with the same bytes (e.g. after a resync) can
|
|
72
90
|
// decrypt afresh.
|
|
91
|
+
//
|
|
92
|
+
// `.finally()` returns a NEW promise that rejects with the same reason, so
|
|
93
|
+
// this cleanup branch needs a handler of its own. The caller handles the
|
|
94
|
+
// promise it was handed; without the catch here the derived one escapes as an
|
|
95
|
+
// unhandled rejection. Not an edge case: a frame this handle cannot open —
|
|
96
|
+
// another epoch's, another group's — is ordinary on a shared log, so the
|
|
97
|
+
// rejecting path is the common one. Only this branch is absorbed; the promise
|
|
98
|
+
// returned below still rejects to the caller, which is what decides the frame
|
|
99
|
+
// is dead.
|
|
73
100
|
unwrapCache.set(key, promise);
|
|
74
101
|
void promise.finally(()=>{
|
|
75
102
|
unwrapCache.delete(key);
|
|
76
|
-
});
|
|
103
|
+
}).catch(()=>{});
|
|
77
104
|
return promise;
|
|
78
105
|
};
|
|
106
|
+
// Reads the epoch every MLSMessage carries in its own cleartext. Needs no
|
|
107
|
+
// handle and no key, and must never throw: it is asked about every frame a log
|
|
108
|
+
// holds, most of which are not this member's to open. `null` for anything
|
|
109
|
+
// ts-mls will not decode, which is how a caller tells a frame sealed AHEAD
|
|
110
|
+
// (openable once it catches up) from one sealed BELOW (gone forever, since MLS
|
|
111
|
+
// ratchets forward) — the distinction the app lane's durable cursor rests on.
|
|
112
|
+
// Untrusted: it is the publisher's word relayed by an untrusted hub, so it may
|
|
113
|
+
// only decide what to TRY, never that bytes are authentic.
|
|
114
|
+
const frameEpoch = (bytes)=>{
|
|
115
|
+
const epochValue = readMessageEpoch(bytes);
|
|
116
|
+
return epochValue == null ? null : Number(epochValue);
|
|
117
|
+
};
|
|
118
|
+
// Seals the ledger-entry blob a Commit carries, under a key derived from this
|
|
119
|
+
// epoch's exporter secret rather than the message ratchet `wrap` consumes.
|
|
120
|
+
// Derived-key sealing is what makes the open PURE, so it can run from inside
|
|
121
|
+
// the apply of the very commit whose blob it opens — the only place it runs,
|
|
122
|
+
// and one the ratchet-backed pair cannot serve. Byte format is fixed by
|
|
123
|
+
// `@kumiai/mls-rpc`; a one-byte drift is a group that silently partitions.
|
|
124
|
+
const sealEntries = async (bytes)=>{
|
|
125
|
+
const key = await exportEntryKey();
|
|
126
|
+
// Random per seal: two members can frame a commit at the same epoch, and a
|
|
127
|
+
// repeated nonce under one key is a break. 24 bytes puts a collision out of
|
|
128
|
+
// reach without needing a counter.
|
|
129
|
+
const nonce = runtime.getRandomValues(new Uint8Array(ENTRY_NONCE_BYTES));
|
|
130
|
+
const ciphertext = xchacha20poly1305(key, nonce).encrypt(bytes);
|
|
131
|
+
const sealed = new Uint8Array(1 + nonce.length + ciphertext.length);
|
|
132
|
+
sealed[0] = ENTRY_VERSION;
|
|
133
|
+
sealed.set(nonce, 1);
|
|
134
|
+
sealed.set(ciphertext, 1 + nonce.length);
|
|
135
|
+
return sealed;
|
|
136
|
+
};
|
|
137
|
+
const openEntries = async (sealed)=>{
|
|
138
|
+
if (sealed.length <= 1 + ENTRY_NONCE_BYTES) {
|
|
139
|
+
throw new Error('openEntries: not a sealed blob');
|
|
140
|
+
}
|
|
141
|
+
if (sealed[0] !== ENTRY_VERSION) {
|
|
142
|
+
// Distinguishable on purpose: every other failure here is an opaque AEAD
|
|
143
|
+
// refusal, and this lets an operator tell "unsupported version" apart from
|
|
144
|
+
// a wrong epoch or a tampered frame. The lane handles all three the same.
|
|
145
|
+
throw new Error(`openEntries: unsupported blob version ${sealed[0]}`);
|
|
146
|
+
}
|
|
147
|
+
const key = await exportEntryKey();
|
|
148
|
+
return xchacha20poly1305(key, sealed.subarray(1, 1 + ENTRY_NONCE_BYTES)).decrypt(sealed.subarray(1 + ENTRY_NONCE_BYTES));
|
|
149
|
+
};
|
|
79
150
|
return {
|
|
80
151
|
epoch,
|
|
81
152
|
exportSecret,
|
|
82
153
|
wrap,
|
|
83
|
-
unwrap
|
|
154
|
+
unwrap,
|
|
155
|
+
frameEpoch,
|
|
156
|
+
sealEntries,
|
|
157
|
+
openEntries
|
|
84
158
|
};
|
|
85
159
|
}
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
2
|
import { type Logger } from '@kubun/logger';
|
|
3
3
|
import type { GroupHandle } from '@kumiai/mls';
|
|
4
|
+
import { type LedgerEntryResolver, type LedgerEntrySink } from './mls-group-handle.js';
|
|
5
|
+
/**
|
|
6
|
+
* Per-call handle wiring: the two hooks a caller may bind for the duration of
|
|
7
|
+
* ONE operation rather than for the lifetime of the cached handle. Both are
|
|
8
|
+
* scoped to the working handle a call restores, so nothing a caller binds here
|
|
9
|
+
* leaks into another caller's handle.
|
|
10
|
+
*/
|
|
11
|
+
export type HandleCallOptions = {
|
|
12
|
+
/** Replaces the store-based resolver — a received commit's bodies ride its own frame. */
|
|
13
|
+
resolveLedgerEntries?: LedgerEntryResolver;
|
|
14
|
+
/** Collects the entries an accepted commit surfaces, for the caller to fold. */
|
|
15
|
+
onLedgerEntries?: LedgerEntrySink;
|
|
16
|
+
};
|
|
4
17
|
export type GroupHandleRegistryParams = {
|
|
5
18
|
stores: StoreProvider;
|
|
6
19
|
deviceID: string;
|
|
7
20
|
logger?: Logger;
|
|
21
|
+
/**
|
|
22
|
+
* How long an operation waits for a group's handle before failing. Test-only
|
|
23
|
+
* knob — production runs on {@link MUTEX_ACQUIRE_TIMEOUT_MS}, which is orders
|
|
24
|
+
* of magnitude above any legitimate hold.
|
|
25
|
+
*/
|
|
26
|
+
mutexTimeoutMs?: number;
|
|
8
27
|
};
|
|
9
28
|
export type SeedParams = {
|
|
10
29
|
groupID: string;
|
|
@@ -17,6 +36,22 @@ export type SeedParams = {
|
|
|
17
36
|
* leaves the cache empty.
|
|
18
37
|
*/
|
|
19
38
|
stores: StoreProvider;
|
|
39
|
+
/**
|
|
40
|
+
* Replace a handle this device already holds for the group, rather than
|
|
41
|
+
* refusing.
|
|
42
|
+
*
|
|
43
|
+
* Only for a REJOIN: a device that left, or that someone else removed, keeps
|
|
44
|
+
* its MLS state and therefore its cached handle, and that handle can decrypt
|
|
45
|
+
* nothing at the group's current epoch. Being admitted back hands it a live
|
|
46
|
+
* one, and the guard below — which exists to stop a live member being rolled
|
|
47
|
+
* back to a handle frozen at its joining epoch — would otherwise refuse the
|
|
48
|
+
* only handle that works.
|
|
49
|
+
*
|
|
50
|
+
* The caller establishes the rejoin from the group row's own `status`, never
|
|
51
|
+
* from the invite: an unauthenticated payload must not be able to displace a
|
|
52
|
+
* live member's handle.
|
|
53
|
+
*/
|
|
54
|
+
replace?: boolean;
|
|
20
55
|
};
|
|
21
56
|
/**
|
|
22
57
|
* Single canonical access point for a device's MLS `GroupHandle` instances.
|
|
@@ -28,8 +63,7 @@ export type SeedParams = {
|
|
|
28
63
|
*
|
|
29
64
|
* All five mutation paths (manager member ops, context handlers, broadcast
|
|
30
65
|
* service encrypt/decrypt, hub-tunnel sync listener, hub-tunnel sync provider)
|
|
31
|
-
* route MLS state through this registry.
|
|
32
|
-
* `docs/superpowers/specs/2026-05-09-group-handle-registry-design.md`.
|
|
66
|
+
* route MLS state through this registry.
|
|
33
67
|
*/
|
|
34
68
|
export declare class GroupHandleRegistry {
|
|
35
69
|
#private;
|
|
@@ -43,22 +77,15 @@ export declare class GroupHandleRegistry {
|
|
|
43
77
|
* epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
|
|
44
78
|
* removeMember, processCommit), use `withHandleReplacing` instead.
|
|
45
79
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* to avoid the window entirely.
|
|
56
|
-
*
|
|
57
|
-
* `options.stores` overrides the constructor-stored `StoreProvider` for the
|
|
58
|
-
* restore + persist DB calls. Callers running inside an engine transaction
|
|
59
|
-
* (e.g. context handlers under `mutateGraph`) MUST pass the transactional
|
|
60
|
-
* `tx` so reads + writes share the tx's connection. Without this, SQLite's
|
|
61
|
-
* write lock held by the outer tx blocks the registry's persist forever.
|
|
80
|
+
* BOUND: under a caller transaction that commits later, the mutex releases
|
|
81
|
+
* before that commit, so a concurrent same-group op can observe the uncommitted
|
|
82
|
+
* handle. `onRollback` repairs the cache; the live exposure is a cross-epoch
|
|
83
|
+
* decrypt that fails the MLS check and skips its persist — a dropped message,
|
|
84
|
+
* never a durable divergence, and only on a multi-connection adapter. Use
|
|
85
|
+
* `withHandleReplacingInTransaction` to avoid the window entirely.
|
|
86
|
+
*
|
|
87
|
+
* The persist runs AFTER the mutex is released — see {@link #withLockedHandle}
|
|
88
|
+
* — but is still awaited here, so a caller that returns has its state durable.
|
|
62
89
|
*/
|
|
63
90
|
withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
|
|
64
91
|
stores?: StoreProvider;
|
|
@@ -104,37 +131,80 @@ export declare class GroupHandleRegistry {
|
|
|
104
131
|
* the working handle and the transactional `StoreProvider` so it can write
|
|
105
132
|
* those other rows on the same connection.
|
|
106
133
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* rollback the cache still matches the reverted DB; on commit the cache is
|
|
122
|
-
* invalidated and the next access lazily re-restores the committed advanced
|
|
123
|
-
* state. This costs one extra MLS-state restore per call.
|
|
134
|
+
* The lock MUST span the commit: the `onCommit` invalidation then fires while it
|
|
135
|
+
* is still held, so there is no post-mutex / pre-commit window for a concurrent
|
|
136
|
+
* op to clobber the advance.
|
|
137
|
+
*
|
|
138
|
+
* The working handle MUST be fresh, not the cached one: `processMessage` advances
|
|
139
|
+
* the ratchet IN PLACE, so running against the cache would advance it while the
|
|
140
|
+
* transaction is still uncommitted, and a rollback would leave the cache ahead of
|
|
141
|
+
* the reverted DB row. Costs one extra MLS-state restore per call.
|
|
142
|
+
*
|
|
143
|
+
* THE ONE PATH that still touches the database under the mutex, because its
|
|
144
|
+
* whole contract is that the lock spans the commit. It is therefore the only
|
|
145
|
+
* remaining candidate to be the holder in a lock-order cycle — see
|
|
146
|
+
* {@link #withLockedHandle} for why that matters — and the acquisition
|
|
147
|
+
* deadline on every other caller is what stops such a cycle being permanent.
|
|
124
148
|
*/
|
|
125
149
|
withHandleReplacingInTransaction<T>(groupID: string, stores: StoreProvider, fn: (handle: GroupHandle, tx: StoreProvider) => Promise<{
|
|
126
150
|
result: T;
|
|
127
151
|
updated: GroupHandle;
|
|
128
|
-
}
|
|
152
|
+
}>, options?: HandleCallOptions): Promise<T>;
|
|
153
|
+
/**
|
|
154
|
+
* The last epoch this registry observed for `groupID`, or `null` if it has
|
|
155
|
+
* never held a handle for it. Synchronous, because the one port that needs it
|
|
156
|
+
* — `GroupCrypto.epoch()` — is synchronous by contract while every handle
|
|
157
|
+
* access here is async and mutex-guarded.
|
|
158
|
+
*
|
|
159
|
+
* Publishes a NUMBER, never the handle: a scalar cannot advance a ratchet, skip
|
|
160
|
+
* the per-group mutex, or mutate MLS state. DO NOT widen this to return the
|
|
161
|
+
* handle — the mutex is what makes the ratchet advance and its row write one
|
|
162
|
+
* atomic step.
|
|
163
|
+
*
|
|
164
|
+
* Deliberately NOT monotonic: a rolled-back advance must republish the LOWER
|
|
165
|
+
* epoch, or the reader is pinned above the state the device holds. Genuine
|
|
166
|
+
* backwards installs are refused upstream, by `replaceHandle`'s epoch guard and
|
|
167
|
+
* `saveMLSState`'s no-op on a lower row.
|
|
168
|
+
*/
|
|
169
|
+
groupEpoch(groupID: string): number | null;
|
|
170
|
+
/**
|
|
171
|
+
* Called when the epoch published for a group CHANGES — an advance, or the
|
|
172
|
+
* lower republish a rolled-back one produces. Returns the unsubscribe.
|
|
173
|
+
*
|
|
174
|
+
* The first observation of a group is deliberately NOT a change: restoring a
|
|
175
|
+
* handle at boot publishes an epoch where there was none, and treating that as
|
|
176
|
+
* a rotation would have every group fire its epoch-change consumers once per
|
|
177
|
+
* process start.
|
|
178
|
+
*
|
|
179
|
+
* Listeners run INSIDE the per-group mutex and inside whatever transaction the
|
|
180
|
+
* advancing operation holds, so one must never await the registry or the store
|
|
181
|
+
* from here. Defer the work.
|
|
182
|
+
*/
|
|
183
|
+
onEpochChanged(listener: (groupID: string, epoch: number) => void): () => void;
|
|
129
184
|
/**
|
|
130
185
|
* Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
|
|
131
186
|
* Called by `leaveGroup` after the MLS state row is deleted, and by tests.
|
|
187
|
+
*
|
|
188
|
+
* The published epoch is deliberately LEFT in place. Invalidation is how this
|
|
189
|
+
* registry says "re-read the durable row", not "this device no longer has an
|
|
190
|
+
* epoch" — and the transactional advance path invalidates immediately after
|
|
191
|
+
* committing an advance, so clearing here would discard the newest epoch the
|
|
192
|
+
* device has. The next restore republishes whatever the row actually holds.
|
|
132
193
|
*/
|
|
133
194
|
invalidate(groupID: string): void;
|
|
134
195
|
/**
|
|
135
196
|
* Atomically swap+persist the cached handle under the per-group mutex, so a
|
|
136
197
|
* concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
|
|
137
198
|
* after (new handle), never interleaved.
|
|
199
|
+
*
|
|
200
|
+
* MONOTONIC in the epoch: a handle strictly BEHIND the one held is refused, no
|
|
201
|
+
* cache swap and no persist. Installing one would roll this device's key schedule
|
|
202
|
+
* back and have it encrypt at an epoch its co-members have left, producing traffic
|
|
203
|
+
* nobody can open with nothing raised. Reachable because a journalled commit is
|
|
204
|
+
* adopted at-least-once, carrying a handle frozen at the epoch it was built.
|
|
205
|
+
*
|
|
206
|
+
* The bound is STRICT — a same-epoch re-install must still win, because decrypting
|
|
207
|
+
* an application message advances the secret-tree generation within one epoch.
|
|
138
208
|
*/
|
|
139
209
|
replaceHandle(groupID: string, handle: GroupHandle, options?: {
|
|
140
210
|
stores?: StoreProvider;
|