@gryt/core 0.12.0 → 0.13.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/dist/index.d.ts CHANGED
@@ -14,4 +14,4 @@ export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./ml
14
14
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
15
15
  export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, type MlsDmContent, readMlsDmContent, } from "./mls/content.js";
16
16
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
17
- 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";
17
+ export type { DmSealingMode, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsListedDevice, MlsLogEntry, MlsOwnDevice, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
@@ -1,5 +1,5 @@
1
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" | "too_many_attachments" | "commit_retries";
2
+ export type MlsDriverErrorCode = "refused" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "too_many_attachments" | "commit_retries" | "device_removed";
3
3
  export declare class MlsDriverError extends Error {
4
4
  code: MlsDriverErrorCode;
5
5
  /** The server's refusal, when there was one. */
@@ -1,4 +1,4 @@
1
- import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decodeMlsGroupState, encodeMlsGroupState, encryptMlsMessage, forgetMlsSecrets, generateMlsKeyPackage, joinMlsGroup, mlsGroupInfo, mlsGroupMembers, mlsWelcomeRefs, processMlsMessage, readMlsKeyPackage, removeMlsMembers, } from "@gryt/crypto";
1
+ import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decodeMlsGroupState, encodeMlsGroupState, encryptMlsMessage, forgetMlsSecrets, generateMlsKeyPackage, joinMlsGroup, mlsGroupInfo, mlsGroupMembers, mlsWelcomeRefs, processMlsMessage, readDeviceCertificate, readMlsKeyPackage, removeMlsMembers, } from "@gryt/crypto";
2
2
  /** Five devices per person per server (design, section 1). The server holds the same line. */
3
3
  const MLS_MAX_DEVICES_PER_PERSON = 5;
4
4
  const COMMIT_ATTEMPTS = 5;
@@ -55,7 +55,39 @@ function keyedQueue() {
55
55
  };
56
56
  }
