@tavrn/e2ee 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,294 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Text processing of the local search index (design note 4.4 "Search"): normalization, the tokenizer, the
4
+ * lookup keys of a text and of a query, the verification of candidates and the snippet builder.
5
+ *
6
+ * A word is a maximal run of letters and digits (plus the combining marks following them). Runs of CJK characters (Han,
7
+ * Hiragana, Katakana, Hangul) have no word boundaries, so they are indexed as unigrams and bigrams
8
+ * with exact keys; every other word gets its prefixes of 3..8 code points as keys (`p:`), words
9
+ * shorter than 3 code points an exact key (`x:`). Each word is indexed both as written (normalized)
10
+ * and diacritic-folded, so "Müller" is found by "müller" and by "muller".
11
+ *
12
+ * The query string is never interpreted as syntax: quotes, parentheses, `*`, `OR`, `NEAR`, `:` are
13
+ * separators or plain words, and all terms must match (AND).
14
+ */
15
+
16
+ /** Highlight start inside a snippet. */
17
+ export const MARK_START = "\u0001";
18
+ /** Highlight end inside a snippet. */
19
+ export const MARK_END = "\u0002";
20
+ export const MIN_PREFIX = 3;
21
+ export const MAX_PREFIX = 8;
22
+ /** Text kept per message (UTF-16 units, cut at a code point boundary). */
23
+ export const MAX_INDEXED_CHARS = 4000;
24
+ /** Characters of context around the first match on each side of a snippet. */
25
+ export const SNIPPET_CONTEXT = 60;
26
+ /** Lookup keys per message at most. */
27
+ export const MAX_KEYS_PER_MESSAGE = 512;
28
+ /** Code points of a word at most (longer words are cut). */
29
+ export const MAX_WORD = 64;
30
+ /** Terms per query at most. */
31
+ export const MAX_QUERY_TERMS = 16;
32
+ /** Code points of a query at most (longer queries are truncated). */
33
+ export const MAX_QUERY_CHARS = 256;
34
+
35
+ /** One term of a parsed query. */
36
+ export interface QueryTerm {
37
+ /** Lookup key of the posting list (`p:` prefix of at most 8 code points, or `x:` exact). */
38
+ key: string;
39
+ /** The full normalized term (verification of terms longer than 8 code points, highlighting). */
40
+ word: string;
41
+ /** Prefix term (matches words starting with `word`) or exact term (word or CJK gram equal to `word`). */
42
+ prefix: boolean;
43
+ }
44
+
45
+ // A word starts with a letter or digit; combining marks (Indic vowel signs, unnormalized accents) stay
46
+ // part of it, variation selectors (emoji presentation) do not.
47
+ const RUN = /[\p{L}\p{N}](?:(?![\uFE00-\uFE0F])[\p{L}\p{N}\p{M}])*/gu;
48
+ const CJK = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u;
49
+ const MARKS = /\p{M}/gu;
50
+ const CONTROL_MARKS = /[\u0001\u0002]/g;
51
+
52
+ /** NFKC and lower case. */
53
+ export function normalize(s: string): string {
54
+ return s.normalize("NFKC").toLowerCase();
55
+ }
56
+
57
+ /** Diacritics folded: NFD, combining marks dropped, `ß` → `ss`, NFC ("Müller" → "muller"). */
58
+ export function fold(s: string): string {
59
+ return s.normalize("NFD").replace(MARKS, "").replace(/ß/g, "ss").normalize("NFC");
60
+ }
61
+
62
+ /** A piece of a letter/digit run: a (non-CJK) word or a run of CJK characters, with its UTF-16 offsets. */
63
+ interface Segment {
64
+ text: string;
65
+ cjk: boolean;
66
+ start: number;
67
+ end: number;
68
+ }
69
+
70
+ /** Splits `text` (already normalized or original) into words and CJK runs. */
71
+ function segments(text: string): Segment[] {
72
+ const out: Segment[] = [];
73
+ for (const m of text.matchAll(RUN)) {
74
+ const run = m[0];
75
+ const base = m.index;
76
+ let cur = "";
77
+ let curCjk = false;
78
+ let curStart = 0;
79
+ let pos = 0;
80
+ for (const ch of run) {
81
+ // A combining mark stays with the preceding character.
82
+ const isCjk: boolean = /\p{M}/u.test(ch) && cur !== "" ? curCjk : CJK.test(ch);
83
+ if (cur !== "" && isCjk !== curCjk) {
84
+ out.push({ text: cur, cjk: curCjk, start: base + curStart, end: base + pos });
85
+ cur = "";
86
+ }
87
+ if (cur === "") {
88
+ curStart = pos;
89
+ curCjk = isCjk;
90
+ }
91
+ cur += ch;
92
+ pos += ch.length;
93
+ }
94
+ if (cur !== "") out.push({ text: cur, cjk: curCjk, start: base + curStart, end: base + pos });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function cutWord(w: string): string {
100
+ if (w.length <= MAX_WORD) return w;
101
+ const cps = Array.from(w);
102
+ return cps.length <= MAX_WORD ? w : cps.slice(0, MAX_WORD).join("");
103
+ }
104
+
105
+ /** Unigrams and bigrams of a CJK run. */
106
+ function grams(run: string): string[] {
107
+ const cps = Array.from(run);
108
+ const out = [...cps];
109
+ for (let i = 0; i + 1 < cps.length; i++) out.push(cps[i]! + cps[i + 1]!);
110
+ return out;
111
+ }
112
+
113
+ interface Tokens {
114
+ /** Normalized non-CJK words (cut to 64 code points), unique. */
115
+ words: string[];
116
+ /** CJK unigrams and bigrams, unique. */
117
+ grams: string[];
118
+ }
119
+
120
+ function tokens(text: string): Tokens {
121
+ const words = new Set<string>();
122
+ const cjk = new Set<string>();
123
+ for (const seg of segments(normalize(text))) {
124
+ if (seg.cjk) for (const g of grams(seg.text)) cjk.add(g);
125
+ else words.add(cutWord(seg.text));
126
+ }
127
+ return { words: [...words], grams: [...cjk] };
128
+ }
129
+
130
+ /** Unique normalized words of `text`; CJK runs contribute their unigrams and bigrams. */
131
+ export function words(text: string): string[] {
132
+ const t = tokens(text);
133
+ return [...t.words, ...t.grams];
134
+ }
135
+
136
+ function addWordKeys(out: Set<string>, w: string): void {
137
+ const cps = Array.from(w);
138
+ if (cps.length < MIN_PREFIX) {
139
+ out.add("x:" + w);
140
+ return;
141
+ }
142
+ const max = Math.min(cps.length, MAX_PREFIX);
143
+ let prefix = cps.slice(0, MIN_PREFIX - 1).join("");
144
+ for (let k = MIN_PREFIX; k <= max; k++) {
145
+ prefix += cps[k - 1];
146
+ out.add("p:" + prefix);
147
+ }
148
+ }
149
+
150
+ /** The unique lookup keys of a text (at most {@link MAX_KEYS_PER_MESSAGE}, earlier words first). */
151
+ export function indexKeys(text: string): string[] {
152
+ const out = new Set<string>();
153
+ const t = tokens(text);
154
+ for (const w of t.words) {
155
+ addWordKeys(out, w);
156
+ const f = fold(w);
157
+ if (f !== w && f !== "") addWordKeys(out, cutWord(f));
158
+ if (out.size >= MAX_KEYS_PER_MESSAGE) break;
159
+ }
160
+ for (const g of t.grams) {
161
+ if (out.size >= MAX_KEYS_PER_MESSAGE) break;
162
+ out.add("x:" + g);
163
+ }
164
+ return [...out].slice(0, MAX_KEYS_PER_MESSAGE);
165
+ }
166
+
167
+ /** Parses a user query into terms; never throws and never interprets syntax. */
168
+ export function queryTerms(query: string): QueryTerm[] {
169
+ let q = typeof query === "string" ? query : "";
170
+ if (q.length > MAX_QUERY_CHARS) q = Array.from(q).slice(0, MAX_QUERY_CHARS).join("");
171
+ const out: QueryTerm[] = [];
172
+ const seen = new Set<string>();
173
+ const push = (t: QueryTerm) => {
174
+ if (out.length >= MAX_QUERY_TERMS || seen.has(t.key)) return;
175
+ seen.add(t.key);
176
+ out.push(t);
177
+ };
178
+ for (const seg of segments(normalize(q))) {
179
+ if (seg.cjk) {
180
+ const cps = Array.from(seg.text);
181
+ if (cps.length === 1) push({ key: "x:" + cps[0], word: cps[0]!, prefix: false });
182
+ else for (let i = 0; i + 1 < cps.length; i++) {
183
+ const g = cps[i]! + cps[i + 1]!;
184
+ push({ key: "x:" + g, word: g, prefix: false });
185
+ }
186
+ } else {
187
+ const w = cutWord(seg.text);
188
+ const cps = Array.from(w);
189
+ if (cps.length < MIN_PREFIX) push({ key: "x:" + w, word: w, prefix: false });
190
+ else push({ key: "p:" + cps.slice(0, MAX_PREFIX).join(""), word: w, prefix: true });
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ function wordMatches(w: string, term: QueryTerm): boolean {
197
+ if (term.prefix) return w.startsWith(term.word) || fold(w).startsWith(fold(term.word));
198
+ return w === term.word || fold(w) === fold(term.word);
199
+ }
200
+
201
+ /** True if every term matches `text` (prefix terms: a word starts with it; exact terms: equal word or CJK gram). */
202
+ export function matches(text: string, terms: QueryTerm[]): boolean {
203
+ if (terms.length === 0) return true;
204
+ const t = tokens(text);
205
+ let gramSet: Set<string> | null = null;
206
+ for (const term of terms) {
207
+ if (CJK.test(term.word)) {
208
+ gramSet ??= new Set(t.grams);
209
+ if (!gramSet.has(term.word)) return false;
210
+ } else if (!t.words.some((w) => wordMatches(w, term))) return false;
211
+ }
212
+ return true;
213
+ }
214
+
215
+ /** Highlight ranges [start, end) of every term match in `text` (UTF-16 offsets), sorted and merged. */
216
+ function matchRanges(text: string, terms: QueryTerm[]): [number, number][] {
217
+ const ranges: [number, number][] = [];
218
+ const cjkTerms = terms.filter((t) => CJK.test(t.word));
219
+ const wordTerms = terms.filter((t) => !CJK.test(t.word));
220
+ for (const seg of segments(text)) {
221
+ if (seg.cjk) {
222
+ if (cjkTerms.length === 0) continue;
223
+ const cps = Array.from(seg.text);
224
+ let off = seg.start;
225
+ for (let i = 0; i < cps.length; i++) {
226
+ for (const term of cjkTerms) {
227
+ const len = Array.from(term.word).length;
228
+ const piece = cps.slice(i, i + len).join("");
229
+ if (normalize(piece) === term.word) ranges.push([off, off + piece.length]);
230
+ }
231
+ off += cps[i]!.length;
232
+ }
233
+ continue;
234
+ }
235
+ const w = cutWord(normalize(seg.text));
236
+ for (const term of wordTerms) {
237
+ if (!wordMatches(w, term)) continue;
238
+ // The whole matching word is marked (like the node's snippets), not only the typed prefix.
239
+ ranges.push([seg.start, seg.start + seg.text.length]);
240
+ }
241
+ }
242
+ ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
243
+ const merged: [number, number][] = [];
244
+ for (const r of ranges) {
245
+ const last = merged[merged.length - 1];
246
+ if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
247
+ else merged.push([r[0], r[1]]);
248
+ }
249
+ return merged;
250
+ }
251
+
252
+ /** Moves `i` off the low half of a surrogate pair (never cuts a code point). */
253
+ function cpBoundary(text: string, i: number, forward: boolean): number {
254
+ if (i <= 0 || i >= text.length) return Math.max(0, Math.min(i, text.length));
255
+ const c = text.charCodeAt(i);
256
+ if (c >= 0xdc00 && c <= 0xdfff) return forward ? i + 1 : i - 1;
257
+ return i;
258
+ }
259
+
260
+ /** Cuts `text` to at most `max` UTF-16 units at a code point boundary. */
261
+ export function cutText(text: string, max: number): string {
262
+ return text.length <= max ? text : text.slice(0, cpBoundary(text, max, false));
263
+ }
264
+
265
+ /**
266
+ * A plain-text snippet: ±{@link SNIPPET_CONTEXT} characters around the first match, `…` at cut ends,
267
+ * every match wrapped in {@link MARK_START}/{@link MARK_END} (the control characters are removed from
268
+ * the text first). Without a match, the first 2×{@link SNIPPET_CONTEXT} characters without markers.
269
+ * Never contains HTML; the caller renders the markers.
270
+ */
271
+ export function snippet(text: string, terms: QueryTerm[]): string {
272
+ const clean = text.replace(CONTROL_MARKS, "");
273
+ const ranges = terms.length ? matchRanges(clean, terms) : [];
274
+ const first = ranges[0];
275
+ if (!first) {
276
+ const cut = cutText(clean, 2 * SNIPPET_CONTEXT);
277
+ return cut.length < clean.length ? cut + "…" : cut;
278
+ }
279
+ const start = cpBoundary(clean, Math.max(0, first[0] - SNIPPET_CONTEXT), true);
280
+ const end = cpBoundary(clean, Math.min(clean.length, first[1] + SNIPPET_CONTEXT), false);
281
+ let out = start > 0 ? "…" : "";
282
+ let pos = start;
283
+ for (const [a, b] of ranges) {
284
+ if (a >= end) break;
285
+ const s = Math.max(a, start);
286
+ const e = Math.min(b, end);
287
+ if (e <= s) continue;
288
+ out += clean.slice(pos, s) + MARK_START + clean.slice(s, e) + MARK_END;
289
+ pos = e;
290
+ }
291
+ out += clean.slice(pos, end);
292
+ if (end < clean.length) out += "…";
293
+ return out;
294
+ }
package/src/session.ts ADDED
@@ -0,0 +1,172 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Wiring of the MLS session manager to a {@link KeyStore}: the client state, the pinned identity keys,
4
+ * the MLS bookkeeping and the verified contacts are named secrets of the open account; the backup key is
5
+ * derived from its master secret on demand, the own identity key (derived or rotated, ENCRYPTION.md §9a) is read
6
+ * once when the manager opens (a rotation reopens it).
7
+ */
8
+ import { loadClient, saveClient } from "./account";
9
+ import { ownIdentityPublicKey } from "./identity";
10
+ import { readJsonSecret, withMasterSecret, writeJsonSecret, type KeyStore } from "./keystore";
11
+ import { MlsError, MlsManager, type MlsApi, type MlsDeps, type MlsEvent, type MlsMeta, type MlsTabLink, type Pin, type VerifiedContact } from "./mls";
12
+ import { TabCoordinator, type TabOptions } from "./tabs";
13
+
14
+ /** Names of the manager's secrets in the store. */
15
+ export const PINS = "pins";
16
+ export const META = "mls-meta";
17
+ /** Verified contacts (FEDERATION.md §10.2), stored encrypted next to the pins. */
18
+ export const VERIFIED = "verified-contacts";
19
+
20
+ export interface OpenMlsOptions {
21
+ api: MlsApi;
22
+ /** Own user address `name@domain`. */
23
+ address: string;
24
+ deviceId: string;
25
+ /** Domain of this user's home node. */
26
+ homeDomain: string;
27
+ onEvent?: MlsDeps["onEvent"];
28
+ /** Failed background writes of the bookkeeping (see {@link MlsDeps.onError}). */
29
+ onError?: MlsDeps["onError"];
30
+ backupDelayMs?: number;
31
+ /**
32
+ * Browsers: coordinate the MLS state with the other tabs of this browser profile that open the same account
33
+ * (`tabs.ts`): one tab owns and writes the state, the others forward their state-changing calls to it. Without
34
+ * Web Locks or BroadcastChannel the manager works alone. Omit it in a single process (bots).
35
+ */
36
+ tabs?: TabOptions;
37
+ }
38
+
39
+ /** Events the owning tab passes on to the other tabs (their views re-check rosters and messages). */
40
+ const SHARED_EVENTS = new Set<MlsEvent["type"]>(["epoch", "rejoined", "removed"]);
41
+
42
+ /**
43
+ * The session manager of the store's open account, or null if this device has no MLS client yet
44
+ * (run `ensureDevice` first). Requires an unlocked store. The caller starts the background work it
45
+ * wants (`restoreBackups`, `processWelcomes` per node, `flushBackups` if `hasPendingBackups()`).
46
+ */
47
+ /** The store and the cached own identity key of every open manager (see {@link reloadOwnIdentity}). */
48
+ const ownKeys = new WeakMap<MlsManager, { store: KeyStore; own: { key: string } }>();
49
+
50
+ /**
51
+ * After the account's identity key was rotated (here or on another device, ENCRYPTION.md §9a): the manager verifies
52
+ * its own devices against the new key from now on. Nothing else changes (device key, groups, epoch keys).
53
+ */
54
+ export async function reloadOwnIdentity(manager: MlsManager): Promise<void> {
55
+ const entry = ownKeys.get(manager);
56
+ if (!entry) return;
57
+ entry.own.key = await ownIdentityPublicKey(entry.store);
58
+ manager.forgetDevices(manager.address);
59
+ }
60
+
61
+ export async function openMlsManager(store: KeyStore, opts: OpenMlsOptions): Promise<MlsManager | null> {
62
+ // Tab coordination: the election comes first, so that an owner loads the newest state.
63
+ let resolveManager: (m: MlsManager | null) => void = () => undefined;
64
+ const ready = new Promise<MlsManager | null>((r) => (resolveManager = r));
65
+ /** Only the owning tab (or a manager without coordination) writes the client state and the bookkeeping. */
66
+ let writable = true;
67
+ let stale = false;
68
+ let reloading: Promise<void> = Promise.resolve();
69
+ const loadState = async () => {
70
+ const c = await loadClient(store);
71
+ return c ? { client: c, meta: await readJsonSecret<MlsMeta>(store, META, { convs: {} }) } : null;
72
+ };
73
+ /** Follower: reloads the copy when the owner wrote the state since the last reload. */
74
+ const refresh = async (): Promise<void> => {
75
+ const m = await ready;
76
+ if (stale && m) {
77
+ stale = false;
78
+ reloading = m.reloadState(loadState).catch(() => undefined);
79
+ }
80
+ return reloading;
81
+ };
82
+ const tabs = opts.tabs
83
+ ? await TabCoordinator.open(opts.tabs, {
84
+ serve: async (method, args) => {
85
+ const m = await ready;
86
+ if (!m) throw new MlsError("owner_unavailable");
87
+ return m.serveForwarded(method, args);
88
+ },
89
+ takeOver: async () => {
90
+ const m = await ready;
91
+ if (!m) return;
92
+ if (!store.isUnlocked(opts.address)) throw new MlsError("owner_unavailable");
93
+ await m.reloadState(loadState, () => {
94
+ writable = true;
95
+ stale = false;
96
+ });
97
+ },
98
+ stale: () => {
99
+ stale = true;
100
+ },
101
+ event: (e) => {
102
+ void refresh()
103
+ .then(() => opts.onEvent?.(e as MlsEvent))
104
+ .catch(() => undefined);
105
+ },
106
+ })
107
+ : null;
108
+ if (tabs) writable = tabs.ready;
109
+ const client = await loadClient(store);
110
+ if (!client) {
111
+ tabs?.close();
112
+ resolveManager(null);
113
+ return null;
114
+ }
115
+ let pins: Record<string, Pin>;
116
+ let meta: MlsMeta;
117
+ let verified: Record<string, VerifiedContact>;
118
+ try {
119
+ // Pins and verified contacts must not reset silently (every changed key would be trusted again, TOFU).
120
+ pins = await readJsonSecret<Record<string, Pin>>(store, PINS, {}, { strict: true });
121
+ meta = await readJsonSecret<MlsMeta>(store, META, { convs: {} });
122
+ verified = await readJsonSecret<Record<string, VerifiedContact>>(store, VERIFIED, {}, { strict: true });
123
+ } catch (e) {
124
+ tabs?.close();
125
+ resolveManager(null);
126
+ throw e;
127
+ }
128
+ const own = { key: await ownIdentityPublicKey(store) };
129
+ const link: MlsTabLink | undefined = tabs
130
+ ? {
131
+ local: () => tabs.ready,
132
+ forward: async (operation, args) => {
133
+ const value = await tabs.call(operation, args);
134
+ await refresh();
135
+ return value;
136
+ },
137
+ close: () => tabs.close(),
138
+ }
139
+ : undefined;
140
+ const manager = new MlsManager({
141
+ api: opts.api,
142
+ client,
143
+ address: opts.address,
144
+ deviceId: opts.deviceId,
145
+ homeDomain: opts.homeDomain,
146
+ persist: async (c) => {
147
+ if (!writable) return;
148
+ await saveClient(store, c);
149
+ tabs?.notifyState();
150
+ },
151
+ backupKey: () => withMasterSecret(store, (ms) => ms.backupKey()),
152
+ ownIdentityKey: () => own.key,
153
+ pins,
154
+ savePins: (p) => writeJsonSecret(store, PINS, p),
155
+ meta,
156
+ saveMeta: async (v) => {
157
+ if (writable) await writeJsonSecret(store, META, v);
158
+ },
159
+ verified,
160
+ saveVerified: (v) => writeJsonSecret(store, VERIFIED, v),
161
+ onEvent: (e) => {
162
+ opts.onEvent?.(e);
163
+ if (SHARED_EVENTS.has(e.type)) tabs?.notifyEvent(e);
164
+ },
165
+ onError: opts.onError,
166
+ backupDelayMs: opts.backupDelayMs,
167
+ tabs: link,
168
+ });
169
+ ownKeys.set(manager, { store, own });
170
+ resolveManager(manager);
171
+ return manager;
172
+ }