@gryt/core 0.9.0 → 0.10.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/README.md +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/mls/dmDriver.d.ts +9 -0
- package/dist/mls/dmDriver.js +591 -0
- package/dist/mls/interfaces.d.ts +274 -0
- package/dist/mls/interfaces.js +1 -0
- package/dist/mls/pins.d.ts +21 -0
- package/dist/mls/pins.js +26 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -47,6 +47,12 @@ goes missing **and** when one appears that nobody listed.
|
|
|
47
47
|
- **`permissions`** — the channel scope matrix: rules in, grid out, and back.
|
|
48
48
|
The two apps keyed their cell map differently, a NUL on the desktop and a
|
|
49
49
|
space on the phone, which is a key one of them could collide on.
|
|
50
|
+
- **`mls`** — the DM half of MLS stage 1. It keeps a device's KeyPackages
|
|
51
|
+
topped up, opens a DM group, joins from a Welcome, sends, and reads the group
|
|
52
|
+
log in order. Each app hands it the socket, the storage and the pins through
|
|
53
|
+
the interfaces in `src/mls/interfaces.ts`. `mlsPinsFromPeerPins` builds the
|
|
54
|
+
pins from the peer pins `@gryt/crypto` already keeps. It's the one module with
|
|
55
|
+
a dependency, `@gryt/crypto`, pinned exactly the way the apps pin it.
|
|
50
56
|
|
|
51
57
|
## What deliberately isn't
|
|
52
58
|
|
package/dist/index.d.ts
CHANGED
|
@@ -10,3 +10,6 @@ export { groupMembersByRole, OFFLINE_GROUP_KEY, UNGROUPED_GROUP_KEY, type Groupa
|
|
|
10
10
|
export { senderStreamId } from "./webrtc/senderStreamId.js";
|
|
11
11
|
export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissions/folderRules.js";
|
|
12
12
|
export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
|
|
13
|
+
export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
|
|
14
|
+
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
15
|
+
export type { DmSealingMode, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsLogEntry, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
|
package/dist/index.js
CHANGED
|
@@ -12,3 +12,5 @@ export { groupMembersByRole, OFFLINE_GROUP_KEY, UNGROUPED_GROUP_KEY, } from "./m
|
|
|
12
12
|
export { senderStreamId } from "./webrtc/senderStreamId.js";
|
|
13
13
|
export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissions/folderRules.js";
|
|
14
14
|
export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
|
|
15
|
+
export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
|
|
16
|
+
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MlsDmDriver, MlsDmDriverOptions, MlsRefusal } from "./interfaces.js";
|
|
2
|
+
export type MlsDriverErrorCode = "refused" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "commit_retries";
|
|
3
|
+
export declare class MlsDriverError extends Error {
|
|
4
|
+
code: MlsDriverErrorCode;
|
|
5
|
+
/** The server's refusal, when there was one. */
|
|
6
|
+
refusal?: MlsRefusal;
|
|
7
|
+
constructor(code: MlsDriverErrorCode, message: string, refusal?: MlsRefusal);
|
|
8
|
+
}
|
|
9
|
+
export declare function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver;
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decodeMlsGroupState, encodeMlsGroupState, encryptMlsMessage, forgetMlsSecrets, generateMlsKeyPackage, joinMlsGroup, mlsGroupInfo, mlsGroupMembers, mlsWelcomeRefs, processMlsMessage, readMlsKeyPackage, removeMlsMembers, } from "@gryt/crypto";
|
|
2
|
+
/** Five devices per person per server (design, section 1). The server holds the same line. */
|
|
3
|
+
const MLS_MAX_DEVICES_PER_PERSON = 5;
|
|
4
|
+
const COMMIT_ATTEMPTS = 5;
|
|
5
|
+
const LOG_PAGE = 200;
|
|
6
|
+
const WELCOME_WAIT_MS = 10_000;
|
|
7
|
+
/** The server keeps Welcomes 30 days; a last-resort package is replaced with a week to go. */
|
|
8
|
+
const WELCOME_KEEP_S = 30 * 86_400;
|
|
9
|
+
const RENEW_BEFORE_S = 7 * 86_400;
|
|
10
|
+
export class MlsDriverError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
/** The server's refusal, when there was one. */
|
|
13
|
+
refusal;
|
|
14
|
+
constructor(code, message, refusal) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "MlsDriverError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.refusal = refusal;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function refused(what, r) {
|
|
22
|
+
return new MlsDriverError("refused", `${what}: ${r.error} (${r.message})`, r);
|
|
23
|
+
}
|
|
24
|
+
function toHex(bytes) {
|
|
25
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
26
|
+
}
|
|
27
|
+
function sameBytes(a, b) {
|
|
28
|
+
if (a.length !== b.length)
|
|
29
|
+
return false;
|
|
30
|
+
for (let i = 0; i < a.length; i++)
|
|
31
|
+
if (a[i] !== b[i])
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const epochOf = (state) => Number(mlsGroupInfo(state).epoch);
|
|
36
|
+
const groupIdOf = (state) => toHex(base64UrlDecode(mlsGroupInfo(state).groupId));
|
|
37
|
+
/* One job at a time per key, in the order they were asked for. A job must never wait on
|
|
38
|
+
another job with its own key. */
|
|
39
|
+
function keyedQueue() {
|
|
40
|
+
const tails = new Map();
|
|
41
|
+
return (key, job) => {
|
|
42
|
+
const result = (tails.get(key) ?? Promise.resolve()).then(job);
|
|
43
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
44
|
+
tails.set(key, tail);
|
|
45
|
+
void tail.then(() => {
|
|
46
|
+
if (tails.get(key) === tail)
|
|
47
|
+
tails.delete(key);
|
|
48
|
+
});
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function createMlsDmDriver(options) {
|
|
53
|
+
const { transport, store, pins, events, serverUserId: self, capability } = options;
|
|
54
|
+
const scope = asIdentityScope(options.scope);
|
|
55
|
+
const trusts = new Map();
|
|
56
|
+
/** What the engine asks about every leaf: is it somebody in this conversation, by pin. */
|
|
57
|
+
function trustFor(conversationId) {
|
|
58
|
+
let t = trusts.get(conversationId);
|
|
59
|
+
if (!t) {
|
|
60
|
+
const trustPersonKey = pins.trustFor?.(conversationId) ?? (async (c) => (await pins.personOf(conversationId, c.personPublicKey)) !== null);
|
|
61
|
+
t = { scope, trustPersonKey };
|
|
62
|
+
trusts.set(conversationId, t);
|
|
63
|
+
}
|
|
64
|
+
return t;
|
|
65
|
+
}
|
|
66
|
+
const run = keyedQueue();
|
|
67
|
+
let device = null;
|
|
68
|
+
let registered = false;
|
|
69
|
+
/** Conversations whose device list was checked since the last `mls:devices:changed`. */
|
|
70
|
+
const reconciled = new Set();
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const welcomeWaiters = new Map();
|
|
73
|
+
async function ownDevice() {
|
|
74
|
+
if (device)
|
|
75
|
+
return device;
|
|
76
|
+
let d = await store.loadDevice();
|
|
77
|
+
if (!d) {
|
|
78
|
+
d = await options.newDevice();
|
|
79
|
+
await store.saveDevice(d);
|
|
80
|
+
}
|
|
81
|
+
device = d;
|
|
82
|
+
return d;
|
|
83
|
+
}
|
|
84
|
+
async function markSeen(serverUserId) {
|
|
85
|
+
if (serverUserId === self || seen.has(serverUserId))
|
|
86
|
+
return;
|
|
87
|
+
await pins.markSeenOnMls(serverUserId);
|
|
88
|
+
seen.add(serverUserId);
|
|
89
|
+
}
|
|
90
|
+
/** Every person with a leaf in the group, by pin. Null for a leaf nobody's pin covers. */
|
|
91
|
+
async function peopleIn(conversationId, state) {
|
|
92
|
+
const members = mlsGroupMembers(state, scope);
|
|
93
|
+
return Promise.all(members.map((m) => pins.personOf(conversationId, m.certificate.personPublicKey)));
|
|
94
|
+
}
|
|
95
|
+
async function notePeople(conversationId, state) {
|
|
96
|
+
for (const person of await peopleIn(conversationId, state))
|
|
97
|
+
if (person)
|
|
98
|
+
await markSeen(person);
|
|
99
|
+
}
|
|
100
|
+
async function save(open, cursor = open.rec.cursor) {
|
|
101
|
+
open.rec = { ...open.rec, state: encodeMlsGroupState(open.state), cursor };
|
|
102
|
+
await store.saveGroup(open.rec);
|
|
103
|
+
}
|
|
104
|
+
async function load(conversationId) {
|
|
105
|
+
const rec = await store.loadGroup(conversationId);
|
|
106
|
+
return rec ? { rec, state: decodeMlsGroupState(rec.state, trustFor(conversationId)) } : null;
|
|
107
|
+
}
|
|
108
|
+
// ── KeyPackages ─────────────────────────────────────────────────────
|
|
109
|
+
async function topUp(counts, forceLastResort = false) {
|
|
110
|
+
const d = await ownDevice();
|
|
111
|
+
const now = Math.floor(Date.now() / 1000);
|
|
112
|
+
const held = await store.listKeyPackages();
|
|
113
|
+
// A Welcome can still name a package for as long as the server keeps Welcomes.
|
|
114
|
+
for (const k of held)
|
|
115
|
+
if (k.expiresAt !== undefined && k.expiresAt < now - WELCOME_KEEP_S)
|
|
116
|
+
await store.deleteKeyPackage(k.ref);
|
|
117
|
+
const newestLast = held.filter((k) => k.lastResort).sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
118
|
+
// One with no expiresAt predates lifetimes, and readers now refuse it.
|
|
119
|
+
const lastRunningOut = !!newestLast && (newestLast.expiresAt === undefined || newestLast.expiresAt - now < RENEW_BEFORE_S);
|
|
120
|
+
const wanted = !registered || counts.unclaimed < counts.target / 2 ? counts.target - counts.unclaimed : 0;
|
|
121
|
+
const needLastResort = !counts.lastResort || forceLastResort || lastRunningOut;
|
|
122
|
+
if (wanted <= 0 && !needLastResort && registered)
|
|
123
|
+
return;
|
|
124
|
+
const make = async (lastResort) => {
|
|
125
|
+
const kp = await generateMlsKeyPackage(d);
|
|
126
|
+
return { ...kp, lastResort, createdAt: Date.now() };
|
|
127
|
+
};
|
|
128
|
+
const fresh = [];
|
|
129
|
+
for (let i = 0; i < wanted; i++)
|
|
130
|
+
fresh.push(await make(false));
|
|
131
|
+
const last = needLastResort ? await make(true) : undefined;
|
|
132
|
+
// The private halves are stored first, so a Welcome can never name one this device lost.
|
|
133
|
+
await store.putKeyPackages(last ? [...fresh, last] : fresh);
|
|
134
|
+
const r = await transport.publishKeyPackages({
|
|
135
|
+
deviceId: d.deviceId,
|
|
136
|
+
keyPackages: fresh.map((k) => k.keyPackage),
|
|
137
|
+
lastResort: last?.keyPackage,
|
|
138
|
+
});
|
|
139
|
+
if (!r.ok)
|
|
140
|
+
throw refused("Publishing KeyPackages", r);
|
|
141
|
+
registered = true;
|
|
142
|
+
}
|
|
143
|
+
async function syncAndTopUp(forceLastResort = false) {
|
|
144
|
+
const d = await ownDevice();
|
|
145
|
+
const r = await transport.sync({ deviceId: d.deviceId });
|
|
146
|
+
if (!r.ok)
|
|
147
|
+
throw refused("Sync", r);
|
|
148
|
+
registered = r.registered;
|
|
149
|
+
await topUp(r.keyPackages, forceLastResort);
|
|
150
|
+
return r.welcomes;
|
|
151
|
+
}
|
|
152
|
+
// ── The log ─────────────────────────────────────────────────────────
|
|
153
|
+
/** Returns false once the group can't go on from here; the state is kept, not dropped. */
|
|
154
|
+
async function applyEntry(open, e) {
|
|
155
|
+
if (e.seq <= open.rec.cursor)
|
|
156
|
+
return true;
|
|
157
|
+
const d = await ownDevice();
|
|
158
|
+
const current = epochOf(open.state);
|
|
159
|
+
if (e.kind === "commit") {
|
|
160
|
+
const pending = open.rec.pending;
|
|
161
|
+
if (pending && sameBytes(pending.commit, e.data)) {
|
|
162
|
+
open.state = decodeMlsGroupState(pending.state, trustFor(open.rec.conversationId));
|
|
163
|
+
}
|
|
164
|
+
else if (e.epoch < current) {
|
|
165
|
+
// Already in this state: our own commit, or one from before this device joined.
|
|
166
|
+
}
|
|
167
|
+
else if (e.epoch > current) {
|
|
168
|
+
events.onGroupLost?.({ conversationId: open.rec.conversationId, reason: "out_of_sync" });
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
try {
|
|
173
|
+
const r = await processMlsMessage(open.state, e.data);
|
|
174
|
+
if (r.kind === "handshake" && r.removed) {
|
|
175
|
+
await store.deleteGroup(open.rec.conversationId);
|
|
176
|
+
events.onGroupLost?.({ conversationId: open.rec.conversationId, reason: "removed" });
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
open.state = r.state;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
events.onGroupLost?.({ conversationId: open.rec.conversationId, reason: "out_of_sync" });
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
open.rec = { ...open.rec, pending: undefined };
|
|
187
|
+
await notePeople(open.rec.conversationId, open.state);
|
|
188
|
+
}
|
|
189
|
+
else if (e.kind === "proposal") {
|
|
190
|
+
if (e.epoch === current) {
|
|
191
|
+
try {
|
|
192
|
+
open.state = (await processMlsMessage(open.state, e.data)).state;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
events.onUndecryptable?.({ conversationId: open.rec.conversationId, seq: e.seq, reason: "bad_proposal" });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (!(e.senderServerUserId === self && e.senderDeviceId === d.deviceId) && e.epoch >= open.rec.joinedEpoch) {
|
|
200
|
+
let plaintext = null;
|
|
201
|
+
try {
|
|
202
|
+
const r = await processMlsMessage(open.state, e.data);
|
|
203
|
+
if (r.kind !== "application")
|
|
204
|
+
throw new Error("Not an application message.");
|
|
205
|
+
open.state = r.state;
|
|
206
|
+
plaintext = r.plaintext;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
events.onUndecryptable?.({ conversationId: open.rec.conversationId, seq: e.seq, reason: "undecryptable" });
|
|
210
|
+
}
|
|
211
|
+
if (plaintext) {
|
|
212
|
+
await events.onMessage({
|
|
213
|
+
conversationId: open.rec.conversationId,
|
|
214
|
+
seq: e.seq,
|
|
215
|
+
senderServerUserId: e.senderServerUserId,
|
|
216
|
+
senderDeviceId: e.senderDeviceId,
|
|
217
|
+
plaintext,
|
|
218
|
+
createdAt: e.createdAt,
|
|
219
|
+
});
|
|
220
|
+
await markSeen(e.senderServerUserId);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await save(open, e.seq);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
/** Everything after the cursor, a page at a time. False when the group stopped. */
|
|
227
|
+
async function catchUp(open) {
|
|
228
|
+
for (;;) {
|
|
229
|
+
const r = await transport.fetchLog({ conversationId: open.rec.conversationId, after: open.rec.cursor, limit: LOG_PAGE });
|
|
230
|
+
if (!r.ok)
|
|
231
|
+
throw refused("Reading the group log", r);
|
|
232
|
+
if (r.group && r.group.groupId !== open.rec.groupId) {
|
|
233
|
+
events.onGroupLost?.({ conversationId: open.rec.conversationId, reason: "out_of_sync" });
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (r.gap && open.rec.cursor > 0) {
|
|
237
|
+
events.onUndecryptable?.({ conversationId: open.rec.conversationId, seq: open.rec.cursor + 1, reason: "gap" });
|
|
238
|
+
}
|
|
239
|
+
for (const e of r.entries)
|
|
240
|
+
if (!(await applyEntry(open, e)))
|
|
241
|
+
return false;
|
|
242
|
+
if (r.nextCursor > open.rec.cursor)
|
|
243
|
+
await save(open, r.nextCursor);
|
|
244
|
+
if (!r.hasMore)
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ── Commits ─────────────────────────────────────────────────────────
|
|
249
|
+
/** Builds on the latest state, and on stale_epoch catches up and builds again. */
|
|
250
|
+
async function commitWithRetry(open, build) {
|
|
251
|
+
const d = await ownDevice();
|
|
252
|
+
for (let attempt = 0; attempt < COMMIT_ATTEMPTS; attempt++) {
|
|
253
|
+
const c = await build(open.state);
|
|
254
|
+
if (!c)
|
|
255
|
+
return;
|
|
256
|
+
open.rec = { ...open.rec, pending: { commit: c.commit, state: encodeMlsGroupState(c.state) } };
|
|
257
|
+
await save(open);
|
|
258
|
+
const r = await transport.commit({
|
|
259
|
+
conversationId: open.rec.conversationId,
|
|
260
|
+
deviceId: d.deviceId,
|
|
261
|
+
commit: c.commit,
|
|
262
|
+
welcome: c.welcome,
|
|
263
|
+
});
|
|
264
|
+
if (r.ok) {
|
|
265
|
+
open.state = c.state;
|
|
266
|
+
forgetMlsSecrets(c.consumed);
|
|
267
|
+
open.rec = { ...open.rec, pending: undefined };
|
|
268
|
+
await save(open);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
open.rec = { ...open.rec, pending: undefined };
|
|
272
|
+
await save(open);
|
|
273
|
+
if (r.error === "stale_epoch") {
|
|
274
|
+
if (!(await catchUp(open)))
|
|
275
|
+
throw new MlsDriverError("not_in_group", "This device lost its place in the group.");
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (r.error === "no_group") {
|
|
279
|
+
// Saved locally but never registered, from a crash in between.
|
|
280
|
+
const created = await transport.createGroup({ conversationId: open.rec.conversationId, groupId: open.rec.groupId });
|
|
281
|
+
if (created.ok)
|
|
282
|
+
continue;
|
|
283
|
+
await store.deleteGroup(open.rec.conversationId);
|
|
284
|
+
throw refused("Registering the group", created);
|
|
285
|
+
}
|
|
286
|
+
throw refused("Committing", r);
|
|
287
|
+
}
|
|
288
|
+
throw new MlsDriverError("commit_retries", "Other commits kept getting there first.");
|
|
289
|
+
}
|
|
290
|
+
/** Removes leaves for devices the server no longer lists, and with `adds` adds new ones. */
|
|
291
|
+
async function reconcile(open, peer, adds) {
|
|
292
|
+
const d = await ownDevice();
|
|
293
|
+
const conversationId = open.rec.conversationId;
|
|
294
|
+
const r = await transport.listDevices({ conversationId });
|
|
295
|
+
if (!r.ok)
|
|
296
|
+
throw refused("Listing devices", r);
|
|
297
|
+
const listed = r.devices.filter((x) => x.serverUserId === self || x.serverUserId === peer);
|
|
298
|
+
if (!listed.some((x) => x.serverUserId === self && x.deviceId === d.deviceId))
|
|
299
|
+
return;
|
|
300
|
+
const isListed = (id) => listed.some((x) => x.deviceId === id);
|
|
301
|
+
const treeIds = (s) => new Set(mlsGroupMembers(s, scope).map((m) => m.certificate.deviceId));
|
|
302
|
+
await commitWithRetry(open, async (s) => {
|
|
303
|
+
const gone = [...treeIds(s)].filter((id) => id !== d.deviceId && !isListed(id));
|
|
304
|
+
return gone.length ? removeMlsMembers(s, gone, scope) : null;
|
|
305
|
+
});
|
|
306
|
+
if (!adds)
|
|
307
|
+
return;
|
|
308
|
+
const inTree = treeIds(open.state);
|
|
309
|
+
const wanted = listed.filter((x) => !inTree.has(x.deviceId) && x.deviceId !== d.deviceId);
|
|
310
|
+
if (wanted.length === 0)
|
|
311
|
+
return;
|
|
312
|
+
let asking = wanted;
|
|
313
|
+
let addedPeer = false;
|
|
314
|
+
// A package out of date, by the reader or by addMlsMembers, gets one fresh claim for that device.
|
|
315
|
+
for (let round = 0; round < 2 && asking.length > 0; round++) {
|
|
316
|
+
const claim = await transport.claimKeyPackages({ conversationId, deviceId: d.deviceId, devices: asking });
|
|
317
|
+
if (!claim.ok)
|
|
318
|
+
throw refused("Claiming KeyPackages", claim);
|
|
319
|
+
asking = [];
|
|
320
|
+
const verified = [];
|
|
321
|
+
for (const kp of claim.keyPackages) {
|
|
322
|
+
const verdict = await vouchedFor(conversationId, kp, peer);
|
|
323
|
+
if (verdict === "ok")
|
|
324
|
+
verified.push(kp);
|
|
325
|
+
else if (verdict === "unreadable")
|
|
326
|
+
asking.push({ serverUserId: kp.serverUserId, deviceId: kp.deviceId });
|
|
327
|
+
}
|
|
328
|
+
await commitWithRetry(open, async (s) => {
|
|
329
|
+
const tree = treeIds(s);
|
|
330
|
+
const counts = new Map();
|
|
331
|
+
for (const person of await peopleIn(conversationId, s))
|
|
332
|
+
if (person)
|
|
333
|
+
counts.set(person, (counts.get(person) ?? 0) + 1);
|
|
334
|
+
const add = [];
|
|
335
|
+
for (const kp of verified) {
|
|
336
|
+
const n = counts.get(kp.serverUserId) ?? 0;
|
|
337
|
+
if (tree.has(kp.deviceId) || n >= MLS_MAX_DEVICES_PER_PERSON)
|
|
338
|
+
continue;
|
|
339
|
+
counts.set(kp.serverUserId, n + 1);
|
|
340
|
+
add.push(kp);
|
|
341
|
+
}
|
|
342
|
+
if (add.length === 0)
|
|
343
|
+
return null;
|
|
344
|
+
try {
|
|
345
|
+
const c = await addMlsMembers(s, add.map((k) => k.keyPackage));
|
|
346
|
+
addedPeer ||= add.some((k) => k.serverUserId === peer);
|
|
347
|
+
return c;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
asking.push(...add.map((k) => ({ serverUserId: k.serverUserId, deviceId: k.deviceId })));
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (addedPeer)
|
|
356
|
+
await markSeen(peer);
|
|
357
|
+
}
|
|
358
|
+
/** The certificate names the device the server said, under the person key pinned for that member. */
|
|
359
|
+
async function vouchedFor(conversationId, kp, peer) {
|
|
360
|
+
if (kp.serverUserId !== self && kp.serverUserId !== peer)
|
|
361
|
+
return "refused";
|
|
362
|
+
let certificate;
|
|
363
|
+
try {
|
|
364
|
+
({ certificate } = await readMlsKeyPackage(kp.keyPackage, scope));
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return "unreadable";
|
|
368
|
+
}
|
|
369
|
+
if (certificate.deviceId !== kp.deviceId)
|
|
370
|
+
return "refused";
|
|
371
|
+
return (await pins.personOf(conversationId, certificate.personPublicKey)) === kp.serverUserId ? "ok" : "refused";
|
|
372
|
+
}
|
|
373
|
+
// ── Opening and joining ─────────────────────────────────────────────
|
|
374
|
+
/** "open" when this device has the group, "exists" when somebody else made it first. */
|
|
375
|
+
async function openGroup(conversationId) {
|
|
376
|
+
if (await store.loadGroup(conversationId))
|
|
377
|
+
return "open";
|
|
378
|
+
const d = await ownDevice();
|
|
379
|
+
const state = await createMlsGroup(d, trustFor(conversationId));
|
|
380
|
+
const rec = {
|
|
381
|
+
conversationId,
|
|
382
|
+
groupId: groupIdOf(state),
|
|
383
|
+
state: encodeMlsGroupState(state),
|
|
384
|
+
cursor: 0,
|
|
385
|
+
joinedEpoch: 0,
|
|
386
|
+
};
|
|
387
|
+
// Saved before the server hears of it, so a crash can't leave a group nobody holds.
|
|
388
|
+
await store.saveGroup(rec);
|
|
389
|
+
const r = await transport.createGroup({ conversationId, groupId: rec.groupId });
|
|
390
|
+
if (r.ok)
|
|
391
|
+
return "open";
|
|
392
|
+
await store.deleteGroup(conversationId);
|
|
393
|
+
if (r.error === "group_exists")
|
|
394
|
+
return "exists";
|
|
395
|
+
throw refused("Creating the group", r);
|
|
396
|
+
}
|
|
397
|
+
function waitForWelcome(conversationId) {
|
|
398
|
+
return new Promise((resolve, reject) => {
|
|
399
|
+
const done = () => {
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
resolve();
|
|
402
|
+
};
|
|
403
|
+
const timer = setTimeout(() => {
|
|
404
|
+
const list = welcomeWaiters.get(conversationId) ?? [];
|
|
405
|
+
welcomeWaiters.set(conversationId, list.filter((w) => w !== done));
|
|
406
|
+
reject(new MlsDriverError("waiting_for_welcome", "The group exists and this device hasn't been added yet."));
|
|
407
|
+
}, WELCOME_WAIT_MS);
|
|
408
|
+
welcomeWaiters.set(conversationId, [...(welcomeWaiters.get(conversationId) ?? []), done]);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
async function ensureOpen(conversationId) {
|
|
412
|
+
if ((await run(conversationId, () => openGroup(conversationId))) === "open")
|
|
413
|
+
return;
|
|
414
|
+
for (const w of await syncAndTopUp())
|
|
415
|
+
await handleWelcome(w);
|
|
416
|
+
if (!(await store.loadGroup(conversationId)))
|
|
417
|
+
await waitForWelcome(conversationId);
|
|
418
|
+
}
|
|
419
|
+
/** "joined", "not_ours" (ack it and forget it), or "retry" (keep it for next time). */
|
|
420
|
+
async function joinFrom(w) {
|
|
421
|
+
const conversationId = w.conversationId;
|
|
422
|
+
if (!conversationId)
|
|
423
|
+
return { result: "not_ours" };
|
|
424
|
+
let kp = null;
|
|
425
|
+
try {
|
|
426
|
+
for (const ref of mlsWelcomeRefs(w.data))
|
|
427
|
+
if ((kp = await store.getKeyPackage(ref)))
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return { result: "not_ours" };
|
|
432
|
+
}
|
|
433
|
+
if (!kp)
|
|
434
|
+
return { result: "not_ours" };
|
|
435
|
+
const existing = await store.loadGroup(conversationId);
|
|
436
|
+
if (existing && existing.groupId === w.groupId)
|
|
437
|
+
return { result: "not_ours" };
|
|
438
|
+
let state;
|
|
439
|
+
try {
|
|
440
|
+
state = await joinMlsGroup(w.data, kp.keyPackage, kp.privatePackage, trustFor(conversationId));
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return { result: "retry" };
|
|
444
|
+
}
|
|
445
|
+
const people = new Set(await peopleIn(conversationId, state));
|
|
446
|
+
if (groupIdOf(state) !== w.groupId || people.has(null) || people.size > 2 || !people.has(self)) {
|
|
447
|
+
return { result: "not_ours" };
|
|
448
|
+
}
|
|
449
|
+
const open = {
|
|
450
|
+
rec: { conversationId, groupId: w.groupId, state: new Uint8Array(), cursor: 0, joinedEpoch: epochOf(state) },
|
|
451
|
+
state,
|
|
452
|
+
};
|
|
453
|
+
await save(open);
|
|
454
|
+
if (!kp.lastResort)
|
|
455
|
+
await store.deleteKeyPackage(kp.ref);
|
|
456
|
+
await notePeople(conversationId, state);
|
|
457
|
+
await catchUp(open);
|
|
458
|
+
return { result: "joined", usedLastResort: kp.lastResort };
|
|
459
|
+
}
|
|
460
|
+
async function handleWelcome(w) {
|
|
461
|
+
const d = await ownDevice();
|
|
462
|
+
if (w.deviceId !== d.deviceId || !w.conversationId)
|
|
463
|
+
return;
|
|
464
|
+
const conversationId = w.conversationId;
|
|
465
|
+
const { result, usedLastResort } = await run(conversationId, () => joinFrom(w));
|
|
466
|
+
if (result === "retry")
|
|
467
|
+
return;
|
|
468
|
+
await transport.ackWelcomes({ deviceId: d.deviceId, welcomeIds: [w.welcomeId] });
|
|
469
|
+
if (result !== "joined")
|
|
470
|
+
return;
|
|
471
|
+
reconciled.delete(conversationId);
|
|
472
|
+
events.onJoined?.({ conversationId, groupId: w.groupId });
|
|
473
|
+
for (const done of welcomeWaiters.get(conversationId) ?? [])
|
|
474
|
+
done();
|
|
475
|
+
welcomeWaiters.delete(conversationId);
|
|
476
|
+
// One package was used up, and a used last-resort one gets replaced.
|
|
477
|
+
await syncAndTopUp(usedLastResort);
|
|
478
|
+
}
|
|
479
|
+
// ── What the app calls ──────────────────────────────────────────────
|
|
480
|
+
async function send(conversationId, peer, plaintext) {
|
|
481
|
+
if (!registered)
|
|
482
|
+
throw new MlsDriverError("no_device", "This device isn't registered for MLS here. Call start() first.");
|
|
483
|
+
await ensureOpen(conversationId);
|
|
484
|
+
return run(conversationId, async () => {
|
|
485
|
+
const d = await ownDevice();
|
|
486
|
+
const open = await load(conversationId);
|
|
487
|
+
if (!open || !(await catchUp(open)))
|
|
488
|
+
throw new MlsDriverError("not_in_group", "This device isn't in that group.");
|
|
489
|
+
if (!reconciled.has(conversationId))
|
|
490
|
+
await reconcile(open, peer, true);
|
|
491
|
+
const people = await peopleIn(conversationId, open.state);
|
|
492
|
+
if (people.some((p) => p !== self && p !== peer)) {
|
|
493
|
+
throw new MlsDriverError("unexpected_member", "Somebody other than the two of you is in this group.");
|
|
494
|
+
}
|
|
495
|
+
if (!people.includes(peer)) {
|
|
496
|
+
throw new MlsDriverError("peer_unverified", "None of their devices could be added. Is their person key pinned?");
|
|
497
|
+
}
|
|
498
|
+
reconciled.add(conversationId);
|
|
499
|
+
const out = await encryptMlsMessage(open.state, plaintext);
|
|
500
|
+
open.state = out.state;
|
|
501
|
+
// The generation is used up whether or not the send arrives, so the state is saved first.
|
|
502
|
+
await save(open);
|
|
503
|
+
const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message });
|
|
504
|
+
if (!r.ok)
|
|
505
|
+
throw refused("Sending", r);
|
|
506
|
+
return { seq: r.seq };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
async function modeFor(conversationId, peer) {
|
|
510
|
+
const wasSeen = seen.has(peer) || (await pins.seenOnMls(peer));
|
|
511
|
+
if (wasSeen)
|
|
512
|
+
seen.add(peer);
|
|
513
|
+
if (!capability) {
|
|
514
|
+
return wasSeen ? { kind: "refused", reason: "server_dropped_mls" } : { kind: "sealed-v1", reason: "server_without_mls" };
|
|
515
|
+
}
|
|
516
|
+
let peerOnMls = !!(await store.loadGroup(conversationId));
|
|
517
|
+
if (!peerOnMls) {
|
|
518
|
+
const r = await transport.listDevices({ conversationId });
|
|
519
|
+
if (!r.ok)
|
|
520
|
+
throw refused("Listing devices", r);
|
|
521
|
+
peerOnMls = r.devices.some((x) => x.serverUserId === peer);
|
|
522
|
+
}
|
|
523
|
+
if (!peerOnMls) {
|
|
524
|
+
return wasSeen ? { kind: "refused", reason: "peer_left_mls" } : { kind: "sealed-v1", reason: "peer_without_mls" };
|
|
525
|
+
}
|
|
526
|
+
await markSeen(peer);
|
|
527
|
+
return registered ? { kind: "mls" } : { kind: "refused", reason: "no_own_device" };
|
|
528
|
+
}
|
|
529
|
+
async function handleMessage(entry) {
|
|
530
|
+
await run(entry.conversationId, async () => {
|
|
531
|
+
const open = await load(entry.conversationId);
|
|
532
|
+
if (!open || entry.seq <= open.rec.cursor)
|
|
533
|
+
return;
|
|
534
|
+
if (entry.seq === open.rec.cursor + 1 && entry.groupId === open.rec.groupId)
|
|
535
|
+
await applyEntry(open, entry);
|
|
536
|
+
else
|
|
537
|
+
await catchUp(open);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/** Adds wait for the next send; a device that's gone is removed straight away. */
|
|
541
|
+
async function handleDevicesChanged(push) {
|
|
542
|
+
reconciled.clear();
|
|
543
|
+
for (const rec of await store.listGroups()) {
|
|
544
|
+
await run(rec.conversationId, async () => {
|
|
545
|
+
const open = await load(rec.conversationId);
|
|
546
|
+
if (!open)
|
|
547
|
+
return;
|
|
548
|
+
const people = await peopleIn(rec.conversationId, open.state);
|
|
549
|
+
const peer = people.find((p) => !!p && p !== self);
|
|
550
|
+
if (!peer || (push.serverUserId !== self && push.serverUserId !== peer))
|
|
551
|
+
return;
|
|
552
|
+
if (await catchUp(open))
|
|
553
|
+
await reconcile(open, peer, false);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
async start() {
|
|
559
|
+
await ownDevice();
|
|
560
|
+
if (!capability)
|
|
561
|
+
return;
|
|
562
|
+
for (const w of await syncAndTopUp())
|
|
563
|
+
await handleWelcome(w);
|
|
564
|
+
reconciled.clear();
|
|
565
|
+
for (const rec of await store.listGroups()) {
|
|
566
|
+
await run(rec.conversationId, async () => {
|
|
567
|
+
const open = await load(rec.conversationId);
|
|
568
|
+
if (open)
|
|
569
|
+
await catchUp(open);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
modeFor,
|
|
574
|
+
send,
|
|
575
|
+
handleMessage,
|
|
576
|
+
handleWelcome,
|
|
577
|
+
handleDevicesChanged,
|
|
578
|
+
async ownDevices() {
|
|
579
|
+
const r = await transport.listDevices({});
|
|
580
|
+
if (!r.ok)
|
|
581
|
+
throw refused("Listing devices", r);
|
|
582
|
+
return r.devices;
|
|
583
|
+
},
|
|
584
|
+
async removeOwnDevice(deviceId) {
|
|
585
|
+
const r = await transport.removeDevice({ deviceId });
|
|
586
|
+
if (!r.ok)
|
|
587
|
+
throw refused("Removing the device", r);
|
|
588
|
+
await handleDevicesChanged({ serverUserId: self });
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { TrustPersonKey } from "@gryt/crypto";
|
|
2
|
+
/** A refusal, exactly as the server's ack sends it. Extra fields ride along. */
|
|
3
|
+
export interface MlsRefusal {
|
|
4
|
+
ok: false;
|
|
5
|
+
error: string;
|
|
6
|
+
message: string;
|
|
7
|
+
[extra: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
export type MlsReply<T> = ({
|
|
10
|
+
ok: true;
|
|
11
|
+
} & T) | MlsRefusal;
|
|
12
|
+
export interface MlsDeviceRef {
|
|
13
|
+
serverUserId: string;
|
|
14
|
+
deviceId: string;
|
|
15
|
+
}
|
|
16
|
+
/** The server's view of a group. `groupId` is lower-case hex. */
|
|
17
|
+
export interface MlsGroupView {
|
|
18
|
+
conversationId: string;
|
|
19
|
+
groupId: string;
|
|
20
|
+
epoch: number;
|
|
21
|
+
headSeq: number;
|
|
22
|
+
}
|
|
23
|
+
/** One entry of a group's log, from `mls:log:fetch` or the `mls:message` push. */
|
|
24
|
+
export interface MlsLogEntry {
|
|
25
|
+
conversationId: string;
|
|
26
|
+
groupId: string;
|
|
27
|
+
seq: number;
|
|
28
|
+
kind: "commit" | "proposal" | "application";
|
|
29
|
+
epoch: number;
|
|
30
|
+
senderServerUserId: string;
|
|
31
|
+
senderDeviceId: string;
|
|
32
|
+
data: Uint8Array;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
}
|
|
35
|
+
/** A Welcome, from `mls:sync` or the `mls:welcome` push. Pushes go to every device of a person. */
|
|
36
|
+
export interface MlsWelcomeDelivery {
|
|
37
|
+
welcomeId: string;
|
|
38
|
+
conversationId: string | null;
|
|
39
|
+
groupId: string;
|
|
40
|
+
deviceId: string;
|
|
41
|
+
data: Uint8Array;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
}
|
|
44
|
+
export interface MlsClaimedKeyPackage extends MlsDeviceRef {
|
|
45
|
+
keyPackage: Uint8Array;
|
|
46
|
+
lastResort: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One method per mls:* request. The app adds `accessToken`, emits, and resolves with the ack,
|
|
50
|
+
* turning ArrayBuffers into Uint8Arrays. Pushes go to the driver's `handle*` methods instead.
|
|
51
|
+
*/
|
|
52
|
+
export interface MlsTransport {
|
|
53
|
+
publishKeyPackages(req: {
|
|
54
|
+
deviceId: string;
|
|
55
|
+
keyPackages: Uint8Array[];
|
|
56
|
+
lastResort?: Uint8Array;
|
|
57
|
+
}): Promise<MlsReply<{
|
|
58
|
+
stored: number;
|
|
59
|
+
unclaimed: number;
|
|
60
|
+
lastResort: boolean;
|
|
61
|
+
}>>;
|
|
62
|
+
claimKeyPackages(req: {
|
|
63
|
+
conversationId: string;
|
|
64
|
+
deviceId: string;
|
|
65
|
+
devices?: MlsDeviceRef[];
|
|
66
|
+
}): Promise<MlsReply<{
|
|
67
|
+
keyPackages: MlsClaimedKeyPackage[];
|
|
68
|
+
missing: MlsDeviceRef[];
|
|
69
|
+
}>>;
|
|
70
|
+
/** Your own devices with no conversation, or every member's with one. */
|
|
71
|
+
listDevices(req: {
|
|
72
|
+
conversationId?: string;
|
|
73
|
+
}): Promise<MlsReply<{
|
|
74
|
+
devices: MlsDeviceRef[];
|
|
75
|
+
}>>;
|
|
76
|
+
removeDevice(req: {
|
|
77
|
+
deviceId: string;
|
|
78
|
+
}): Promise<MlsReply<object>>;
|
|
79
|
+
/** The loser gets `error: "group_exists"` with the winner's `group`. */
|
|
80
|
+
createGroup(req: {
|
|
81
|
+
conversationId: string;
|
|
82
|
+
groupId: string;
|
|
83
|
+
}): Promise<MlsReply<{
|
|
84
|
+
group: MlsGroupView;
|
|
85
|
+
}>>;
|
|
86
|
+
/** `error: "stale_epoch"` carries `epoch` and `headSeq`. */
|
|
87
|
+
commit(req: {
|
|
88
|
+
conversationId: string;
|
|
89
|
+
deviceId: string;
|
|
90
|
+
commit: Uint8Array;
|
|
91
|
+
welcome?: Uint8Array;
|
|
92
|
+
}): Promise<MlsReply<{
|
|
93
|
+
seq: number;
|
|
94
|
+
epoch: number;
|
|
95
|
+
}>>;
|
|
96
|
+
send(req: {
|
|
97
|
+
conversationId: string;
|
|
98
|
+
deviceId: string;
|
|
99
|
+
message: Uint8Array;
|
|
100
|
+
}): Promise<MlsReply<{
|
|
101
|
+
seq: number;
|
|
102
|
+
}>>;
|
|
103
|
+
fetchLog(req: {
|
|
104
|
+
conversationId: string;
|
|
105
|
+
after: number;
|
|
106
|
+
limit?: number;
|
|
107
|
+
}): Promise<MlsReply<{
|
|
108
|
+
group: MlsGroupView | null;
|
|
109
|
+
entries: MlsLogEntry[];
|
|
110
|
+
nextCursor: number;
|
|
111
|
+
hasMore: boolean;
|
|
112
|
+
gap: boolean;
|
|
113
|
+
}>>;
|
|
114
|
+
sync(req: {
|
|
115
|
+
deviceId: string;
|
|
116
|
+
}): Promise<MlsReply<{
|
|
117
|
+
registered: boolean;
|
|
118
|
+
groups: (MlsGroupView & {
|
|
119
|
+
oldestSeq: number | null;
|
|
120
|
+
})[];
|
|
121
|
+
welcomes: MlsWelcomeDelivery[];
|
|
122
|
+
keyPackages: {
|
|
123
|
+
unclaimed: number;
|
|
124
|
+
lastResort: boolean;
|
|
125
|
+
target: number;
|
|
126
|
+
};
|
|
127
|
+
}>>;
|
|
128
|
+
ackWelcomes(req: {
|
|
129
|
+
deviceId: string;
|
|
130
|
+
welcomeIds: string[];
|
|
131
|
+
}): Promise<MlsReply<{
|
|
132
|
+
deleted: number;
|
|
133
|
+
}>>;
|
|
134
|
+
}
|
|
135
|
+
/** `server:info.mls`. Absent means the server can't do MLS. */
|
|
136
|
+
export interface MlsServerCapability {
|
|
137
|
+
version: number;
|
|
138
|
+
ciphersuites: number[];
|
|
139
|
+
retentionDays: number;
|
|
140
|
+
}
|
|
141
|
+
/** This device's leaf key and certificate, from `createMlsDevice` in @gryt/crypto. */
|
|
142
|
+
export interface MlsDeviceRecord {
|
|
143
|
+
deviceId: string;
|
|
144
|
+
signKey: Uint8Array;
|
|
145
|
+
publicKey: Uint8Array;
|
|
146
|
+
certificate: Uint8Array;
|
|
147
|
+
}
|
|
148
|
+
/** The private half of a published KeyPackage, found again by the ref a Welcome names. */
|
|
149
|
+
export interface MlsKeyPackageRecord {
|
|
150
|
+
ref: string;
|
|
151
|
+
keyPackage: Uint8Array;
|
|
152
|
+
privatePackage: Uint8Array;
|
|
153
|
+
lastResort: boolean;
|
|
154
|
+
/** Milliseconds. */
|
|
155
|
+
createdAt: number;
|
|
156
|
+
/** Seconds, from `generateMlsKeyPackage`. Absent on records written before 0.7.0. */
|
|
157
|
+
expiresAt?: number;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* One group. `state` and `cursor` are always written together (design, section 2).
|
|
161
|
+
* `pending` is a commit sent but not yet seen accepted, so a crash can't strand it.
|
|
162
|
+
*/
|
|
163
|
+
export interface MlsGroupRecord {
|
|
164
|
+
conversationId: string;
|
|
165
|
+
groupId: string;
|
|
166
|
+
state: Uint8Array;
|
|
167
|
+
cursor: number;
|
|
168
|
+
/** Entries from before this epoch were never meant for this device. */
|
|
169
|
+
joinedEpoch: number;
|
|
170
|
+
pending?: {
|
|
171
|
+
commit: Uint8Array;
|
|
172
|
+
state: Uint8Array;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* One store per server. Keep it out of OS backups: restored MLS state is out of sync. On the
|
|
177
|
+
* web, only the tab holding the Web Lock may write.
|
|
178
|
+
*/
|
|
179
|
+
export interface MlsStateStore {
|
|
180
|
+
loadDevice(): Promise<MlsDeviceRecord | null>;
|
|
181
|
+
saveDevice(device: MlsDeviceRecord): Promise<void>;
|
|
182
|
+
putKeyPackages(records: MlsKeyPackageRecord[]): Promise<void>;
|
|
183
|
+
getKeyPackage(ref: string): Promise<MlsKeyPackageRecord | null>;
|
|
184
|
+
listKeyPackages(): Promise<MlsKeyPackageRecord[]>;
|
|
185
|
+
deleteKeyPackage(ref: string): Promise<void>;
|
|
186
|
+
loadGroup(conversationId: string): Promise<MlsGroupRecord | null>;
|
|
187
|
+
listGroups(): Promise<MlsGroupRecord[]>;
|
|
188
|
+
saveGroup(record: MlsGroupRecord): Promise<void>;
|
|
189
|
+
deleteGroup(conversationId: string): Promise<void>;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The person-key pin (design, section 1). The app verifies each member's person key binding
|
|
193
|
+
* and pins it on first sight, before the driver meets their leaves; the driver only asks.
|
|
194
|
+
*/
|
|
195
|
+
export interface MlsPins {
|
|
196
|
+
/** Which member of this conversation holds this person key by pin, you included, or null. */
|
|
197
|
+
personOf(conversationId: string, personPublicKey: Uint8Array): string | null | Promise<string | null>;
|
|
198
|
+
/** The engine's check for every leaf. Without it, any leaf `personOf` names passes. */
|
|
199
|
+
trustFor?(conversationId: string): TrustPersonKey;
|
|
200
|
+
/** Decision 4: once a peer is seen on MLS, never seal to them with version 1 again. */
|
|
201
|
+
seenOnMls(serverUserId: string): boolean | Promise<boolean>;
|
|
202
|
+
markSeenOnMls(serverUserId: string): void | Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
export interface MlsDecryptedMessage {
|
|
205
|
+
conversationId: string;
|
|
206
|
+
seq: number;
|
|
207
|
+
senderServerUserId: string;
|
|
208
|
+
senderDeviceId: string;
|
|
209
|
+
plaintext: Uint8Array;
|
|
210
|
+
createdAt: string;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* `onMessage` must have stored the message before it resolves: the key is gone once the
|
|
214
|
+
* cursor moves. It may see the same seq twice after a crash.
|
|
215
|
+
*/
|
|
216
|
+
export interface MlsDmEvents {
|
|
217
|
+
onMessage(message: MlsDecryptedMessage): void | Promise<void>;
|
|
218
|
+
/** "Some messages couldn't be decrypted on this device" (design, section 2). */
|
|
219
|
+
onUndecryptable?(info: {
|
|
220
|
+
conversationId: string;
|
|
221
|
+
seq: number;
|
|
222
|
+
reason: string;
|
|
223
|
+
}): void;
|
|
224
|
+
/** This device is out of the group: removed, or its state couldn't keep up. */
|
|
225
|
+
onGroupLost?(info: {
|
|
226
|
+
conversationId: string;
|
|
227
|
+
reason: "removed" | "out_of_sync" | "gap";
|
|
228
|
+
}): void;
|
|
229
|
+
onJoined?(info: {
|
|
230
|
+
conversationId: string;
|
|
231
|
+
groupId: string;
|
|
232
|
+
}): void;
|
|
233
|
+
}
|
|
234
|
+
/** Decision 4, per DM. `refused` never falls back to version 1. */
|
|
235
|
+
export type DmSealingMode = {
|
|
236
|
+
kind: "mls";
|
|
237
|
+
} | {
|
|
238
|
+
kind: "sealed-v1";
|
|
239
|
+
reason: "server_without_mls" | "peer_without_mls";
|
|
240
|
+
} | {
|
|
241
|
+
kind: "refused";
|
|
242
|
+
reason: "peer_left_mls" | "server_dropped_mls" | "no_own_device";
|
|
243
|
+
};
|
|
244
|
+
export interface MlsDmDriverOptions {
|
|
245
|
+
transport: MlsTransport;
|
|
246
|
+
store: MlsStateStore;
|
|
247
|
+
pins: MlsPins;
|
|
248
|
+
events: MlsDmEvents;
|
|
249
|
+
/** The identity scope this server's keys are derived under. */
|
|
250
|
+
scope: string;
|
|
251
|
+
serverUserId: string;
|
|
252
|
+
/** `server:info.mls`, or null when the server doesn't advertise it. */
|
|
253
|
+
capability: MlsServerCapability | null;
|
|
254
|
+
/** The first time only: `createMlsDevice` from @gryt/crypto, so the seed stays in the app. */
|
|
255
|
+
newDevice(): MlsDeviceRecord | Promise<MlsDeviceRecord>;
|
|
256
|
+
}
|
|
257
|
+
/** One per server. Calls for one conversation run one at a time, in order. */
|
|
258
|
+
export interface MlsDmDriver {
|
|
259
|
+
/** On connect: register, top up KeyPackages, take waiting Welcomes, catch up every group. */
|
|
260
|
+
start(): Promise<void>;
|
|
261
|
+
modeFor(conversationId: string, peerServerUserId: string): Promise<DmSealingMode>;
|
|
262
|
+
/** Opens the group if need be, adds or removes devices that changed, then sends. */
|
|
263
|
+
send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array): Promise<{
|
|
264
|
+
seq: number;
|
|
265
|
+
}>;
|
|
266
|
+
handleMessage(entry: MlsLogEntry): Promise<void>;
|
|
267
|
+
handleWelcome(welcome: MlsWelcomeDelivery): Promise<void>;
|
|
268
|
+
handleDevicesChanged(push: {
|
|
269
|
+
serverUserId: string;
|
|
270
|
+
}): Promise<void>;
|
|
271
|
+
ownDevices(): Promise<MlsDeviceRef[]>;
|
|
272
|
+
/** Your own devices only. Every group you share drops it on the next pass. */
|
|
273
|
+
removeOwnDevice(deviceId: string): Promise<void>;
|
|
274
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type PeerPinStore } from "@gryt/crypto";
|
|
2
|
+
import type { MlsPins } from "./interfaces.js";
|
|
3
|
+
/**
|
|
4
|
+
* `MlsPins` over the peer pins @gryt/crypto keeps, person keys included. The app still pins
|
|
5
|
+
* with `evaluateMemberKeys`, passing each member's `personKeyBinding`, before the driver asks.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mlsPinsFromPeerPins({ store, scope, serverUserId, ownPersonKey, membersOf, seen, }: {
|
|
8
|
+
store: PeerPinStore;
|
|
9
|
+
scope: string;
|
|
10
|
+
/** Yours on this server. Your own person key answers as you. */
|
|
11
|
+
serverUserId: string;
|
|
12
|
+
/** The public half of `derivePersonKeyPair(seed, scope)`. */
|
|
13
|
+
ownPersonKey: Uint8Array;
|
|
14
|
+
/** Everybody in the conversation. Anybody else's person key is refused there. */
|
|
15
|
+
membersOf(conversationId: string): readonly string[] | Promise<readonly string[]>;
|
|
16
|
+
/** Decision 4's record, kept by the app: a peer pin has no field for it yet. */
|
|
17
|
+
seen: {
|
|
18
|
+
has(serverUserId: string): boolean | Promise<boolean>;
|
|
19
|
+
add(serverUserId: string): void | Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
}): MlsPins;
|
package/dist/mls/pins.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { asIdentityScope, base64Url, getPeerPin, trustPinnedPersonKeys } from "@gryt/crypto";
|
|
2
|
+
/**
|
|
3
|
+
* `MlsPins` over the peer pins @gryt/crypto keeps, person keys included. The app still pins
|
|
4
|
+
* with `evaluateMemberKeys`, passing each member's `personKeyBinding`, before the driver asks.
|
|
5
|
+
*/
|
|
6
|
+
export function mlsPinsFromPeerPins({ store, scope, serverUserId, ownPersonKey, membersOf, seen, }) {
|
|
7
|
+
const identityScope = asIdentityScope(scope);
|
|
8
|
+
const own = base64Url(ownPersonKey);
|
|
9
|
+
return {
|
|
10
|
+
async personOf(conversationId, personPublicKey) {
|
|
11
|
+
const presented = base64Url(personPublicKey);
|
|
12
|
+
if (presented === own)
|
|
13
|
+
return serverUserId;
|
|
14
|
+
const members = await membersOf(conversationId);
|
|
15
|
+
return members.find((id) => id !== serverUserId && getPeerPin(store, identityScope, id)?.personPublicKey === presented) ?? null;
|
|
16
|
+
},
|
|
17
|
+
trustFor(conversationId) {
|
|
18
|
+
return async (certificate) => {
|
|
19
|
+
const memberIds = await membersOf(conversationId);
|
|
20
|
+
return trustPinnedPersonKeys({ store, scope: identityScope, memberIds, ownPersonKey })(certificate);
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
seenOnMls: (id) => seen.has(id),
|
|
24
|
+
markSeenOnMls: (id) => seen.add(id),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gryt/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
@@ -33,5 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@gryt/crypto": "0.7.0"
|
|
36
39
|
}
|
|
37
40
|
}
|