57
57
  export function createMlsDmDriver(options) {
58
- const { transport, store, pins, events, serverUserId: self, capability } = options;
58
+ const { store, pins, events, serverUserId: self, capability } = options;
59
+ /* Once the server says `device_removed`, nothing goes to it again from this driver:
60
+ no sync, and above all no publish that could register this device back. */
61
+ let removed = false;
62
+ const removedError = (r) => new MlsDriverError("device_removed", "This device was removed from encrypted messages on this server.", r);
63
+ function stopOnRemoval(call) {
64
+ return async (req) => {
65
+ if (removed)
66
+ throw removedError();
67
+ const r = await call(req);
68
+ if (!r.ok && r.error === "device_removed") {
69
+ if (!removed) {
70
+ removed = true;
71
+ events.onDeviceRemoved?.();
72
+ }
73
+ throw removedError(r);
74
+ }
75
+ return r;
76
+ };
77
+ }
78
+ const raw = options.transport;
79
+ const transport = {
80
+ publishKeyPackages: stopOnRemoval((req) => raw.publishKeyPackages(req)),
81
+ claimKeyPackages: stopOnRemoval((req) => raw.claimKeyPackages(req)),
82
+ listDevices: stopOnRemoval((req) => raw.listDevices(req)),
83
+ removeDevice: stopOnRemoval((req) => raw.removeDevice(req)),
84
+ createGroup: stopOnRemoval((req) => raw.createGroup(req)),
85
+ commit: stopOnRemoval((req) => raw.commit(req)),
86
+ send: stopOnRemoval((req) => raw.send(req)),
87
+ fetchLog: stopOnRemoval((req) => raw.fetchLog(req)),
88
+ sync: stopOnRemoval((req) => raw.sync(req)),
89
+ ackWelcomes: stopOnRemoval((req) => raw.ackWelcomes(req)),
90
+ };
59
91
  const scope = asIdentityScope(options.scope);
60
92
  const trusts = new Map();
61
93
  /** What the engine asks about every leaf: is it somebody in this conversation, by pin. */
@@ -579,6 +611,9 @@ export function createMlsDmDriver(options) {
579
611
  /** Adds wait for the next send; a device that's gone is removed straight away. */
580
612
  async function handleDevicesChanged(push) {
581
613
  reconciled.clear();
614
+ // One of yours changed, and it may be this one: a sync is how the server says so.
615
+ if (push.serverUserId === self && registered)
616
+ await transport.sync({ deviceId: (await ownDevice()).deviceId });
582
617
  for (const rec of await store.listGroups()) {
583
618
  await run(rec.conversationId, async () => {
584
619
  const open = await load(rec.conversationId);
@@ -593,6 +628,45 @@ export function createMlsDmDriver(options) {
593
628
  });
594
629
  }
595
630
  }
631
+ /** Names from certificates under your own person key: this device's, then your leaves in each group. */
632
+ async function ownDeviceNames(d, wanted) {
633
+ const mine = readDeviceCertificate(d.certificate, scope);
634
+ const names = new Map([[mine.deviceId, mine.deviceName]]);
635
+ const unnamed = () => [...wanted].some((id) => !names.has(id));
636
+ for (const rec of await store.listGroups()) {
637
+ if (!unnamed())
638
+ break;
639
+ let members;
640
+ try {
641
+ members = mlsGroupMembers(decodeMlsGroupState(rec.state, trustFor(rec.conversationId)), scope);
642
+ }
643
+ catch {
644
+ continue;
645
+ }
646
+ for (const { certificate: c } of members) {
647
+ if (wanted.has(c.deviceId) && !names.has(c.deviceId) && sameBytes(c.personPublicKey, mine.personPublicKey)) {
648
+ names.set(c.deviceId, c.deviceName);
649
+ }
650
+ }
651
+ }
652
+ return names;
653
+ }
654
+ async function ownDevices() {
655
+ const r = await transport.listDevices({});
656
+ if (!r.ok)
657
+ throw refused("Listing devices", r);
658
+ const d = await ownDevice();
659
+ const listed = r.devices.filter((x) => x.serverUserId === self);
660
+ const names = await ownDeviceNames(d, new Set(listed.map((x) => x.deviceId)));
661
+ return listed.map((x) => ({
662
+ serverUserId: x.serverUserId,
663
+ deviceId: x.deviceId,
664
+ name: names.get(x.deviceId) ?? null,
665
+ addedAt: x.createdAt ?? null,
666
+ lastSeenAt: x.lastSeenAt ?? null,
667
+ thisDevice: x.deviceId === d.deviceId,
668
+ }));
669
+ }
596
670
  return {
597
671
  async start() {
598
672
  await ownDevice();
@@ -613,12 +687,7 @@ export function createMlsDmDriver(options) {
613
687
  handleMessage,
614
688
  handleWelcome,
615
689
  handleDevicesChanged,
616
- async ownDevices() {
617
- const r = await transport.listDevices({});
618
- if (!r.ok)
619
- throw refused("Listing devices", r);
620
- return r.devices;
621
- },
690
+ ownDevices,
622
691
  async removeOwnDevice(deviceId) {
623
692
  const r = await transport.removeDevice({ deviceId });
624
693
  if (!r.ok)
@@ -13,6 +13,11 @@ export interface MlsDeviceRef {
13
13
  serverUserId: string;
14
14
  deviceId: string;
15
15
  }
16
+ /** A row of `mls:devices`. Your own rows say when the server first and last saw each one. */
17
+ export interface MlsListedDevice extends MlsDeviceRef {
18
+ createdAt?: string;
19
+ lastSeenAt?: string;
20
+ }
16
21
  /** The server's view of a group. `groupId` is lower-case hex. */
17
22
  export interface MlsGroupView {
18
23
  conversationId: string;
@@ -71,7 +76,7 @@ export interface MlsTransport {
71
76
  listDevices(req: {
72
77
  conversationId?: string;
73
78
  }): Promise<MlsReply<{
74
- devices: MlsDeviceRef[];
79
+ devices: MlsListedDevice[];
75
80
  }>>;
76
81
  removeDevice(req: {
77
82
  deviceId: string;
@@ -232,6 +237,11 @@ export interface MlsDmEvents {
232
237
  conversationId: string;
233
238
  groupId: string;
234
239
  }): void;
240
+ /**
241
+ * The server says this device was removed here (`device_removed`). The driver has stopped for
242
+ * good. Wipe this server's MLS state, and don't make a new device until the person asks.
243
+ */
244
+ onDeviceRemoved?(): void;
235
245
  }
236
246
  /** Decision 4, per DM. `refused` never falls back to version 1. */
237
247
  export type DmSealingMode = {
@@ -243,6 +253,15 @@ export type DmSealingMode = {
243
253
  kind: "refused";
244
254
  reason: "peer_left_mls" | "server_dropped_mls" | "no_own_device";
245
255
  };
256
+ /** One of your own devices on this server, for a device list. */
257
+ export interface MlsOwnDevice extends MlsDeviceRef {
258
+ /** From its device certificate, found in this device or a group you share. Null when none shows it. */
259
+ name: string | null;
260
+ /** ISO times from the server, or null from a server that doesn't send them. */
261
+ addedAt: string | null;
262
+ lastSeenAt: string | null;
263
+ thisDevice: boolean;
264
+ }
246
265
  export interface MlsDmDriverOptions {
247
266
  transport: MlsTransport;
248
267
  store: MlsStateStore;
@@ -272,7 +291,8 @@ export interface MlsDmDriver {
272
291
  handleDevicesChanged(push: {
273
292
  serverUserId: string;
274
293
  }): Promise<void>;
275
- ownDevices(): Promise<MlsDeviceRef[]>;
294
+ /** Oldest first, as the server lists them. */
295
+ ownDevices(): Promise<MlsOwnDevice[]>;
276
296
  /** Your own devices only. Every group you share drops it on the next pass. */
277
297
  removeOwnDevice(deviceId: string): Promise<void>;
278
298
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": {