@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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Storage backend of the local search index: three key-value stores of opaque strings to opaque bytes.
|
|
4
|
+
* The index seals every value and derives every key with HMAC before it reaches the backend, so a
|
|
5
|
+
* backend never sees plaintext. Implementations: {@link MemorySearchBackend} (tests, tools), the
|
|
6
|
+
* IndexedDB backend of the web client (tavrn-web `src/search/idbBackend.ts`) and, later (phase 6), a SQLite
|
|
7
|
+
* backend of the Tauri app. Keep the interface minimal so that all of them stay simple; the shared
|
|
8
|
+
* behaviour is pinned by `backendContract` (`@tavrn/e2ee/search-contract`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type SearchStore = "docs" | "postings" | "meta";
|
|
12
|
+
|
|
13
|
+
export const SEARCH_STORES: readonly SearchStore[] = ["docs", "postings", "meta"];
|
|
14
|
+
|
|
15
|
+
export interface SearchWrite {
|
|
16
|
+
store: SearchStore;
|
|
17
|
+
key: string;
|
|
18
|
+
/** null deletes the record. */
|
|
19
|
+
value: Uint8Array | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SearchBackend {
|
|
23
|
+
get(store: SearchStore, key: string): Promise<Uint8Array | null>;
|
|
24
|
+
/** One result per key, in the order of `keys`; null for a missing record. */
|
|
25
|
+
getMany(store: SearchStore, keys: string[]): Promise<(Uint8Array | null)[]>;
|
|
26
|
+
/** Applies all writes atomically where the backend can (one IndexedDB transaction); null deletes. */
|
|
27
|
+
write(batch: SearchWrite[]): Promise<void>;
|
|
28
|
+
/** Every key of a store (meta and maintenance only; never needed on the search path). */
|
|
29
|
+
keys(store: SearchStore): Promise<string[]>;
|
|
30
|
+
/** Sum of value byte lengths (stats). */
|
|
31
|
+
size(): Promise<number>;
|
|
32
|
+
clear(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A {@link SearchBackend} in process memory. Values are copied on write and on read. */
|
|
36
|
+
export class MemorySearchBackend implements SearchBackend {
|
|
37
|
+
private readonly stores = new Map<SearchStore, Map<string, Uint8Array>>(SEARCH_STORES.map((s) => [s, new Map()]));
|
|
38
|
+
|
|
39
|
+
private store(store: SearchStore): Map<string, Uint8Array> {
|
|
40
|
+
const m = this.stores.get(store);
|
|
41
|
+
if (!m) throw new Error(`unknown search store ${String(store)}`);
|
|
42
|
+
return m;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(store: SearchStore, key: string): Promise<Uint8Array | null> {
|
|
46
|
+
return this.store(store).get(key)?.slice() ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getMany(store: SearchStore, keys: string[]): Promise<(Uint8Array | null)[]> {
|
|
50
|
+
const m = this.store(store);
|
|
51
|
+
return keys.map((k) => m.get(k)?.slice() ?? null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async write(batch: SearchWrite[]): Promise<void> {
|
|
55
|
+
const copies = batch.map((w) => ({ m: this.store(w.store), key: w.key, value: w.value?.slice() ?? null }));
|
|
56
|
+
for (const w of copies) {
|
|
57
|
+
if (w.value) w.m.set(w.key, w.value);
|
|
58
|
+
else w.m.delete(w.key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async keys(store: SearchStore): Promise<string[]> {
|
|
63
|
+
return [...this.store(store).keys()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async size(): Promise<number> {
|
|
67
|
+
let n = 0;
|
|
68
|
+
for (const m of this.stores.values()) for (const v of m.values()) n += v.byteLength;
|
|
69
|
+
return n;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async clear(): Promise<void> {
|
|
73
|
+
for (const m of this.stores.values()) m.clear();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every record (copies), for tests that inspect what is stored at rest. */
|
|
77
|
+
dump(): { store: SearchStore; key: string; value: Uint8Array }[] {
|
|
78
|
+
const out: { store: SearchStore; key: string; value: Uint8Array }[] = [];
|
|
79
|
+
for (const [store, m] of this.stores) for (const [key, value] of m) out.push({ store, key, value: value.slice() });
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* The behaviour every {@link SearchBackend} must have, as a Vitest `describe` block. Exported from
|
|
4
|
+
* `@tavrn/e2ee/search-contract` (not from the main entry, which must not import Vitest) and run against
|
|
5
|
+
* every implementation: `backendContract("memory", () => new MemorySearchBackend())`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import type { SearchBackend, SearchWrite } from "./backend";
|
|
9
|
+
|
|
10
|
+
const b = (...v: number[]) => new Uint8Array(v);
|
|
11
|
+
|
|
12
|
+
export function backendContract(name: string, make: () => SearchBackend | Promise<SearchBackend>): void {
|
|
13
|
+
describe(`search backend contract: ${name}`, () => {
|
|
14
|
+
it("puts, reads and deletes records through write()", async () => {
|
|
15
|
+
const be = await make();
|
|
16
|
+
expect(await be.get("docs", "a")).toBeNull();
|
|
17
|
+
await be.write([{ store: "docs", key: "a", value: b(1, 2, 3) }]);
|
|
18
|
+
expect(Array.from((await be.get("docs", "a"))!)).toEqual([1, 2, 3]);
|
|
19
|
+
await be.write([{ store: "docs", key: "a", value: b(4) }]);
|
|
20
|
+
expect(Array.from((await be.get("docs", "a"))!)).toEqual([4]);
|
|
21
|
+
await be.write([{ store: "docs", key: "a", value: null }]);
|
|
22
|
+
expect(await be.get("docs", "a")).toBeNull();
|
|
23
|
+
// Deleting a missing record is not an error.
|
|
24
|
+
await be.write([{ store: "docs", key: "missing", value: null }]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps the stores apart", async () => {
|
|
28
|
+
const be = await make();
|
|
29
|
+
await be.write([
|
|
30
|
+
{ store: "docs", key: "k", value: b(1) },
|
|
31
|
+
{ store: "postings", key: "k", value: b(2) },
|
|
32
|
+
{ store: "meta", key: "k", value: b(3) },
|
|
33
|
+
]);
|
|
34
|
+
expect(Array.from((await be.get("docs", "k"))!)).toEqual([1]);
|
|
35
|
+
expect(Array.from((await be.get("postings", "k"))!)).toEqual([2]);
|
|
36
|
+
expect(Array.from((await be.get("meta", "k"))!)).toEqual([3]);
|
|
37
|
+
await be.write([{ store: "postings", key: "k", value: null }]);
|
|
38
|
+
expect(await be.get("postings", "k")).toBeNull();
|
|
39
|
+
expect(await be.get("docs", "k")).not.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("getMany keeps the order of the keys and returns null for misses", async () => {
|
|
43
|
+
const be = await make();
|
|
44
|
+
await be.write([
|
|
45
|
+
{ store: "postings", key: "x", value: b(1) },
|
|
46
|
+
{ store: "postings", key: "z", value: b(3) },
|
|
47
|
+
]);
|
|
48
|
+
const got = await be.getMany("postings", ["z", "nope", "x", "z"]);
|
|
49
|
+
expect(got.map((v) => (v ? Array.from(v) : null))).toEqual([[3], null, [1], [3]]);
|
|
50
|
+
expect(await be.getMany("postings", [])).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("lists the keys of a store", async () => {
|
|
54
|
+
const be = await make();
|
|
55
|
+
await be.write([
|
|
56
|
+
{ store: "meta", key: "m1", value: b(1) },
|
|
57
|
+
{ store: "meta", key: "m2", value: b(2) },
|
|
58
|
+
{ store: "docs", key: "d1", value: b(3) },
|
|
59
|
+
]);
|
|
60
|
+
expect((await be.keys("meta")).sort()).toEqual(["m1", "m2"]);
|
|
61
|
+
expect(await be.keys("docs")).toEqual(["d1"]);
|
|
62
|
+
expect(await be.keys("postings")).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("size() sums the value lengths of all stores", async () => {
|
|
66
|
+
const be = await make();
|
|
67
|
+
expect(await be.size()).toBe(0);
|
|
68
|
+
await be.write([
|
|
69
|
+
{ store: "docs", key: "a", value: new Uint8Array(100) },
|
|
70
|
+
{ store: "postings", key: "b", value: new Uint8Array(20) },
|
|
71
|
+
{ store: "meta", key: "c", value: new Uint8Array(3) },
|
|
72
|
+
]);
|
|
73
|
+
expect(await be.size()).toBe(123);
|
|
74
|
+
await be.write([{ store: "docs", key: "a", value: null }]);
|
|
75
|
+
expect(await be.size()).toBe(23);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clear() empties every store", async () => {
|
|
79
|
+
const be = await make();
|
|
80
|
+
await be.write([
|
|
81
|
+
{ store: "docs", key: "a", value: b(1) },
|
|
82
|
+
{ store: "postings", key: "b", value: b(2) },
|
|
83
|
+
{ store: "meta", key: "c", value: b(3) },
|
|
84
|
+
]);
|
|
85
|
+
await be.clear();
|
|
86
|
+
for (const s of ["docs", "postings", "meta"] as const) expect(await be.keys(s)).toEqual([]);
|
|
87
|
+
expect(await be.size()).toBe(0);
|
|
88
|
+
expect(await be.get("docs", "a")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("applies a batch as a whole: every value is visible once write() resolves, the last write of a key wins", async () => {
|
|
92
|
+
const be = await make();
|
|
93
|
+
const batch: SearchWrite[] = Array.from({ length: 300 }, (_, i) => ({ store: "postings", key: `k${i}`, value: b(i % 256) }));
|
|
94
|
+
batch.push({ store: "postings", key: "k0", value: b(9, 9) });
|
|
95
|
+
batch.push({ store: "postings", key: "k1", value: null });
|
|
96
|
+
await be.write(batch);
|
|
97
|
+
const got = await be.getMany("postings", batch.slice(0, 300).map((w) => w.key));
|
|
98
|
+
expect(got.filter((v) => v !== null)).toHaveLength(299);
|
|
99
|
+
expect(Array.from(got[0]!)).toEqual([9, 9]);
|
|
100
|
+
expect(got[1]).toBeNull();
|
|
101
|
+
expect(Array.from(got[299]!)).toEqual([299 % 256]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("copies values: changing the input or a result afterwards does not change the stored value", async () => {
|
|
105
|
+
const be = await make();
|
|
106
|
+
const v = b(1, 2, 3);
|
|
107
|
+
await be.write([{ store: "docs", key: "a", value: v }]);
|
|
108
|
+
v[0] = 99;
|
|
109
|
+
const got = (await be.get("docs", "a"))!;
|
|
110
|
+
expect(Array.from(got)).toEqual([1, 2, 3]);
|
|
111
|
+
got[1] = 77;
|
|
112
|
+
expect(Array.from((await be.get("docs", "a"))!)).toEqual([1, 2, 3]);
|
|
113
|
+
const [many] = await be.getMany("docs", ["a"]);
|
|
114
|
+
many![2] = 55;
|
|
115
|
+
expect(Array.from((await be.get("docs", "a"))!)).toEqual([1, 2, 3]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("handles arbitrary string keys", async () => {
|
|
119
|
+
const be = await make();
|
|
120
|
+
const keys = ["", "a:b", "ü/ß\u0000x", "0123456789abcdef0123456789abcdef"];
|
|
121
|
+
await be.write(keys.map((key, i) => ({ store: "meta" as const, key, value: b(i) })));
|
|
122
|
+
expect((await be.getMany("meta", keys)).map((v) => (v ? v[0] : null))).toEqual([0, 1, 2, 3]);
|
|
123
|
+
expect((await be.keys("meta")).sort()).toEqual([...keys].sort());
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/** The local encrypted search index (design note 4.4 "Search"). The backend contract test lives in `@tavrn/e2ee/search-contract`. */
|
|
3
|
+
export * from "./tokenize";
|
|
4
|
+
export * from "./keys";
|
|
5
|
+
export * from "./backend";
|
|
6
|
+
export * from "./searchIndex";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Keys of the local search index. Two non-extractable WebCrypto keys are derived from the device's
|
|
4
|
+
* local storage key (ENCRYPTION.md §2.1) with HKDF-SHA-256: an HMAC key that turns every record name
|
|
5
|
+
* (message ids, conversation ids, lookup keys) into an opaque token, and an AES-256-GCM key that seals
|
|
6
|
+
* every record value. A stolen index store reveals record counts and sizes, nothing else.
|
|
7
|
+
*/
|
|
8
|
+
import { localStorageKey, type KeyStore } from "../keystore";
|
|
9
|
+
|
|
10
|
+
export interface SearchKeys {
|
|
11
|
+
/** HMAC-SHA-256 key of the token keys (record names). */
|
|
12
|
+
mac: CryptoKey;
|
|
13
|
+
/** AES-256-GCM key of the record values. */
|
|
14
|
+
enc: CryptoKey;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Thrown while the key store (or the index, after `lock()`) is locked. */
|
|
18
|
+
export class SearchLockedError extends Error {
|
|
19
|
+
override name = "search_locked";
|
|
20
|
+
constructor() {
|
|
21
|
+
super("The local search index is locked");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const enc = new TextEncoder();
|
|
26
|
+
const dec = new TextDecoder();
|
|
27
|
+
|
|
28
|
+
function subtle(): SubtleCrypto {
|
|
29
|
+
const s = globalThis.crypto?.subtle;
|
|
30
|
+
if (!s) throw new Error("WebCrypto is unavailable");
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bytes(s: string): Uint8Array<ArrayBuffer> {
|
|
35
|
+
return enc.encode(s);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* HKDF-SHA-256 (salt = 32 zero bytes) of the local key: info "tavrn search index v1 mac" → HMAC-SHA-256
|
|
40
|
+
* key, info "tavrn search index v1 enc" → AES-GCM-256 key. Both non-extractable.
|
|
41
|
+
*/
|
|
42
|
+
export async function deriveSearchKeys(localKey: Uint8Array): Promise<SearchKeys> {
|
|
43
|
+
const s = subtle();
|
|
44
|
+
const ikm = await s.importKey("raw", localKey as Uint8Array<ArrayBuffer>, "HKDF", false, ["deriveKey"]);
|
|
45
|
+
const salt = new Uint8Array(32);
|
|
46
|
+
const [mac, aes] = await Promise.all([
|
|
47
|
+
s.deriveKey(
|
|
48
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: bytes("tavrn search index v1 mac") },
|
|
49
|
+
ikm,
|
|
50
|
+
{ name: "HMAC", hash: "SHA-256", length: 256 },
|
|
51
|
+
false,
|
|
52
|
+
["sign"],
|
|
53
|
+
),
|
|
54
|
+
s.deriveKey(
|
|
55
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: bytes("tavrn search index v1 enc") },
|
|
56
|
+
ikm,
|
|
57
|
+
{ name: "AES-GCM", length: 256 },
|
|
58
|
+
false,
|
|
59
|
+
["encrypt", "decrypt"],
|
|
60
|
+
),
|
|
61
|
+
]);
|
|
62
|
+
return { mac, enc: aes };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The search keys of the open account of `store`; throws {@link SearchLockedError} while it is locked. */
|
|
66
|
+
export async function searchKeysFromStore(store: KeyStore): Promise<SearchKeys> {
|
|
67
|
+
if (!store.isUnlocked()) throw new SearchLockedError();
|
|
68
|
+
const master = store.masterSecret();
|
|
69
|
+
let local: Uint8Array | null = null;
|
|
70
|
+
try {
|
|
71
|
+
local = await localStorageKey(master, store.deviceId());
|
|
72
|
+
return await deriveSearchKeys(local);
|
|
73
|
+
} finally {
|
|
74
|
+
master.fill(0);
|
|
75
|
+
local?.fill(0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
80
|
+
|
|
81
|
+
/** The token key of a record name: hex(HMAC(mac, parts joined by NUL)), first 32 hex digits (128 bits). */
|
|
82
|
+
export async function tokenKey(keys: SearchKeys, ...parts: string[]): Promise<string> {
|
|
83
|
+
const sig = new Uint8Array(await subtle().sign("HMAC", keys.mac, bytes(parts.join("\u0000"))));
|
|
84
|
+
let out = "";
|
|
85
|
+
for (let i = 0; i < 16; i++) out += HEX[sig[i]!];
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** iv (12 bytes) || AES-GCM(JSON of `value`), bound to `aad` (store name + NUL + record key). */
|
|
90
|
+
export async function seal(keys: SearchKeys, aad: string, value: unknown): Promise<Uint8Array> {
|
|
91
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
92
|
+
const ct = await subtle().encrypt({ name: "AES-GCM", iv, additionalData: bytes(aad) }, keys.enc, bytes(JSON.stringify(value)));
|
|
93
|
+
const out = new Uint8Array(12 + ct.byteLength);
|
|
94
|
+
out.set(iv);
|
|
95
|
+
out.set(new Uint8Array(ct), 12);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Opens a value of {@link seal}; throws if the key, the aad or the bytes do not match. */
|
|
100
|
+
export async function open<T>(keys: SearchKeys, aad: string, sealed: Uint8Array): Promise<T> {
|
|
101
|
+
if (sealed.length < 12 + 16) throw new Error("sealed search record too short");
|
|
102
|
+
const plain = await subtle().decrypt(
|
|
103
|
+
{ name: "AES-GCM", iv: sealed.slice(0, 12), additionalData: bytes(aad) },
|
|
104
|
+
keys.enc,
|
|
105
|
+
sealed.slice(12),
|
|
106
|
+
);
|
|
107
|
+
return JSON.parse(dec.decode(plain)) as T;
|
|
108
|
+
}
|