@gryt/core 0.17.1 → 0.18.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.
@@ -14,6 +14,10 @@ interface GroupRow {
14
14
  epoch: number;
15
15
  headSeq: number;
16
16
  log: MlsLogEntry[];
17
+ info?: {
18
+ epoch: number;
19
+ data: Uint8Array;
20
+ };
17
21
  }
18
22
  interface Socket {
19
23
  serverUserId: string;
@@ -264,6 +264,29 @@ export class FakeDeliveryService {
264
264
  this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId && welcomeIds.includes(w.welcomeId)));
265
265
  return { ok: true, deleted: before - this.welcomes.length };
266
266
  },
267
+ putGroupInfo: async ({ conversationId, deviceId, groupInfo }) => {
268
+ await tick();
269
+ if (!member(conversationId))
270
+ return fail("not_found", "No such conversation.");
271
+ if (!this.isDevice(me, deviceId))
272
+ return fail("unknown_device", "Publish KeyPackages from this device first.");
273
+ const g = this.groups.get(conversationId);
274
+ if (!g)
275
+ return fail("no_group", "This conversation has no group yet.");
276
+ g.info = { epoch: g.epoch, data: groupInfo.slice() };
277
+ return { ok: true };
278
+ },
279
+ getGroupInfo: async ({ conversationId, deviceId }) => {
280
+ await tick();
281
+ if (!member(conversationId))
282
+ return fail("not_found", "No such conversation.");
283
+ if (!this.isDevice(me, deviceId))
284
+ return fail("unknown_device", "Publish KeyPackages from this device first.");
285
+ const g = this.groups.get(conversationId);
286
+ if (!g?.info || g.info.epoch !== g.epoch)
287
+ return fail("no_group_info", "There's nothing to join from yet.");
288
+ return { ok: true, epoch: g.info.epoch, groupInfo: g.info.data.slice() };
289
+ },
267
290
  requestAdd: async ({ conversationId, deviceId }) => {
268
291
  await tick();
269
292
  if (!member(conversationId))
@@ -1,4 +1,4 @@
1
- import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decodeMlsGroupState, encodeMlsGroupState, encryptMlsMessage, forgetMlsSecrets, generateMlsKeyPackage, joinMlsGroup, mlsGroupInfo, mlsGroupMembers, mlsWelcomeRefs, processMlsMessage, readDeviceCertificate, readMlsKeyPackage, removeMlsMembers, } from "@gryt/crypto";
1
+ import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decodeMlsGroupState, encodeMlsGroupState, encryptMlsMessage, forgetMlsSecrets, generateMlsKeyPackage, joinMlsGroup, joinMlsGroupExternally, mlsGroupInfo, mlsGroupInfoForJoining, mlsGroupMembers, mlsWelcomeRefs, processMlsMessage, readDeviceCertificate, readMlsKeyPackage, removeMlsMembers, } from "@gryt/crypto";
2
2
  /** Devices per person per server. Must match the server's MLS_MAX_DEVICES, or extra devices are left out quietly. */
3
3
  const MLS_MAX_DEVICES_PER_PERSON = 100;
4
4
  const COMMIT_ATTEMPTS = 5;
@@ -90,6 +90,8 @@ export function createMlsDmDriver(options) {
90
90
  }
91
91
  const raw = options.transport;
92
92
  const rawRequestAdd = raw.requestAdd?.bind(raw);
