@gryt/core 0.13.0 → 0.15.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 +18 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/mls/deliveryService.fake.d.ts +96 -0
- package/dist/mls/deliveryService.fake.js +413 -0
- package/dist/mls/dmDriver.d.ts +1 -1
- package/dist/mls/dmDriver.js +222 -2
- package/dist/mls/interfaces.d.ts +54 -3
- package/dist/pairing/approver.d.ts +71 -0
- package/dist/pairing/approver.js +198 -0
- package/dist/pairing/channel.d.ts +17 -0
- package/dist/pairing/channel.js +81 -0
- package/dist/pairing/interfaces.d.ts +87 -0
- package/dist/pairing/interfaces.js +1 -0
- package/dist/pairing/newDevice.d.ts +62 -0
- package/dist/pairing/newDevice.js +209 -0
- package/dist/pairing/relayClient.d.ts +45 -0
- package/dist/pairing/relayClient.js +48 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +3 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -49,15 +49,30 @@ goes missing **and** when one appears that nobody listed.
|
|
|
49
49
|
space on the phone, which is a key one of them could collide on.
|
|
50
50
|
- **`mls`** — the DM half of MLS stage 1. It keeps a device's KeyPackages
|
|
51
51
|
topped up, opens a DM group, joins from a Welcome, sends, and reads the group
|
|
52
|
-
log in order.
|
|
52
|
+
log in order. For linking a device, `addOwnDevice` puts a new device of yours
|
|
53
|
+
in every DM straight away and says where in each group's log it went in.
|
|
54
|
+
`groupPositions` says where each log stood when the history snapshot was
|
|
55
|
+
taken. Each app hands it the socket, the storage and the pins through
|
|
53
56
|
the interfaces in `src/mls/interfaces.ts`. `mlsPinsFromPeerPins` builds the
|
|
54
57
|
pins from the peer pins `@gryt/crypto` already keeps.
|
|
55
58
|
`encodeMlsDmContent` and `decodeMlsDmContent` write and read what goes inside
|
|
56
59
|
each message, so both apps use the same JSON: a message, an edit, a delete or
|
|
57
60
|
a reaction. `readMlsDmContent` also says when something came from a newer app,
|
|
58
61
|
so it can be skipped quietly rather than counted as broken. `applyMlsReaction` puts a reaction on a message in the archive,
|
|
59
|
-
since the server never sees one. It
|
|
60
|
-
|
|
62
|
+
since the server never sees one. It depends on `@gryt/crypto`, pinned
|
|
63
|
+
exactly the way the apps pin it.
|
|
64
|
+
- **`pairing`** — linking a new device by QR code or short code, as
|
|
65
|
+
`docs/pairing-design.md` in `@gryt/crypto` lays it out. There's one state
|
|
66
|
+
machine per side. `createNewDevicePairing` shows the QR and code, checks the
|
|
67
|
+
emoji, opens the envelope and, for an account, signs in with the device grant
|
|
68
|
+
and checks the ID token is for the right person. It writes nothing until all
|
|
69
|
+
of that passes. `createApproverPairing` claims the session, shows who's
|
|
70
|
+
asking, seals the envelope, approves the sign-in through the Keycloak
|
|
71
|
+
extension, and adds the new device to every DM. If the extension isn't there,
|
|
72
|
+
it hands back Keycloak's own device page to open instead. The app passes in
|
|
73
|
+
`fetch`, a clock, storage and the OIDC calls through
|
|
74
|
+
`src/pairing/interfaces.ts`. History isn't in it yet. It uses
|
|
75
|
+
`@noble/hashes` for the PKCE challenge, which `@gryt/crypto` already pulls in.
|
|
61
76
|
|
|
62
77
|
## What deliberately isn't
|
|
63
78
|
|
package/dist/index.d.ts
CHANGED
|
@@ -14,4 +14,8 @@ 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, MlsListedDevice, MlsLogEntry, MlsOwnDevice, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
|
|
17
|
+
export type { DmSealingMode, MlsAddOwnDeviceOptions, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupPosition, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsListedDevice, MlsLogEntry, MlsOwnDevice, MlsOwnDeviceAdd, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
|
|
18
|
+
export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
|
|
19
|
+
export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
|
|
20
|
+
export { createPairingRelay, PairingRelayError, type PairingRelay } from "./pairing/relayClient.js";
|
|
21
|
+
export type { DeviceAuthorization, DeviceTokenPoll, OwnDeviceAdder, PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch, PairingOidc, PairingStorage, PairingTokens, } from "./pairing/interfaces.js";
|
package/dist/index.js
CHANGED
|
@@ -16,3 +16,6 @@ export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
|
|
|
16
16
|
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
17
17
|
export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
|
|
18
18
|
export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
|
|
19
|
+
export { createApproverPairing, } from "./pairing/approver.js";
|
|
20
|
+
export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
|
|
21
|
+
export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type IdentityScope } from "@gryt/crypto";
|
|
2
|
+
import type { MlsDmDriver, MlsGroupRecord, MlsKeyPackageRecord, MlsDeviceRecord, MlsLogEntry, MlsStateStore, MlsTransport, MlsWelcomeDelivery } from "./interfaces.js";
|
|
3
|
+
interface KeyPackageRow {
|
|
4
|
+
ref: string;
|
|
5
|
+
serverUserId: string;
|
|
6
|
+
deviceId: string;
|
|
7
|
+
data: Uint8Array;
|
|
8
|
+
lastResort: boolean;
|
|
9
|
+
claimed: boolean;
|
|
10
|
+
}
|
|
11
|
+
interface GroupRow {
|
|
12
|
+
conversationId: string;
|
|
13
|
+
groupId: string;
|
|
14
|
+
epoch: number;
|
|
15
|
+
headSeq: number;
|
|
16
|
+
log: MlsLogEntry[];
|
|
17
|
+
}
|
|
18
|
+
interface Socket {
|
|
19
|
+
serverUserId: string;
|
|
20
|
+
driver: MlsDmDriver | null;
|
|
21
|
+
online: boolean;
|
|
22
|
+
/** False: every call answers `offline` without reaching the server, as an app's transport does. */
|
|
23
|
+
reachable: boolean;
|
|
24
|
+
/** The next call to each method either never arrives or has its answer lost. Both answer `timeout`. */
|
|
25
|
+
lose: Map<keyof MlsTransport, "request" | "answer">;
|
|
26
|
+
}
|
|
27
|
+
export declare class FakeDeliveryService {
|
|
28
|
+
scope: IdentityScope;
|
|
29
|
+
conversations: Map<string, string[]>;
|
|
30
|
+
devices: Map<string, string[]>;
|
|
31
|
+
/** Ids their owner removed, as `${serverUserId}:${deviceId}`. Anything naming one is refused (GRYT-1555). */
|
|
32
|
+
removed: Set<string>;
|
|
33
|
+
/** Every call that named a device, in order, by device id. */
|
|
34
|
+
callsBy: Map<string, string[]>;
|
|
35
|
+
/** When each device id first published, as `mls:devices` reports it. */
|
|
36
|
+
deviceAddedAt: Map<string, string>;
|
|
37
|
+
keyPackages: KeyPackageRow[];
|
|
38
|
+
groups: Map<string, GroupRow>;
|
|
39
|
+
welcomes: (MlsWelcomeDelivery & {
|
|
40
|
+
serverUserId: string;
|
|
41
|
+
})[];
|
|
42
|
+
sockets: Socket[];
|
|
43
|
+
stats: {
|
|
44
|
+
commits: number;
|
|
45
|
+
staleEpoch: number;
|
|
46
|
+
groupExists: number;
|
|
47
|
+
claims: number;
|
|
48
|
+
publishes: number;
|
|
49
|
+
mostBinaryParts: number;
|
|
50
|
+
};
|
|
51
|
+
/** A cap on Welcomes per sync, for a server that pages them (GRYT-1528). server#241 has none. */
|
|
52
|
+
syncWelcomeLimit: number;
|
|
53
|
+
/** What each `mls:send` said it carried, by seq. */
|
|
54
|
+
attachmentIds: Map<number, string[] | undefined>;
|
|
55
|
+
private inFlight;
|
|
56
|
+
private held;
|
|
57
|
+
private nextWelcome;
|
|
58
|
+
constructor(scope: IdentityScope);
|
|
59
|
+
dm(a: string, b: string): string;
|
|
60
|
+
/** Commits wait until `count` of them are in, then go through in the order they came. */
|
|
61
|
+
holdCommits(count: number): void;
|
|
62
|
+
/** Waits until every push has been handled, including what those handlers set off. */
|
|
63
|
+
settle(): Promise<void>;
|
|
64
|
+
connect(serverUserId: string): {
|
|
65
|
+
transport: MlsTransport;
|
|
66
|
+
socket: Socket;
|
|
67
|
+
};
|
|
68
|
+
dropLogThrough(conversationId: string, seq: number): void;
|
|
69
|
+
private push;
|
|
70
|
+
private peopleSharing;
|
|
71
|
+
private devicesOf;
|
|
72
|
+
private isDevice;
|
|
73
|
+
private groupView;
|
|
74
|
+
private transportFor;
|
|
75
|
+
private checkParts;
|
|
76
|
+
private counts;
|
|
77
|
+
private append;
|
|
78
|
+
private commit;
|
|
79
|
+
}
|
|
80
|
+
/** What an app's store does, kept in maps and copied on the way in and out. */
|
|
81
|
+
export declare class MemoryMlsStore implements MlsStateStore {
|
|
82
|
+
device: MlsDeviceRecord | null;
|
|
83
|
+
keyPackages: Map<string, MlsKeyPackageRecord>;
|
|
84
|
+
groups: Map<string, MlsGroupRecord>;
|
|
85
|
+
loadDevice(): Promise<MlsDeviceRecord | null>;
|
|
86
|
+
saveDevice(device: MlsDeviceRecord): Promise<void>;
|
|
87
|
+
putKeyPackages(records: MlsKeyPackageRecord[]): Promise<void>;
|
|
88
|
+
listKeyPackages(): Promise<MlsKeyPackageRecord[]>;
|
|
89
|
+
getKeyPackage(ref: string): Promise<MlsKeyPackageRecord | null>;
|
|
90
|
+
deleteKeyPackage(ref: string): Promise<void>;
|
|
91
|
+
loadGroup(conversationId: string): Promise<MlsGroupRecord | null>;
|
|
92
|
+
listGroups(): Promise<MlsGroupRecord[]>;
|
|
93
|
+
saveGroup(record: MlsGroupRecord): Promise<void>;
|
|
94
|
+
deleteGroup(conversationId: string): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
export {};
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/* An in-memory stand-in for the delivery service in Gryt-chat/server#241, for tests only.
|
|
2
|
+
Same answers and pushes; no auth, blocks, rate limits or member checks on adds. */
|
|
3
|
+
import { base64UrlDecode, inspectMlsMessage, mlsWelcomeRefs, readMlsKeyPackage } from "@gryt/crypto";
|
|
4
|
+
const MAX_DEVICES = 5;
|
|
5
|
+
const MAX_KEY_PACKAGES = 20;
|
|
6
|
+
const MAX_LOG_PAGE = 200;
|
|
7
|
+
const DUPLICATE_WINDOW = 100;
|
|
8
|
+
const fail = (error, message, extra = {}) => ({ ok: false, error, message, ...extra });
|
|
9
|
+
const hexOf = (b64) => Array.from(base64UrlDecode(b64), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
function same(a, b) {
|
|
11
|
+
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
12
|
+
}
|
|
13
|
+
export class FakeDeliveryService {
|
|
14
|
+
scope;
|
|
15
|
+
conversations = new Map();
|
|
16
|
+
devices = new Map();
|
|
17
|
+
/** Ids their owner removed, as `${serverUserId}:${deviceId}`. Anything naming one is refused (GRYT-1555). */
|
|
18
|
+
removed = new Set();
|
|
19
|
+
/** Every call that named a device, in order, by device id. */
|
|
20
|
+
callsBy = new Map();
|
|
21
|
+
/** When each device id first published, as `mls:devices` reports it. */
|
|
22
|
+
deviceAddedAt = new Map();
|
|
23
|
+
keyPackages = [];
|
|
24
|
+
groups = new Map();
|
|
25
|
+
welcomes = [];
|
|
26
|
+
sockets = [];
|
|
27
|
+
stats = { commits: 0, staleEpoch: 0, groupExists: 0, claims: 0, publishes: 0, mostBinaryParts: 0 };
|
|
28
|
+
/** A cap on Welcomes per sync, for a server that pages them (GRYT-1528). server#241 has none. */
|
|
29
|
+
syncWelcomeLimit = Infinity;
|
|
30
|
+
/** What each `mls:send` said it carried, by seq. */
|
|
31
|
+
attachmentIds = new Map();
|
|
32
|
+
inFlight = new Set();
|
|
33
|
+
held = null;
|
|
34
|
+
nextWelcome = 1;
|
|
35
|
+
constructor(scope) {
|
|
36
|
+
this.scope = scope;
|
|
37
|
+
}
|
|
38
|
+
dm(a, b) {
|
|
39
|
+
const id = `dm:${[a, b].sort().join(":")}`;
|
|
40
|
+
this.conversations.set(id, [a, b]);
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
/** Commits wait until `count` of them are in, then go through in the order they came. */
|
|
44
|
+
holdCommits(count) {
|
|
45
|
+
this.held = { count, waiting: [] };
|
|
46
|
+
}
|
|
47
|
+
/** Waits until every push has been handled, including what those handlers set off. */
|
|
48
|
+
async settle() {
|
|
49
|
+
while (this.inFlight.size > 0)
|
|
50
|
+
await Promise.allSettled([...this.inFlight]);
|
|
51
|
+
}
|
|
52
|
+
connect(serverUserId) {
|
|
53
|
+
const socket = { serverUserId, driver: null, online: true, reachable: true, lose: new Map() };
|
|
54
|
+
this.sockets.push(socket);
|
|
55
|
+
return { transport: this.transportFor(socket), socket };
|
|
56
|
+
}
|
|
57
|
+
dropLogThrough(conversationId, seq) {
|
|
58
|
+
const g = this.groups.get(conversationId);
|
|
59
|
+
if (g)
|
|
60
|
+
g.log = g.log.filter((e) => e.seq > seq);
|
|
61
|
+
}
|
|
62
|
+
push(to, deliver) {
|
|
63
|
+
for (const s of this.sockets) {
|
|
64
|
+
if (!s.online || !s.reachable || !s.driver || !to.includes(s.serverUserId))
|
|
65
|
+
continue;
|
|
66
|
+
const driver = s.driver;
|
|
67
|
+
const p = new Promise((resolve) => setTimeout(resolve, 0)).then(() => deliver(driver));
|
|
68
|
+
const tracked = p.catch(() => undefined).finally(() => this.inFlight.delete(tracked));
|
|
69
|
+
this.inFlight.add(tracked);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
peopleSharing(serverUserId) {
|
|
73
|
+
const out = new Set([serverUserId]);
|
|
74
|
+
for (const members of this.conversations.values())
|
|
75
|
+
if (members.includes(serverUserId))
|
|
76
|
+
members.forEach((m) => out.add(m));
|
|
77
|
+
return [...out];
|
|
78
|
+
}
|
|
79
|
+
devicesOf(ids) {
|
|
80
|
+
return ids.flatMap((serverUserId) => (this.devices.get(serverUserId) ?? []).map((deviceId) => ({ serverUserId, deviceId })));
|
|
81
|
+
}
|
|
82
|
+
isDevice(serverUserId, deviceId) {
|
|
83
|
+
return (this.devices.get(serverUserId) ?? []).includes(deviceId);
|
|
84
|
+
}
|
|
85
|
+
groupView(g) {
|
|
86
|
+
return { conversationId: g.conversationId, groupId: g.groupId, epoch: g.epoch, headSeq: g.headSeq };
|
|
87
|
+
}
|
|
88
|
+
transportFor(socket) {
|
|
89
|
+
const me = socket.serverUserId;
|
|
90
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
+
const member = (conversationId) => this.conversations.get(conversationId)?.includes(me) ?? false;
|
|
92
|
+
const methods = {
|
|
93
|
+
publishKeyPackages: async ({ deviceId, keyPackages, lastResort }) => {
|
|
94
|
+
await tick();
|
|
95
|
+
this.stats.publishes += 1;
|
|
96
|
+
const isNew = !this.isDevice(me, deviceId);
|
|
97
|
+
if (isNew && (this.devices.get(me) ?? []).length >= MAX_DEVICES) {
|
|
98
|
+
return fail("too_many_devices", "You have five devices using encrypted messages here.");
|
|
99
|
+
}
|
|
100
|
+
const raw = [...keyPackages.map((b) => ({ b, lastResort: false }))];
|
|
101
|
+
if (lastResort)
|
|
102
|
+
raw.push({ b: lastResort, lastResort: true });
|
|
103
|
+
const rows = [];
|
|
104
|
+
for (const { b, lastResort: lr } of raw) {
|
|
105
|
+
const { ref } = await readMlsKeyPackage(b, this.scope);
|
|
106
|
+
rows.push({ ref, serverUserId: me, deviceId, data: b.slice(), lastResort: lr, claimed: false });
|
|
107
|
+
}
|
|
108
|
+
if (isNew) {
|
|
109
|
+
this.devices.set(me, [...(this.devices.get(me) ?? []), deviceId]);
|
|
110
|
+
this.deviceAddedAt.set(deviceId, new Date().toISOString());
|
|
111
|
+
}
|
|
112
|
+
let stored = 0;
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const unclaimed = this.keyPackages.filter((k) => k.deviceId === deviceId && !k.lastResort && !k.claimed).length;
|
|
115
|
+
if (!row.lastResort && unclaimed >= MAX_KEY_PACKAGES)
|
|
116
|
+
continue;
|
|
117
|
+
if (row.lastResort) {
|
|
118
|
+
for (const k of this.keyPackages)
|
|
119
|
+
if (k.deviceId === deviceId && k.lastResort)
|
|
120
|
+
k.claimed = true;
|
|
121
|
+
}
|
|
122
|
+
this.keyPackages.push(row);
|
|
123
|
+
stored += 1;
|
|
124
|
+
}
|
|
125
|
+
if (isNew)
|
|
126
|
+
this.push(this.peopleSharing(me), (d) => d.handleDevicesChanged({ serverUserId: me }));
|
|
127
|
+
return { ok: true, stored, ...this.counts(me, deviceId) };
|
|
128
|
+
},
|
|
129
|
+
claimKeyPackages: async ({ conversationId, deviceId, devices }) => {
|
|
130
|
+
await tick();
|
|
131
|
+
if (!member(conversationId))
|
|
132
|
+
return fail("not_found", "No such conversation.");
|
|
133
|
+
this.stats.claims += 1;
|
|
134
|
+
let targets = this.devicesOf(this.conversations.get(conversationId)).filter((d) => !(d.serverUserId === me && d.deviceId === deviceId));
|
|
135
|
+
if (devices)
|
|
136
|
+
targets = targets.filter((t) => devices.some((d) => d.serverUserId === t.serverUserId && d.deviceId === t.deviceId));
|
|
137
|
+
const keyPackages = [];
|
|
138
|
+
const missing = [];
|
|
139
|
+
for (const t of targets) {
|
|
140
|
+
const mine = this.keyPackages.filter((k) => k.serverUserId === t.serverUserId && k.deviceId === t.deviceId && !k.claimed);
|
|
141
|
+
const fresh = mine.find((k) => !k.lastResort);
|
|
142
|
+
const kp = fresh ?? mine.find((k) => k.lastResort);
|
|
143
|
+
if (!kp) {
|
|
144
|
+
missing.push(t);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (fresh)
|
|
148
|
+
fresh.claimed = true;
|
|
149
|
+
keyPackages.push({ ...t, keyPackage: kp.data.slice(), lastResort: kp.lastResort });
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, keyPackages, missing };
|
|
152
|
+
},
|
|
153
|
+
listDevices: async ({ conversationId }) => {
|
|
154
|
+
await tick();
|
|
155
|
+
if (conversationId === undefined) {
|
|
156
|
+
const devices = this.devicesOf([me]).map((d) => {
|
|
157
|
+
const at = this.deviceAddedAt.get(d.deviceId);
|
|
158
|
+
return at ? { ...d, createdAt: at, lastSeenAt: at } : d;
|
|
159
|
+
});
|
|
160
|
+
return { ok: true, devices };
|
|
161
|
+
}
|
|
162
|
+
if (!member(conversationId))
|
|
163
|
+
return fail("not_found", "No such conversation.");
|
|
164
|
+
return { ok: true, devices: this.devicesOf(this.conversations.get(conversationId)) };
|
|
165
|
+
},
|
|
166
|
+
removeDevice: async ({ deviceId }) => {
|
|
167
|
+
await tick();
|
|
168
|
+
if (!this.isDevice(me, deviceId))
|
|
169
|
+
return fail("unknown_device", "Publish KeyPackages from this device first.");
|
|
170
|
+
this.devices.set(me, (this.devices.get(me) ?? []).filter((d) => d !== deviceId));
|
|
171
|
+
this.removed.add(`${me}:${deviceId}`);
|
|
172
|
+
this.keyPackages = this.keyPackages.filter((k) => !(k.serverUserId === me && k.deviceId === deviceId));
|
|
173
|
+
this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId));
|
|
174
|
+
this.push(this.peopleSharing(me), (d) => d.handleDevicesChanged({ serverUserId: me }));
|
|
175
|
+
return { ok: true };
|
|
176
|
+
},
|
|
177
|
+
createGroup: async ({ conversationId, groupId }) => {
|
|
178
|
+
await tick();
|
|
179
|
+
if (!member(conversationId))
|
|
180
|
+
return fail("not_found", "No such conversation.");
|
|
181
|
+
const existing = this.groups.get(conversationId);
|
|
182
|
+
if (existing) {
|
|
183
|
+
this.stats.groupExists += 1;
|
|
184
|
+
return fail("group_exists", "This conversation already has a group.", { group: this.groupView(existing) });
|
|
185
|
+
}
|
|
186
|
+
const g = { conversationId, groupId, epoch: 0, headSeq: 0, log: [] };
|
|
187
|
+
this.groups.set(conversationId, g);
|
|
188
|
+
return { ok: true, group: this.groupView(g) };
|
|
189
|
+
},
|
|
190
|
+
commit: async ({ conversationId, deviceId, commit, welcome }) => {
|
|
191
|
+
await tick();
|
|
192
|
+
if (this.held) {
|
|
193
|
+
const held = this.held;
|
|
194
|
+
await new Promise((resolve) => {
|
|
195
|
+
held.waiting.push(resolve);
|
|
196
|
+
if (held.waiting.length >= held.count) {
|
|
197
|
+
this.held = null;
|
|
198
|
+
held.waiting.forEach((go) => go());
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return this.commit(me, conversationId, deviceId, commit, welcome);
|
|
203
|
+
},
|
|
204
|
+
send: async ({ conversationId, deviceId, message, attachmentIds }) => {
|
|
205
|
+
await tick();
|
|
206
|
+
if (!member(conversationId))
|
|
207
|
+
return fail("not_found", "No such conversation.");
|
|
208
|
+
const g = this.groups.get(conversationId);
|
|
209
|
+
if (!g)
|
|
210
|
+
return fail("no_group", "This conversation has no group yet.");
|
|
211
|
+
const info = inspectMlsMessage(message);
|
|
212
|
+
if (info.contentType === "commit")
|
|
213
|
+
return fail("use_commit", "Send commits with mls:commit.");
|
|
214
|
+
if (hexOf(info.groupId) !== g.groupId)
|
|
215
|
+
return fail("wrong_group", "That message is for another group.");
|
|
216
|
+
const epoch = Number(info.epoch);
|
|
217
|
+
if (epoch > g.epoch)
|
|
218
|
+
return fail("future_epoch", "That epoch hasn't happened yet.", { epoch: g.epoch });
|
|
219
|
+
const dup = g.log.find((e) => e.seq > g.headSeq - DUPLICATE_WINDOW && same(e.data, message));
|
|
220
|
+
if (dup)
|
|
221
|
+
return { ok: true, seq: dup.seq };
|
|
222
|
+
const kind = info.contentType === "application" ? "application" : "proposal";
|
|
223
|
+
const entry = this.append(g, kind, epoch, me, deviceId, message);
|
|
224
|
+
this.attachmentIds.set(entry.seq, attachmentIds);
|
|
225
|
+
this.push(this.conversations.get(conversationId), (d) => d.handleMessage(entry));
|
|
226
|
+
return { ok: true, seq: entry.seq };
|
|
227
|
+
},
|
|
228
|
+
fetchLog: async ({ conversationId, after, limit }) => {
|
|
229
|
+
await tick();
|
|
230
|
+
if (!member(conversationId))
|
|
231
|
+
return fail("not_found", "No such conversation.");
|
|
232
|
+
const g = this.groups.get(conversationId);
|
|
233
|
+
if (!g)
|
|
234
|
+
return { ok: true, group: null, entries: [], nextCursor: after, hasMore: false, gap: false };
|
|
235
|
+
const n = Math.min(limit ?? 100, MAX_LOG_PAGE);
|
|
236
|
+
const rows = g.log.filter((e) => e.seq > after).slice(0, n + 1);
|
|
237
|
+
const page = rows.slice(0, n);
|
|
238
|
+
const oldest = g.log.length ? g.log[0].seq : null;
|
|
239
|
+
const gap = after < g.headSeq && (oldest === null || oldest > after + 1);
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
group: this.groupView(g),
|
|
243
|
+
entries: page.map((e) => ({ ...e, data: e.data.slice() })),
|
|
244
|
+
nextCursor: page.length ? page[page.length - 1].seq : Math.max(after, gap ? g.headSeq : after),
|
|
245
|
+
hasMore: rows.length > n,
|
|
246
|
+
gap,
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
sync: async ({ deviceId }) => {
|
|
250
|
+
await tick();
|
|
251
|
+
const registered = this.isDevice(me, deviceId);
|
|
252
|
+
const groups = [...this.groups.values()]
|
|
253
|
+
.filter((g) => member(g.conversationId))
|
|
254
|
+
.map((g) => ({ ...this.groupView(g), oldestSeq: g.log.length ? g.log[0].seq : null }));
|
|
255
|
+
const welcomes = registered
|
|
256
|
+
? this.welcomes.filter((w) => w.serverUserId === me && w.deviceId === deviceId).slice(0, this.syncWelcomeLimit)
|
|
257
|
+
: [];
|
|
258
|
+
return { ok: true, registered, groups, welcomes, keyPackages: { ...this.counts(me, deviceId), target: MAX_KEY_PACKAGES } };
|
|
259
|
+
},
|
|
260
|
+
ackWelcomes: async ({ deviceId, welcomeIds }) => {
|
|
261
|
+
await tick();
|
|
262
|
+
const before = this.welcomes.length;
|
|
263
|
+
this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId && welcomeIds.includes(w.welcomeId)));
|
|
264
|
+
return { ok: true, deleted: before - this.welcomes.length };
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
// socket.io-parser closes the connection on a packet with more than ten binary parts.
|
|
268
|
+
const guarded = {};
|
|
269
|
+
for (const [name, method] of Object.entries(methods)) {
|
|
270
|
+
guarded[name] = async (req) => {
|
|
271
|
+
if (!socket.reachable)
|
|
272
|
+
return fail("offline", "Not connected to this server.");
|
|
273
|
+
const lost = socket.lose.get(name);
|
|
274
|
+
socket.lose.delete(name);
|
|
275
|
+
if (lost === "request")
|
|
276
|
+
return fail("timeout", `No answer to ${name}.`);
|
|
277
|
+
if (lost === "answer") {
|
|
278
|
+
await guarded[name](req).catch(() => undefined);
|
|
279
|
+
return fail("timeout", `No answer to ${name}.`);
|
|
280
|
+
}
|
|
281
|
+
this.checkParts(socket, name, req);
|
|
282
|
+
const deviceId = req?.deviceId;
|
|
283
|
+
if (typeof deviceId === "string") {
|
|
284
|
+
this.callsBy.set(deviceId, [...(this.callsBy.get(deviceId) ?? []), name]);
|
|
285
|
+
if (this.removed.has(`${me}:${deviceId}`)) {
|
|
286
|
+
await tick();
|
|
287
|
+
return fail("device_removed", "You removed this device from encrypted messages here.");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const reply = await method(req);
|
|
291
|
+
this.checkParts(socket, name, reply);
|
|
292
|
+
return reply;
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return guarded;
|
|
296
|
+
}
|
|
297
|
+
checkParts(socket, event, value) {
|
|
298
|
+
const count = (v) => v instanceof Uint8Array ? 1 : Array.isArray(v) ? v.reduce((n, x) => n + count(x), 0)
|
|
299
|
+
: v && typeof v === "object" ? Object.values(v).reduce((n, x) => n + count(x), 0) : 0;
|
|
300
|
+
const parts = count(value);
|
|
301
|
+
this.stats.mostBinaryParts = Math.max(this.stats.mostBinaryParts, parts);
|
|
302
|
+
if (parts > 10) {
|
|
303
|
+
socket.online = false;
|
|
304
|
+
throw new Error(`${event}: ${parts} binary parts, so the socket was closed ("too many attachments").`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
counts(serverUserId, deviceId) {
|
|
308
|
+
const mine = this.keyPackages.filter((k) => k.serverUserId === serverUserId && k.deviceId === deviceId && !k.claimed);
|
|
309
|
+
return { unclaimed: mine.filter((k) => !k.lastResort).length, lastResort: mine.some((k) => k.lastResort) };
|
|
310
|
+
}
|
|
311
|
+
append(g, kind, epoch, sender, deviceId, data) {
|
|
312
|
+
g.headSeq += 1;
|
|
313
|
+
const entry = {
|
|
314
|
+
conversationId: g.conversationId,
|
|
315
|
+
groupId: g.groupId,
|
|
316
|
+
seq: g.headSeq,
|
|
317
|
+
kind,
|
|
318
|
+
epoch,
|
|
319
|
+
senderServerUserId: sender,
|
|
320
|
+
senderDeviceId: deviceId,
|
|
321
|
+
data: data.slice(),
|
|
322
|
+
createdAt: new Date().toISOString(),
|
|
323
|
+
};
|
|
324
|
+
g.log.push(entry);
|
|
325
|
+
return entry;
|
|
326
|
+
}
|
|
327
|
+
async commit(me, conversationId, deviceId, commit, welcome) {
|
|
328
|
+
if (!this.conversations.get(conversationId)?.includes(me))
|
|
329
|
+
return fail("not_found", "No such conversation.");
|
|
330
|
+
if (!this.isDevice(me, deviceId))
|
|
331
|
+
return fail("unknown_device", "Publish KeyPackages from this device first.");
|
|
332
|
+
const g = this.groups.get(conversationId);
|
|
333
|
+
if (!g)
|
|
334
|
+
return fail("no_group", "This conversation has no group yet.");
|
|
335
|
+
const info = inspectMlsMessage(commit);
|
|
336
|
+
if (info.wireformat !== "mls_public_message" || info.contentType !== "commit")
|
|
337
|
+
return fail("not_a_commit", "Not a commit.");
|
|
338
|
+
if (hexOf(info.groupId) !== g.groupId)
|
|
339
|
+
return fail("wrong_group", "That commit is for another group.");
|
|
340
|
+
const dup = g.log.find((e) => e.seq > g.headSeq - DUPLICATE_WINDOW && same(e.data, commit));
|
|
341
|
+
if (dup)
|
|
342
|
+
return { ok: true, seq: dup.seq, epoch: dup.epoch + 1 };
|
|
343
|
+
const epoch = Number(info.epoch);
|
|
344
|
+
if (epoch !== g.epoch) {
|
|
345
|
+
this.stats.staleEpoch += 1;
|
|
346
|
+
return fail("stale_epoch", "Another commit got there first.", { epoch: g.epoch, headSeq: g.headSeq });
|
|
347
|
+
}
|
|
348
|
+
const recipients = [];
|
|
349
|
+
if (welcome) {
|
|
350
|
+
for (const ref of mlsWelcomeRefs(welcome)) {
|
|
351
|
+
const owner = this.keyPackages.find((k) => k.ref === ref);
|
|
352
|
+
if (!owner)
|
|
353
|
+
return fail("not_a_member_device", "That Welcome is for somebody outside the conversation.");
|
|
354
|
+
recipients.push({ serverUserId: owner.serverUserId, deviceId: owner.deviceId });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.stats.commits += 1;
|
|
358
|
+
const entry = this.append(g, "commit", epoch, me, deviceId, commit);
|
|
359
|
+
g.epoch += 1;
|
|
360
|
+
this.push(this.conversations.get(conversationId), (d) => d.handleMessage(entry));
|
|
361
|
+
for (const r of recipients) {
|
|
362
|
+
const w = {
|
|
363
|
+
welcomeId: `w${this.nextWelcome++}`,
|
|
364
|
+
conversationId,
|
|
365
|
+
groupId: g.groupId,
|
|
366
|
+
deviceId: r.deviceId,
|
|
367
|
+
data: welcome.slice(),
|
|
368
|
+
createdAt: entry.createdAt,
|
|
369
|
+
};
|
|
370
|
+
this.welcomes.push({ ...w, serverUserId: r.serverUserId });
|
|
371
|
+
this.push([r.serverUserId], (d) => d.handleWelcome(w));
|
|
372
|
+
}
|
|
373
|
+
return { ok: true, seq: entry.seq, epoch: g.epoch };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** What an app's store does, kept in maps and copied on the way in and out. */
|
|
377
|
+
export class MemoryMlsStore {
|
|
378
|
+
device = null;
|
|
379
|
+
keyPackages = new Map();
|
|
380
|
+
groups = new Map();
|
|
381
|
+
async loadDevice() {
|
|
382
|
+
return this.device;
|
|
383
|
+
}
|
|
384
|
+
async saveDevice(device) {
|
|
385
|
+
this.device = device;
|
|
386
|
+
}
|
|
387
|
+
async putKeyPackages(records) {
|
|
388
|
+
for (const r of records)
|
|
389
|
+
this.keyPackages.set(r.ref, r);
|
|
390
|
+
}
|
|
391
|
+
async listKeyPackages() {
|
|
392
|
+
return [...this.keyPackages.values()];
|
|
393
|
+
}
|
|
394
|
+
async getKeyPackage(ref) {
|
|
395
|
+
return this.keyPackages.get(ref) ?? null;
|
|
396
|
+
}
|
|
397
|
+
async deleteKeyPackage(ref) {
|
|
398
|
+
this.keyPackages.delete(ref);
|
|
399
|
+
}
|
|
400
|
+
async loadGroup(conversationId) {
|
|
401
|
+
const r = this.groups.get(conversationId);
|
|
402
|
+
return r ? structuredClone(r) : null;
|
|
403
|
+
}
|
|
404
|
+
async listGroups() {
|
|
405
|
+
return [...this.groups.values()].map((r) => structuredClone(r));
|
|
406
|
+
}
|
|
407
|
+
async saveGroup(record) {
|
|
408
|
+
this.groups.set(record.conversationId, structuredClone(record));
|
|
409
|
+
}
|
|
410
|
+
async deleteGroup(conversationId) {
|
|
411
|
+
this.groups.delete(conversationId);
|
|
412
|
+
}
|
|
413
|
+
}
|
package/dist/mls/dmDriver.d.ts
CHANGED
|
@@ -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" | "device_removed";
|
|
2
|
+
export type MlsDriverErrorCode = "refused" | "not_own_device" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "too_many_attachments" | "commit_retries" | "device_removed" | "offline" | "stopped";
|
|
3
3
|
export declare class MlsDriverError extends Error {
|
|
4
4
|
code: MlsDriverErrorCode;
|
|
5
5
|
/** The server's refusal, when there was one. */
|