@kubun/plugin-p2p 0.10.1 → 0.12.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/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- 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 +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- 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 +55 -0
- package/lib/groups/admin-roster.js +69 -0
- 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-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- 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 +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- 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 +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- 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 +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- 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 +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- 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 +155 -1
- 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 +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- 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 +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- 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 +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- 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 +704 -22
- package/lib/types.js +1 -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 +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { GroupCrypto } from '@kumiai/rpc';
|
|
2
|
+
import { type Runtime } from '@sozai/runtime';
|
|
3
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Adapts a `GroupHandleRegistry` group into `@kumiai/rpc`'s {@link GroupCrypto}
|
|
6
|
+
* port: epoch, an epoch-bound topic-derivation secret, and byte-level
|
|
7
|
+
* encrypt/decrypt over the live MLS handle.
|
|
8
|
+
*
|
|
9
|
+
* `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle`, taking the
|
|
10
|
+
* per-group mutex and observing the canonical handle, framed as in
|
|
11
|
+
* `mls-codec.ts`. `exportSecret` passes the caller's label through untouched:
|
|
12
|
+
* the labels belong to the package deriving topics from the result, so
|
|
13
|
+
* substituting one moves every topic ID away from what the caller addressed.
|
|
14
|
+
*
|
|
15
|
+
* `sealEntries`/`openEntries` are a SECOND seal, NOT interchangeable with
|
|
16
|
+
* `wrap`/`unwrap`: those consume a ratchet generation and mutate the handle, so
|
|
17
|
+
* they cannot serve an open running inside the apply of the commit carrying the
|
|
18
|
+
* blob. The bytes must match `@kumiai/mls-rpc` exactly — the seal is agreed
|
|
19
|
+
* without exchange, so a divergence is not a decode error but a group whose
|
|
20
|
+
* members silently stop reading each other's commits.
|
|
21
|
+
*
|
|
22
|
+
* The epoch is READ FROM THE REGISTRY, never cached: applying someone else's
|
|
23
|
+
* commit advances the handle without touching this file, and a stale number
|
|
24
|
+
* classifies every later frame as `ahead`, so the cursor skips it forever.
|
|
25
|
+
* `initialEpoch` covers only construction before the registry has observed a
|
|
26
|
+
* handle — the caller MUST read the handle's epoch first, or a returning peer
|
|
27
|
+
* answering `0` steps over every commit it missed. Construction is synchronous
|
|
28
|
+
* and eager, leaving no window to prime it afterwards.
|
|
29
|
+
*/
|
|
30
|
+
export type GroupCryptoParams = {
|
|
31
|
+
registry: GroupHandleRegistry;
|
|
32
|
+
groupID: string;
|
|
33
|
+
/** The handle's CURRENT epoch — see the note above on why `0` is not a safe placeholder. */
|
|
34
|
+
initialEpoch: number;
|
|
35
|
+
/**
|
|
36
|
+
* Host runtime supplying the seal's randomness, so the source follows the
|
|
37
|
+
* platform the host is on. Pass the host's — the default constructs a SECOND
|
|
38
|
+
* runtime rather than propagating one.
|
|
39
|
+
*/
|
|
40
|
+
runtime?: Runtime;
|
|
41
|
+
};
|
|
42
|
+
export declare function createGroupCrypto(params: GroupCryptoParams): GroupCrypto;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readMessageEpoch } from '@kumiai/mls';
|
|
2
|
+
import { ENTRY_SEAL_LABEL } from '@kumiai/mls-rpc';
|
|
3
|
+
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
4
|
+
import { toB64 } from '@sozai/codec';
|
|
5
|
+
import { createRuntime } from '@sozai/runtime';
|
|
6
|
+
import { mlsExporter } from 'ts-mls';
|
|
7
|
+
import { mlsDecryptFramed, mlsEncryptFramed } from './mls-codec.js';
|
|
8
|
+
// Empty context: the epoch already varies the secret; topic derivation mixes in
|
|
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.
|
|
12
|
+
const TOPIC_SECRET_CONTEXT = new Uint8Array(0);
|
|
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);
|
|
35
|
+
});
|
|
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);
|
|
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));
|
|
55
|
+
// In-flight decrypt cache, keyed by the ciphertext bytes.
|
|
56
|
+
//
|
|
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.
|
|
73
|
+
const unwrapCache = new Map();
|
|
74
|
+
const unwrap = (bytes)=>{
|
|
75
|
+
const key = toB64(bytes);
|
|
76
|
+
const cached = unwrapCache.get(key);
|
|
77
|
+
if (cached != null) {
|
|
78
|
+
return cached;
|
|
79
|
+
}
|
|
80
|
+
const promise = registry.readHandle(groupID, async (handle)=>{
|
|
81
|
+
const { payload, senderDID } = await mlsDecryptFramed(handle, bytes);
|
|
82
|
+
return {
|
|
83
|
+
payload,
|
|
84
|
+
senderDID
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
// Drop the entry once it settles (success or failure): the sibling transport
|
|
88
|
+
// has already taken the in-flight promise, and a genuine failure must not be
|
|
89
|
+
// cached so a later valid frame with the same bytes (e.g. after a resync) can
|
|
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.
|
|
100
|
+
unwrapCache.set(key, promise);
|
|
101
|
+
void promise.finally(()=>{
|
|
102
|
+
unwrapCache.delete(key);
|
|
103
|
+
}).catch(()=>{});
|
|
104
|
+
return promise;
|
|
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
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
epoch,
|
|
152
|
+
exportSecret,
|
|
153
|
+
wrap,
|
|
154
|
+
unwrap,
|
|
155
|
+
frameEpoch,
|
|
156
|
+
sealEntries,
|
|
157
|
+
openEntries
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
import type { GroupHandle } from '@enkaku/group';
|
|
2
1
|
import type { StoreProvider } from '@kubun/db';
|
|
3
2
|
import { type Logger } from '@kubun/logger';
|
|
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,19 +77,36 @@ 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
|
-
*
|
|
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.
|
|
51
89
|
*/
|
|
52
90
|
withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
|
|
53
91
|
stores?: StoreProvider;
|
|
54
92
|
}): Promise<T>;
|
|
93
|
+
/**
|
|
94
|
+
* Acquire the canonical handle for `groupID`, run `fn` under the per-group
|
|
95
|
+
* mutex, return the result, release — WITHOUT persisting MLS state.
|
|
96
|
+
*
|
|
97
|
+
* Read-only access: takes the lock and lazy-restores the cached handle if
|
|
98
|
+
* absent, but never writes MLS state. Use this for pure reads that do not
|
|
99
|
+
* mutate the handle (anchor reads, `GroupInfo` export, credential reads). For
|
|
100
|
+
* in-place-mutating ops that advance the MLS secret tree (encrypt/decrypt),
|
|
101
|
+
* use `withHandle`, which persists the advanced state.
|
|
102
|
+
*/
|
|
103
|
+
readHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T> | T, options?: {
|
|
104
|
+
stores?: StoreProvider;
|
|
105
|
+
}): Promise<T>;
|
|
55
106
|
/**
|
|
56
107
|
* Like `withHandle`, but the callback returns `{ result, updated }`.
|
|
57
108
|
* Registry replaces its cached handle with `updated` before persisting on
|
|
58
|
-
* success. Required for `@
|
|
109
|
+
* success. Required for `@kumiai/mls` epoch ops (`commitInvite`,
|
|
59
110
|
* `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
|
|
60
111
|
* rather than mutating the input.
|
|
61
112
|
*
|
|
@@ -67,15 +118,93 @@ export declare class GroupHandleRegistry {
|
|
|
67
118
|
}>, options?: {
|
|
68
119
|
stores?: StoreProvider;
|
|
69
120
|
}): Promise<T>;
|
|
121
|
+
/**
|
|
122
|
+
* Like `withHandleReplacing`, but the per-group mutex is held across the
|
|
123
|
+
* ENTIRE transaction — it is acquired once, then a transaction is opened, run,
|
|
124
|
+
* and committed under that single lock, which releases only AFTER commit (or
|
|
125
|
+
* rollback). The cached `entry.handle` is NEVER touched by this method; the
|
|
126
|
+
* cache swap is the `onCommit` invalidation.
|
|
127
|
+
*
|
|
128
|
+
* Use this when the MLS advance must commit or roll back together with other
|
|
129
|
+
* rows written in the same transaction (e.g. receive-side commit processing
|
|
130
|
+
* that advances the ratchet AND writes the roster delta). `fn` receives both
|
|
131
|
+
* the working handle and the transactional `StoreProvider` so it can write
|
|
132
|
+
* those other rows on the same connection.
|
|
133
|
+
*
|
|
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.
|
|
148
|
+
*/
|
|
149
|
+
withHandleReplacingInTransaction<T>(groupID: string, stores: StoreProvider, fn: (handle: GroupHandle, tx: StoreProvider) => Promise<{
|
|
150
|
+
result: T;
|
|
151
|
+
updated: GroupHandle;
|
|
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;
|
|
70
184
|
/**
|
|
71
185
|
* Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
|
|
72
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.
|
|
73
193
|
*/
|
|
74
194
|
invalidate(groupID: string): void;
|
|
75
195
|
/**
|
|
76
196
|
* Atomically swap+persist the cached handle under the per-group mutex, so a
|
|
77
197
|
* concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
|
|
78
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.
|
|
79
208
|
*/
|
|
80
209
|
replaceHandle(groupID: string, handle: GroupHandle, options?: {
|
|
81
210
|
stores?: StoreProvider;
|