@tavrn/e2ee 0.0.0-stage → 0.2.2
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
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* File-based {@link KeyStore} for Node.js (bots, APPLICATIONS.md §3.3): one state directory per account
|
|
4
|
+
* and device holding a single encrypted state file, `state.json`:
|
|
5
|
+
*
|
|
6
|
+
* ```json
|
|
7
|
+
* { "v": 1, "account": "bot@node.example", "device_id": "01J…", "iv": "<base64url>", "data": "<base64url>" }
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* `data` is AES-256-GCM (16-byte tag appended) over the JSON `{ "master": …, "secrets": { name: … } }`
|
|
11
|
+
* (values base64url) with additional data `"tavrn local v1" 0x00 account 0x00 "state"`, under the
|
|
12
|
+
* device's local storage key `MasterSecret.localStorageKey(device_id)` (ENCRYPTION.md §2.1). The key is
|
|
13
|
+
* never stored: the process unlocks the store with the master secret (from the bot key file) on every
|
|
14
|
+
* start, so the state directory alone is useless to a thief.
|
|
15
|
+
*
|
|
16
|
+
* Every change rewrites the file atomically (temporary file with mode 0600, fsync, rename, directory
|
|
17
|
+
* fsync); writes are serialized. The directory is created with mode 0700. One process per directory:
|
|
18
|
+
* two processes sharing it would overwrite each other's MLS state.
|
|
19
|
+
*/
|
|
20
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
21
|
+
import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { b64urlDecode } from "@tavrn/sdk";
|
|
24
|
+
import { LocalKeyMismatchError, localAad, localStorageKey, type CryptoStatus, type KeyStore } from "../keystore";
|
|
25
|
+
|
|
26
|
+
/** Name of the state file inside the directory. */
|
|
27
|
+
export const STATE_FILE = "state.json";
|
|
28
|
+
const ENTRY = "state";
|
|
29
|
+
const TAG = 16;
|
|
30
|
+
|
|
31
|
+
interface Header {
|
|
32
|
+
v: 1;
|
|
33
|
+
account: string;
|
|
34
|
+
device_id: string;
|
|
35
|
+
iv: string;
|
|
36
|
+
data: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Open {
|
|
40
|
+
account: string;
|
|
41
|
+
deviceId: string;
|
|
42
|
+
key: Buffer;
|
|
43
|
+
master: Uint8Array;
|
|
44
|
+
secrets: Map<string, Uint8Array>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Thrown when the state directory holds the data of another account. */
|
|
48
|
+
export class StateDirInUseError extends Error {
|
|
49
|
+
constructor() {
|
|
50
|
+
super("The state directory belongs to another account");
|
|
51
|
+
this.name = "state_dir_in_use";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isHeader(v: unknown): v is Header {
|
|
56
|
+
if (!v || typeof v !== "object") return false;
|
|
57
|
+
const h = v as Record<string, unknown>;
|
|
58
|
+
return h.v === 1 && typeof h.account === "string" && typeof h.device_id === "string" && typeof h.iv === "string" && typeof h.data === "string";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class FileKeyStore implements KeyStore {
|
|
62
|
+
private current: Open | null = null;
|
|
63
|
+
private writing: Promise<void> = Promise.resolve();
|
|
64
|
+
private readonly file: string;
|
|
65
|
+
|
|
66
|
+
constructor(readonly dir: string) {
|
|
67
|
+
this.file = join(dir, STATE_FILE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The header of the state file, or null if there is none. Malformed files are an error (never overwritten silently). */
|
|
71
|
+
private async header(): Promise<Header | null> {
|
|
72
|
+
await this.writing;
|
|
73
|
+
let text: string;
|
|
74
|
+
try {
|
|
75
|
+
text = await readFile(this.file, "utf8");
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
let parsed: unknown;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
parsed = null;
|
|
85
|
+
}
|
|
86
|
+
if (!isHeader(parsed)) throw new Error(`unreadable state file ${this.file}`);
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async status(account: string): Promise<CryptoStatus> {
|
|
91
|
+
if (this.current?.account === account) return "unlocked";
|
|
92
|
+
return (await this.header())?.account === account ? "locked" : "none";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async deviceIdOf(account: string): Promise<string | null> {
|
|
96
|
+
const h = await this.header();
|
|
97
|
+
return h?.account === account ? h.device_id : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** There is no persisted key: the store opens only with the master secret ({@link unlockWithMaster}). */
|
|
101
|
+
async resume(account: string): Promise<boolean> {
|
|
102
|
+
return this.isUnlocked(account);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async saveMaster(account: string, deviceId: string, master: Uint8Array): Promise<void> {
|
|
106
|
+
const h = await this.header();
|
|
107
|
+
if (h && h.account !== account) throw new StateDirInUseError();
|
|
108
|
+
const raw = await localStorageKey(master, deviceId);
|
|
109
|
+
const key = Buffer.from(raw);
|
|
110
|
+
raw.fill(0);
|
|
111
|
+
this.setCurrent({ account, deviceId, key, master: master.slice(), secrets: new Map() });
|
|
112
|
+
await this.flush();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async unlockWithMaster(account: string, master: Uint8Array): Promise<void> {
|
|
116
|
+
const h = await this.header();
|
|
117
|
+
if (!h || h.account !== account) throw new Error("no local data for this account");
|
|
118
|
+
const raw = await localStorageKey(master, h.device_id);
|
|
119
|
+
const key = Buffer.from(raw);
|
|
120
|
+
raw.fill(0);
|
|
121
|
+
let plain: Buffer;
|
|
122
|
+
try {
|
|
123
|
+
const sealed = b64urlDecode(h.data);
|
|
124
|
+
if (sealed.length < TAG) throw new Error("short");
|
|
125
|
+
const d = createDecipheriv("aes-256-gcm", key, b64urlDecode(h.iv));
|
|
126
|
+
d.setAAD(localAad(account, ENTRY));
|
|
127
|
+
d.setAuthTag(sealed.subarray(sealed.length - TAG));
|
|
128
|
+
plain = Buffer.concat([d.update(sealed.subarray(0, sealed.length - TAG)), d.final()]);
|
|
129
|
+
} catch {
|
|
130
|
+
key.fill(0);
|
|
131
|
+
throw new LocalKeyMismatchError();
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const body = JSON.parse(plain.toString("utf8")) as { master?: unknown; secrets?: unknown };
|
|
135
|
+
if (typeof body.master !== "string" || !body.secrets || typeof body.secrets !== "object") throw new Error(`unreadable state file ${this.file}`);
|
|
136
|
+
const secrets = new Map<string, Uint8Array>();
|
|
137
|
+
for (const [name, value] of Object.entries(body.secrets as Record<string, unknown>)) if (typeof value === "string") secrets.set(name, b64urlDecode(value));
|
|
138
|
+
this.setCurrent({ account, deviceId: h.device_id, key, master: master.slice(), secrets });
|
|
139
|
+
} finally {
|
|
140
|
+
plain.fill(0);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
isUnlocked(account?: string): boolean {
|
|
145
|
+
return !!this.current && (account === undefined || this.current.account === account);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
lock(): void {
|
|
149
|
+
this.setCurrent(null);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Waits for every queued write, then locks (process shutdown). */
|
|
153
|
+
async close(): Promise<void> {
|
|
154
|
+
await this.writing;
|
|
155
|
+
this.lock();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async wipe(account: string): Promise<void> {
|
|
159
|
+
if (this.current?.account === account) this.lock();
|
|
160
|
+
const h = await this.header();
|
|
161
|
+
if (h?.account !== account) return;
|
|
162
|
+
await unlink(this.file).catch((e: NodeJS.ErrnoException) => {
|
|
163
|
+
if (e.code !== "ENOENT") throw e;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
account(): string | null {
|
|
168
|
+
return this.current?.account ?? null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private require(): Open {
|
|
172
|
+
if (!this.current) throw new Error("crypto store is locked");
|
|
173
|
+
return this.current;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
deviceId(): string {
|
|
177
|
+
return this.require().deviceId;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
masterSecret(): Uint8Array {
|
|
181
|
+
return this.require().master.slice();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async putSecret(name: string, value: Uint8Array): Promise<void> {
|
|
185
|
+
const cur = this.require();
|
|
186
|
+
cur.secrets.get(name)?.fill(0);
|
|
187
|
+
cur.secrets.set(name, value.slice());
|
|
188
|
+
await this.flush();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async getSecret(name: string): Promise<Uint8Array | null> {
|
|
192
|
+
return this.require().secrets.get(name)?.slice() ?? null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async deleteSecret(name: string): Promise<void> {
|
|
196
|
+
const cur = this.require();
|
|
197
|
+
cur.secrets.get(name)?.fill(0);
|
|
198
|
+
cur.secrets.delete(name);
|
|
199
|
+
await this.flush();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private setCurrent(next: Open | null) {
|
|
203
|
+
const prev = this.current;
|
|
204
|
+
if (prev && prev !== next) {
|
|
205
|
+
prev.key.fill(0);
|
|
206
|
+
prev.master.fill(0);
|
|
207
|
+
for (const v of prev.secrets.values()) v.fill(0);
|
|
208
|
+
}
|
|
209
|
+
this.current = next;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Seals the open state now and queues the atomic write (writes happen in call order). While a write runs, the
|
|
214
|
+
* changes that arrive are coalesced: the queued write takes the newest sealed state, and every caller waiting for it
|
|
215
|
+
* resolves when the file holds its change (one file rewrite with two fsyncs per burst, not per secret).
|
|
216
|
+
*/
|
|
217
|
+
private flush(): Promise<void> {
|
|
218
|
+
const cur = this.require();
|
|
219
|
+
const b64 = (v: Uint8Array) => Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64url");
|
|
220
|
+
const body = JSON.stringify({ master: b64(cur.master), secrets: Object.fromEntries([...cur.secrets].map(([k, v]) => [k, b64(v)])) });
|
|
221
|
+
const plain = Buffer.from(body, "utf8");
|
|
222
|
+
const iv = randomBytes(12);
|
|
223
|
+
const c = createCipheriv("aes-256-gcm", cur.key, iv);
|
|
224
|
+
c.setAAD(localAad(cur.account, ENTRY));
|
|
225
|
+
const sealed = Buffer.concat([c.update(plain), c.final(), c.getAuthTag()]);
|
|
226
|
+
plain.fill(0);
|
|
227
|
+
const header: Header = { v: 1, account: cur.account, device_id: cur.deviceId, iv: b64(iv), data: b64(sealed) };
|
|
228
|
+
const content = `${JSON.stringify(header)}\n`;
|
|
229
|
+
if (this.queued) {
|
|
230
|
+
this.queued.content = content;
|
|
231
|
+
return this.queued.done;
|
|
232
|
+
}
|
|
233
|
+
const slot: { content: string; done: Promise<void> } = { content, done: Promise.resolve() };
|
|
234
|
+
slot.done = this.writing.then(() => {
|
|
235
|
+
if (this.queued === slot) this.queued = null;
|
|
236
|
+
return this.writeAtomic(slot.content);
|
|
237
|
+
});
|
|
238
|
+
this.queued = slot;
|
|
239
|
+
this.writing = slot.done.catch(() => undefined);
|
|
240
|
+
return slot.done;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The write that has not started yet (it takes the state of every later change until it starts). */
|
|
244
|
+
private queued: { content: string; done: Promise<void> } | null = null;
|
|
245
|
+
/** Atomic file writes so far (the tests check that bursts are coalesced). */
|
|
246
|
+
private filesWritten = 0;
|
|
247
|
+
|
|
248
|
+
private async writeAtomic(content: string): Promise<void> {
|
|
249
|
+
this.filesWritten++;
|
|
250
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
251
|
+
const tmp = join(this.dir, `.${STATE_FILE}.${randomBytes(8).toString("hex")}.tmp`);
|
|
252
|
+
// "wx": never follow or reuse an existing file; 0600 before any byte is written.
|
|
253
|
+
const fh = await open(tmp, "wx", 0o600);
|
|
254
|
+
try {
|
|
255
|
+
await fh.writeFile(content, "utf8");
|
|
256
|
+
await fh.sync();
|
|
257
|
+
} finally {
|
|
258
|
+
await fh.close();
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
await rename(tmp, this.file);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
await unlink(tmp).catch(() => undefined);
|
|
264
|
+
throw e;
|
|
265
|
+
}
|
|
266
|
+
// Make the rename durable (best effort: not every platform can open or sync a directory).
|
|
267
|
+
const dir = await open(this.dir, "r").catch(() => null);
|
|
268
|
+
if (dir) {
|
|
269
|
+
await dir.sync().catch(() => undefined);
|
|
270
|
+
await dir.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* A {@link KeyStore} that keeps everything in process memory (tests, one-shot tools). Nothing is written
|
|
4
|
+
* anywhere; the data is gone with the object. Like the file store it keeps no local key: after `lock()`
|
|
5
|
+
* it opens only with the master secret.
|
|
6
|
+
*/
|
|
7
|
+
import { LocalKeyMismatchError, type CryptoStatus, type KeyStore } from "../keystore";
|
|
8
|
+
|
|
9
|
+
interface Account {
|
|
10
|
+
deviceId: string;
|
|
11
|
+
master: Uint8Array;
|
|
12
|
+
secrets: Map<string, Uint8Array>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class MemoryKeyStore implements KeyStore {
|
|
16
|
+
private readonly accounts = new Map<string, Account>();
|
|
17
|
+
private current: string | null = null;
|
|
18
|
+
|
|
19
|
+
async status(account: string): Promise<CryptoStatus> {
|
|
20
|
+
if (this.current === account) return "unlocked";
|
|
21
|
+
return this.accounts.has(account) ? "locked" : "none";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async deviceIdOf(account: string): Promise<string | null> {
|
|
25
|
+
return this.accounts.get(account)?.deviceId ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async resume(account: string): Promise<boolean> {
|
|
29
|
+
return this.isUnlocked(account);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async saveMaster(account: string, deviceId: string, master: Uint8Array): Promise<void> {
|
|
33
|
+
await this.wipe(account);
|
|
34
|
+
this.accounts.set(account, { deviceId, master: master.slice(), secrets: new Map() });
|
|
35
|
+
this.current = account;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async unlockWithMaster(account: string, master: Uint8Array): Promise<void> {
|
|
39
|
+
const rec = this.accounts.get(account);
|
|
40
|
+
if (!rec) throw new Error("no local data for this account");
|
|
41
|
+
if (rec.master.length !== master.length || !rec.master.every((v, i) => v === master[i])) throw new LocalKeyMismatchError();
|
|
42
|
+
this.current = account;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
isUnlocked(account?: string): boolean {
|
|
46
|
+
return this.current !== null && (account === undefined || this.current === account);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
lock(): void {
|
|
50
|
+
this.current = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async wipe(account: string): Promise<void> {
|
|
54
|
+
if (this.current === account) this.lock();
|
|
55
|
+
const rec = this.accounts.get(account);
|
|
56
|
+
if (!rec) return;
|
|
57
|
+
rec.master.fill(0);
|
|
58
|
+
for (const v of rec.secrets.values()) v.fill(0);
|
|
59
|
+
this.accounts.delete(account);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
account(): string | null {
|
|
63
|
+
return this.current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private open(): Account {
|
|
67
|
+
const rec = this.current ? this.accounts.get(this.current) : undefined;
|
|
68
|
+
if (!rec) throw new Error("crypto store is locked");
|
|
69
|
+
return rec;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
deviceId(): string {
|
|
73
|
+
return this.open().deviceId;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
masterSecret(): Uint8Array {
|
|
77
|
+
return this.open().master.slice();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async putSecret(name: string, value: Uint8Array): Promise<void> {
|
|
81
|
+
const rec = this.open();
|
|
82
|
+
rec.secrets.get(name)?.fill(0);
|
|
83
|
+
rec.secrets.set(name, value.slice());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getSecret(name: string): Promise<Uint8Array | null> {
|
|
87
|
+
return this.open().secrets.get(name)?.slice() ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async deleteSecret(name: string): Promise<void> {
|
|
91
|
+
const rec = this.open();
|
|
92
|
+
rec.secrets.get(name)?.fill(0);
|
|
93
|
+
rec.secrets.delete(name);
|
|
94
|
+
}
|
|
95
|
+
}
|