93
+ const rawPutGroupInfo = raw.putGroupInfo?.bind(raw);
94
+ const rawGetGroupInfo = raw.getGroupInfo?.bind(raw);
93
95
  const transport = {
94
96
  publishKeyPackages: stopOnRemoval((req) => raw.publishKeyPackages(req)),
95
97
  claimKeyPackages: stopOnRemoval((req) => raw.claimKeyPackages(req)),
@@ -102,6 +104,8 @@ export function createMlsDmDriver(options) {
102
104
  sync: stopOnRemoval((req) => raw.sync(req)),
103
105
  ackWelcomes: stopOnRemoval((req) => raw.ackWelcomes(req)),
104
106
  ...(rawRequestAdd ? { requestAdd: stopOnRemoval(rawRequestAdd) } : {}),
107
+ ...(rawPutGroupInfo ? { putGroupInfo: stopOnRemoval(rawPutGroupInfo) } : {}),
108
+ ...(rawGetGroupInfo ? { getGroupInfo: stopOnRemoval(rawGetGroupInfo) } : {}),
105
109
  };
106
110
  const scope = asIdentityScope(options.scope);
107
111
  const trusts = new Map();
@@ -277,7 +281,7 @@ export function createMlsDmDriver(options) {
277
281
  }
278
282
  else {
279
283
  try {
280
- const r = await processMlsMessage(open.state, e.data);
284
+ const r = await processMlsMessage(open.state, e.data, scope);
281
285
  if (r.kind === "handshake" && r.removed) {
282
286
  await store.deleteGroup(open.rec.conversationId);
283
287
  left.add(open.rec.conversationId);
@@ -384,6 +388,7 @@ export function createMlsDmDriver(options) {
384
388
  forgetMlsSecrets(c.consumed);
385
389
  open.rec = { ...open.rec, pending: undefined };
386
390
  await save(open);
391
+ void publishGroupInfo(open);
387
392
  return;
388
393
  }
389
394
  open.rec = { ...open.rec, pending: undefined };
@@ -526,6 +531,53 @@ export function createMlsDmDriver(options) {
526
531
  welcomeWaiters.set(conversationId, [...(welcomeWaiters.get(conversationId) ?? []), done]);
527
532
  });
528
533
  }
534
+ /** For the next device of either person to join by itself. An old server has nowhere to keep it. */
535
+ async function publishGroupInfo(open) {
536
+ if (!transport.putGroupInfo)
537
+ return;
538
+ try {
539
+ const groupInfo = await mlsGroupInfoForJoining(open.state);
540
+ await transport.putGroupInfo({ conversationId: open.rec.conversationId, deviceId: (await ownDevice()).deviceId, groupInfo });
541
+ }
542
+ catch {
543
+ // A stale epoch or an old server: the next commit uploads one again.
544
+ }
545
+ }
546
+ /* Joins from the GroupInfo a member left, with nobody else online (GRYT-1859). False when
547
+ there's none, it's behind, or this server can't: then the device asks to be added. */
548
+ async function joinByItself(conversationId) {
549
+ if (!transport.getGroupInfo)
550
+ return false;
551
+ const d = await ownDevice();
552
+ const got = await transport.getGroupInfo({ conversationId, deviceId: d.deviceId }).catch(() => null);
553
+ if (!got?.ok)
554
+ return false;
555
+ return run(conversationId, async () => {
556
+ if (await store.loadGroup(conversationId))
557
+ return true;
558
+ let joined;
559
+ try {
560
+ joined = await joinMlsGroupExternally({ groupInfo: got.groupInfo, device: d, trust: trustFor(conversationId) });
561
+ }
562
+ catch {
563
+ return false;
564
+ }
565
+ const r = await transport.commit({ conversationId, deviceId: d.deviceId, commit: joined.commit });
566
+ if (!r.ok)
567
+ return false;
568
+ const open = {
569
+ rec: { conversationId, groupId: groupIdOf(joined.state), state: new Uint8Array(), cursor: r.seq, joinedEpoch: epochOf(joined.state) },
570
+ state: joined.state,
571
+ };
572
+ await save(open);
573
+ await notePeople(conversationId, joined.state);
574
+ reconciled.delete(conversationId);
575
+ left.delete(conversationId);
576
+ events.onJoined?.({ conversationId, groupId: open.rec.groupId });
577
+ void publishGroupInfo(open);
578
+ return true;
579
+ });
580
+ }
529
581
  /* Only a member can add a device, so one that's out asks the members' apps to. An old
530
582
  server doesn't answer, which is why nothing waits on it. */
531
583
  async function askToBeAdded(conversationId, force = false) {
@@ -543,7 +595,10 @@ export function createMlsDmDriver(options) {
543
595
  return;
544
596
  void store
545
597
  .loadGroup(conversationId)
546
- .then((rec) => (rec ? undefined : askToBeAdded(conversationId)))
598
+ .then(async (rec) => {
599
+ if (!rec && !(await joinByItself(conversationId)))
600
+ await askToBeAdded(conversationId);
601
+ })
547
602
  .catch(() => undefined);
548
603
  }, NOT_ADDED_GRACE_MS);
549
604
  }
@@ -556,6 +611,8 @@ export function createMlsDmDriver(options) {
556
611
  if (unjoinable.has(conversationId)) {
557
612
  throw new MlsDriverError("peer_unverified", "This device was added, and a member's key isn't pinned here to join with.");
558
613
  }
614
+ if (await joinByItself(conversationId))
615
+ return;
559
616
  void askToBeAdded(conversationId).catch(() => undefined);
560
617
  await waitForWelcome(conversationId);
561
618
  }
@@ -146,6 +146,19 @@ export interface MlsTransport {
146
146
  conversationId: string;
147
147
  deviceId: string;
148
148
  }): Promise<MlsReply<object>>;
149
+ /** The group's GroupInfo for its current epoch, so a device of a member can join by itself (GRYT-1859). */
150
+ putGroupInfo?(req: {
151
+ conversationId: string;
152
+ deviceId: string;
153
+ groupInfo: Uint8Array;
154
+ }): Promise<MlsReply<object>>;
155
+ getGroupInfo?(req: {
156
+ conversationId: string;
157
+ deviceId: string;
158
+ }): Promise<MlsReply<{
159
+ epoch: number;
160
+ groupInfo: Uint8Array;
161
+ }>>;
149
162
  }
150
163
  /** `server:info.mls`. Absent means the server can't do MLS. */
151
164
  export interface MlsServerCapability {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.17.1",
3
+ "version": "0.18.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": {
@@ -43,7 +43,7 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@gryt/crypto": "0.9.0",
46
+ "@gryt/crypto": "0.10.0",
47
47
  "@noble/hashes": "^2.3.0"
48
48
  }
49
49
  }