@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
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tavrn/e2ee",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "tavrn end-to-end encryption client logic (MLS session manager, content codecs, key storage) for the web client and bots",
|
|
5
|
+
"license": "MPL-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/just-tavrn/tavrn-js.git",
|
|
9
|
+
"directory": "e2ee"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/just-tavrn/tavrn-js/tree/main/e2ee#readme",
|
|
12
|
+
"bugs": "https://github.com/just-tavrn/tavrn-js/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"tavrn",
|
|
15
|
+
"e2ee",
|
|
16
|
+
"mls",
|
|
17
|
+
"end-to-end-encryption"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./src/index.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.ts",
|
|
23
|
+
"./node": "./src/node.ts",
|
|
24
|
+
"./search-contract": "./src/search/contract.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@tavrn/crypto": "^0.1.2",
|
|
31
|
+
"@tavrn/sdk": "0.2.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^26.6.6",
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^5.0.5"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
}
|
|
6
42
|
}
|
package/src/account.ts
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Account-level encryption flows (ENCRYPTION.md §2.4 client flows, §3, §8, §9): setting up a new account,
|
|
4
|
+
* unlocking a device with the recovery key or a passkey PRF output, attesting the device and keeping
|
|
5
|
+
* KeyPackages topped up, regenerating the recovery key and the identity reset. Every flow works on an
|
|
6
|
+
* injected {@link KeyStore}; nothing here touches the DOM (the WebAuthn ceremony that yields a PRF output
|
|
7
|
+
* stays in the web client). Secrets stay in memory as briefly as possible; WASM objects are freed after use.
|
|
8
|
+
*/
|
|
9
|
+
import { Client, MasterSecret, RecoveryKey, passkeyMethod, passkeyWrappingKey, unwrapIdentityKey, unwrapMasterSecret, wrapMasterSecret } from "@tavrn/crypto";
|
|
10
|
+
import { TavrnApiError, b64urlDecode, b64urlEncode, type TavrnApi } from "@tavrn/sdk";
|
|
11
|
+
import { E2eeError } from "./errors";
|
|
12
|
+
import { IDENTITY_SEED, chainReaches, ownIdentityPublicKey, resolveIdentity, syncIdentity, withIdentityKey } from "./identity";
|
|
13
|
+
import { LocalKeyMismatchError, withMasterSecret, type CryptoStatus, type KeyStore } from "./keystore";
|
|
14
|
+
import { ensureCrypto } from "./wasm";
|
|
15
|
+
|
|
16
|
+
/** Name of the exported MLS client state (device key + groups) in the key store. */
|
|
17
|
+
export const CLIENT_STATE = "mls-client";
|
|
18
|
+
/** KeyPackages uploaded at once, and the level below which the client tops up (ENCRYPTION.md §3.2). */
|
|
19
|
+
export const KEY_PACKAGE_BATCH = 20;
|
|
20
|
+
export const KEY_PACKAGE_MIN = 5;
|
|
21
|
+
const METHOD_RECOVERY = "recovery";
|
|
22
|
+
|
|
23
|
+
export interface E2eeContext {
|
|
24
|
+
/** The routes used: `keys` (all flows) and `secretStorage` (recovery key and passkey flows). */
|
|
25
|
+
api: Pick<TavrnApi, "keys" | "secretStorage">;
|
|
26
|
+
/** Local username (path segment of `GET /users/{username}/keys`). */
|
|
27
|
+
username: string;
|
|
28
|
+
/** Full user address `username@domain`. */
|
|
29
|
+
address: string;
|
|
30
|
+
/** Device id of the current session (or bot token). */
|
|
31
|
+
deviceId: string;
|
|
32
|
+
/** Local storage of this device. */
|
|
33
|
+
store: KeyStore;
|
|
34
|
+
/** `client` in the params of secret storage entries (default `tavrn-web`). */
|
|
35
|
+
clientName?: string;
|
|
36
|
+
/**
|
|
37
|
+
* `ensureDevice`: creates `count` KeyPackages (base64url) in the client state of the open session manager
|
|
38
|
+
* (`MlsManager.createKeyPackages`, which also runs in the tab that owns the state), so that no second copy of the
|
|
39
|
+
* state is written next to it. Omitted or null: they are created in the stored state.
|
|
40
|
+
*/
|
|
41
|
+
keyPackages?: (count: number) => Promise<string[] | null>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { E2eeError, type E2eeErrorCode } from "./errors";
|
|
45
|
+
|
|
46
|
+
function code(e: unknown): string | undefined {
|
|
47
|
+
return e instanceof TavrnApiError ? e.code : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function params(ctx: E2eeContext) {
|
|
51
|
+
return { client: ctx.clientName ?? "tavrn-web", v: 1 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Status of this device for the account: resumes with a persisted local key if possible. Local data of
|
|
56
|
+
* another device (a login that could not continue the stored device, PROTOCOL.md §5b) is discarded; that
|
|
57
|
+
* old device is reported to `onStaleDevice` first, so the caller can have it removed (its keys are gone
|
|
58
|
+
* with the local data, its MLS leaves would stay as ghosts otherwise, ENCRYPTION.md §4.7).
|
|
59
|
+
*/
|
|
60
|
+
export async function localStatus(
|
|
61
|
+
store: KeyStore,
|
|
62
|
+
address: string,
|
|
63
|
+
deviceId: string | null,
|
|
64
|
+
opts: { onStaleDevice?: (oldDeviceId: string) => void | Promise<void> } = {},
|
|
65
|
+
): Promise<CryptoStatus> {
|
|
66
|
+
const owner = await store.deviceIdOf(address);
|
|
67
|
+
if (owner && deviceId && owner !== deviceId) {
|
|
68
|
+
try {
|
|
69
|
+
await opts.onStaleDevice?.(owner);
|
|
70
|
+
} catch {
|
|
71
|
+
/* best effort: housekeeping deletes the device when it stays unused */
|
|
72
|
+
}
|
|
73
|
+
await store.wipe(address);
|
|
74
|
+
return "none";
|
|
75
|
+
}
|
|
76
|
+
if (await store.resume(address)) return "unlocked";
|
|
77
|
+
return store.status(address);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A device proof as login requests carry it (`device`, PROTOCOL.md §5b). */
|
|
81
|
+
export interface DeviceProof {
|
|
82
|
+
id: string;
|
|
83
|
+
nonce: string;
|
|
84
|
+
proof: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Proof that this store holds the key of the account's stored device, over a login nonce of the node
|
|
89
|
+
* (`POST /auth/device-nonce`, ENCRYPTION.md §3.4). Null when the store has no device for `address` or
|
|
90
|
+
* cannot open it without the user (lock on close): the login then creates a new device.
|
|
91
|
+
*/
|
|
92
|
+
export async function deviceLoginProof(store: KeyStore, address: string, nonce: () => Promise<string>): Promise<DeviceProof | null> {
|
|
93
|
+
const deviceId = await store.deviceIdOf(address);
|
|
94
|
+
if (!deviceId) return null;
|
|
95
|
+
if (!store.isUnlocked(address) && !(await store.resume(address))) return null;
|
|
96
|
+
const client = await loadClient(store);
|
|
97
|
+
if (!client) return null;
|
|
98
|
+
try {
|
|
99
|
+
if (client.deviceId !== deviceId || client.userAddress !== address) return null;
|
|
100
|
+
const n = await nonce();
|
|
101
|
+
return { id: deviceId, nonce: n, proof: b64urlEncode(client.signDeviceLogin(n)) };
|
|
102
|
+
} finally {
|
|
103
|
+
client.free();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The MLS client of this device from the store (null if none was created yet). Requires an unlocked store. */
|
|
108
|
+
export async function loadClient(store: KeyStore): Promise<Client | null> {
|
|
109
|
+
await ensureCrypto();
|
|
110
|
+
const state = await store.getSecret(CLIENT_STATE);
|
|
111
|
+
if (!state) return null;
|
|
112
|
+
try {
|
|
113
|
+
return Client.importState(state);
|
|
114
|
+
} finally {
|
|
115
|
+
state.fill(0);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Persists the client state; call after every mutating call on the client. */
|
|
120
|
+
export async function saveClient(store: KeyStore, client: Client): Promise<void> {
|
|
121
|
+
const state = client.exportState();
|
|
122
|
+
try {
|
|
123
|
+
await store.putSecret(CLIENT_STATE, state);
|
|
124
|
+
} finally {
|
|
125
|
+
state.fill(0);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function withMaster<T>(store: KeyStore, fn: (m: MasterSecret) => T): T {
|
|
130
|
+
if (!store.isUnlocked()) throw new E2eeError("locked");
|
|
131
|
+
return withMasterSecret(store, fn);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Devices (`address/device id`) whose share key this session published or asked the node for: asked once. */
|
|
135
|
+
const sharePublished = new Set<string>();
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Makes this device usable for encryption: follows a rotation of the identity key done on another device (§9a),
|
|
139
|
+
* creates its MLS client (device key) if needed, attests it with the current identity key (§3.1) and keeps at least
|
|
140
|
+
* {@link KEY_PACKAGE_MIN} KeyPackages uploaded (§3.2). Idempotent; requires an unlocked store.
|
|
141
|
+
*/
|
|
142
|
+
export async function ensureDevice(ctx: E2eeContext): Promise<void> {
|
|
143
|
+
await ensureCrypto();
|
|
144
|
+
const { store } = ctx;
|
|
145
|
+
const doc = await ctx.api.keys.get(ctx.username);
|
|
146
|
+
await syncIdentity(ctx.api, store, ctx.username, ctx.address, doc);
|
|
147
|
+
const stored = await loadClient(store);
|
|
148
|
+
const client = stored ?? Client.create(ctx.address, ctx.deviceId);
|
|
149
|
+
/**
|
|
150
|
+
* §3.1: a device key is immutable. When this session's device already has another key on the node, this device lost
|
|
151
|
+
* it (its local key store is gone while the session lives on, tavrn-web#83): a second key would sign commits and
|
|
152
|
+
* messages that the node and every member refuse. A new key is never stored, a stored one (left by earlier versions)
|
|
153
|
+
* is dropped, so no session manager opens with it; the device needs a new device id (sign in again).
|
|
154
|
+
*/
|
|
155
|
+
const lost = async () => {
|
|
156
|
+
await store.deleteSecret(CLIENT_STATE);
|
|
157
|
+
return new E2eeError("device_key_lost");
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
const devicePublicKey = client.devicePublicKey;
|
|
161
|
+
const listed = doc.devices.find((d) => d.device_id === ctx.deviceId);
|
|
162
|
+
if (listed && listed.device_public_key !== b64urlEncode(devicePublicKey)) throw await lost();
|
|
163
|
+
if (!stored) await saveClient(store, client);
|
|
164
|
+
if (!store.isUnlocked()) throw new E2eeError("locked");
|
|
165
|
+
const signature = await withIdentityKey(store, (k) => k.attestDevice(ctx.address, ctx.deviceId, devicePublicKey));
|
|
166
|
+
// The device's share key goes with the device key (ENCRYPTION.md §3.1); a device attested without one publishes it
|
|
167
|
+
// once (best effort: nodes without `history.shares2` do not know the route, shares then stay format 1).
|
|
168
|
+
const shareKey = client.shareKey();
|
|
169
|
+
const share = { share_public_key: b64urlEncode(shareKey.publicKey), share_key_signature: b64urlEncode(shareKey.signature) };
|
|
170
|
+
shareKey.free();
|
|
171
|
+
try {
|
|
172
|
+
await ctx.api.keys.putDeviceKey(ctx.deviceId, { device_public_key: b64urlEncode(devicePublicKey), identity_signature: b64urlEncode(signature), ...share });
|
|
173
|
+
} catch (e) {
|
|
174
|
+
// Not listed, yet the node keeps another key (or share key) for it: a device attested under an earlier identity
|
|
175
|
+
// key (§9) whose own key is gone. Listed with this key, `device_key_exists` is the usual answer.
|
|
176
|
+
if (!listed && (code(e) === "e2ee.device_key_exists" || code(e) === "e2ee.share_key_exists")) throw await lost();
|
|
177
|
+
if (code(e) !== "e2ee.device_key_exists") throw e;
|
|
178
|
+
if (!sharePublished.has(`${ctx.address}/${ctx.deviceId}`)) {
|
|
179
|
+
// Asked once per session, also when it fails (a node without the route); the next start asks again.
|
|
180
|
+
sharePublished.add(`${ctx.address}/${ctx.deviceId}`);
|
|
181
|
+
await Promise.resolve()
|
|
182
|
+
.then(() => ctx.api.keys.putShareKey(ctx.deviceId, share))
|
|
183
|
+
.catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const { count } = await ctx.api.keys.countKeyPackages(ctx.deviceId);
|
|
187
|
+
if (count < KEY_PACKAGE_MIN) {
|
|
188
|
+
let packages = (await ctx.keyPackages?.(KEY_PACKAGE_BATCH - count)) ?? null;
|
|
189
|
+
if (!packages) {
|
|
190
|
+
packages = Array.from({ length: KEY_PACKAGE_BATCH - count }, () => b64urlEncode(client.createKeyPackage()));
|
|
191
|
+
await saveClient(store, client);
|
|
192
|
+
}
|
|
193
|
+
await ctx.api.keys.uploadKeyPackages(ctx.deviceId, { key_packages: packages });
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
client.free();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether this session's device lost its key (tavrn-web#83): the node lists the device with a key, and this store
|
|
202
|
+
* holds no MLS client with that key (no local data, or a client with another key). Then no unlock can make it usable
|
|
203
|
+
* for encryption again; it needs a new device (sign in again). False while the store is locked (unknown until then).
|
|
204
|
+
*/
|
|
205
|
+
export async function localDeviceLost(ctx: Pick<E2eeContext, "api" | "username" | "address" | "deviceId" | "store">): Promise<boolean> {
|
|
206
|
+
const { store } = ctx;
|
|
207
|
+
const status = await store.status(ctx.address);
|
|
208
|
+
if (status === "locked") return false;
|
|
209
|
+
const listed = (await ctx.api.keys.get(ctx.username)).devices.find((d) => d.device_id === ctx.deviceId);
|
|
210
|
+
if (!listed) return false;
|
|
211
|
+
if (status === "none") return true;
|
|
212
|
+
const client = await loadClient(store);
|
|
213
|
+
if (!client) return true;
|
|
214
|
+
try {
|
|
215
|
+
return b64urlEncode(client.devicePublicKey) !== listed.device_public_key;
|
|
216
|
+
} finally {
|
|
217
|
+
client.free();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function putRecovery(ctx: E2eeContext, recovery: RecoveryKey) {
|
|
222
|
+
const key = recovery.wrappingKey();
|
|
223
|
+
try {
|
|
224
|
+
const wrapped = withMaster(ctx.store, (m) => wrapMasterSecret(m, key, ctx.address, METHOD_RECOVERY));
|
|
225
|
+
await ctx.api.secretStorage.putRecovery({ wrapped: b64urlEncode(wrapped), params: params(ctx) });
|
|
226
|
+
} finally {
|
|
227
|
+
key.fill(0);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Uploads the `passkey:<id>` entry for a PRF-capable credential (requires an unlocked store). */
|
|
232
|
+
export async function addPasskeySecret(ctx: E2eeContext, credentialId: string, prf: Uint8Array): Promise<void> {
|
|
233
|
+
await ensureCrypto();
|
|
234
|
+
const key = passkeyWrappingKey(prf);
|
|
235
|
+
try {
|
|
236
|
+
const method = passkeyMethod(credentialId);
|
|
237
|
+
const wrapped = withMaster(ctx.store, (m) => wrapMasterSecret(m, key, ctx.address, method));
|
|
238
|
+
await ctx.api.secretStorage.putPasskey(credentialId, { wrapped: b64urlEncode(wrapped), params: params(ctx) });
|
|
239
|
+
} finally {
|
|
240
|
+
key.fill(0);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function identityKeyOnNode(ctx: E2eeContext): Promise<string | null> {
|
|
245
|
+
return (await ctx.api.keys.get(ctx.username)).identity_public_key ?? null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Registration (ENCRYPTION.md §2.4 flow 1), after the user confirmed `recovery`: master secret (kept
|
|
250
|
+
* locally first, so a retry after an error reuses it), identity key, `recovery` entry, optional
|
|
251
|
+
* passkey entry, device attestation and KeyPackages. Refused (`already_set_up`) when this device's store holds the
|
|
252
|
+
* account's master secret but is locked, or the node has a recovery entry for it: such an account unlocks instead.
|
|
253
|
+
*/
|
|
254
|
+
export async function setupAccount(ctx: E2eeContext, recovery: RecoveryKey, passkey?: { credentialId: string; prf: Uint8Array } | null): Promise<void> {
|
|
255
|
+
await ensureCrypto();
|
|
256
|
+
const status = await localStatus(ctx.store, ctx.address, ctx.deviceId);
|
|
257
|
+
// A node that claims the account has no identity key must not make this device replace its local data (and with it
|
|
258
|
+
// its pins and groups): with a stored master secret or a recovery entry the account is set up, the user unlocks.
|
|
259
|
+
// An unlocked store is the retry of an interrupted setup and keeps its master secret.
|
|
260
|
+
if (status === "locked") throw new E2eeError("already_set_up");
|
|
261
|
+
if (status === "none" && (await entry(ctx, METHOD_RECOVERY))) throw new E2eeError("already_set_up");
|
|
262
|
+
if (status !== "unlocked") {
|
|
263
|
+
const fresh = MasterSecret.generate();
|
|
264
|
+
const bytes = fresh.toBytes();
|
|
265
|
+
fresh.free();
|
|
266
|
+
try {
|
|
267
|
+
await ctx.store.saveMaster(ctx.address, ctx.deviceId, bytes);
|
|
268
|
+
} finally {
|
|
269
|
+
bytes.fill(0);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const identity = b64urlEncode(withMaster(ctx.store, (m) => m.identityPublicKey()));
|
|
273
|
+
try {
|
|
274
|
+
await ctx.api.keys.putIdentity({ identity_public_key: identity });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
if (code(e) !== "e2ee.identity_exists" || (await identityKeyOnNode(ctx)) !== identity) throw code(e) === "e2ee.identity_exists" ? new E2eeError("identity_mismatch") : e;
|
|
277
|
+
}
|
|
278
|
+
await putRecovery(ctx, recovery);
|
|
279
|
+
if (passkey) await addPasskeySecret(ctx, passkey.credentialId, passkey.prf);
|
|
280
|
+
await ensureDevice(ctx);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Device key carried over an identity reset done on another device, per store: the local master secret
|
|
285
|
+
* is stale, but the device keeps its key (ENCRYPTION.md §9 step 4) and re-attests it after unlocking.
|
|
286
|
+
* Memory only; a restart before unlocking loses it and the device has to be signed in anew.
|
|
287
|
+
*/
|
|
288
|
+
const carried = new WeakMap<KeyStore, Uint8Array>();
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Own `identity.changed` from another device: deletes the local master secret and everything else of
|
|
292
|
+
* the account in the store except the MLS client state, which is kept in memory until the user
|
|
293
|
+
* unlocked with the new recovery key ({@link adoptMaster} stores it again).
|
|
294
|
+
*/
|
|
295
|
+
export async function forgetIdentityKeepDevice(store: KeyStore, address: string): Promise<void> {
|
|
296
|
+
const state = store.isUnlocked(address) ? await store.getSecret(CLIENT_STATE) : null;
|
|
297
|
+
carried.get(store)?.fill(0);
|
|
298
|
+
if (state) carried.set(store, state);
|
|
299
|
+
else carried.delete(store);
|
|
300
|
+
await store.wipe(address);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Takes over an unwrapped master secret after checking it against the published identity key. */
|
|
304
|
+
async function adoptMaster(ctx: E2eeContext, master: MasterSecret): Promise<void> {
|
|
305
|
+
const { store } = ctx;
|
|
306
|
+
const derived = b64urlEncode(master.identityPublicKey());
|
|
307
|
+
const bytes = master.toBytes();
|
|
308
|
+
let seed: Uint8Array | null = null;
|
|
309
|
+
try {
|
|
310
|
+
// §2.4: a stale or forged entry must not be used. The published key is the derived one, or a rotated key the
|
|
311
|
+
// continuity chain reaches from it and the blob holds (§9a).
|
|
312
|
+
const doc = await ctx.api.keys.get(ctx.username);
|
|
313
|
+
seed = await resolveIdentity(ctx.api, doc, derived, (blob) => unwrapIdentityKey(master, blob, ctx.address));
|
|
314
|
+
const keepSeed = async () => {
|
|
315
|
+
if (seed) await store.putSecret(IDENTITY_SEED, seed);
|
|
316
|
+
else await store.deleteSecret(IDENTITY_SEED);
|
|
317
|
+
};
|
|
318
|
+
if ((await localStatus(store, ctx.address, ctx.deviceId)) === "locked") {
|
|
319
|
+
try {
|
|
320
|
+
await store.unlockWithMaster(ctx.address, bytes);
|
|
321
|
+
// Forward only (§9a): local data that already follows a rotated key accepts the published key only when the
|
|
322
|
+
// chain leads to it from that key, so the node cannot roll this device back to an older generation.
|
|
323
|
+
const own = await ownIdentityPublicKey(store);
|
|
324
|
+
const published = doc.identity_public_key ?? derived;
|
|
325
|
+
if (own !== derived && own !== published && !chainReaches(doc.continuity, own, published)) {
|
|
326
|
+
store.lock();
|
|
327
|
+
throw new E2eeError("identity_mismatch");
|
|
328
|
+
}
|
|
329
|
+
await keepSeed();
|
|
330
|
+
await ensureDevice(ctx);
|
|
331
|
+
return;
|
|
332
|
+
} catch (e) {
|
|
333
|
+
// Local data under an older identity (reset on another device): start this device over.
|
|
334
|
+
if (!(e instanceof LocalKeyMismatchError)) throw e;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
await store.saveMaster(ctx.address, ctx.deviceId, bytes);
|
|
338
|
+
await keepSeed();
|
|
339
|
+
const state = carried.get(store);
|
|
340
|
+
if (state) {
|
|
341
|
+
await store.putSecret(CLIENT_STATE, state);
|
|
342
|
+
state.fill(0);
|
|
343
|
+
carried.delete(store);
|
|
344
|
+
}
|
|
345
|
+
} finally {
|
|
346
|
+
bytes.fill(0);
|
|
347
|
+
seed?.fill(0);
|
|
348
|
+
master.free();
|
|
349
|
+
}
|
|
350
|
+
await ensureDevice(ctx);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The identity public key derived from the local master secret (generation 0, base64url), or null if locked: tells
|
|
355
|
+
* whether this device holds the master secret behind the published key (an identity reset elsewhere replaces it).
|
|
356
|
+
*/
|
|
357
|
+
export function localIdentityKey(store: KeyStore): string | null {
|
|
358
|
+
if (!store.isUnlocked()) return null;
|
|
359
|
+
return withMasterSecret(store, (m) => b64urlEncode(m.identityPublicKey()));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function entry(ctx: E2eeContext, method: string): Promise<Uint8Array | null> {
|
|
363
|
+
const items = (await ctx.api.secretStorage.list()).items;
|
|
364
|
+
const e = items.find((i) => i.method === method);
|
|
365
|
+
return e ? b64urlDecode(e.wrapped) : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** New device, cleared storage or lock on close: unlock with the 24 words, the compact string or the file. */
|
|
369
|
+
export async function unlockWithRecoveryKey(ctx: E2eeContext, text: string): Promise<void> {
|
|
370
|
+
await ensureCrypto();
|
|
371
|
+
let recovery: RecoveryKey;
|
|
372
|
+
try {
|
|
373
|
+
recovery = RecoveryKey.fromText(text);
|
|
374
|
+
} catch {
|
|
375
|
+
throw new E2eeError("recovery_key_invalid");
|
|
376
|
+
}
|
|
377
|
+
const key = recovery.wrappingKey();
|
|
378
|
+
recovery.free();
|
|
379
|
+
try {
|
|
380
|
+
const wrapped = await entry(ctx, METHOD_RECOVERY);
|
|
381
|
+
if (!wrapped) throw new E2eeError("no_recovery_entry");
|
|
382
|
+
let master: MasterSecret;
|
|
383
|
+
try {
|
|
384
|
+
master = unwrapMasterSecret(wrapped, key, ctx.address, METHOD_RECOVERY);
|
|
385
|
+
} catch {
|
|
386
|
+
throw new E2eeError("recovery_key_wrong");
|
|
387
|
+
}
|
|
388
|
+
await adoptMaster(ctx, master);
|
|
389
|
+
} finally {
|
|
390
|
+
key.fill(0);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Unlock with the PRF output of a passkey ceremony (login or the unlock screen). */
|
|
395
|
+
export async function unlockWithPasskeyPrf(ctx: E2eeContext, credentialId: string, prf: Uint8Array): Promise<void> {
|
|
396
|
+
await ensureCrypto();
|
|
397
|
+
const method = passkeyMethod(credentialId);
|
|
398
|
+
const wrapped = await entry(ctx, method);
|
|
399
|
+
if (!wrapped) throw new E2eeError("no_passkey_entry");
|
|
400
|
+
const key = passkeyWrappingKey(prf);
|
|
401
|
+
try {
|
|
402
|
+
let master: MasterSecret;
|
|
403
|
+
try {
|
|
404
|
+
master = unwrapMasterSecret(wrapped, key, ctx.address, method);
|
|
405
|
+
} catch {
|
|
406
|
+
throw new E2eeError("passkey_wrong");
|
|
407
|
+
}
|
|
408
|
+
await adoptMaster(ctx, master);
|
|
409
|
+
} finally {
|
|
410
|
+
key.fill(0);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Credential ids that have a passkey entry in the secret storage. */
|
|
415
|
+
export async function passkeyEntryIds(api: Pick<TavrnApi, "secretStorage">): Promise<string[]> {
|
|
416
|
+
return (await api.secretStorage.list()).items.filter((i) => i.method.startsWith("passkey:")).map((i) => i.method.slice("passkey:".length));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Settings: a new recovery key re-wraps the same master secret (ENCRYPTION.md §2.4 flow 3). */
|
|
420
|
+
export async function regenerateRecoveryKey(ctx: E2eeContext, recovery: RecoveryKey): Promise<void> {
|
|
421
|
+
await ensureCrypto();
|
|
422
|
+
await putRecovery(ctx, recovery);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Identity reset (ENCRYPTION.md §9) after the user confirmed the new recovery key: new master secret
|
|
427
|
+
* and identity key, `recovery` entry, re-attestation of this device's existing key, KeyPackages.
|
|
428
|
+
*/
|
|
429
|
+
export async function resetIdentity(ctx: E2eeContext, password: string, recovery: RecoveryKey): Promise<void> {
|
|
430
|
+
await ensureCrypto();
|
|
431
|
+
const { store } = ctx;
|
|
432
|
+
const master = MasterSecret.generate();
|
|
433
|
+
const bytes = master.toBytes();
|
|
434
|
+
try {
|
|
435
|
+
await ctx.api.keys.resetIdentity({ identity_public_key: b64urlEncode(master.identityPublicKey()), password });
|
|
436
|
+
const keepClient = store.isUnlocked(ctx.address) && (await store.deviceIdOf(ctx.address)) === ctx.deviceId ? await store.getSecret(CLIENT_STATE) : null;
|
|
437
|
+
await store.saveMaster(ctx.address, ctx.deviceId, bytes);
|
|
438
|
+
if (keepClient) {
|
|
439
|
+
await store.putSecret(CLIENT_STATE, keepClient);
|
|
440
|
+
keepClient.fill(0);
|
|
441
|
+
}
|
|
442
|
+
} finally {
|
|
443
|
+
bytes.fill(0);
|
|
444
|
+
master.free();
|
|
445
|
+
}
|
|
446
|
+
await putRecovery(ctx, recovery);
|
|
447
|
+
await ensureDevice(ctx);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Logout: delete the account's local crypto data and lock. */
|
|
451
|
+
export async function wipeLocal(store: KeyStore, address: string): Promise<void> {
|
|
452
|
+
try {
|
|
453
|
+
await store.wipe(address);
|
|
454
|
+
} catch {
|
|
455
|
+
store.lock();
|
|
456
|
+
}
|
|
457
|
+
}
|