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