@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.
- package/LICENSE +373 -0
- package/README.md +269 -2
- package/package.json +39 -3
- package/src/account.ts +457 -0
- package/src/cleanup.ts +336 -0
- package/src/commands.ts +95 -0
- package/src/components.ts +155 -0
- package/src/content.ts +152 -0
- package/src/embeds.ts +148 -0
- package/src/errors.ts +31 -0
- package/src/files.ts +346 -0
- package/src/history.ts +710 -0
- package/src/identity.ts +216 -0
- package/src/index.ts +75 -0
- package/src/keystore.ts +126 -0
- package/src/lru.ts +113 -0
- package/src/mls.ts +2617 -0
- package/src/node.ts +3 -0
- package/src/plaintext.ts +141 -0
- package/src/poll.ts +434 -0
- package/src/sealed.ts +64 -0
- package/src/search/backend.ts +82 -0
- package/src/search/contract.ts +126 -0
- package/src/search/index.ts +6 -0
- package/src/search/keys.ts +108 -0
- package/src/search/searchIndex.ts +844 -0
- package/src/search/tokenize.ts +294 -0
- package/src/session.ts +172 -0
- package/src/storage/file.ts +273 -0
- package/src/storage/memory.ts +95 -0
- package/src/tabs.ts +367 -0
- package/src/trust.ts +129 -0
- package/src/wasm.ts +38 -0
package/src/tabs.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Coordination of one MLS state between the tabs of a browser profile (they share one IndexedDB store).
|
|
4
|
+
*
|
|
5
|
+
* One tab **owns** the state: it holds a Web Lock (`navigator.locks`) named per node and account, runs every
|
|
6
|
+
* state-changing operation and alone writes the client state. The other tabs keep a read-only copy (loaded from the
|
|
7
|
+
* store) for synchronous reads and decryption, and **forward** their state-changing operations to the owner over a
|
|
8
|
+
* BroadcastChannel of the same name (request and response with ids, a timeout per request). The owner announces
|
|
9
|
+
* every write (`state`: copies reload before they read again) and the events the views need (`event`).
|
|
10
|
+
*
|
|
11
|
+
* **Hand-over:** when the owner tab closes (or signs out) the browser releases its lock and the next waiting tab gets
|
|
12
|
+
* it; that tab reloads the state from the store before it runs anything, then announces itself (`owner`). Requests
|
|
13
|
+
* in flight are sent once more, to the new owner (a tab that becomes the owner runs its own ones itself); a request
|
|
14
|
+
* that loses its owner a second time fails with `MlsError("owner_unavailable")`, like one that gets no answer
|
|
15
|
+
* within the timeout. Every forwarded operation can run twice without harm (joins and catch-ups are idempotent, a
|
|
16
|
+
* second commit is just one more epoch, an encryption whose result never arrived was never sent).
|
|
17
|
+
*
|
|
18
|
+
* Without Web Locks or BroadcastChannel (or in a single process such as a bot) there is no coordination: the
|
|
19
|
+
* manager works alone, as before (a second tab may then overwrite the state and rejoin after an invalid commit).
|
|
20
|
+
*/
|
|
21
|
+
import { TavrnApiError, type ProblemDetails } from "@tavrn/sdk";
|
|
22
|
+
import { MlsError } from "./mls";
|
|
23
|
+
|
|
24
|
+
/** The subset of the Web Locks API (`navigator.locks`) the election needs. */
|
|
25
|
+
export interface TabLockManager {
|
|
26
|
+
request(name: string, options: { ifAvailable?: boolean; signal?: AbortSignal }, callback: (lock: unknown) => Promise<unknown>): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The subset of `BroadcastChannel` the forwarding needs. */
|
|
30
|
+
export interface TabChannel {
|
|
31
|
+
postMessage(message: unknown): void;
|
|
32
|
+
onmessage: ((e: { data: unknown }) => void) | null;
|
|
33
|
+
close(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TabOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Lock and channel name, the same in every tab that shares the state: per node and account (and device), e.g.
|
|
39
|
+
* `tavrn-mls|<home node origin>|<address>/<device id>`.
|
|
40
|
+
*/
|
|
41
|
+
name: string;
|
|
42
|
+
/** Default `navigator.locks` where available; null (or none available) = no coordination. */
|
|
43
|
+
locks?: TabLockManager | null;
|
|
44
|
+
/** Default `new BroadcastChannel(name)` where available; null (or none available) = no coordination. */
|
|
45
|
+
channel?: ((name: string) => TabChannel) | null;
|
|
46
|
+
/** How long a forwarded operation may take before it fails with `owner_unavailable` (default 30 s). */
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the coordination needs from the tab (implemented by `openMlsManager`). */
|
|
51
|
+
export interface TabHost {
|
|
52
|
+
/** Runs an operation in this tab, which owns the state (its own forwarded calls, or another tab's). */
|
|
53
|
+
serve(method: string, args: unknown[]): Promise<unknown>;
|
|
54
|
+
/** This tab got the lock after another owner left: load the stored state before anything runs here. */
|
|
55
|
+
takeOver(): Promise<void>;
|
|
56
|
+
/** The owner wrote the state: reload the copy before reading it again. */
|
|
57
|
+
stale(): void;
|
|
58
|
+
/** An event of the owner's manager, for this tab's views. */
|
|
59
|
+
event(e: unknown): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const TAB_TIMEOUT_MS = 30_000;
|
|
63
|
+
/** Request ids a new owner remembers to serve a request only once (a re-send may cross its announcement). */
|
|
64
|
+
const SERVED_WINDOW = 1024;
|
|
65
|
+
|
|
66
|
+
interface WireError {
|
|
67
|
+
name: string;
|
|
68
|
+
message: string;
|
|
69
|
+
code?: string;
|
|
70
|
+
problem?: ProblemDetails;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Message =
|
|
74
|
+
| { v: 1; t: "req"; id: string; from: string; method: string; args: unknown[] }
|
|
75
|
+
| { v: 1; t: "res"; id: string; to: string; ok: true; value: unknown }
|
|
76
|
+
| { v: 1; t: "res"; id: string; to: string; ok: false; error: WireError }
|
|
77
|
+
| { v: 1; t: "owner"; from: string }
|
|
78
|
+
| { v: 1; t: "state"; from: string }
|
|
79
|
+
| { v: 1; t: "event"; from: string; event: unknown };
|
|
80
|
+
|
|
81
|
+
interface Pending {
|
|
82
|
+
id: string;
|
|
83
|
+
method: string;
|
|
84
|
+
args: unknown[];
|
|
85
|
+
resolve(v: unknown): void;
|
|
86
|
+
reject(e: unknown): void;
|
|
87
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
88
|
+
/** Sent once more after its owner left (or run here after this tab took over). */
|
|
89
|
+
retried: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultLocks(): TabLockManager | null {
|
|
93
|
+
const locks = (globalThis as { navigator?: { locks?: TabLockManager } }).navigator?.locks;
|
|
94
|
+
return locks && typeof locks.request === "function" ? locks : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultChannel(): ((name: string) => TabChannel) | null {
|
|
98
|
+
const BC = (globalThis as { BroadcastChannel?: new (name: string) => TabChannel }).BroadcastChannel;
|
|
99
|
+
return typeof BC === "function" ? (name) => new BC(name) : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function randomId(): string {
|
|
103
|
+
const bytes = new Uint8Array(12);
|
|
104
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
105
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const unavailable = () => new MlsError("owner_unavailable");
|
|
109
|
+
|
|
110
|
+
function toWire(e: unknown): WireError {
|
|
111
|
+
if (e instanceof TavrnApiError) return { name: "TavrnApiError", message: e.message, problem: JSON.parse(JSON.stringify(e.problem)) as ProblemDetails };
|
|
112
|
+
if (e instanceof MlsError) return { name: "MlsError", message: e.message, code: e.code };
|
|
113
|
+
if (e instanceof Error) return { name: e.name, message: e.message };
|
|
114
|
+
return { name: "Error", message: String(e) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function fromWire(w: WireError): Error {
|
|
118
|
+
if (w.name === "TavrnApiError" && w.problem) return new TavrnApiError(w.problem);
|
|
119
|
+
if (w.name === "MlsError" && w.code) return new MlsError(w.code as MlsError["code"]);
|
|
120
|
+
const e = new Error(w.message);
|
|
121
|
+
e.name = w.name;
|
|
122
|
+
return e;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isMessage(m: unknown): m is Message {
|
|
126
|
+
if (!m || typeof m !== "object") return false;
|
|
127
|
+
const o = m as Record<string, unknown>;
|
|
128
|
+
if (o.v !== 1) return false;
|
|
129
|
+
switch (o.t) {
|
|
130
|
+
case "req":
|
|
131
|
+
return typeof o.id === "string" && typeof o.from === "string" && typeof o.method === "string" && Array.isArray(o.args);
|
|
132
|
+
case "res":
|
|
133
|
+
return typeof o.id === "string" && typeof o.to === "string" && typeof o.ok === "boolean" && (o.ok || (!!o.error && typeof o.error === "object"));
|
|
134
|
+
case "owner":
|
|
135
|
+
case "state":
|
|
136
|
+
return typeof o.from === "string";
|
|
137
|
+
case "event":
|
|
138
|
+
return typeof o.from === "string" && "event" in o;
|
|
139
|
+
default:
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class TabCoordinator {
|
|
145
|
+
private readonly id = randomId();
|
|
146
|
+
private seq = 0;
|
|
147
|
+
private holds = false;
|
|
148
|
+
private serving = false;
|
|
149
|
+
private closed = false;
|
|
150
|
+
/** Resolves when this tab may run operations after a take-over (null while it is not taking over). */
|
|
151
|
+
private takingOver: Promise<void> | null = null;
|
|
152
|
+
private readonly pending = new Map<string, Pending>();
|
|
153
|
+
private readonly served = new Set<string>();
|
|
154
|
+
private readonly abort = new AbortController();
|
|
155
|
+
private release: () => void = () => undefined;
|
|
156
|
+
private readonly held = new Promise<void>((r) => (this.release = r));
|
|
157
|
+
|
|
158
|
+
private constructor(
|
|
159
|
+
private readonly opts: { name: string; locks: TabLockManager; timeoutMs: number },
|
|
160
|
+
private readonly channel: TabChannel,
|
|
161
|
+
private readonly host: TabHost,
|
|
162
|
+
) {
|
|
163
|
+
channel.onmessage = (e) => this.receive(e.data);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Joins the coordination of `opts.name`: resolves once it is known whether this tab owns the state right now.
|
|
168
|
+
* Null when the browser lacks Web Locks or BroadcastChannel (the caller works alone, as before).
|
|
169
|
+
*/
|
|
170
|
+
static async open(opts: TabOptions, host: TabHost): Promise<TabCoordinator | null> {
|
|
171
|
+
const locks = opts.locks === undefined ? defaultLocks() : opts.locks;
|
|
172
|
+
const makeChannel = opts.channel === undefined ? defaultChannel() : opts.channel;
|
|
173
|
+
if (!locks || !makeChannel) return null;
|
|
174
|
+
let channel: TabChannel;
|
|
175
|
+
try {
|
|
176
|
+
channel = makeChannel(opts.name);
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const tabs = new TabCoordinator({ name: opts.name, locks, timeoutMs: opts.timeoutMs ?? TAB_TIMEOUT_MS }, channel, host);
|
|
181
|
+
try {
|
|
182
|
+
await tabs.elect();
|
|
183
|
+
} catch {
|
|
184
|
+
tabs.close();
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return tabs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether this tab holds the lock (it may still be reloading the state after a take-over). */
|
|
191
|
+
get owner(): boolean {
|
|
192
|
+
return this.holds && !this.closed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Whether this tab runs operations itself right now: it owns the state and has loaded it. */
|
|
196
|
+
get ready(): boolean {
|
|
197
|
+
return this.serving && !this.closed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private elect(): Promise<void> {
|
|
201
|
+
return new Promise<void>((resolve, reject) => {
|
|
202
|
+
this.opts.locks
|
|
203
|
+
.request(this.opts.name, { ifAvailable: true }, async (lock) => {
|
|
204
|
+
if (!lock) {
|
|
205
|
+
// Another tab owns the state: wait for the lock (hand-over) and forward until then.
|
|
206
|
+
this.wait();
|
|
207
|
+
resolve();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.holds = true;
|
|
211
|
+
this.serving = true;
|
|
212
|
+
resolve();
|
|
213
|
+
await this.held;
|
|
214
|
+
})
|
|
215
|
+
.catch(reject);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Queues for the lock: granted when the owner tab closes. */
|
|
220
|
+
private wait() {
|
|
221
|
+
this.opts.locks
|
|
222
|
+
.request(this.opts.name, { signal: this.abort.signal }, async () => {
|
|
223
|
+
if (this.closed) return;
|
|
224
|
+
this.holds = true;
|
|
225
|
+
this.takingOver = this.host.takeOver();
|
|
226
|
+
try {
|
|
227
|
+
await this.takingOver;
|
|
228
|
+
} catch {
|
|
229
|
+
// The state cannot be loaded here (locked meanwhile): leave the lock to the next tab.
|
|
230
|
+
this.close();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.takingOver = null;
|
|
234
|
+
this.serving = true;
|
|
235
|
+
this.post({ v: 1, t: "owner", from: this.id });
|
|
236
|
+
this.retryPending(true);
|
|
237
|
+
await this.held;
|
|
238
|
+
})
|
|
239
|
+
.catch(() => undefined);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Runs `method` in the owner tab: here when this tab owns the state (after a take-over has loaded it), otherwise
|
|
244
|
+
* forwarded. Fails with `MlsError("owner_unavailable")` without an answer in time or after losing two owners.
|
|
245
|
+
*/
|
|
246
|
+
call(method: string, args: unknown[]): Promise<unknown> {
|
|
247
|
+
if (this.closed) return Promise.reject(unavailable());
|
|
248
|
+
if (this.serving) return this.serve(method, args);
|
|
249
|
+
if (this.holds && this.takingOver) return this.takingOver.then(() => this.serve(method, args));
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
const p: Pending = { id: `${this.id}:${++this.seq}`, method, args, resolve, reject, timer: null, retried: false };
|
|
252
|
+
this.pending.set(p.id, p);
|
|
253
|
+
this.send(p);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Owner: the stored state changed (the other tabs reload their copies). */
|
|
258
|
+
notifyState(): void {
|
|
259
|
+
if (this.ready) this.post({ v: 1, t: "state", from: this.id });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Owner: an event the other tabs' views need. */
|
|
263
|
+
notifyEvent(event: unknown): void {
|
|
264
|
+
if (this.ready) this.post({ v: 1, t: "event", from: this.id, event });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Leaves the coordination: releases the lock (the next tab takes over) and fails the calls in flight. */
|
|
268
|
+
close(): void {
|
|
269
|
+
if (this.closed) return;
|
|
270
|
+
this.closed = true;
|
|
271
|
+
this.release();
|
|
272
|
+
this.abort.abort();
|
|
273
|
+
for (const p of this.pending.values()) this.settle(p, unavailable(), false);
|
|
274
|
+
this.channel.onmessage = null;
|
|
275
|
+
try {
|
|
276
|
+
this.channel.close();
|
|
277
|
+
} catch {
|
|
278
|
+
/* already closed */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private serve(method: string, args: unknown[]): Promise<unknown> {
|
|
283
|
+
return Promise.resolve().then(() => this.host.serve(method, args));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Answers a request; a result the channel cannot clone becomes an error (the caller does not wait for nothing). */
|
|
287
|
+
private respond(id: string, to: string, value: unknown) {
|
|
288
|
+
if (this.closed) return;
|
|
289
|
+
try {
|
|
290
|
+
this.channel.postMessage({ v: 1, t: "res", id, to, ok: true, value } satisfies Message);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
this.post({ v: 1, t: "res", id, to, ok: false, error: toWire(e) });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private post(m: Message) {
|
|
297
|
+
if (this.closed) return;
|
|
298
|
+
try {
|
|
299
|
+
this.channel.postMessage(m);
|
|
300
|
+
} catch {
|
|
301
|
+
/* closed channel: the timeouts take care of the calls */
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private send(p: Pending) {
|
|
306
|
+
if (p.timer) clearTimeout(p.timer);
|
|
307
|
+
p.timer = setTimeout(() => this.settle(p, unavailable(), false), this.opts.timeoutMs);
|
|
308
|
+
this.post({ v: 1, t: "req", id: p.id, from: this.id, method: p.method, args: p.args });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private settle(p: Pending, value: unknown, ok: boolean) {
|
|
312
|
+
if (!this.pending.delete(p.id)) return;
|
|
313
|
+
if (p.timer) clearTimeout(p.timer);
|
|
314
|
+
if (ok) p.resolve(value);
|
|
315
|
+
else p.reject(value);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** After a hand-over: every call in flight once more (here when this tab is the new owner); twice fails. */
|
|
319
|
+
private retryPending(here: boolean) {
|
|
320
|
+
for (const p of [...this.pending.values()]) {
|
|
321
|
+
if (p.retried) {
|
|
322
|
+
this.settle(p, unavailable(), false);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
p.retried = true;
|
|
326
|
+
if (here) {
|
|
327
|
+
this.pending.delete(p.id);
|
|
328
|
+
if (p.timer) clearTimeout(p.timer);
|
|
329
|
+
this.serve(p.method, p.args).then(p.resolve, p.reject);
|
|
330
|
+
} else {
|
|
331
|
+
this.send(p);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private receive(data: unknown) {
|
|
337
|
+
if (this.closed || !isMessage(data)) return;
|
|
338
|
+
switch (data.t) {
|
|
339
|
+
case "req":
|
|
340
|
+
if (!this.serving || this.served.has(data.id)) return;
|
|
341
|
+
this.served.add(data.id);
|
|
342
|
+
if (this.served.size > SERVED_WINDOW) this.served.delete(this.served.values().next().value as string);
|
|
343
|
+
this.serve(data.method, data.args).then(
|
|
344
|
+
(value) => this.respond(data.id, data.from, value),
|
|
345
|
+
(e: unknown) => this.post({ v: 1, t: "res", id: data.id, to: data.from, ok: false, error: toWire(e) }),
|
|
346
|
+
);
|
|
347
|
+
return;
|
|
348
|
+
case "res": {
|
|
349
|
+
if (data.to !== this.id) return;
|
|
350
|
+
const p = this.pending.get(data.id);
|
|
351
|
+
if (!p) return;
|
|
352
|
+
if (data.ok) this.settle(p, data.value, true);
|
|
353
|
+
else this.settle(p, fromWire(data.error), false);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
case "owner":
|
|
357
|
+
if (!this.holds) this.retryPending(false);
|
|
358
|
+
return;
|
|
359
|
+
case "state":
|
|
360
|
+
if (!this.holds) this.host.stale();
|
|
361
|
+
return;
|
|
362
|
+
case "event":
|
|
363
|
+
if (!this.holds) this.host.event(data.event);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
package/src/trust.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* The trust record of an account (ENCRYPTION.md §7 "Trust record"): the pinned identity keys of other users (§3.3),
|
|
4
|
+
* the verified contacts (FEDERATION.md §10.2) and the account's own devices this account attested (device id → device
|
|
5
|
+
* key, §4.7). It is kept in the local store and, encrypted under the backup key, as a backup blob on the home node, so
|
|
6
|
+
* that it survives a sign-out and reaches every device of the account: a contact's key that changed while the user
|
|
7
|
+
* was signed out still raises the "security key changed" warning, and only a device key the account attested itself
|
|
8
|
+
* is ever attested again as retired.
|
|
9
|
+
*
|
|
10
|
+
* ```text
|
|
11
|
+
* blob = 0x01 || nonce(12) || AES-256-GCM(backup_key, nonce, aad, plaintext)
|
|
12
|
+
* aad = "tavrn trust v1" || 0x00 || account address
|
|
13
|
+
* plaintext = {"v":1,"pins":{…},"verified":{…},"devices":{"<device id>":"<base64url device key>"}}
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Path: `PUT /users/me/backups/<home domain>/00000000000000000000000000` (the ULID of time 0, never a conversation).
|
|
17
|
+
* The node cannot read or forge it; at worst it serves an older blob or none (entries merge, nothing is replaced).
|
|
18
|
+
*/
|
|
19
|
+
import { b64urlDecode } from "@tavrn/sdk";
|
|
20
|
+
import type { Pin, VerifiedContact } from "./mls";
|
|
21
|
+
|
|
22
|
+
/** The conversation ULID under which the trust record is stored as a backup (time 0, no conversation has it). */
|
|
23
|
+
export const TRUST_BACKUP_ULID = "00000000000000000000000000";
|
|
24
|
+
|
|
25
|
+
export interface TrustRecord {
|
|
26
|
+
pins: Record<string, Pin>;
|
|
27
|
+
verified: Record<string, VerifiedContact>;
|
|
28
|
+
/** Own devices attested by this account: device id → base64url device key. */
|
|
29
|
+
devices: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function aad(account: string): Uint8Array<ArrayBuffer> {
|
|
33
|
+
return new TextEncoder().encode(`tavrn trust v1\u0000${account}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function aesKey(backupKey: Uint8Array, usage: KeyUsage): Promise<CryptoKey> {
|
|
37
|
+
return globalThis.crypto.subtle.importKey("raw", backupKey as Uint8Array<ArrayBuffer>, { name: "AES-GCM" }, false, [usage]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Encrypts a trust record of `account` under the backup key. */
|
|
41
|
+
export async function sealTrust(backupKey: Uint8Array, account: string, record: TrustRecord): Promise<Uint8Array> {
|
|
42
|
+
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
43
|
+
const plain = new TextEncoder().encode(JSON.stringify({ v: 1, pins: record.pins, verified: record.verified, devices: record.devices }));
|
|
44
|
+
const ct = new Uint8Array(await globalThis.crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad(account) }, await aesKey(backupKey, "encrypt"), plain));
|
|
45
|
+
const out = new Uint8Array(1 + 12 + ct.length);
|
|
46
|
+
out[0] = 1;
|
|
47
|
+
out.set(nonce, 1);
|
|
48
|
+
out.set(ct, 13);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
53
|
+
|
|
54
|
+
/** Decrypts and checks a trust record of `account`; null when it does not open or is malformed. */
|
|
55
|
+
export async function openTrust(backupKey: Uint8Array, account: string, blob: Uint8Array): Promise<TrustRecord | null> {
|
|
56
|
+
if (blob.length < 1 + 12 + 16 || blob[0] !== 1) return null;
|
|
57
|
+
let plain: ArrayBuffer;
|
|
58
|
+
try {
|
|
59
|
+
plain = await globalThis.crypto.subtle.decrypt(
|
|
60
|
+
{ name: "AES-GCM", iv: blob.slice(1, 13), additionalData: aad(account) },
|
|
61
|
+
await aesKey(backupKey, "decrypt"),
|
|
62
|
+
blob.slice(13),
|
|
63
|
+
);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
let v: unknown;
|
|
68
|
+
try {
|
|
69
|
+
v = JSON.parse(new TextDecoder().decode(plain));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!isObject(v) || v.v !== 1 || !isObject(v.pins) || !isObject(v.verified) || !isObject(v.devices)) return null;
|
|
74
|
+
const pins: Record<string, Pin> = {};
|
|
75
|
+
for (const [a, p] of Object.entries(v.pins)) {
|
|
76
|
+
if (!isObject(p) || typeof p.key !== "string") continue;
|
|
77
|
+
pins[a] = { key: p.key, ...(p.changed === true ? { changed: true } : {}), ...(p.rotated === true ? { rotated: true } : {}) };
|
|
78
|
+
}
|
|
79
|
+
const verified: Record<string, VerifiedContact> = {};
|
|
80
|
+
for (const [a, c] of Object.entries(v.verified)) {
|
|
81
|
+
if (!isObject(c) || c.address !== a || typeof c.identity_public_key !== "string" || typeof c.verified_at !== "string") continue;
|
|
82
|
+
if (c.method !== "qr" && c.method !== "numeric") continue;
|
|
83
|
+
verified[a] = {
|
|
84
|
+
address: a,
|
|
85
|
+
identity_public_key: c.identity_public_key,
|
|
86
|
+
verified_at: c.verified_at,
|
|
87
|
+
method: c.method,
|
|
88
|
+
...(typeof c.rotated_to === "string" ? { rotated_to: c.rotated_to } : {}),
|
|
89
|
+
...(c.refresh_dismissed === true ? { refresh_dismissed: true } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const devices: Record<string, string> = {};
|
|
93
|
+
for (const [d, k] of Object.entries(v.devices)) {
|
|
94
|
+
if (typeof k !== "string") continue;
|
|
95
|
+
try {
|
|
96
|
+
if (b64urlDecode(k).length === 32) devices[d] = k;
|
|
97
|
+
} catch {
|
|
98
|
+
/* malformed entry */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { pins, verified, devices };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Adds what `from` has and `into` lacks (in place); entries `into` has stay as they are. Verified contacts named in
|
|
106
|
+
* `removed` (removed on this device, not uploaded yet) are not taken over. True when `into` changed.
|
|
107
|
+
*/
|
|
108
|
+
export function mergeTrust(into: TrustRecord, from: TrustRecord, removed: ReadonlySet<string> = new Set()): boolean {
|
|
109
|
+
let changed = false;
|
|
110
|
+
for (const [a, p] of Object.entries(from.pins)) {
|
|
111
|
+
if (!into.pins[a]) {
|
|
112
|
+
into.pins[a] = p;
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [a, c] of Object.entries(from.verified)) {
|
|
117
|
+
if (!into.verified[a] && !removed.has(a)) {
|
|
118
|
+
into.verified[a] = c;
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [d, k] of Object.entries(from.devices)) {
|
|
123
|
+
if (!into.devices[d]) {
|
|
124
|
+
into.devices[d] = k;
|
|
125
|
+
changed = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return changed;
|
|
129
|
+
}
|
package/src/wasm.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import init, { initSync } from "@tavrn/crypto";
|
|
3
|
+
|
|
4
|
+
let ready: Promise<void> | null = null;
|
|
5
|
+
|
|
6
|
+
/** True in Node.js (also under jsdom test environments), false in browsers and workers. */
|
|
7
|
+
function isNode(): boolean {
|
|
8
|
+
const p = (globalThis as { process?: { versions?: { node?: unknown } } }).process;
|
|
9
|
+
return typeof p?.versions?.node === "string";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Node.js: reads `tavrn_crypto_bg.wasm` from the installed `@tavrn/crypto` package and instantiates it
|
|
14
|
+
* synchronously. The Node built-ins are imported at run time under computed names, so that browser
|
|
15
|
+
* bundlers never try to resolve them.
|
|
16
|
+
*/
|
|
17
|
+
async function loadInNode(): Promise<void> {
|
|
18
|
+
const fsName = "node:fs/promises";
|
|
19
|
+
const moduleName = "node:module";
|
|
20
|
+
const { readFile } = (await import(/* @vite-ignore */ fsName)) as typeof import("node:fs/promises");
|
|
21
|
+
const { createRequire } = (await import(/* @vite-ignore */ moduleName)) as typeof import("node:module");
|
|
22
|
+
const path = createRequire(import.meta.url).resolve("@tavrn/crypto/tavrn_crypto_bg.wasm");
|
|
23
|
+
initSync({ module: await readFile(path) });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Loads and instantiates the WebAssembly module of `@tavrn/crypto` once, in browsers with `init()`
|
|
28
|
+
* (fetches the .wasm next to the module, which bundlers emit as an asset) and in Node.js from the
|
|
29
|
+
* package directory. Every function that uses the crypto package awaits this first. When the module was
|
|
30
|
+
* already initialized (e.g. by a test with `initSync`), this resolves immediately.
|
|
31
|
+
*/
|
|
32
|
+
export function ensureCrypto(): Promise<void> {
|
|
33
|
+
ready ??= (isNode() ? loadInNode() : init().then(() => undefined)).catch((e: unknown) => {
|
|
34
|
+
ready = null;
|
|
35
|
+
throw e;
|
|
36
|
+
});
|
|
37
|
+
return ready;
|
|
38
|
+
}
|