@lapis-notes/api 0.1.0 → 0.1.3
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/CHANGELOG.md +39 -0
- package/README.md +5 -0
- package/dist/app-database-host.d.ts +1 -0
- package/dist/app-database-host.js +1 -0
- package/dist/browser-sync-security.d.ts +45 -0
- package/dist/browser-sync-security.js +189 -0
- package/dist/build-tools/vite.d.ts +15 -0
- package/dist/build-tools/vite.js +36 -0
- package/dist/cache.svelte.d.ts +1 -1
- package/dist/cache.svelte.js +4 -1
- package/dist/components/editor/editor.css +54 -2
- package/dist/components/editor/extensions/lint/lapis-lint-tooltip.svelte +18 -5
- package/dist/components/editor/extensions/lint/lint-tooltip-actions.d.ts +0 -2
- package/dist/components/editor/extensions/lint/lint-tooltip-actions.js +0 -14
- package/dist/components/editor/language-service/index.js +23 -2
- package/dist/components/icon/icon-list.svelte.d.ts +1 -1
- package/dist/components/select/select.svelte.d.ts +1 -1
- package/dist/context.svelte.d.ts +11 -1
- package/dist/context.svelte.js +38 -1
- package/dist/docs-api.d.ts +2 -0
- package/dist/docs-api.js +1 -0
- package/dist/generated/plugin-host-modules.schema.generated.d.ts +443 -13
- package/dist/generated/plugin-host-modules.schema.generated.js +970 -306
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/language-service/manager.d.ts +5 -0
- package/dist/language-service/manager.js +58 -14
- package/dist/lapis-extension.d.ts +1 -1
- package/dist/lapis-extension.js +12 -0
- package/dist/nostr-signer.d.ts +68 -0
- package/dist/nostr-signer.js +49 -0
- package/dist/plugin-asset-server.d.ts +1 -0
- package/dist/plugin-asset-server.js +3 -0
- package/dist/plugin-capability-facade.d.ts +1 -1
- package/dist/plugin-capability-facade.js +6 -0
- package/dist/plugin-distribution/catalog-media.d.ts +4 -0
- package/dist/plugin-distribution/catalog-media.js +40 -0
- package/dist/plugin-distribution/download-stats.d.ts +4 -0
- package/dist/plugin-distribution/download-stats.js +19 -0
- package/dist/plugin-distribution/index.d.ts +4 -0
- package/dist/plugin-distribution/index.js +4 -0
- package/dist/plugin-distribution/installer.d.ts +3 -0
- package/dist/plugin-distribution/installer.js +26 -3
- package/dist/plugin-distribution/manager.d.ts +71 -5
- package/dist/plugin-distribution/manager.js +376 -37
- package/dist/plugin-distribution/nostr-registry-adapter.d.ts +29 -0
- package/dist/plugin-distribution/nostr-registry-adapter.js +152 -0
- package/dist/plugin-distribution/plugin-bundle-verifier.d.ts +43 -0
- package/dist/plugin-distribution/plugin-bundle-verifier.js +64 -1
- package/dist/plugin-distribution/plugin-bundle.d.ts +14 -0
- package/dist/plugin-distribution/plugin-bundle.js +46 -7
- package/dist/plugin-distribution/plugin-markdown.d.ts +6 -0
- package/dist/plugin-distribution/plugin-markdown.js +94 -0
- package/dist/plugin-distribution/registry-client.d.ts +6 -1
- package/dist/plugin-distribution/registry-client.js +32 -10
- package/dist/plugin-distribution/registry-source-state.d.ts +15 -0
- package/dist/plugin-distribution/registry-source-state.js +42 -0
- package/dist/plugin-distribution/schemas.d.ts +2511 -2824
- package/dist/plugin-distribution/schemas.js +111 -42
- package/dist/plugin-distribution/types.d.ts +116 -5
- package/dist/plugin-distribution-host.d.ts +4 -0
- package/dist/plugin-distribution-host.js +2 -0
- package/dist/plugin-install-progress.js +1 -1
- package/dist/plugin-manager.d.ts +3 -0
- package/dist/plugin-manager.js +21 -13
- package/dist/plugin-settings.d.ts +21 -0
- package/dist/plugin-settings.js +41 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.js +56 -0
- package/dist/registry-schema/catalog-v1.d.ts +4143 -0
- package/dist/registry-schema/catalog-v1.js +172 -0
- package/dist/registry-schema/catalog-v2.d.ts +5275 -0
- package/dist/registry-schema/catalog-v2.js +137 -0
- package/dist/registry-schema/index.d.ts +1 -0
- package/dist/registry-schema/index.js +1 -0
- package/dist/registry-schema/release-notes.d.ts +5 -0
- package/dist/registry-schema/release-notes.js +21 -0
- package/dist/server-host.d.ts +28 -0
- package/dist/server-host.js +17 -0
- package/dist/storage/app-database.d.ts +37 -2
- package/dist/storage/app-database.js +41 -1
- package/dist/storage/browser-coordinated-app-database.d.ts +3 -1
- package/dist/storage/browser-coordinated-app-database.js +8 -0
- package/dist/storage/browser.d.ts +4 -1
- package/dist/storage/browser.js +84 -0
- package/dist/storage/desktop-native.d.ts +4 -2
- package/dist/storage/desktop-native.js +90 -1
- package/dist/storage/fs.d.ts +17 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/memory-vault-adapter.d.ts +3 -1
- package/dist/storage/memory-vault-adapter.js +60 -0
- package/dist/storage/remote-app-database.d.ts +19 -0
- package/dist/storage/remote-app-database.js +130 -0
- package/dist/storage/task-projection.d.ts +2 -1
- package/dist/storage/task-projection.js +7 -2
- package/dist/storage/turso-app-database.d.ts +3 -1
- package/dist/storage/turso-app-database.js +45 -0
- package/dist/storage/vault-session.js +11 -0
- package/dist/storage/vault-state.d.ts +1 -1
- package/dist/storage/vault.svelte.d.ts +3 -1
- package/dist/storage/vault.svelte.js +76 -3
- package/dist/sync-host-security.d.ts +285 -0
- package/dist/sync-host-security.js +2237 -0
- package/dist/sync-host.d.ts +179 -0
- package/dist/sync-host.js +769 -0
- package/dist/workspace.svelte.d.ts +1 -1
- package/dist/workspace.svelte.js +1 -1
- package/package.json +48 -10
|
@@ -0,0 +1,2237 @@
|
|
|
1
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
2
|
+
import { argon2idAsync } from "@noble/hashes/argon2.js";
|
|
3
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
4
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
5
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
6
|
+
import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils.js";
|
|
7
|
+
import { finalizeEvent, getPublicKey, verifyEvent, } from "nostr-tools/pure";
|
|
8
|
+
import { v2 as nip44 } from "nostr-tools/nip44";
|
|
9
|
+
const SECURITY_PREFIX = "sync-host:v1:security:";
|
|
10
|
+
const KEY_LENGTH = 32;
|
|
11
|
+
const NONCE_LENGTH = 24;
|
|
12
|
+
const OBJECT_ENVELOPE_VERSION = 1;
|
|
13
|
+
const KEY_ENVELOPE_VERSION = 3;
|
|
14
|
+
const MAX_CRYPTO_INPUT_BYTES = 32 * 1024 * 1024;
|
|
15
|
+
const MAX_PAIRING_EVENT_BYTES = 64 * 1024;
|
|
16
|
+
const MAX_PAIRING_METADATA_BYTES = 16 * 1024;
|
|
17
|
+
const MAX_RECOVERY_METADATA_BYTES = 64 * 1024;
|
|
18
|
+
const PAIRING_KIND = 24_134;
|
|
19
|
+
const MEMBERSHIP_KIND = 30_079;
|
|
20
|
+
const PAIRING_TTL_MS = 120_000;
|
|
21
|
+
const MAX_REDIRECTS = 5;
|
|
22
|
+
function typeTag(type) {
|
|
23
|
+
return {
|
|
24
|
+
"file-content": 1,
|
|
25
|
+
"file-manifest": 2,
|
|
26
|
+
"tree-manifest": 3,
|
|
27
|
+
"revision-payload": 4,
|
|
28
|
+
"revision-archive": 5,
|
|
29
|
+
"head-payload": 6,
|
|
30
|
+
"membership-payload": 7,
|
|
31
|
+
}[type];
|
|
32
|
+
}
|
|
33
|
+
function requireCryptoSize(bytes) {
|
|
34
|
+
if (bytes.byteLength > MAX_CRYPTO_INPUT_BYTES) {
|
|
35
|
+
throw new Error(`Sync crypto input exceeds ${MAX_CRYPTO_INPUT_BYTES} bytes`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function requireWrappingKey(bytes) {
|
|
39
|
+
if (bytes.byteLength !== KEY_LENGTH) {
|
|
40
|
+
throw new Error(`Sync wrapping key must contain exactly ${KEY_LENGTH} bytes`);
|
|
41
|
+
}
|
|
42
|
+
return bytes.slice();
|
|
43
|
+
}
|
|
44
|
+
function objectAad(type) {
|
|
45
|
+
return new TextEncoder().encode(`lapis-sync-v1:${type}:${OBJECT_ENVELOPE_VERSION}`);
|
|
46
|
+
}
|
|
47
|
+
function deriveObjectKey(vaultKey, epoch, purpose) {
|
|
48
|
+
if (!Number.isSafeInteger(epoch) || epoch < 1) {
|
|
49
|
+
throw new Error("Sync crypto epoch must be a positive safe integer");
|
|
50
|
+
}
|
|
51
|
+
return hkdf(sha256, vaultKey, undefined, new TextEncoder().encode(`lapis-sync-v1:${purpose}:epoch:${epoch}`), KEY_LENGTH);
|
|
52
|
+
}
|
|
53
|
+
function envelopeKey(pluginId) {
|
|
54
|
+
return `${SECURITY_PREFIX}crypto:${pluginId}`;
|
|
55
|
+
}
|
|
56
|
+
function toBase64Url(bytes) {
|
|
57
|
+
let binary = "";
|
|
58
|
+
for (const byte of bytes)
|
|
59
|
+
binary += String.fromCharCode(byte);
|
|
60
|
+
return btoa(binary)
|
|
61
|
+
.replaceAll("+", "-")
|
|
62
|
+
.replaceAll("/", "_")
|
|
63
|
+
.replace(/=+$/u, "");
|
|
64
|
+
}
|
|
65
|
+
function fromBase64Url(value) {
|
|
66
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value)) {
|
|
67
|
+
throw new Error("Pairing data is not valid base64url");
|
|
68
|
+
}
|
|
69
|
+
const padded = value
|
|
70
|
+
.replaceAll("-", "+")
|
|
71
|
+
.replaceAll("_", "/")
|
|
72
|
+
.padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
73
|
+
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
|
74
|
+
}
|
|
75
|
+
function requireAlias(value) {
|
|
76
|
+
const normalized = value.trim().normalize("NFC");
|
|
77
|
+
if (!normalized ||
|
|
78
|
+
normalized.length > 80 ||
|
|
79
|
+
/[\u0000-\u001f\u007f]/u.test(normalized)) {
|
|
80
|
+
throw new Error("Device alias must contain between 1 and 80 printable characters");
|
|
81
|
+
}
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
function clonePairingMetadata(value) {
|
|
85
|
+
if (value === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
const bytes = new TextEncoder().encode(canonicalJson(value));
|
|
88
|
+
if (bytes.byteLength > MAX_PAIRING_METADATA_BYTES) {
|
|
89
|
+
bytes.fill(0);
|
|
90
|
+
throw new Error("Pairing metadata exceeds the host limit");
|
|
91
|
+
}
|
|
92
|
+
bytes.fill(0);
|
|
93
|
+
return structuredClone(value);
|
|
94
|
+
}
|
|
95
|
+
function requirePublicKey(value, label = "Device public key") {
|
|
96
|
+
if (!/^[0-9a-f]{64}$/u.test(value)) {
|
|
97
|
+
throw new Error(`${label} must be a 32-byte lowercase hex key`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function requireRelayUrls(values) {
|
|
102
|
+
if (values.length < 1 || values.length > 8) {
|
|
103
|
+
throw new Error("Pairing requires between one and eight relay URLs");
|
|
104
|
+
}
|
|
105
|
+
const result = [];
|
|
106
|
+
for (const value of values) {
|
|
107
|
+
const url = new URL(value);
|
|
108
|
+
if (!["wss:", "ws:"].includes(url.protocol)) {
|
|
109
|
+
throw new Error("Pairing relays must use wss or loopback ws");
|
|
110
|
+
}
|
|
111
|
+
if (url.protocol === "ws:" && !isLoopback(url.hostname)) {
|
|
112
|
+
throw new Error("Insecure pairing relays are limited to loopback development");
|
|
113
|
+
}
|
|
114
|
+
url.hash = "";
|
|
115
|
+
const normalized = url.href.replace(/\/$/u, "");
|
|
116
|
+
if (!result.includes(normalized))
|
|
117
|
+
result.push(normalized);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
function requireCutoffHeads(value) {
|
|
122
|
+
const entries = Object.entries(value);
|
|
123
|
+
if (entries.length > 256)
|
|
124
|
+
throw new Error("Sync cutoff HEADs exceed the host limit");
|
|
125
|
+
return Object.fromEntries(entries.map(([publicKey, eventId]) => {
|
|
126
|
+
requirePublicKey(publicKey);
|
|
127
|
+
if (typeof eventId !== "string" ||
|
|
128
|
+
eventId.length < 1 ||
|
|
129
|
+
eventId.length > 128) {
|
|
130
|
+
throw new Error("Sync cutoff HEAD IDs must be bounded strings");
|
|
131
|
+
}
|
|
132
|
+
return [publicKey, eventId];
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
function canonicalJson(value) {
|
|
136
|
+
if (value === null || typeof value !== "object")
|
|
137
|
+
return JSON.stringify(value);
|
|
138
|
+
if (Array.isArray(value))
|
|
139
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
140
|
+
return `{${Object.entries(value)
|
|
141
|
+
.filter(([, child]) => child !== undefined)
|
|
142
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
143
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
|
|
144
|
+
.join(",")}}`;
|
|
145
|
+
}
|
|
146
|
+
function cloneMembership(snapshot) {
|
|
147
|
+
return {
|
|
148
|
+
...snapshot,
|
|
149
|
+
devices: snapshot.devices.map((device) => ({ ...device })),
|
|
150
|
+
epochEnvelopes: snapshot.epochEnvelopes
|
|
151
|
+
? { ...snapshot.epochEnvelopes }
|
|
152
|
+
: undefined,
|
|
153
|
+
syncIdentity: snapshot.syncIdentity
|
|
154
|
+
? {
|
|
155
|
+
...snapshot.syncIdentity,
|
|
156
|
+
history: cloneSyncIdentityHistory(snapshot.syncIdentity.history),
|
|
157
|
+
}
|
|
158
|
+
: undefined,
|
|
159
|
+
syncIdentityEnvelopes: snapshot.syncIdentityEnvelopes
|
|
160
|
+
? { ...snapshot.syncIdentityEnvelopes }
|
|
161
|
+
: undefined,
|
|
162
|
+
legacyTransportCutoffHeads: snapshot.legacyTransportCutoffHeads
|
|
163
|
+
? { ...snapshot.legacyTransportCutoffHeads }
|
|
164
|
+
: undefined,
|
|
165
|
+
event: snapshot.event ? structuredClone(snapshot.event) : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function cloneSyncIdentityHistory(history) {
|
|
169
|
+
return (history ?? []).map((entry) => ({
|
|
170
|
+
...entry,
|
|
171
|
+
cutoffHeads: entry.cutoffHeads ? { ...entry.cutoffHeads } : undefined,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
function pairingSessionId(secret) {
|
|
175
|
+
return bytesToHex(sha256(new TextEncoder().encode(`lapis-pairing-v1:${secret}`)));
|
|
176
|
+
}
|
|
177
|
+
function pairingSas(sessionId, firstPublicKey, secondPublicKey) {
|
|
178
|
+
const ordered = [firstPublicKey, secondPublicKey].sort().join(":");
|
|
179
|
+
const digest = sha256(new TextEncoder().encode(`lapis-pairing-sas-v1:${sessionId}:${ordered}`));
|
|
180
|
+
const value = ((digest[0] << 16) | (digest[1] << 8) | digest[2]) % 1_000_000;
|
|
181
|
+
return value.toString().padStart(6, "0");
|
|
182
|
+
}
|
|
183
|
+
function networkGrantKey(pluginId) {
|
|
184
|
+
return `${SECURITY_PREFIX}network:${pluginId}`;
|
|
185
|
+
}
|
|
186
|
+
function normalizeOrigin(input) {
|
|
187
|
+
const url = new URL(input);
|
|
188
|
+
if (url.username || url.password) {
|
|
189
|
+
throw new Error("Network grants must not contain URL credentials");
|
|
190
|
+
}
|
|
191
|
+
if (!["https:", "wss:", "http:", "ws:"].includes(url.protocol)) {
|
|
192
|
+
throw new Error(`Unsupported network protocol ${url.protocol}`);
|
|
193
|
+
}
|
|
194
|
+
return url.origin;
|
|
195
|
+
}
|
|
196
|
+
function isLoopback(hostname) {
|
|
197
|
+
return (hostname === "localhost" ||
|
|
198
|
+
hostname === "127.0.0.1" ||
|
|
199
|
+
hostname === "[::1]" ||
|
|
200
|
+
hostname === "::1");
|
|
201
|
+
}
|
|
202
|
+
function isPrivateIpv4(hostname) {
|
|
203
|
+
const parts = hostname.split(".").map(Number);
|
|
204
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return (parts[0] === 10 ||
|
|
208
|
+
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
|
209
|
+
(parts[0] === 192 && parts[1] === 168) ||
|
|
210
|
+
parts[0] === 127);
|
|
211
|
+
}
|
|
212
|
+
function validateGrantedUrl(url, grant) {
|
|
213
|
+
if (url.origin !== grant.origin)
|
|
214
|
+
throw new Error(`Network origin is not granted: ${url.origin}`);
|
|
215
|
+
const insecure = url.protocol === "http:" || url.protocol === "ws:";
|
|
216
|
+
if (insecure && !isLoopback(url.hostname)) {
|
|
217
|
+
throw new Error("Insecure network protocols are limited to explicit loopback grants");
|
|
218
|
+
}
|
|
219
|
+
if ((isLoopback(url.hostname) || isPrivateIpv4(url.hostname)) &&
|
|
220
|
+
!grant.allowPrivateNetwork) {
|
|
221
|
+
throw new Error(`Private network access requires an explicit grant: ${url.origin}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function combineAbort(signal, timeoutMs) {
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const abort = () => controller.abort(signal?.reason);
|
|
227
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
228
|
+
const timeout = setTimeout(() => controller.abort(new DOMException("Network request timed out", "TimeoutError")), Math.max(1, Math.min(timeoutMs, 5 * 60_000)));
|
|
229
|
+
return {
|
|
230
|
+
signal: controller.signal,
|
|
231
|
+
abort(reason) {
|
|
232
|
+
controller.abort(reason);
|
|
233
|
+
},
|
|
234
|
+
dispose() {
|
|
235
|
+
clearTimeout(timeout);
|
|
236
|
+
signal?.removeEventListener("abort", abort);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function keepRequestAlive(source, dispose) {
|
|
241
|
+
const reader = source.getReader();
|
|
242
|
+
let finished = false;
|
|
243
|
+
const finish = () => {
|
|
244
|
+
if (finished)
|
|
245
|
+
return;
|
|
246
|
+
finished = true;
|
|
247
|
+
dispose();
|
|
248
|
+
};
|
|
249
|
+
return new ReadableStream({
|
|
250
|
+
async pull(controller) {
|
|
251
|
+
try {
|
|
252
|
+
const result = await reader.read();
|
|
253
|
+
if (result.done) {
|
|
254
|
+
controller.close();
|
|
255
|
+
finish();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
controller.enqueue(result.value);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
controller.error(error);
|
|
262
|
+
finish();
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
async cancel(reason) {
|
|
266
|
+
try {
|
|
267
|
+
await reader.cancel(reason);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
finish();
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
export class SyncHostSecurityService {
|
|
276
|
+
database;
|
|
277
|
+
assertRuntime;
|
|
278
|
+
#unlocked = new Map();
|
|
279
|
+
#pairingSessions = new Map();
|
|
280
|
+
#pendingEnrollments = new Map();
|
|
281
|
+
#sockets = new Map();
|
|
282
|
+
#requests = new Map();
|
|
283
|
+
#fetch;
|
|
284
|
+
#WebSocket;
|
|
285
|
+
#argon2;
|
|
286
|
+
constructor(database, assertRuntime, options = {}) {
|
|
287
|
+
this.database = database;
|
|
288
|
+
this.assertRuntime = assertRuntime;
|
|
289
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
290
|
+
this.#WebSocket = options.WebSocket ?? globalThis.WebSocket;
|
|
291
|
+
this.#argon2 = {
|
|
292
|
+
timeCost: options.argon2?.timeCost ?? 3,
|
|
293
|
+
memoryKiB: options.argon2?.memoryKiB ?? 64 * 1024,
|
|
294
|
+
parallelism: options.argon2?.parallelism ?? 1,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async enroll(pluginId, passphrase, request = { mode: "new-vault" }) {
|
|
298
|
+
if (passphrase.length < 10) {
|
|
299
|
+
throw new Error("Sync unlock passphrase must contain at least 10 characters");
|
|
300
|
+
}
|
|
301
|
+
if (await this.database.getMeta(envelopeKey(pluginId))) {
|
|
302
|
+
throw new Error(`Sync crypto is already enrolled for ${pluginId}`);
|
|
303
|
+
}
|
|
304
|
+
const salt = randomBytes(16);
|
|
305
|
+
const wrappingKey = await argon2idAsync(new TextEncoder().encode(passphrase), salt, {
|
|
306
|
+
t: this.#argon2.timeCost,
|
|
307
|
+
m: this.#argon2.memoryKiB,
|
|
308
|
+
p: this.#argon2.parallelism,
|
|
309
|
+
dkLen: KEY_LENGTH,
|
|
310
|
+
});
|
|
311
|
+
try {
|
|
312
|
+
return await this.#enrollWithKey(pluginId, wrappingKey, {
|
|
313
|
+
name: "argon2id",
|
|
314
|
+
timeCost: this.#argon2.timeCost,
|
|
315
|
+
memoryKiB: this.#argon2.memoryKiB,
|
|
316
|
+
parallelism: this.#argon2.parallelism,
|
|
317
|
+
dkLen: 32,
|
|
318
|
+
saltHex: bytesToHex(salt),
|
|
319
|
+
}, request);
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
wrappingKey.fill(0);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async enrollWithWrappingKey(pluginId, wrappingKey, descriptor, request = { mode: "new-vault" }) {
|
|
326
|
+
const key = requireWrappingKey(wrappingKey);
|
|
327
|
+
try {
|
|
328
|
+
return await this.#enrollWithKey(pluginId, key, descriptor, request);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
key.fill(0);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async unlock(pluginId, passphrase) {
|
|
335
|
+
const envelope = await this.database.getMeta(envelopeKey(pluginId));
|
|
336
|
+
if (!envelope)
|
|
337
|
+
throw new Error(`Sync crypto is not enrolled for ${pluginId}`);
|
|
338
|
+
if (![1, 2, 3].includes(envelope.version) ||
|
|
339
|
+
envelope.kdf.name !== "argon2id" ||
|
|
340
|
+
envelope.suite !== "lapis-sync-v1-xchacha20poly1305-schnorr") {
|
|
341
|
+
throw new Error("Unsupported sync crypto envelope");
|
|
342
|
+
}
|
|
343
|
+
const wrappingKey = await argon2idAsync(new TextEncoder().encode(passphrase), hexToBytes(envelope.kdf.saltHex), {
|
|
344
|
+
t: envelope.kdf.timeCost,
|
|
345
|
+
m: envelope.kdf.memoryKiB,
|
|
346
|
+
p: envelope.kdf.parallelism,
|
|
347
|
+
dkLen: envelope.kdf.dkLen,
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
return await this.#unlockWithKey(pluginId, envelope, wrappingKey);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
wrappingKey.fill(0);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async unlockWithWrappingKey(pluginId, wrappingKey) {
|
|
357
|
+
const envelope = await this.database.getMeta(envelopeKey(pluginId));
|
|
358
|
+
if (!envelope)
|
|
359
|
+
throw new Error(`Sync crypto is not enrolled for ${pluginId}`);
|
|
360
|
+
if (![1, 2, 3].includes(envelope.version) ||
|
|
361
|
+
envelope.kdf.name !== "external" ||
|
|
362
|
+
envelope.suite !== "lapis-sync-v1-xchacha20poly1305-schnorr") {
|
|
363
|
+
throw new Error("Sync crypto enrollment requires passphrase unlock");
|
|
364
|
+
}
|
|
365
|
+
const key = requireWrappingKey(wrappingKey);
|
|
366
|
+
try {
|
|
367
|
+
return await this.#unlockWithKey(pluginId, envelope, key);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
key.fill(0);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async #enrollWithKey(pluginId, wrappingKey, descriptor, request) {
|
|
374
|
+
if (await this.database.getMeta(envelopeKey(pluginId))) {
|
|
375
|
+
throw new Error(`Sync crypto is already enrolled for ${pluginId}`);
|
|
376
|
+
}
|
|
377
|
+
const pending = this.#pendingEnrollments.get(pluginId);
|
|
378
|
+
if (request.mode !== "new-vault") {
|
|
379
|
+
if (!pending || pending.request.mode !== request.mode) {
|
|
380
|
+
throw new Error(`No pending ${request.mode} enrollment exists for ${pluginId}`);
|
|
381
|
+
}
|
|
382
|
+
if (request.sessionId &&
|
|
383
|
+
pending.request.sessionId !== request.sessionId) {
|
|
384
|
+
throw new Error("Pending sync enrollment session does not match");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const keys = pending?.material
|
|
388
|
+
? this.#materialToUnlocked(pending.material, wrappingKey, descriptor)
|
|
389
|
+
: this.#createOwnerKeys(requireAlias(request.deviceAlias ?? "This device"), wrappingKey, descriptor);
|
|
390
|
+
const envelope = this.#sealEnvelope(pluginId, keys);
|
|
391
|
+
const stored = await this.database.transactMeta({
|
|
392
|
+
checks: [{ key: envelopeKey(pluginId), exists: false }],
|
|
393
|
+
mutations: [{ key: envelopeKey(pluginId), value: envelope }],
|
|
394
|
+
});
|
|
395
|
+
if (!stored.applied) {
|
|
396
|
+
this.#destroyKeys(keys);
|
|
397
|
+
throw new Error(`Sync crypto enrollment changed concurrently for ${pluginId}`);
|
|
398
|
+
}
|
|
399
|
+
this.#pendingEnrollments.delete(pluginId);
|
|
400
|
+
this.#unlocked.set(pluginId, keys);
|
|
401
|
+
return this.status(pluginId);
|
|
402
|
+
}
|
|
403
|
+
#createOwnerKeys(deviceAlias, wrappingKey, kdf) {
|
|
404
|
+
const deviceSecret = randomBytes(KEY_LENGTH);
|
|
405
|
+
const ownerSecret = randomBytes(KEY_LENGTH);
|
|
406
|
+
const recoverySecret = randomBytes(KEY_LENGTH);
|
|
407
|
+
const syncSecret = randomBytes(KEY_LENGTH);
|
|
408
|
+
const publicKey = getPublicKey(deviceSecret);
|
|
409
|
+
const syncPublicKey = getPublicKey(syncSecret);
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const membership = {
|
|
412
|
+
schemaVersion: 2,
|
|
413
|
+
vaultId: bytesToHex(randomBytes(16)),
|
|
414
|
+
ownerPublicKey: getPublicKey(ownerSecret),
|
|
415
|
+
generation: 1,
|
|
416
|
+
currentEpoch: 1,
|
|
417
|
+
devices: [
|
|
418
|
+
{
|
|
419
|
+
publicKey,
|
|
420
|
+
alias: deviceAlias,
|
|
421
|
+
role: "owner",
|
|
422
|
+
state: "active",
|
|
423
|
+
addedAt: now,
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
syncIdentity: {
|
|
427
|
+
publicKey: syncPublicKey,
|
|
428
|
+
generation: 1,
|
|
429
|
+
history: [
|
|
430
|
+
{
|
|
431
|
+
publicKey: syncPublicKey,
|
|
432
|
+
generation: 1,
|
|
433
|
+
activatedMembershipGeneration: 1,
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
const syncIdentity = membership.syncIdentity;
|
|
439
|
+
const keys = {
|
|
440
|
+
vaultId: membership.vaultId,
|
|
441
|
+
deviceAlias,
|
|
442
|
+
role: "owner",
|
|
443
|
+
currentEpoch: 1,
|
|
444
|
+
membershipGeneration: 1,
|
|
445
|
+
vaultKeys: new Map([[1, randomBytes(KEY_LENGTH)]]),
|
|
446
|
+
deviceSecret,
|
|
447
|
+
publicKey,
|
|
448
|
+
ownerSecret,
|
|
449
|
+
ownerPublicKey: membership.ownerPublicKey,
|
|
450
|
+
recoverySecret,
|
|
451
|
+
recoveryPublicKey: getPublicKey(recoverySecret),
|
|
452
|
+
syncSecret,
|
|
453
|
+
syncPublicKey,
|
|
454
|
+
syncIdentityGeneration: 1,
|
|
455
|
+
syncIdentityHistory: structuredClone(syncIdentity.history),
|
|
456
|
+
membership,
|
|
457
|
+
wrappingKey: wrappingKey.slice(),
|
|
458
|
+
kdf: structuredClone(kdf),
|
|
459
|
+
};
|
|
460
|
+
this.#sealSyncIdentity(keys, keys.membership);
|
|
461
|
+
keys.membership.event = this.#signMembership(keys, keys.membership);
|
|
462
|
+
return keys;
|
|
463
|
+
}
|
|
464
|
+
#materialToUnlocked(material, wrappingKey, kdf) {
|
|
465
|
+
if (material.schemaVersion !== 2 && material.schemaVersion !== 3) {
|
|
466
|
+
throw new Error("Unsupported sync key material");
|
|
467
|
+
}
|
|
468
|
+
const vaultKeys = new Map();
|
|
469
|
+
for (const [epochText, value] of Object.entries(material.vaultKeys)) {
|
|
470
|
+
const epoch = Number(epochText);
|
|
471
|
+
const key = hexToBytes(value);
|
|
472
|
+
if (!Number.isSafeInteger(epoch) ||
|
|
473
|
+
epoch < 1 ||
|
|
474
|
+
key.byteLength !== KEY_LENGTH) {
|
|
475
|
+
key.fill(0);
|
|
476
|
+
throw new Error("Sync key material contains an invalid epoch key");
|
|
477
|
+
}
|
|
478
|
+
vaultKeys.set(epoch, key);
|
|
479
|
+
}
|
|
480
|
+
if (!vaultKeys.has(material.currentEpoch)) {
|
|
481
|
+
throw new Error("Sync key material is missing its current epoch");
|
|
482
|
+
}
|
|
483
|
+
const deviceSecret = hexToBytes(material.deviceSecretHex);
|
|
484
|
+
const ownerSecret = material.ownerSecretHex
|
|
485
|
+
? hexToBytes(material.ownerSecretHex)
|
|
486
|
+
: undefined;
|
|
487
|
+
const recoverySecret = material.recoverySecretHex
|
|
488
|
+
? hexToBytes(material.recoverySecretHex)
|
|
489
|
+
: undefined;
|
|
490
|
+
const syncSecret = material.schemaVersion === 3 && material.syncSecretHex
|
|
491
|
+
? hexToBytes(material.syncSecretHex)
|
|
492
|
+
: undefined;
|
|
493
|
+
const pendingSyncSecret = material.schemaVersion === 3 && material.pendingSyncSecretHex
|
|
494
|
+
? hexToBytes(material.pendingSyncSecretHex)
|
|
495
|
+
: undefined;
|
|
496
|
+
if (deviceSecret.byteLength !== KEY_LENGTH ||
|
|
497
|
+
(ownerSecret && ownerSecret.byteLength !== KEY_LENGTH) ||
|
|
498
|
+
(recoverySecret && recoverySecret.byteLength !== KEY_LENGTH) ||
|
|
499
|
+
(syncSecret && syncSecret.byteLength !== KEY_LENGTH) ||
|
|
500
|
+
(pendingSyncSecret && pendingSyncSecret.byteLength !== KEY_LENGTH)) {
|
|
501
|
+
throw new Error("Sync key material contains an invalid secret length");
|
|
502
|
+
}
|
|
503
|
+
const publicKey = getPublicKey(deviceSecret);
|
|
504
|
+
if ((ownerSecret && getPublicKey(ownerSecret) !== material.ownerPublicKey) ||
|
|
505
|
+
(recoverySecret &&
|
|
506
|
+
getPublicKey(recoverySecret) !== material.recoveryPublicKey) ||
|
|
507
|
+
(syncSecret &&
|
|
508
|
+
getPublicKey(syncSecret) !==
|
|
509
|
+
(material.schemaVersion === 3
|
|
510
|
+
? material.syncPublicKey
|
|
511
|
+
: undefined)) ||
|
|
512
|
+
(pendingSyncSecret &&
|
|
513
|
+
getPublicKey(pendingSyncSecret) !==
|
|
514
|
+
(material.schemaVersion === 3
|
|
515
|
+
? material.pendingSyncPublicKey
|
|
516
|
+
: undefined))) {
|
|
517
|
+
throw new Error("Sync key material authority verification failed");
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
vaultId: material.vaultId,
|
|
521
|
+
deviceAlias: requireAlias(material.deviceAlias),
|
|
522
|
+
role: material.role,
|
|
523
|
+
currentEpoch: material.currentEpoch,
|
|
524
|
+
membershipGeneration: material.membershipGeneration,
|
|
525
|
+
vaultKeys,
|
|
526
|
+
deviceSecret,
|
|
527
|
+
publicKey,
|
|
528
|
+
ownerSecret,
|
|
529
|
+
ownerPublicKey: requirePublicKey(material.ownerPublicKey, "Owner public key"),
|
|
530
|
+
recoverySecret,
|
|
531
|
+
recoveryPublicKey: requirePublicKey(material.recoveryPublicKey, "Recovery public key"),
|
|
532
|
+
syncSecret,
|
|
533
|
+
syncPublicKey: material.schemaVersion === 3 && material.syncPublicKey
|
|
534
|
+
? requirePublicKey(material.syncPublicKey, "Sync public key")
|
|
535
|
+
: undefined,
|
|
536
|
+
syncIdentityGeneration: material.schemaVersion === 3 ? material.syncIdentityGeneration : 0,
|
|
537
|
+
syncIdentityHistory: material.schemaVersion === 3
|
|
538
|
+
? cloneSyncIdentityHistory(material.syncIdentityHistory)
|
|
539
|
+
: [],
|
|
540
|
+
pendingSyncSecret,
|
|
541
|
+
pendingSyncPublicKey: material.schemaVersion === 3 && material.pendingSyncPublicKey
|
|
542
|
+
? requirePublicKey(material.pendingSyncPublicKey, "Pending Sync public key")
|
|
543
|
+
: undefined,
|
|
544
|
+
membership: cloneMembership(material.membership),
|
|
545
|
+
wrappingKey: wrappingKey.slice(),
|
|
546
|
+
kdf: structuredClone(kdf),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
#keyMaterial(keys) {
|
|
550
|
+
return {
|
|
551
|
+
schemaVersion: 3,
|
|
552
|
+
vaultId: keys.vaultId,
|
|
553
|
+
deviceAlias: keys.deviceAlias,
|
|
554
|
+
role: keys.role,
|
|
555
|
+
currentEpoch: keys.currentEpoch,
|
|
556
|
+
membershipGeneration: keys.membershipGeneration,
|
|
557
|
+
vaultKeys: Object.fromEntries([...keys.vaultKeys.entries()]
|
|
558
|
+
.sort(([left], [right]) => left - right)
|
|
559
|
+
.map(([epoch, key]) => [String(epoch), bytesToHex(key)])),
|
|
560
|
+
deviceSecretHex: bytesToHex(keys.deviceSecret),
|
|
561
|
+
ownerSecretHex: keys.ownerSecret
|
|
562
|
+
? bytesToHex(keys.ownerSecret)
|
|
563
|
+
: undefined,
|
|
564
|
+
ownerPublicKey: keys.ownerPublicKey,
|
|
565
|
+
recoverySecretHex: keys.recoverySecret
|
|
566
|
+
? bytesToHex(keys.recoverySecret)
|
|
567
|
+
: undefined,
|
|
568
|
+
recoveryPublicKey: keys.recoveryPublicKey,
|
|
569
|
+
syncSecretHex: keys.syncSecret ? bytesToHex(keys.syncSecret) : undefined,
|
|
570
|
+
syncPublicKey: keys.syncPublicKey,
|
|
571
|
+
syncIdentityGeneration: keys.syncIdentityGeneration,
|
|
572
|
+
syncIdentityHistory: cloneSyncIdentityHistory(keys.syncIdentityHistory),
|
|
573
|
+
pendingSyncSecretHex: keys.pendingSyncSecret
|
|
574
|
+
? bytesToHex(keys.pendingSyncSecret)
|
|
575
|
+
: undefined,
|
|
576
|
+
pendingSyncPublicKey: keys.pendingSyncPublicKey,
|
|
577
|
+
membership: cloneMembership(keys.membership),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
#sealEnvelope(pluginId, keys) {
|
|
581
|
+
const nonce = randomBytes(NONCE_LENGTH);
|
|
582
|
+
const plaintext = new TextEncoder().encode(canonicalJson(this.#keyMaterial(keys)));
|
|
583
|
+
const ciphertext = xchacha20poly1305(keys.wrappingKey, nonce, new TextEncoder().encode(`lapis-sync-envelope:${pluginId}:v3`)).encrypt(plaintext);
|
|
584
|
+
plaintext.fill(0);
|
|
585
|
+
return {
|
|
586
|
+
version: KEY_ENVELOPE_VERSION,
|
|
587
|
+
suite: "lapis-sync-v1-xchacha20poly1305-schnorr",
|
|
588
|
+
kdf: structuredClone(keys.kdf),
|
|
589
|
+
nonceHex: bytesToHex(nonce),
|
|
590
|
+
ciphertextHex: bytesToHex(ciphertext),
|
|
591
|
+
publicKey: keys.publicKey,
|
|
592
|
+
vaultId: keys.vaultId,
|
|
593
|
+
deviceAlias: keys.deviceAlias,
|
|
594
|
+
role: keys.role,
|
|
595
|
+
deviceState: this.#deviceState(keys),
|
|
596
|
+
currentEpoch: keys.currentEpoch,
|
|
597
|
+
membershipGeneration: keys.membershipGeneration,
|
|
598
|
+
ownerPublicKey: keys.ownerPublicKey,
|
|
599
|
+
recoveryPublicKey: keys.recoveryPublicKey,
|
|
600
|
+
syncIdentityState: this.#syncIdentityState(keys),
|
|
601
|
+
syncPublicKey: keys.syncPublicKey,
|
|
602
|
+
syncIdentityGeneration: keys.syncIdentityGeneration,
|
|
603
|
+
pendingSyncPublicKey: keys.pendingSyncPublicKey,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
#decodeKeyMaterial(plaintext, wrappingKey, kdf) {
|
|
607
|
+
let parsed;
|
|
608
|
+
try {
|
|
609
|
+
parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
throw new Error("Sync crypto envelope is invalid");
|
|
613
|
+
}
|
|
614
|
+
return this.#materialToUnlocked(parsed, wrappingKey, kdf);
|
|
615
|
+
}
|
|
616
|
+
#migrateLegacyKeys(envelope, plaintext, wrappingKey) {
|
|
617
|
+
if (plaintext.byteLength !== KEY_LENGTH * 2) {
|
|
618
|
+
throw new Error("Sync crypto envelope is invalid");
|
|
619
|
+
}
|
|
620
|
+
const keys = this.#createOwnerKeys("This device", wrappingKey, envelope.kdf);
|
|
621
|
+
keys.vaultKeys.get(1)?.fill(0);
|
|
622
|
+
keys.vaultKeys.set(1, plaintext.slice(0, KEY_LENGTH));
|
|
623
|
+
keys.deviceSecret.fill(0);
|
|
624
|
+
keys.deviceSecret = plaintext.slice(KEY_LENGTH);
|
|
625
|
+
keys.publicKey = envelope.publicKey;
|
|
626
|
+
keys.membership.devices[0].publicKey = envelope.publicKey;
|
|
627
|
+
keys.membership.event = this.#signMembership(keys, keys.membership);
|
|
628
|
+
return keys;
|
|
629
|
+
}
|
|
630
|
+
#destroyKeys(keys) {
|
|
631
|
+
for (const key of keys.vaultKeys.values())
|
|
632
|
+
key.fill(0);
|
|
633
|
+
keys.deviceSecret.fill(0);
|
|
634
|
+
keys.ownerSecret?.fill(0);
|
|
635
|
+
keys.recoverySecret?.fill(0);
|
|
636
|
+
keys.syncSecret?.fill(0);
|
|
637
|
+
keys.pendingSyncSecret?.fill(0);
|
|
638
|
+
keys.wrappingKey.fill(0);
|
|
639
|
+
}
|
|
640
|
+
#vaultKey(keys, epoch) {
|
|
641
|
+
const key = keys.vaultKeys.get(epoch);
|
|
642
|
+
if (!key)
|
|
643
|
+
throw new Error(`Sync crypto epoch ${epoch} is unavailable`);
|
|
644
|
+
return key;
|
|
645
|
+
}
|
|
646
|
+
async #persistKeys(pluginId, keys) {
|
|
647
|
+
await this.database.setMeta(envelopeKey(pluginId), this.#sealEnvelope(pluginId, keys));
|
|
648
|
+
}
|
|
649
|
+
#signMembership(keys, snapshot, encryptionEpoch = snapshot.currentEpoch) {
|
|
650
|
+
if (!keys.ownerSecret)
|
|
651
|
+
throw new Error("Only the owner may sign membership");
|
|
652
|
+
const publicSnapshot = { ...cloneMembership(snapshot), event: undefined };
|
|
653
|
+
const plaintext = new TextEncoder().encode(canonicalJson(publicSnapshot));
|
|
654
|
+
const nonce = randomBytes(NONCE_LENGTH);
|
|
655
|
+
const ciphertext = xchacha20poly1305(deriveObjectKey(this.#vaultKey(keys, encryptionEpoch), encryptionEpoch, "membership-payload"), nonce, objectAad("membership-payload")).encrypt(plaintext);
|
|
656
|
+
plaintext.fill(0);
|
|
657
|
+
const envelope = new Uint8Array(5 + NONCE_LENGTH + ciphertext.byteLength);
|
|
658
|
+
envelope[0] = OBJECT_ENVELOPE_VERSION;
|
|
659
|
+
new DataView(envelope.buffer).setUint32(1, encryptionEpoch, false);
|
|
660
|
+
envelope.set(nonce, 5);
|
|
661
|
+
envelope.set(ciphertext, 5 + NONCE_LENGTH);
|
|
662
|
+
return finalizeEvent({
|
|
663
|
+
kind: MEMBERSHIP_KIND,
|
|
664
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
665
|
+
tags: [["d", `lapis-sync-v1:${keys.vaultId}:membership`]],
|
|
666
|
+
content: toBase64Url(envelope),
|
|
667
|
+
}, keys.ownerSecret);
|
|
668
|
+
}
|
|
669
|
+
async #unlockWithKey(pluginId, envelope, wrappingKey) {
|
|
670
|
+
let plaintext;
|
|
671
|
+
try {
|
|
672
|
+
plaintext = xchacha20poly1305(wrappingKey, hexToBytes(envelope.nonceHex), new TextEncoder().encode(`lapis-sync-envelope:${pluginId}:v${envelope.version}`)).decrypt(hexToBytes(envelope.ciphertextHex));
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
throw new Error("Sync unlock failed");
|
|
676
|
+
}
|
|
677
|
+
const keys = envelope.version === 1
|
|
678
|
+
? this.#migrateLegacyKeys(envelope, plaintext, wrappingKey)
|
|
679
|
+
: this.#decodeKeyMaterial(plaintext, wrappingKey, envelope.kdf);
|
|
680
|
+
plaintext.fill(0);
|
|
681
|
+
if (getPublicKey(keys.deviceSecret) !== envelope.publicKey) {
|
|
682
|
+
this.#destroyKeys(keys);
|
|
683
|
+
throw new Error("Sync crypto public key verification failed");
|
|
684
|
+
}
|
|
685
|
+
this.lock(pluginId);
|
|
686
|
+
this.#unlocked.set(pluginId, keys);
|
|
687
|
+
if (envelope.version < 3) {
|
|
688
|
+
await this.database.setMeta(envelopeKey(pluginId), this.#sealEnvelope(pluginId, keys));
|
|
689
|
+
}
|
|
690
|
+
return this.status(pluginId);
|
|
691
|
+
}
|
|
692
|
+
lock(pluginId) {
|
|
693
|
+
const keys = this.#unlocked.get(pluginId);
|
|
694
|
+
if (keys)
|
|
695
|
+
this.#destroyKeys(keys);
|
|
696
|
+
this.#unlocked.delete(pluginId);
|
|
697
|
+
}
|
|
698
|
+
async status(pluginId) {
|
|
699
|
+
const unlocked = this.#unlocked.get(pluginId);
|
|
700
|
+
if (unlocked) {
|
|
701
|
+
return {
|
|
702
|
+
state: "unlocked",
|
|
703
|
+
suite: "lapis-sync-v1-xchacha20poly1305-schnorr",
|
|
704
|
+
publicKey: unlocked.publicKey,
|
|
705
|
+
devicePublicKey: unlocked.publicKey,
|
|
706
|
+
vaultId: unlocked.vaultId,
|
|
707
|
+
deviceAlias: unlocked.deviceAlias,
|
|
708
|
+
role: unlocked.role,
|
|
709
|
+
deviceState: this.#deviceState(unlocked),
|
|
710
|
+
currentEpoch: unlocked.currentEpoch,
|
|
711
|
+
membershipGeneration: unlocked.membershipGeneration,
|
|
712
|
+
canManageDevices: unlocked.role === "owner",
|
|
713
|
+
syncIdentity: this.#syncIdentityStatus(unlocked),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const envelope = await this.database.getMeta(envelopeKey(pluginId));
|
|
717
|
+
return envelope
|
|
718
|
+
? {
|
|
719
|
+
state: "locked",
|
|
720
|
+
suite: envelope.suite,
|
|
721
|
+
publicKey: envelope.publicKey,
|
|
722
|
+
devicePublicKey: envelope.publicKey,
|
|
723
|
+
...(envelope.version === 2 || envelope.version === 3
|
|
724
|
+
? {
|
|
725
|
+
vaultId: envelope.vaultId,
|
|
726
|
+
deviceAlias: envelope.deviceAlias,
|
|
727
|
+
role: envelope.role,
|
|
728
|
+
deviceState: envelope.deviceState,
|
|
729
|
+
currentEpoch: envelope.currentEpoch,
|
|
730
|
+
membershipGeneration: envelope.membershipGeneration,
|
|
731
|
+
canManageDevices: envelope.role === "owner",
|
|
732
|
+
...(envelope.version === 3
|
|
733
|
+
? {
|
|
734
|
+
syncIdentity: {
|
|
735
|
+
state: envelope.syncIdentityState,
|
|
736
|
+
publicKey: envelope.syncPublicKey,
|
|
737
|
+
generation: envelope.syncIdentityGeneration,
|
|
738
|
+
history: [],
|
|
739
|
+
pendingPublicKey: envelope.pendingSyncPublicKey,
|
|
740
|
+
canRotate: envelope.role === "owner",
|
|
741
|
+
},
|
|
742
|
+
}
|
|
743
|
+
: {
|
|
744
|
+
syncIdentity: {
|
|
745
|
+
state: "setup-required",
|
|
746
|
+
generation: 0,
|
|
747
|
+
history: [],
|
|
748
|
+
canRotate: envelope.role === "owner",
|
|
749
|
+
},
|
|
750
|
+
}),
|
|
751
|
+
}
|
|
752
|
+
: {}),
|
|
753
|
+
}
|
|
754
|
+
: {
|
|
755
|
+
state: "unenrolled",
|
|
756
|
+
suite: "lapis-sync-v1-xchacha20poly1305-schnorr",
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
crypto(pluginId, runtimeId, assertGranted = () => undefined, requestUnlock, requestEnrollment) {
|
|
760
|
+
const keys = () => {
|
|
761
|
+
assertGranted();
|
|
762
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
763
|
+
const value = this.#unlocked.get(pluginId);
|
|
764
|
+
if (!value)
|
|
765
|
+
throw new Error(`Sync crypto is locked for ${pluginId}`);
|
|
766
|
+
return value;
|
|
767
|
+
};
|
|
768
|
+
return {
|
|
769
|
+
status: async () => {
|
|
770
|
+
assertGranted();
|
|
771
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
772
|
+
return this.status(pluginId);
|
|
773
|
+
},
|
|
774
|
+
enrollNewVault: async ({ deviceAlias }) => {
|
|
775
|
+
assertGranted();
|
|
776
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
777
|
+
const status = await this.status(pluginId);
|
|
778
|
+
if (status.state !== "unenrolled") {
|
|
779
|
+
throw new Error(`Sync crypto is already enrolled for ${pluginId}`);
|
|
780
|
+
}
|
|
781
|
+
if (!requestEnrollment) {
|
|
782
|
+
throw new Error(`The host has no sync enrollment flow for ${pluginId}`);
|
|
783
|
+
}
|
|
784
|
+
return requestEnrollment({
|
|
785
|
+
mode: "new-vault",
|
|
786
|
+
deviceAlias: requireAlias(deviceAlias),
|
|
787
|
+
});
|
|
788
|
+
},
|
|
789
|
+
requestUnlock: async () => {
|
|
790
|
+
assertGranted();
|
|
791
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
792
|
+
const status = await this.status(pluginId);
|
|
793
|
+
if (status.state === "unenrolled") {
|
|
794
|
+
throw new Error(`Sync enrollment is required for ${pluginId}; unlock cannot create a vault`);
|
|
795
|
+
}
|
|
796
|
+
if (status.state === "unlocked")
|
|
797
|
+
return status;
|
|
798
|
+
if (!requestUnlock) {
|
|
799
|
+
throw new Error(`The host has no sync unlock flow for ${pluginId}`);
|
|
800
|
+
}
|
|
801
|
+
return requestUnlock();
|
|
802
|
+
},
|
|
803
|
+
getDevicePublicKey: async () => keys().publicKey,
|
|
804
|
+
encryptObject: async ({ epoch, type, plaintext }) => {
|
|
805
|
+
this.#assertDeviceActive(keys());
|
|
806
|
+
if (epoch !== keys().currentEpoch) {
|
|
807
|
+
throw new Error("New sync objects must use the current key epoch");
|
|
808
|
+
}
|
|
809
|
+
requireCryptoSize(plaintext);
|
|
810
|
+
const nonce = randomBytes(NONCE_LENGTH);
|
|
811
|
+
const ciphertext = xchacha20poly1305(deriveObjectKey(this.#vaultKey(keys(), epoch), epoch, type), nonce, objectAad(type)).encrypt(plaintext);
|
|
812
|
+
const bytes = new Uint8Array(2 + NONCE_LENGTH + ciphertext.byteLength);
|
|
813
|
+
bytes[0] = OBJECT_ENVELOPE_VERSION;
|
|
814
|
+
bytes[1] = typeTag(type);
|
|
815
|
+
bytes.set(nonce, 2);
|
|
816
|
+
bytes.set(ciphertext, 2 + NONCE_LENGTH);
|
|
817
|
+
return { bytes, objectHash: bytesToHex(sha256(bytes)) };
|
|
818
|
+
},
|
|
819
|
+
decryptObject: async ({ epoch, type, envelope }) => {
|
|
820
|
+
requireCryptoSize(envelope);
|
|
821
|
+
if (envelope.byteLength < 2 + NONCE_LENGTH + 16 ||
|
|
822
|
+
envelope[0] !== OBJECT_ENVELOPE_VERSION ||
|
|
823
|
+
envelope[1] !== typeTag(type)) {
|
|
824
|
+
throw new Error("Encrypted sync object envelope is invalid");
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
return xchacha20poly1305(deriveObjectKey(this.#vaultKey(keys(), epoch), epoch, type), envelope.slice(2, 2 + NONCE_LENGTH), objectAad(type)).decrypt(envelope.slice(2 + NONCE_LENGTH));
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
throw new Error("Encrypted sync object authentication failed");
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
calculateContentId: async ({ epoch, plaintext }) => {
|
|
834
|
+
requireCryptoSize(plaintext);
|
|
835
|
+
return bytesToHex(hmac(sha256, deriveObjectKey(this.#vaultKey(keys(), epoch), epoch, "content-id"), plaintext));
|
|
836
|
+
},
|
|
837
|
+
signEvent: async (event) => {
|
|
838
|
+
this.#assertDeviceActive(keys());
|
|
839
|
+
if (!Number.isSafeInteger(event.kind) ||
|
|
840
|
+
!Number.isSafeInteger(event.created_at)) {
|
|
841
|
+
throw new Error("Sync event kind and timestamp must be safe integers");
|
|
842
|
+
}
|
|
843
|
+
if (event.content.length > 2 * 1024 * 1024 || event.tags.length > 256) {
|
|
844
|
+
throw new Error("Sync event exceeds the signing policy");
|
|
845
|
+
}
|
|
846
|
+
return finalizeEvent(event, keys().deviceSecret);
|
|
847
|
+
},
|
|
848
|
+
signDeviceEvent: async (event) => {
|
|
849
|
+
const value = keys();
|
|
850
|
+
this.#assertDeviceActive(value);
|
|
851
|
+
if (![78, 30_078].includes(event.kind)) {
|
|
852
|
+
throw new Error("Device attestations are limited to revision and HEAD kinds");
|
|
853
|
+
}
|
|
854
|
+
this.#validateUnsignedEvent(event);
|
|
855
|
+
return finalizeEvent(event, value.deviceSecret);
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
syncIdentity(pluginId, runtimeId, assertGranted = () => undefined) {
|
|
860
|
+
const keys = () => {
|
|
861
|
+
assertGranted();
|
|
862
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
863
|
+
return this.#requireUnlocked(pluginId);
|
|
864
|
+
};
|
|
865
|
+
return {
|
|
866
|
+
status: async () => this.#syncIdentityStatus(keys()),
|
|
867
|
+
initialize: async ({ cutoffHeads }) => {
|
|
868
|
+
const value = keys();
|
|
869
|
+
this.#assertOwner(value);
|
|
870
|
+
this.#assertDeviceActive(value);
|
|
871
|
+
if (value.syncSecret || value.syncPublicKey) {
|
|
872
|
+
throw new Error("The vault Sync Identity is already initialized");
|
|
873
|
+
}
|
|
874
|
+
const previousMembership = cloneMembership(value.membership);
|
|
875
|
+
const previousMembershipGeneration = value.membershipGeneration;
|
|
876
|
+
const secret = randomBytes(KEY_LENGTH);
|
|
877
|
+
const publicKey = getPublicKey(secret);
|
|
878
|
+
value.syncSecret = secret;
|
|
879
|
+
value.syncPublicKey = publicKey;
|
|
880
|
+
value.syncIdentityGeneration = 1;
|
|
881
|
+
value.syncIdentityHistory = [
|
|
882
|
+
{
|
|
883
|
+
publicKey,
|
|
884
|
+
generation: 1,
|
|
885
|
+
activatedMembershipGeneration: value.membershipGeneration + 1,
|
|
886
|
+
},
|
|
887
|
+
];
|
|
888
|
+
const snapshot = cloneMembership(value.membership);
|
|
889
|
+
snapshot.schemaVersion = 2;
|
|
890
|
+
snapshot.legacyTransportCutoffHeads = requireCutoffHeads(cutoffHeads);
|
|
891
|
+
snapshot.syncIdentity = {
|
|
892
|
+
publicKey,
|
|
893
|
+
generation: 1,
|
|
894
|
+
history: cloneSyncIdentityHistory(value.syncIdentityHistory),
|
|
895
|
+
};
|
|
896
|
+
this.#sealSyncIdentity(value, snapshot);
|
|
897
|
+
try {
|
|
898
|
+
await this.#advanceMembership(pluginId, value, snapshot);
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
secret.fill(0);
|
|
902
|
+
value.syncSecret = undefined;
|
|
903
|
+
value.syncPublicKey = undefined;
|
|
904
|
+
value.syncIdentityGeneration = 0;
|
|
905
|
+
value.syncIdentityHistory = [];
|
|
906
|
+
value.membership = previousMembership;
|
|
907
|
+
value.membershipGeneration = previousMembershipGeneration;
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
status: this.#syncIdentityStatus(value),
|
|
912
|
+
membershipEvent: structuredClone(value.membership.event),
|
|
913
|
+
};
|
|
914
|
+
},
|
|
915
|
+
signEvent: async ({ purpose, identity = "active", event }) => {
|
|
916
|
+
const value = keys();
|
|
917
|
+
this.#assertDeviceActive(value);
|
|
918
|
+
this.#validateSyncIdentityEvent(purpose, event);
|
|
919
|
+
const secret = identity === "pending" ? value.pendingSyncSecret : value.syncSecret;
|
|
920
|
+
const expectedPublicKey = identity === "pending"
|
|
921
|
+
? value.pendingSyncPublicKey
|
|
922
|
+
: value.syncPublicKey;
|
|
923
|
+
if (!secret || !expectedPublicKey) {
|
|
924
|
+
throw new Error(`The ${identity} vault Sync Identity is unavailable`);
|
|
925
|
+
}
|
|
926
|
+
const signed = finalizeEvent(event, secret);
|
|
927
|
+
if (signed.pubkey !== expectedPublicKey) {
|
|
928
|
+
throw new Error("Sync Identity public key verification failed");
|
|
929
|
+
}
|
|
930
|
+
return signed;
|
|
931
|
+
},
|
|
932
|
+
beginRotation: async () => {
|
|
933
|
+
const value = keys();
|
|
934
|
+
this.#assertOwner(value);
|
|
935
|
+
this.#assertDeviceActive(value);
|
|
936
|
+
if (!value.syncSecret || !value.syncPublicKey) {
|
|
937
|
+
throw new Error("Initialize the vault Sync Identity before rotating it");
|
|
938
|
+
}
|
|
939
|
+
if (!value.pendingSyncSecret) {
|
|
940
|
+
const pendingSecret = randomBytes(KEY_LENGTH);
|
|
941
|
+
value.pendingSyncSecret = pendingSecret;
|
|
942
|
+
value.pendingSyncPublicKey = getPublicKey(pendingSecret);
|
|
943
|
+
try {
|
|
944
|
+
await this.#persistKeys(pluginId, value);
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
pendingSecret.fill(0);
|
|
948
|
+
value.pendingSyncSecret = undefined;
|
|
949
|
+
value.pendingSyncPublicKey = undefined;
|
|
950
|
+
throw error;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return this.#syncIdentityStatus(value);
|
|
954
|
+
},
|
|
955
|
+
commitRotation: async ({ pendingPublicKey, cutoffHeads }) => {
|
|
956
|
+
const value = keys();
|
|
957
|
+
this.#assertOwner(value);
|
|
958
|
+
this.#assertDeviceActive(value);
|
|
959
|
+
if (!value.syncSecret ||
|
|
960
|
+
!value.syncPublicKey ||
|
|
961
|
+
!value.pendingSyncSecret ||
|
|
962
|
+
value.pendingSyncPublicKey !==
|
|
963
|
+
requirePublicKey(pendingPublicKey, "Pending Sync public key")) {
|
|
964
|
+
throw new Error("The pending Sync Identity does not match");
|
|
965
|
+
}
|
|
966
|
+
const nextMembershipGeneration = value.membershipGeneration + 1;
|
|
967
|
+
const history = cloneSyncIdentityHistory(value.syncIdentityHistory);
|
|
968
|
+
const active = history.find((entry) => entry.generation === value.syncIdentityGeneration);
|
|
969
|
+
if (!active || active.publicKey !== value.syncPublicKey) {
|
|
970
|
+
throw new Error("Sync Identity history is inconsistent");
|
|
971
|
+
}
|
|
972
|
+
active.retiredMembershipGeneration = nextMembershipGeneration;
|
|
973
|
+
active.cutoffHeads = requireCutoffHeads(cutoffHeads);
|
|
974
|
+
const nextGeneration = value.syncIdentityGeneration + 1;
|
|
975
|
+
history.push({
|
|
976
|
+
publicKey: value.pendingSyncPublicKey,
|
|
977
|
+
generation: nextGeneration,
|
|
978
|
+
activatedMembershipGeneration: nextMembershipGeneration,
|
|
979
|
+
});
|
|
980
|
+
const previousSecret = value.syncSecret;
|
|
981
|
+
const previousPublicKey = value.syncPublicKey;
|
|
982
|
+
const previousGeneration = value.syncIdentityGeneration;
|
|
983
|
+
const previousHistory = cloneSyncIdentityHistory(value.syncIdentityHistory);
|
|
984
|
+
const previousMembership = cloneMembership(value.membership);
|
|
985
|
+
const previousMembershipGeneration = value.membershipGeneration;
|
|
986
|
+
const pendingSecret = value.pendingSyncSecret;
|
|
987
|
+
const pendingKey = value.pendingSyncPublicKey;
|
|
988
|
+
value.syncSecret = pendingSecret;
|
|
989
|
+
value.syncPublicKey = pendingKey;
|
|
990
|
+
value.syncIdentityGeneration = nextGeneration;
|
|
991
|
+
value.syncIdentityHistory = history;
|
|
992
|
+
value.pendingSyncSecret = undefined;
|
|
993
|
+
value.pendingSyncPublicKey = undefined;
|
|
994
|
+
const snapshot = cloneMembership(value.membership);
|
|
995
|
+
snapshot.schemaVersion = 2;
|
|
996
|
+
snapshot.syncIdentity = {
|
|
997
|
+
publicKey: value.syncPublicKey,
|
|
998
|
+
generation: nextGeneration,
|
|
999
|
+
history: cloneSyncIdentityHistory(history),
|
|
1000
|
+
};
|
|
1001
|
+
this.#sealSyncIdentity(value, snapshot);
|
|
1002
|
+
try {
|
|
1003
|
+
await this.#advanceMembership(pluginId, value, snapshot);
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
value.syncSecret = previousSecret;
|
|
1007
|
+
value.syncPublicKey = previousPublicKey;
|
|
1008
|
+
value.syncIdentityGeneration = previousGeneration;
|
|
1009
|
+
value.syncIdentityHistory = previousHistory;
|
|
1010
|
+
value.pendingSyncSecret = pendingSecret;
|
|
1011
|
+
value.pendingSyncPublicKey = pendingKey;
|
|
1012
|
+
value.membership = previousMembership;
|
|
1013
|
+
value.membershipGeneration = previousMembershipGeneration;
|
|
1014
|
+
throw error;
|
|
1015
|
+
}
|
|
1016
|
+
previousSecret.fill(0);
|
|
1017
|
+
return {
|
|
1018
|
+
status: this.#syncIdentityStatus(value),
|
|
1019
|
+
membershipEvent: structuredClone(value.membership.event),
|
|
1020
|
+
};
|
|
1021
|
+
},
|
|
1022
|
+
cancelRotation: async () => {
|
|
1023
|
+
const value = keys();
|
|
1024
|
+
this.#assertOwner(value);
|
|
1025
|
+
const pendingSecret = value.pendingSyncSecret;
|
|
1026
|
+
const pendingPublicKey = value.pendingSyncPublicKey;
|
|
1027
|
+
value.pendingSyncSecret = undefined;
|
|
1028
|
+
value.pendingSyncPublicKey = undefined;
|
|
1029
|
+
try {
|
|
1030
|
+
await this.#persistKeys(pluginId, value);
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
value.pendingSyncSecret = pendingSecret;
|
|
1034
|
+
value.pendingSyncPublicKey = pendingPublicKey;
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
pendingSecret?.fill(0);
|
|
1038
|
+
return this.#syncIdentityStatus(value);
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
pairing(pluginId, runtimeId, assertGranted = () => undefined, requestEnrollment) {
|
|
1043
|
+
const assertActive = () => {
|
|
1044
|
+
assertGranted();
|
|
1045
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
1046
|
+
};
|
|
1047
|
+
const getSession = (sessionId) => {
|
|
1048
|
+
assertActive();
|
|
1049
|
+
const session = this.#pairingSessions.get(`${pluginId}:${sessionId}`);
|
|
1050
|
+
if (!session)
|
|
1051
|
+
throw new Error("Pairing session is unavailable");
|
|
1052
|
+
if (Date.now() >= session.expiresAt && session.state !== "complete") {
|
|
1053
|
+
this.#expirePairingSession(session);
|
|
1054
|
+
throw new Error("Pairing session expired");
|
|
1055
|
+
}
|
|
1056
|
+
return session;
|
|
1057
|
+
};
|
|
1058
|
+
const result = (session, outboundEvents = [], extras = {}) => ({
|
|
1059
|
+
session: this.#pairingSnapshot(session),
|
|
1060
|
+
outboundEvents,
|
|
1061
|
+
relayUrls: [...session.relays],
|
|
1062
|
+
...extras,
|
|
1063
|
+
});
|
|
1064
|
+
return {
|
|
1065
|
+
beginOwner: async ({ relayUrls, cutoffHeads = {}, metadata }) => {
|
|
1066
|
+
assertActive();
|
|
1067
|
+
const keys = this.#requireUnlocked(pluginId);
|
|
1068
|
+
if (keys.role !== "owner" || !keys.ownerSecret) {
|
|
1069
|
+
throw new Error("Only the owner device may begin pairing");
|
|
1070
|
+
}
|
|
1071
|
+
const ephemeralSecret = randomBytes(KEY_LENGTH);
|
|
1072
|
+
const sessionSecret = toBase64Url(randomBytes(KEY_LENGTH));
|
|
1073
|
+
const sessionId = pairingSessionId(sessionSecret);
|
|
1074
|
+
const expiresAt = Date.now() + PAIRING_TTL_MS;
|
|
1075
|
+
const relays = requireRelayUrls(relayUrls);
|
|
1076
|
+
const session = {
|
|
1077
|
+
pluginId,
|
|
1078
|
+
sessionId,
|
|
1079
|
+
role: "owner",
|
|
1080
|
+
state: "waiting-for-peer",
|
|
1081
|
+
expiresAt,
|
|
1082
|
+
ephemeralSecret,
|
|
1083
|
+
ephemeralPublicKey: getPublicKey(ephemeralSecret),
|
|
1084
|
+
sessionSecret,
|
|
1085
|
+
relays,
|
|
1086
|
+
sequence: 0,
|
|
1087
|
+
lastReceivedSequence: 0,
|
|
1088
|
+
localConfirmed: false,
|
|
1089
|
+
peerConfirmed: false,
|
|
1090
|
+
cutoffHeads: { ...cutoffHeads },
|
|
1091
|
+
metadata: clonePairingMetadata(metadata),
|
|
1092
|
+
seenEventIds: new Set(),
|
|
1093
|
+
};
|
|
1094
|
+
this.#pairingSessions.set(`${pluginId}:${sessionId}`, session);
|
|
1095
|
+
const invitation = {
|
|
1096
|
+
v: 1,
|
|
1097
|
+
p: session.ephemeralPublicKey,
|
|
1098
|
+
s: sessionSecret,
|
|
1099
|
+
e: expiresAt,
|
|
1100
|
+
r: relays,
|
|
1101
|
+
};
|
|
1102
|
+
const data = toBase64Url(new TextEncoder().encode(canonicalJson(invitation)));
|
|
1103
|
+
const invitationUri = `lapis://sync-pair?v=1&data=${data}`;
|
|
1104
|
+
if (invitationUri.length > 2_048) {
|
|
1105
|
+
this.#expirePairingSession(session);
|
|
1106
|
+
throw new Error("Pairing invitation exceeds the app URL limit");
|
|
1107
|
+
}
|
|
1108
|
+
return result(session, [], { invitationUri });
|
|
1109
|
+
},
|
|
1110
|
+
acceptInvite: async ({ invitationUri, deviceAlias }) => {
|
|
1111
|
+
assertActive();
|
|
1112
|
+
if ((await this.status(pluginId)).state !== "unenrolled") {
|
|
1113
|
+
throw new Error("Pairing import requires an unenrolled sync identity");
|
|
1114
|
+
}
|
|
1115
|
+
const invitation = this.#parsePairingInvitation(invitationUri);
|
|
1116
|
+
if (Date.now() >= invitation.e)
|
|
1117
|
+
throw new Error("Pairing invitation expired");
|
|
1118
|
+
const ephemeralSecret = randomBytes(KEY_LENGTH);
|
|
1119
|
+
const pendingDeviceSecret = randomBytes(KEY_LENGTH);
|
|
1120
|
+
const sessionId = pairingSessionId(invitation.s);
|
|
1121
|
+
const session = {
|
|
1122
|
+
pluginId,
|
|
1123
|
+
sessionId,
|
|
1124
|
+
role: "joining",
|
|
1125
|
+
state: "waiting-for-sas",
|
|
1126
|
+
expiresAt: Math.min(invitation.e, Date.now() + PAIRING_TTL_MS),
|
|
1127
|
+
ephemeralSecret,
|
|
1128
|
+
ephemeralPublicKey: getPublicKey(ephemeralSecret),
|
|
1129
|
+
sessionSecret: invitation.s,
|
|
1130
|
+
relays: requireRelayUrls(invitation.r),
|
|
1131
|
+
sequence: 0,
|
|
1132
|
+
lastReceivedSequence: 0,
|
|
1133
|
+
peerEphemeralPublicKey: requirePublicKey(invitation.p, "Pairing public key"),
|
|
1134
|
+
peerAlias: undefined,
|
|
1135
|
+
localConfirmed: false,
|
|
1136
|
+
peerConfirmed: false,
|
|
1137
|
+
cutoffHeads: {},
|
|
1138
|
+
pendingDeviceSecret,
|
|
1139
|
+
seenEventIds: new Set(),
|
|
1140
|
+
};
|
|
1141
|
+
this.#pairingSessions.set(`${pluginId}:${sessionId}`, session);
|
|
1142
|
+
const event = this.#createPairingEvent(session, {
|
|
1143
|
+
type: "hello",
|
|
1144
|
+
devicePublicKey: getPublicKey(pendingDeviceSecret),
|
|
1145
|
+
deviceAlias: requireAlias(deviceAlias),
|
|
1146
|
+
});
|
|
1147
|
+
return result(session, [event]);
|
|
1148
|
+
},
|
|
1149
|
+
ingest: async ({ sessionId, event }) => {
|
|
1150
|
+
const session = getSession(sessionId);
|
|
1151
|
+
const message = this.#readPairingEvent(session, event);
|
|
1152
|
+
const outbound = [];
|
|
1153
|
+
let membershipEvent;
|
|
1154
|
+
if (message.type === "abort") {
|
|
1155
|
+
this.#expirePairingSession(session, "cancelled");
|
|
1156
|
+
return result(session);
|
|
1157
|
+
}
|
|
1158
|
+
if (message.type === "hello") {
|
|
1159
|
+
if (session.role !== "owner" ||
|
|
1160
|
+
session.state !== "waiting-for-peer") {
|
|
1161
|
+
throw new Error("Unexpected pairing hello");
|
|
1162
|
+
}
|
|
1163
|
+
session.peerEphemeralPublicKey = event.pubkey;
|
|
1164
|
+
session.peerDevicePublicKey = requirePublicKey(message.devicePublicKey ?? "");
|
|
1165
|
+
session.peerAlias = requireAlias(message.deviceAlias ?? "");
|
|
1166
|
+
session.sas = pairingSas(session.sessionId, session.ephemeralPublicKey, event.pubkey);
|
|
1167
|
+
session.state = "waiting-for-confirmation";
|
|
1168
|
+
outbound.push(this.#createPairingEvent(session, { type: "ack" }));
|
|
1169
|
+
}
|
|
1170
|
+
else if (message.type === "ack") {
|
|
1171
|
+
if (session.role !== "joining" ||
|
|
1172
|
+
session.state !== "waiting-for-sas") {
|
|
1173
|
+
throw new Error("Unexpected pairing acknowledgement");
|
|
1174
|
+
}
|
|
1175
|
+
session.sas = pairingSas(session.sessionId, session.ephemeralPublicKey, event.pubkey);
|
|
1176
|
+
session.state = "waiting-for-confirmation";
|
|
1177
|
+
}
|
|
1178
|
+
else if (message.type === "confirm") {
|
|
1179
|
+
session.peerConfirmed = true;
|
|
1180
|
+
if (session.role === "owner" && session.localConfirmed) {
|
|
1181
|
+
const transfer = await this.#preparePairingTransfer(pluginId, session);
|
|
1182
|
+
membershipEvent = transfer.membership.event;
|
|
1183
|
+
outbound.push(this.#createPairingEvent(session, {
|
|
1184
|
+
type: "transfer",
|
|
1185
|
+
payload: transfer,
|
|
1186
|
+
}));
|
|
1187
|
+
session.state = "transferring";
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
else if (message.type === "transfer") {
|
|
1191
|
+
if (session.role !== "joining" ||
|
|
1192
|
+
!session.localConfirmed ||
|
|
1193
|
+
!session.peerConfirmed ||
|
|
1194
|
+
!session.pendingDeviceSecret ||
|
|
1195
|
+
!message.payload) {
|
|
1196
|
+
throw new Error("Unexpected pairing transfer");
|
|
1197
|
+
}
|
|
1198
|
+
const transfer = message.payload;
|
|
1199
|
+
transfer.metadata = clonePairingMetadata(transfer.metadata);
|
|
1200
|
+
const member = transfer.membership.devices.find((device) => device.publicKey === getPublicKey(session.pendingDeviceSecret));
|
|
1201
|
+
if (!member || member.state !== "active") {
|
|
1202
|
+
throw new Error("Pairing transfer does not authorise this device");
|
|
1203
|
+
}
|
|
1204
|
+
const material = {
|
|
1205
|
+
schemaVersion: 3,
|
|
1206
|
+
vaultId: transfer.vaultId,
|
|
1207
|
+
deviceAlias: member.alias,
|
|
1208
|
+
role: "member",
|
|
1209
|
+
currentEpoch: transfer.currentEpoch,
|
|
1210
|
+
membershipGeneration: transfer.membership.generation,
|
|
1211
|
+
vaultKeys: { ...transfer.vaultKeys },
|
|
1212
|
+
deviceSecretHex: bytesToHex(session.pendingDeviceSecret),
|
|
1213
|
+
ownerPublicKey: transfer.ownerPublicKey,
|
|
1214
|
+
recoveryPublicKey: transfer.recoveryPublicKey,
|
|
1215
|
+
syncSecretHex: transfer.syncSecretHex,
|
|
1216
|
+
syncPublicKey: transfer.syncPublicKey,
|
|
1217
|
+
syncIdentityGeneration: transfer.syncIdentityGeneration,
|
|
1218
|
+
syncIdentityHistory: cloneSyncIdentityHistory(transfer.syncIdentityHistory),
|
|
1219
|
+
membership: cloneMembership(transfer.membership),
|
|
1220
|
+
};
|
|
1221
|
+
session.pendingTransfer = transfer;
|
|
1222
|
+
session.state = "ready-to-enroll";
|
|
1223
|
+
this.#pendingEnrollments.set(pluginId, {
|
|
1224
|
+
request: {
|
|
1225
|
+
mode: "paired-device",
|
|
1226
|
+
deviceAlias: member.alias,
|
|
1227
|
+
sessionId: session.sessionId,
|
|
1228
|
+
},
|
|
1229
|
+
material,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
else if (message.type === "complete") {
|
|
1233
|
+
if (session.role !== "owner" || session.state !== "transferring") {
|
|
1234
|
+
throw new Error("Unexpected pairing completion");
|
|
1235
|
+
}
|
|
1236
|
+
session.state = "complete";
|
|
1237
|
+
this.#destroyPairingSecrets(session);
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
throw new Error(`Unexpected pairing message ${message.type}`);
|
|
1241
|
+
}
|
|
1242
|
+
return result(session, outbound, {
|
|
1243
|
+
membershipEvent,
|
|
1244
|
+
metadata: session.role === "joining"
|
|
1245
|
+
? session.pendingTransfer?.metadata
|
|
1246
|
+
: undefined,
|
|
1247
|
+
});
|
|
1248
|
+
},
|
|
1249
|
+
confirm: async (sessionId, sas) => {
|
|
1250
|
+
const session = getSession(sessionId);
|
|
1251
|
+
if (session.state === "ready-to-enroll") {
|
|
1252
|
+
if (!requestEnrollment) {
|
|
1253
|
+
throw new Error(`The host has no sync enrollment flow for ${pluginId}`);
|
|
1254
|
+
}
|
|
1255
|
+
const status = await requestEnrollment({
|
|
1256
|
+
mode: "paired-device",
|
|
1257
|
+
deviceAlias: session.peerAlias,
|
|
1258
|
+
sessionId,
|
|
1259
|
+
});
|
|
1260
|
+
if (status.state !== "unlocked") {
|
|
1261
|
+
throw new Error("The host did not complete paired-device enrollment");
|
|
1262
|
+
}
|
|
1263
|
+
session.pendingDeviceSecret?.fill(0);
|
|
1264
|
+
session.pendingDeviceSecret = undefined;
|
|
1265
|
+
session.state = "complete";
|
|
1266
|
+
const completeEvent = this.#createPairingEvent(session, {
|
|
1267
|
+
type: "complete",
|
|
1268
|
+
});
|
|
1269
|
+
this.#destroyPairingSecrets(session);
|
|
1270
|
+
return result(session, [completeEvent], {
|
|
1271
|
+
metadata: session.pendingTransfer?.metadata,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
if (session.state !== "waiting-for-confirmation") {
|
|
1275
|
+
throw new Error("Pairing session is not waiting for confirmation");
|
|
1276
|
+
}
|
|
1277
|
+
if (!session.sas || sas !== session.sas) {
|
|
1278
|
+
throw new Error("Pairing SAS does not match this session");
|
|
1279
|
+
}
|
|
1280
|
+
session.localConfirmed = true;
|
|
1281
|
+
const outbound = [
|
|
1282
|
+
this.#createPairingEvent(session, { type: "confirm" }),
|
|
1283
|
+
];
|
|
1284
|
+
let membershipEvent;
|
|
1285
|
+
if (session.role === "owner" && session.peerConfirmed) {
|
|
1286
|
+
const transfer = await this.#preparePairingTransfer(pluginId, session);
|
|
1287
|
+
membershipEvent = transfer.membership.event;
|
|
1288
|
+
outbound.push(this.#createPairingEvent(session, {
|
|
1289
|
+
type: "transfer",
|
|
1290
|
+
payload: transfer,
|
|
1291
|
+
}));
|
|
1292
|
+
session.state = "transferring";
|
|
1293
|
+
}
|
|
1294
|
+
return result(session, outbound, { membershipEvent });
|
|
1295
|
+
},
|
|
1296
|
+
cancel: async (sessionId) => {
|
|
1297
|
+
const session = getSession(sessionId);
|
|
1298
|
+
this.#expirePairingSession(session, "cancelled");
|
|
1299
|
+
return this.#pairingSnapshot(session);
|
|
1300
|
+
},
|
|
1301
|
+
createRelayProbe: async () => {
|
|
1302
|
+
assertActive();
|
|
1303
|
+
const sender = randomBytes(KEY_LENGTH);
|
|
1304
|
+
const recipient = randomBytes(KEY_LENGTH);
|
|
1305
|
+
try {
|
|
1306
|
+
const recipientPublicKey = getPublicKey(recipient);
|
|
1307
|
+
const content = nip44.encrypt(canonicalJson({ type: "lapis-pairing-relay-probe", v: 1 }), nip44.utils.getConversationKey(sender, recipientPublicKey));
|
|
1308
|
+
return finalizeEvent({
|
|
1309
|
+
kind: PAIRING_KIND,
|
|
1310
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
1311
|
+
tags: [
|
|
1312
|
+
["p", recipientPublicKey],
|
|
1313
|
+
["d", bytesToHex(randomBytes(16))],
|
|
1314
|
+
["expiration", String(Math.floor(Date.now() / 1000) + 120)],
|
|
1315
|
+
],
|
|
1316
|
+
content,
|
|
1317
|
+
}, sender);
|
|
1318
|
+
}
|
|
1319
|
+
finally {
|
|
1320
|
+
sender.fill(0);
|
|
1321
|
+
recipient.fill(0);
|
|
1322
|
+
}
|
|
1323
|
+
},
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
membership(pluginId, runtimeId, assertGranted = () => undefined) {
|
|
1327
|
+
const keys = () => {
|
|
1328
|
+
assertGranted();
|
|
1329
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
1330
|
+
return this.#requireUnlocked(pluginId);
|
|
1331
|
+
};
|
|
1332
|
+
return {
|
|
1333
|
+
getSnapshot: async () => cloneMembership(keys().membership),
|
|
1334
|
+
ingest: async (event) => {
|
|
1335
|
+
const value = keys();
|
|
1336
|
+
const snapshot = this.#decryptMembership(value, event);
|
|
1337
|
+
if (snapshot.vaultId !== value.vaultId) {
|
|
1338
|
+
throw new Error("Membership event belongs to another sync vault");
|
|
1339
|
+
}
|
|
1340
|
+
if (snapshot.ownerPublicKey !== value.ownerPublicKey ||
|
|
1341
|
+
event.pubkey !== value.ownerPublicKey) {
|
|
1342
|
+
throw new Error("Membership event is not signed by the vault owner");
|
|
1343
|
+
}
|
|
1344
|
+
if (snapshot.generation <= value.membershipGeneration) {
|
|
1345
|
+
if (snapshot.event?.id === value.membership.event?.id) {
|
|
1346
|
+
return cloneMembership(value.membership);
|
|
1347
|
+
}
|
|
1348
|
+
throw new Error("Membership generation is stale or replayed");
|
|
1349
|
+
}
|
|
1350
|
+
const recoveryPending = this.#deviceState(value) === "recovery-pending";
|
|
1351
|
+
if (!recoveryPending &&
|
|
1352
|
+
(snapshot.generation !== value.membershipGeneration + 1 ||
|
|
1353
|
+
snapshot.previousEventId !== value.membership.event?.id)) {
|
|
1354
|
+
throw new Error("Membership generation chain is discontinuous");
|
|
1355
|
+
}
|
|
1356
|
+
const own = snapshot.devices.find((device) => device.publicKey === value.publicKey);
|
|
1357
|
+
value.membership = snapshot;
|
|
1358
|
+
value.membershipGeneration = snapshot.generation;
|
|
1359
|
+
value.currentEpoch = snapshot.currentEpoch;
|
|
1360
|
+
if (own) {
|
|
1361
|
+
value.deviceAlias = own.alias;
|
|
1362
|
+
value.role = own.role;
|
|
1363
|
+
}
|
|
1364
|
+
await this.#persistKeys(pluginId, value);
|
|
1365
|
+
return cloneMembership(snapshot);
|
|
1366
|
+
},
|
|
1367
|
+
renameDevice: async ({ publicKey, alias }) => {
|
|
1368
|
+
const value = keys();
|
|
1369
|
+
this.#assertOwner(value);
|
|
1370
|
+
const snapshot = cloneMembership(value.membership);
|
|
1371
|
+
const device = snapshot.devices.find((candidate) => candidate.publicKey === requirePublicKey(publicKey));
|
|
1372
|
+
if (!device || device.state !== "active") {
|
|
1373
|
+
throw new Error("Active sync device was not found");
|
|
1374
|
+
}
|
|
1375
|
+
device.alias = requireAlias(alias);
|
|
1376
|
+
if (value.syncSecret)
|
|
1377
|
+
this.#sealSyncIdentity(value, snapshot);
|
|
1378
|
+
await this.#advanceMembership(pluginId, value, snapshot);
|
|
1379
|
+
return cloneMembership(value.membership);
|
|
1380
|
+
},
|
|
1381
|
+
revokeDevice: async ({ publicKey, cutoffHeads }) => {
|
|
1382
|
+
const value = keys();
|
|
1383
|
+
this.#assertOwner(value);
|
|
1384
|
+
const targetKey = requirePublicKey(publicKey);
|
|
1385
|
+
if (targetKey === value.publicKey) {
|
|
1386
|
+
throw new Error("The active owner device cannot revoke itself");
|
|
1387
|
+
}
|
|
1388
|
+
const snapshot = cloneMembership(value.membership);
|
|
1389
|
+
const device = snapshot.devices.find((candidate) => candidate.publicKey === targetKey);
|
|
1390
|
+
if (!device || device.state !== "active") {
|
|
1391
|
+
throw new Error("Active sync device was not found");
|
|
1392
|
+
}
|
|
1393
|
+
device.state = "revoked";
|
|
1394
|
+
device.revokedAt = Date.now();
|
|
1395
|
+
for (const member of snapshot.devices) {
|
|
1396
|
+
if (cutoffHeads[member.publicKey])
|
|
1397
|
+
member.cutoffHead = cutoffHeads[member.publicKey];
|
|
1398
|
+
}
|
|
1399
|
+
const encryptionEpoch = value.currentEpoch;
|
|
1400
|
+
value.currentEpoch += 1;
|
|
1401
|
+
value.vaultKeys.set(value.currentEpoch, randomBytes(KEY_LENGTH));
|
|
1402
|
+
snapshot.currentEpoch = value.currentEpoch;
|
|
1403
|
+
this.#sealMembershipEpoch(value, snapshot);
|
|
1404
|
+
if (value.syncSecret)
|
|
1405
|
+
this.#sealSyncIdentity(value, snapshot);
|
|
1406
|
+
await this.#advanceMembership(pluginId, value, snapshot, encryptionEpoch);
|
|
1407
|
+
return cloneMembership(value.membership);
|
|
1408
|
+
},
|
|
1409
|
+
recoverOwner: async ({ cutoffHeads }) => {
|
|
1410
|
+
const value = keys();
|
|
1411
|
+
this.#assertOwner(value);
|
|
1412
|
+
if (this.#deviceState(value) !== "recovery-pending") {
|
|
1413
|
+
throw new Error("Owner recovery is not pending on this device");
|
|
1414
|
+
}
|
|
1415
|
+
const snapshot = cloneMembership(value.membership);
|
|
1416
|
+
for (const device of snapshot.devices) {
|
|
1417
|
+
if (cutoffHeads[device.publicKey]) {
|
|
1418
|
+
device.cutoffHead = cutoffHeads[device.publicKey];
|
|
1419
|
+
}
|
|
1420
|
+
if (device.role === "owner" && device.state === "active") {
|
|
1421
|
+
device.state = "revoked";
|
|
1422
|
+
device.revokedAt = Date.now();
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
snapshot.devices.push({
|
|
1426
|
+
publicKey: value.publicKey,
|
|
1427
|
+
alias: value.deviceAlias,
|
|
1428
|
+
role: "owner",
|
|
1429
|
+
state: "active",
|
|
1430
|
+
addedAt: Date.now(),
|
|
1431
|
+
});
|
|
1432
|
+
const encryptionEpoch = value.currentEpoch;
|
|
1433
|
+
value.currentEpoch += 1;
|
|
1434
|
+
value.vaultKeys.set(value.currentEpoch, randomBytes(KEY_LENGTH));
|
|
1435
|
+
snapshot.currentEpoch = value.currentEpoch;
|
|
1436
|
+
this.#sealMembershipEpoch(value, snapshot);
|
|
1437
|
+
if (value.syncSecret)
|
|
1438
|
+
this.#sealSyncIdentity(value, snapshot);
|
|
1439
|
+
await this.#advanceMembership(pluginId, value, snapshot, encryptionEpoch);
|
|
1440
|
+
return cloneMembership(value.membership);
|
|
1441
|
+
},
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
recovery(pluginId, runtimeId, assertGranted = () => undefined, requestPassphrase, requestEnrollment) {
|
|
1445
|
+
const assertActive = () => {
|
|
1446
|
+
assertGranted();
|
|
1447
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
1448
|
+
};
|
|
1449
|
+
const passphrase = async (mode) => {
|
|
1450
|
+
if (!requestPassphrase) {
|
|
1451
|
+
throw new Error(`The host has no sync recovery ${mode} flow for ${pluginId}`);
|
|
1452
|
+
}
|
|
1453
|
+
const value = await requestPassphrase(mode);
|
|
1454
|
+
if (value == null)
|
|
1455
|
+
throw new Error(`Sync recovery ${mode} was cancelled`);
|
|
1456
|
+
if (value.length < 10) {
|
|
1457
|
+
throw new Error("Recovery passphrase must contain at least 10 characters");
|
|
1458
|
+
}
|
|
1459
|
+
return value;
|
|
1460
|
+
};
|
|
1461
|
+
return {
|
|
1462
|
+
exportBundle: async ({ metadata } = {}) => {
|
|
1463
|
+
assertActive();
|
|
1464
|
+
const keys = this.#requireUnlocked(pluginId);
|
|
1465
|
+
this.#assertOwner(keys);
|
|
1466
|
+
if (!keys.recoverySecret) {
|
|
1467
|
+
throw new Error("The owner recovery authority is unavailable");
|
|
1468
|
+
}
|
|
1469
|
+
const metadataBytes = new TextEncoder().encode(canonicalJson(metadata));
|
|
1470
|
+
if (metadataBytes.byteLength > MAX_RECOVERY_METADATA_BYTES) {
|
|
1471
|
+
throw new Error("Recovery metadata exceeds the host limit");
|
|
1472
|
+
}
|
|
1473
|
+
metadataBytes.fill(0);
|
|
1474
|
+
const secret = await passphrase("export");
|
|
1475
|
+
const salt = randomBytes(16);
|
|
1476
|
+
const wrappingKey = await argon2idAsync(new TextEncoder().encode(secret), salt, {
|
|
1477
|
+
t: this.#argon2.timeCost,
|
|
1478
|
+
m: this.#argon2.memoryKiB,
|
|
1479
|
+
p: this.#argon2.parallelism,
|
|
1480
|
+
dkLen: KEY_LENGTH,
|
|
1481
|
+
});
|
|
1482
|
+
try {
|
|
1483
|
+
const material = this.#keyMaterial(keys);
|
|
1484
|
+
material.deviceSecretHex = "";
|
|
1485
|
+
const payload = {
|
|
1486
|
+
schemaVersion: 1,
|
|
1487
|
+
exportedAt: Date.now(),
|
|
1488
|
+
keyMaterial: material,
|
|
1489
|
+
metadata,
|
|
1490
|
+
};
|
|
1491
|
+
const plaintext = new TextEncoder().encode(canonicalJson(payload));
|
|
1492
|
+
const nonce = randomBytes(NONCE_LENGTH);
|
|
1493
|
+
const ciphertext = xchacha20poly1305(wrappingKey, nonce, new TextEncoder().encode("lapis-sync-recovery:v1")).encrypt(plaintext);
|
|
1494
|
+
plaintext.fill(0);
|
|
1495
|
+
const envelope = {
|
|
1496
|
+
version: 1,
|
|
1497
|
+
kdf: {
|
|
1498
|
+
name: "argon2id",
|
|
1499
|
+
timeCost: this.#argon2.timeCost,
|
|
1500
|
+
memoryKiB: this.#argon2.memoryKiB,
|
|
1501
|
+
parallelism: this.#argon2.parallelism,
|
|
1502
|
+
dkLen: 32,
|
|
1503
|
+
saltHex: bytesToHex(salt),
|
|
1504
|
+
},
|
|
1505
|
+
nonceHex: bytesToHex(nonce),
|
|
1506
|
+
ciphertextHex: bytesToHex(ciphertext),
|
|
1507
|
+
};
|
|
1508
|
+
return new TextEncoder().encode(canonicalJson(envelope));
|
|
1509
|
+
}
|
|
1510
|
+
finally {
|
|
1511
|
+
wrappingKey.fill(0);
|
|
1512
|
+
}
|
|
1513
|
+
},
|
|
1514
|
+
importBundle: async ({ bundle, replaceExisting = false }) => {
|
|
1515
|
+
assertActive();
|
|
1516
|
+
if (!requestEnrollment) {
|
|
1517
|
+
throw new Error(`The host has no sync enrollment flow for ${pluginId}`);
|
|
1518
|
+
}
|
|
1519
|
+
requireCryptoSize(bundle);
|
|
1520
|
+
const current = await this.status(pluginId);
|
|
1521
|
+
if (current.state !== "unenrolled" && !replaceExisting) {
|
|
1522
|
+
throw new Error("Recovery import requires an unenrolled identity or explicit replacement");
|
|
1523
|
+
}
|
|
1524
|
+
let envelope;
|
|
1525
|
+
try {
|
|
1526
|
+
envelope = JSON.parse(new TextDecoder().decode(bundle));
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
throw new Error("Recovery bundle is invalid");
|
|
1530
|
+
}
|
|
1531
|
+
if (envelope.version !== 1 ||
|
|
1532
|
+
envelope.kdf?.name !== "argon2id" ||
|
|
1533
|
+
envelope.kdf.dkLen !== 32) {
|
|
1534
|
+
throw new Error("Unsupported recovery bundle");
|
|
1535
|
+
}
|
|
1536
|
+
const secret = await passphrase("import");
|
|
1537
|
+
const wrappingKey = await argon2idAsync(new TextEncoder().encode(secret), hexToBytes(envelope.kdf.saltHex), {
|
|
1538
|
+
t: envelope.kdf.timeCost,
|
|
1539
|
+
m: envelope.kdf.memoryKiB,
|
|
1540
|
+
p: envelope.kdf.parallelism,
|
|
1541
|
+
dkLen: envelope.kdf.dkLen,
|
|
1542
|
+
});
|
|
1543
|
+
let payload;
|
|
1544
|
+
try {
|
|
1545
|
+
const plaintext = xchacha20poly1305(wrappingKey, hexToBytes(envelope.nonceHex), new TextEncoder().encode("lapis-sync-recovery:v1")).decrypt(hexToBytes(envelope.ciphertextHex));
|
|
1546
|
+
try {
|
|
1547
|
+
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
1548
|
+
}
|
|
1549
|
+
finally {
|
|
1550
|
+
plaintext.fill(0);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
catch {
|
|
1554
|
+
throw new Error("Recovery bundle authentication failed");
|
|
1555
|
+
}
|
|
1556
|
+
finally {
|
|
1557
|
+
wrappingKey.fill(0);
|
|
1558
|
+
}
|
|
1559
|
+
if (payload.schemaVersion !== 1 ||
|
|
1560
|
+
![2, 3].includes(payload.keyMaterial.schemaVersion) ||
|
|
1561
|
+
!payload.keyMaterial.ownerSecretHex ||
|
|
1562
|
+
!payload.keyMaterial.recoverySecretHex) {
|
|
1563
|
+
throw new Error("Recovery bundle key material is incomplete");
|
|
1564
|
+
}
|
|
1565
|
+
const metadataBytes = new TextEncoder().encode(canonicalJson(payload.metadata));
|
|
1566
|
+
if (metadataBytes.byteLength > MAX_RECOVERY_METADATA_BYTES) {
|
|
1567
|
+
throw new Error("Recovery metadata exceeds the host limit");
|
|
1568
|
+
}
|
|
1569
|
+
metadataBytes.fill(0);
|
|
1570
|
+
const material = structuredClone(payload.keyMaterial);
|
|
1571
|
+
const newDeviceSecret = randomBytes(KEY_LENGTH);
|
|
1572
|
+
const newPublicKey = getPublicKey(newDeviceSecret);
|
|
1573
|
+
material.deviceSecretHex = bytesToHex(newDeviceSecret);
|
|
1574
|
+
newDeviceSecret.fill(0);
|
|
1575
|
+
material.deviceAlias = "Recovered owner";
|
|
1576
|
+
material.role = "owner";
|
|
1577
|
+
// Recovery import deliberately leaves the fresh owner identity outside
|
|
1578
|
+
// current membership. The plugin first reconciles the latest
|
|
1579
|
+
// owner-signed snapshot, then calls membership.recoverOwner to rotate.
|
|
1580
|
+
if (material.membership.devices.some((device) => device.publicKey === newPublicKey)) {
|
|
1581
|
+
throw new Error("Recovery generated a duplicate device identity");
|
|
1582
|
+
}
|
|
1583
|
+
if (current.state !== "unenrolled") {
|
|
1584
|
+
this.lock(pluginId);
|
|
1585
|
+
await this.database.transactMeta({
|
|
1586
|
+
mutations: [{ key: envelopeKey(pluginId), delete: true }],
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
this.#pendingEnrollments.set(pluginId, {
|
|
1590
|
+
request: { mode: "recovery", deviceAlias: material.deviceAlias },
|
|
1591
|
+
material,
|
|
1592
|
+
});
|
|
1593
|
+
const status = await requestEnrollment({
|
|
1594
|
+
mode: "recovery",
|
|
1595
|
+
deviceAlias: material.deviceAlias,
|
|
1596
|
+
});
|
|
1597
|
+
if (status.state !== "unlocked") {
|
|
1598
|
+
throw new Error("The host did not complete recovery enrollment");
|
|
1599
|
+
}
|
|
1600
|
+
return { status, metadata: payload.metadata };
|
|
1601
|
+
},
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
#requireUnlocked(pluginId) {
|
|
1605
|
+
const keys = this.#unlocked.get(pluginId);
|
|
1606
|
+
if (!keys)
|
|
1607
|
+
throw new Error(`Sync crypto is locked for ${pluginId}`);
|
|
1608
|
+
return keys;
|
|
1609
|
+
}
|
|
1610
|
+
#assertOwner(keys) {
|
|
1611
|
+
if (keys.role !== "owner" || !keys.ownerSecret) {
|
|
1612
|
+
throw new Error("Only the owner device may manage sync membership");
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
#syncIdentityState(keys) {
|
|
1616
|
+
if (!keys.syncSecret || !keys.syncPublicKey)
|
|
1617
|
+
return "setup-required";
|
|
1618
|
+
return keys.pendingSyncSecret ? "rotation-pending" : "active";
|
|
1619
|
+
}
|
|
1620
|
+
#syncIdentityStatus(keys) {
|
|
1621
|
+
return {
|
|
1622
|
+
state: this.#syncIdentityState(keys),
|
|
1623
|
+
publicKey: keys.syncPublicKey,
|
|
1624
|
+
generation: keys.syncIdentityGeneration,
|
|
1625
|
+
history: cloneSyncIdentityHistory(keys.syncIdentityHistory),
|
|
1626
|
+
pendingPublicKey: keys.pendingSyncPublicKey,
|
|
1627
|
+
canRotate: keys.role === "owner" && this.#deviceState(keys) === "active",
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
#validateUnsignedEvent(event) {
|
|
1631
|
+
if (!Number.isSafeInteger(event.kind) ||
|
|
1632
|
+
!Number.isSafeInteger(event.created_at)) {
|
|
1633
|
+
throw new Error("Sync event kind and timestamp must be safe integers");
|
|
1634
|
+
}
|
|
1635
|
+
if (event.content.length > 2 * 1024 * 1024 || event.tags.length > 256) {
|
|
1636
|
+
throw new Error("Sync event exceeds the signing policy");
|
|
1637
|
+
}
|
|
1638
|
+
for (const tag of event.tags) {
|
|
1639
|
+
if (!Array.isArray(tag) || tag.length < 1 || tag.length > 8) {
|
|
1640
|
+
throw new Error("Sync event tags are invalid");
|
|
1641
|
+
}
|
|
1642
|
+
for (const value of tag) {
|
|
1643
|
+
if (typeof value !== "string" || value.length > 2_048) {
|
|
1644
|
+
throw new Error("Sync event tag values are invalid");
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
#validateSyncIdentityEvent(purpose, event) {
|
|
1650
|
+
this.#validateUnsignedEvent(event);
|
|
1651
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1652
|
+
if (Math.abs(event.created_at - now) > 10 * 60) {
|
|
1653
|
+
throw new Error("Sync Identity events must use a current timestamp");
|
|
1654
|
+
}
|
|
1655
|
+
const tag = (name) => event.tags.find(([candidate]) => candidate === name)?.[1];
|
|
1656
|
+
if (purpose === "transport") {
|
|
1657
|
+
if (![78, 30_078, MEMBERSHIP_KIND].includes(event.kind)) {
|
|
1658
|
+
throw new Error("Sync transport signing kind is not allowed");
|
|
1659
|
+
}
|
|
1660
|
+
if (!tag("d"))
|
|
1661
|
+
throw new Error("Sync transport events require a d tag");
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (purpose === "connection-probe") {
|
|
1665
|
+
if (![78, 30_078, MEMBERSHIP_KIND].includes(event.kind) || !tag("d")) {
|
|
1666
|
+
throw new Error("Sync connection probe is invalid");
|
|
1667
|
+
}
|
|
1668
|
+
if (!tag("expiration")) {
|
|
1669
|
+
throw new Error("Sync connection probes require expiration");
|
|
1670
|
+
}
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (purpose === "relay-auth") {
|
|
1674
|
+
if (event.kind !== 22_242 ||
|
|
1675
|
+
event.content !== "" ||
|
|
1676
|
+
!tag("relay") ||
|
|
1677
|
+
!tag("challenge")) {
|
|
1678
|
+
throw new Error("NIP-42 authentication event is invalid");
|
|
1679
|
+
}
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (event.kind !== 24_242 ||
|
|
1683
|
+
!tag("t") ||
|
|
1684
|
+
!tag("expiration") ||
|
|
1685
|
+
!["get", "upload", "list", "delete", "media"].includes(tag("t"))) {
|
|
1686
|
+
throw new Error("BUD-11 authorization event is invalid");
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
#deviceState(keys) {
|
|
1690
|
+
const own = keys.membership.devices.find((device) => device.publicKey === keys.publicKey);
|
|
1691
|
+
return (own?.state ?? (keys.role === "owner" ? "recovery-pending" : "revoked"));
|
|
1692
|
+
}
|
|
1693
|
+
#assertDeviceActive(keys) {
|
|
1694
|
+
const own = keys.membership.devices.find((device) => device.publicKey === keys.publicKey);
|
|
1695
|
+
if (!own || own.state !== "active") {
|
|
1696
|
+
throw new Error("This sync device is revoked");
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
#pairingSnapshot(session) {
|
|
1700
|
+
return {
|
|
1701
|
+
sessionId: session.sessionId,
|
|
1702
|
+
role: session.role,
|
|
1703
|
+
state: session.state,
|
|
1704
|
+
expiresAt: session.expiresAt,
|
|
1705
|
+
sas: session.sas,
|
|
1706
|
+
peerPublicKey: session.peerDevicePublicKey,
|
|
1707
|
+
peerAlias: session.peerAlias,
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
#expirePairingSession(session, state = "expired") {
|
|
1711
|
+
session.state = state;
|
|
1712
|
+
this.#destroyPairingSecrets(session);
|
|
1713
|
+
this.#pendingEnrollments.delete(session.pluginId);
|
|
1714
|
+
}
|
|
1715
|
+
#destroyPairingSecrets(session) {
|
|
1716
|
+
session.ephemeralSecret.fill(0);
|
|
1717
|
+
session.pendingDeviceSecret?.fill(0);
|
|
1718
|
+
session.pendingDeviceSecret = undefined;
|
|
1719
|
+
}
|
|
1720
|
+
#parsePairingInvitation(invitationUri) {
|
|
1721
|
+
if (invitationUri.length > 2_048) {
|
|
1722
|
+
throw new Error("Pairing invitation exceeds the app URL limit");
|
|
1723
|
+
}
|
|
1724
|
+
let url;
|
|
1725
|
+
try {
|
|
1726
|
+
url = new URL(invitationUri);
|
|
1727
|
+
}
|
|
1728
|
+
catch {
|
|
1729
|
+
throw new Error("Pairing invitation is not a valid URL");
|
|
1730
|
+
}
|
|
1731
|
+
if (url.protocol !== "lapis:" || url.hostname !== "sync-pair") {
|
|
1732
|
+
throw new Error("Pairing invitation must use lapis://sync-pair");
|
|
1733
|
+
}
|
|
1734
|
+
if (url.searchParams.get("v") !== "1") {
|
|
1735
|
+
throw new Error("Unsupported pairing invitation version");
|
|
1736
|
+
}
|
|
1737
|
+
const data = url.searchParams.get("data");
|
|
1738
|
+
if (!data)
|
|
1739
|
+
throw new Error("Pairing invitation data is missing");
|
|
1740
|
+
let invitation;
|
|
1741
|
+
try {
|
|
1742
|
+
invitation = JSON.parse(new TextDecoder().decode(fromBase64Url(data)));
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
throw new Error("Pairing invitation data is invalid");
|
|
1746
|
+
}
|
|
1747
|
+
if (invitation.v !== 1 ||
|
|
1748
|
+
!Number.isSafeInteger(invitation.e) ||
|
|
1749
|
+
typeof invitation.s !== "string" ||
|
|
1750
|
+
invitation.s.length < 32 ||
|
|
1751
|
+
!Array.isArray(invitation.r)) {
|
|
1752
|
+
throw new Error("Pairing invitation data is invalid");
|
|
1753
|
+
}
|
|
1754
|
+
requirePublicKey(invitation.p, "Pairing public key");
|
|
1755
|
+
requireRelayUrls(invitation.r);
|
|
1756
|
+
return invitation;
|
|
1757
|
+
}
|
|
1758
|
+
#createPairingEvent(session, input) {
|
|
1759
|
+
const peerPublicKey = session.peerEphemeralPublicKey;
|
|
1760
|
+
if (!peerPublicKey)
|
|
1761
|
+
throw new Error("Pairing peer is not established");
|
|
1762
|
+
const message = {
|
|
1763
|
+
v: 1,
|
|
1764
|
+
sessionId: session.sessionId,
|
|
1765
|
+
sequence: ++session.sequence,
|
|
1766
|
+
type: input.type,
|
|
1767
|
+
sessionSecret: session.sessionSecret,
|
|
1768
|
+
devicePublicKey: input.devicePublicKey,
|
|
1769
|
+
deviceAlias: input.deviceAlias,
|
|
1770
|
+
payload: input.payload,
|
|
1771
|
+
};
|
|
1772
|
+
const content = nip44.encrypt(canonicalJson(message), nip44.utils.getConversationKey(session.ephemeralSecret, peerPublicKey));
|
|
1773
|
+
if (content.length > MAX_PAIRING_EVENT_BYTES) {
|
|
1774
|
+
throw new Error("Pairing event exceeds the host limit");
|
|
1775
|
+
}
|
|
1776
|
+
return finalizeEvent({
|
|
1777
|
+
kind: PAIRING_KIND,
|
|
1778
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
1779
|
+
tags: [
|
|
1780
|
+
["p", peerPublicKey],
|
|
1781
|
+
["d", session.sessionId],
|
|
1782
|
+
],
|
|
1783
|
+
content,
|
|
1784
|
+
}, session.ephemeralSecret);
|
|
1785
|
+
}
|
|
1786
|
+
#readPairingEvent(session, event) {
|
|
1787
|
+
if (event.kind !== PAIRING_KIND ||
|
|
1788
|
+
event.content.length > MAX_PAIRING_EVENT_BYTES ||
|
|
1789
|
+
!verifyEvent(event)) {
|
|
1790
|
+
throw new Error("Pairing event is invalid");
|
|
1791
|
+
}
|
|
1792
|
+
if (session.seenEventIds.has(event.id)) {
|
|
1793
|
+
throw new Error("Pairing event was replayed");
|
|
1794
|
+
}
|
|
1795
|
+
const recipient = event.tags.find(([name]) => name === "p")?.[1];
|
|
1796
|
+
const sessionTag = event.tags.find(([name]) => name === "d")?.[1];
|
|
1797
|
+
if (recipient !== session.ephemeralPublicKey ||
|
|
1798
|
+
sessionTag !== session.sessionId ||
|
|
1799
|
+
(session.peerEphemeralPublicKey &&
|
|
1800
|
+
event.pubkey !== session.peerEphemeralPublicKey)) {
|
|
1801
|
+
throw new Error("Pairing event recipient or peer does not match");
|
|
1802
|
+
}
|
|
1803
|
+
let message;
|
|
1804
|
+
try {
|
|
1805
|
+
message = JSON.parse(nip44.decrypt(event.content, nip44.utils.getConversationKey(session.ephemeralSecret, event.pubkey)));
|
|
1806
|
+
}
|
|
1807
|
+
catch {
|
|
1808
|
+
throw new Error("Pairing event authentication failed");
|
|
1809
|
+
}
|
|
1810
|
+
if (message.v !== 1 ||
|
|
1811
|
+
message.sessionId !== session.sessionId ||
|
|
1812
|
+
message.sessionSecret !== session.sessionSecret ||
|
|
1813
|
+
message.sequence !== session.lastReceivedSequence + 1) {
|
|
1814
|
+
throw new Error("Pairing event sequence or session proof is invalid");
|
|
1815
|
+
}
|
|
1816
|
+
session.lastReceivedSequence = message.sequence;
|
|
1817
|
+
session.seenEventIds.add(event.id);
|
|
1818
|
+
return message;
|
|
1819
|
+
}
|
|
1820
|
+
async #preparePairingTransfer(pluginId, session) {
|
|
1821
|
+
const keys = this.#requireUnlocked(pluginId);
|
|
1822
|
+
this.#assertOwner(keys);
|
|
1823
|
+
if (!keys.syncSecret || !keys.syncPublicKey) {
|
|
1824
|
+
throw new Error("Initialize the vault Sync Identity before pairing a device");
|
|
1825
|
+
}
|
|
1826
|
+
if (!session.peerDevicePublicKey || !session.peerAlias) {
|
|
1827
|
+
throw new Error("Pairing peer identity is incomplete");
|
|
1828
|
+
}
|
|
1829
|
+
const snapshot = cloneMembership(keys.membership);
|
|
1830
|
+
if (snapshot.devices.some((device) => device.publicKey === session.peerDevicePublicKey)) {
|
|
1831
|
+
throw new Error("Pairing device is already present in membership");
|
|
1832
|
+
}
|
|
1833
|
+
for (const device of snapshot.devices) {
|
|
1834
|
+
if (session.cutoffHeads[device.publicKey]) {
|
|
1835
|
+
device.cutoffHead = session.cutoffHeads[device.publicKey];
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
snapshot.devices.push({
|
|
1839
|
+
publicKey: session.peerDevicePublicKey,
|
|
1840
|
+
alias: session.peerAlias,
|
|
1841
|
+
role: "member",
|
|
1842
|
+
state: "active",
|
|
1843
|
+
addedAt: Date.now(),
|
|
1844
|
+
});
|
|
1845
|
+
this.#sealSyncIdentity(keys, snapshot);
|
|
1846
|
+
await this.#advanceMembership(pluginId, keys, snapshot);
|
|
1847
|
+
return {
|
|
1848
|
+
vaultId: keys.vaultId,
|
|
1849
|
+
vaultKeys: Object.fromEntries([...keys.vaultKeys.entries()].map(([epoch, key]) => [
|
|
1850
|
+
String(epoch),
|
|
1851
|
+
bytesToHex(key),
|
|
1852
|
+
])),
|
|
1853
|
+
currentEpoch: keys.currentEpoch,
|
|
1854
|
+
ownerPublicKey: keys.ownerPublicKey,
|
|
1855
|
+
recoveryPublicKey: keys.recoveryPublicKey,
|
|
1856
|
+
syncSecretHex: bytesToHex(keys.syncSecret),
|
|
1857
|
+
syncPublicKey: keys.syncPublicKey,
|
|
1858
|
+
syncIdentityGeneration: keys.syncIdentityGeneration,
|
|
1859
|
+
syncIdentityHistory: cloneSyncIdentityHistory(keys.syncIdentityHistory),
|
|
1860
|
+
membership: cloneMembership(keys.membership),
|
|
1861
|
+
metadata: clonePairingMetadata(session.metadata),
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
async #advanceMembership(pluginId, keys, snapshot, encryptionEpoch = snapshot.currentEpoch) {
|
|
1865
|
+
this.#assertOwner(keys);
|
|
1866
|
+
snapshot.previousEventId = keys.membership.event?.id;
|
|
1867
|
+
snapshot.generation = keys.membershipGeneration + 1;
|
|
1868
|
+
snapshot.currentEpoch = keys.currentEpoch;
|
|
1869
|
+
snapshot.event = this.#signMembership(keys, snapshot, encryptionEpoch);
|
|
1870
|
+
keys.membership = snapshot;
|
|
1871
|
+
keys.membershipGeneration = snapshot.generation;
|
|
1872
|
+
const own = snapshot.devices.find((device) => device.publicKey === keys.publicKey);
|
|
1873
|
+
if (own)
|
|
1874
|
+
keys.deviceAlias = own.alias;
|
|
1875
|
+
await this.#persistKeys(pluginId, keys);
|
|
1876
|
+
}
|
|
1877
|
+
#sealMembershipEpoch(keys, snapshot) {
|
|
1878
|
+
this.#assertOwner(keys);
|
|
1879
|
+
const epochPayload = canonicalJson({
|
|
1880
|
+
schemaVersion: 1,
|
|
1881
|
+
vaultId: keys.vaultId,
|
|
1882
|
+
epoch: keys.currentEpoch,
|
|
1883
|
+
keyHex: bytesToHex(this.#vaultKey(keys, keys.currentEpoch)),
|
|
1884
|
+
});
|
|
1885
|
+
snapshot.epochEnvelopes = Object.fromEntries(snapshot.devices
|
|
1886
|
+
.filter((member) => member.state === "active")
|
|
1887
|
+
.map((member) => [
|
|
1888
|
+
member.publicKey,
|
|
1889
|
+
nip44.encrypt(epochPayload, nip44.utils.getConversationKey(keys.ownerSecret, member.publicKey)),
|
|
1890
|
+
]));
|
|
1891
|
+
snapshot.recoveryEpochEnvelope = nip44.encrypt(epochPayload, nip44.utils.getConversationKey(keys.ownerSecret, keys.recoveryPublicKey));
|
|
1892
|
+
}
|
|
1893
|
+
#sealSyncIdentity(keys, snapshot) {
|
|
1894
|
+
this.#assertOwner(keys);
|
|
1895
|
+
if (!keys.syncSecret || !keys.syncPublicKey) {
|
|
1896
|
+
throw new Error("The vault Sync Identity is unavailable");
|
|
1897
|
+
}
|
|
1898
|
+
const payload = canonicalJson({
|
|
1899
|
+
schemaVersion: 1,
|
|
1900
|
+
vaultId: keys.vaultId,
|
|
1901
|
+
generation: keys.syncIdentityGeneration,
|
|
1902
|
+
publicKey: keys.syncPublicKey,
|
|
1903
|
+
secretHex: bytesToHex(keys.syncSecret),
|
|
1904
|
+
});
|
|
1905
|
+
snapshot.syncIdentityEnvelopes = Object.fromEntries(snapshot.devices
|
|
1906
|
+
.filter((member) => member.state === "active")
|
|
1907
|
+
.map((member) => [
|
|
1908
|
+
member.publicKey,
|
|
1909
|
+
nip44.encrypt(payload, nip44.utils.getConversationKey(keys.ownerSecret, member.publicKey)),
|
|
1910
|
+
]));
|
|
1911
|
+
snapshot.recoverySyncIdentityEnvelope = nip44.encrypt(payload, nip44.utils.getConversationKey(keys.ownerSecret, keys.recoveryPublicKey));
|
|
1912
|
+
}
|
|
1913
|
+
#decryptMembership(keys, event) {
|
|
1914
|
+
if (event.kind !== MEMBERSHIP_KIND ||
|
|
1915
|
+
event.content.length > 2 * 1024 * 1024 ||
|
|
1916
|
+
!verifyEvent(event) ||
|
|
1917
|
+
event.pubkey !== keys.ownerPublicKey) {
|
|
1918
|
+
throw new Error("Membership event is invalid");
|
|
1919
|
+
}
|
|
1920
|
+
const expectedTag = `lapis-sync-v1:${keys.vaultId}:membership`;
|
|
1921
|
+
if (event.tags.find(([name]) => name === "d")?.[1] !== expectedTag) {
|
|
1922
|
+
throw new Error("Membership event address is invalid");
|
|
1923
|
+
}
|
|
1924
|
+
const envelope = fromBase64Url(event.content);
|
|
1925
|
+
if (envelope.byteLength < 5 + NONCE_LENGTH + 16 ||
|
|
1926
|
+
envelope[0] !== OBJECT_ENVELOPE_VERSION) {
|
|
1927
|
+
throw new Error("Membership event envelope is invalid");
|
|
1928
|
+
}
|
|
1929
|
+
const encryptionEpoch = new DataView(envelope.buffer, envelope.byteOffset, envelope.byteLength).getUint32(1, false);
|
|
1930
|
+
const encryptionKey = this.#vaultKey(keys, encryptionEpoch);
|
|
1931
|
+
let snapshot;
|
|
1932
|
+
try {
|
|
1933
|
+
const plaintext = xchacha20poly1305(deriveObjectKey(encryptionKey, encryptionEpoch, "membership-payload"), envelope.slice(5, 5 + NONCE_LENGTH), objectAad("membership-payload")).decrypt(envelope.slice(5 + NONCE_LENGTH));
|
|
1934
|
+
try {
|
|
1935
|
+
snapshot = JSON.parse(new TextDecoder().decode(plaintext));
|
|
1936
|
+
}
|
|
1937
|
+
finally {
|
|
1938
|
+
plaintext.fill(0);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
catch {
|
|
1942
|
+
throw new Error("Membership event authentication failed");
|
|
1943
|
+
}
|
|
1944
|
+
if (![1, 2].includes(snapshot.schemaVersion) ||
|
|
1945
|
+
snapshot.vaultId !== keys.vaultId ||
|
|
1946
|
+
snapshot.ownerPublicKey !== keys.ownerPublicKey ||
|
|
1947
|
+
!Number.isSafeInteger(snapshot.generation) ||
|
|
1948
|
+
!Number.isSafeInteger(snapshot.currentEpoch) ||
|
|
1949
|
+
!Array.isArray(snapshot.devices) ||
|
|
1950
|
+
snapshot.devices.length > 256) {
|
|
1951
|
+
throw new Error("Membership event payload is invalid");
|
|
1952
|
+
}
|
|
1953
|
+
if (snapshot.schemaVersion === 2) {
|
|
1954
|
+
if (!snapshot.syncIdentity ||
|
|
1955
|
+
!Number.isSafeInteger(snapshot.syncIdentity.generation) ||
|
|
1956
|
+
snapshot.syncIdentity.generation < 1 ||
|
|
1957
|
+
requirePublicKey(snapshot.syncIdentity.publicKey, "Membership Sync public key") !== snapshot.syncIdentity.publicKey ||
|
|
1958
|
+
!Array.isArray(snapshot.syncIdentity.history) ||
|
|
1959
|
+
snapshot.syncIdentity.history.length > 32) {
|
|
1960
|
+
throw new Error("Membership Sync Identity is invalid");
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
if (!keys.vaultKeys.has(snapshot.currentEpoch)) {
|
|
1964
|
+
const deviceEnvelope = snapshot.epochEnvelopes?.[keys.publicKey];
|
|
1965
|
+
const own = snapshot.devices.find((device) => device.publicKey === keys.publicKey);
|
|
1966
|
+
const useRecoveryEnvelope = !deviceEnvelope &&
|
|
1967
|
+
!own &&
|
|
1968
|
+
keys.role === "owner" &&
|
|
1969
|
+
keys.recoverySecret &&
|
|
1970
|
+
snapshot.recoveryEpochEnvelope;
|
|
1971
|
+
const sealed = deviceEnvelope ?? useRecoveryEnvelope;
|
|
1972
|
+
if (!sealed && own?.state === "revoked") {
|
|
1973
|
+
snapshot.event = structuredClone(event);
|
|
1974
|
+
return snapshot;
|
|
1975
|
+
}
|
|
1976
|
+
if (!sealed) {
|
|
1977
|
+
throw new Error("Membership update does not include this active device epoch");
|
|
1978
|
+
}
|
|
1979
|
+
let epochPayload;
|
|
1980
|
+
try {
|
|
1981
|
+
epochPayload = JSON.parse(nip44.decrypt(sealed, nip44.utils.getConversationKey(useRecoveryEnvelope ? keys.recoverySecret : keys.deviceSecret, keys.ownerPublicKey)));
|
|
1982
|
+
}
|
|
1983
|
+
catch {
|
|
1984
|
+
throw new Error("Membership epoch envelope authentication failed");
|
|
1985
|
+
}
|
|
1986
|
+
const epochKey = hexToBytes(epochPayload.keyHex);
|
|
1987
|
+
if (epochPayload.vaultId !== keys.vaultId ||
|
|
1988
|
+
epochPayload.epoch !== snapshot.currentEpoch ||
|
|
1989
|
+
epochKey.byteLength !== KEY_LENGTH) {
|
|
1990
|
+
epochKey.fill(0);
|
|
1991
|
+
throw new Error("Membership epoch envelope is invalid");
|
|
1992
|
+
}
|
|
1993
|
+
keys.vaultKeys.set(snapshot.currentEpoch, epochKey);
|
|
1994
|
+
}
|
|
1995
|
+
this.#applyMembershipSyncIdentity(keys, snapshot);
|
|
1996
|
+
snapshot.event = structuredClone(event);
|
|
1997
|
+
return snapshot;
|
|
1998
|
+
}
|
|
1999
|
+
#applyMembershipSyncIdentity(keys, snapshot) {
|
|
2000
|
+
if (snapshot.schemaVersion !== 2 || !snapshot.syncIdentity)
|
|
2001
|
+
return;
|
|
2002
|
+
const sync = snapshot.syncIdentity;
|
|
2003
|
+
if (keys.syncPublicKey === sync.publicKey &&
|
|
2004
|
+
keys.syncSecret &&
|
|
2005
|
+
getPublicKey(keys.syncSecret) === sync.publicKey) {
|
|
2006
|
+
keys.syncIdentityGeneration = sync.generation;
|
|
2007
|
+
keys.syncIdentityHistory = cloneSyncIdentityHistory(sync.history);
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
const own = snapshot.devices.find((device) => device.publicKey === keys.publicKey);
|
|
2011
|
+
const deviceEnvelope = snapshot.syncIdentityEnvelopes?.[keys.publicKey];
|
|
2012
|
+
const useRecoveryEnvelope = !deviceEnvelope &&
|
|
2013
|
+
!own &&
|
|
2014
|
+
keys.role === "owner" &&
|
|
2015
|
+
keys.recoverySecret &&
|
|
2016
|
+
snapshot.recoverySyncIdentityEnvelope;
|
|
2017
|
+
const sealed = deviceEnvelope ?? useRecoveryEnvelope;
|
|
2018
|
+
if (!sealed) {
|
|
2019
|
+
if (own?.state === "revoked")
|
|
2020
|
+
return;
|
|
2021
|
+
throw new Error("Membership update does not include this active device Sync Identity");
|
|
2022
|
+
}
|
|
2023
|
+
let payload;
|
|
2024
|
+
try {
|
|
2025
|
+
payload = JSON.parse(nip44.decrypt(sealed, nip44.utils.getConversationKey(useRecoveryEnvelope ? keys.recoverySecret : keys.deviceSecret, keys.ownerPublicKey)));
|
|
2026
|
+
}
|
|
2027
|
+
catch {
|
|
2028
|
+
throw new Error("Membership Sync Identity envelope authentication failed");
|
|
2029
|
+
}
|
|
2030
|
+
const secret = hexToBytes(payload.secretHex);
|
|
2031
|
+
if (payload.schemaVersion !== 1 ||
|
|
2032
|
+
payload.vaultId !== keys.vaultId ||
|
|
2033
|
+
payload.generation !== sync.generation ||
|
|
2034
|
+
payload.publicKey !== sync.publicKey ||
|
|
2035
|
+
secret.byteLength !== KEY_LENGTH ||
|
|
2036
|
+
getPublicKey(secret) !== sync.publicKey) {
|
|
2037
|
+
secret.fill(0);
|
|
2038
|
+
throw new Error("Membership Sync Identity envelope is invalid");
|
|
2039
|
+
}
|
|
2040
|
+
keys.syncSecret?.fill(0);
|
|
2041
|
+
keys.syncSecret = secret;
|
|
2042
|
+
keys.syncPublicKey = sync.publicKey;
|
|
2043
|
+
keys.syncIdentityGeneration = sync.generation;
|
|
2044
|
+
keys.syncIdentityHistory = cloneSyncIdentityHistory(sync.history);
|
|
2045
|
+
keys.pendingSyncSecret?.fill(0);
|
|
2046
|
+
keys.pendingSyncSecret = undefined;
|
|
2047
|
+
keys.pendingSyncPublicKey = undefined;
|
|
2048
|
+
}
|
|
2049
|
+
async grantNetworkOrigin(pluginId, origin, options = {}) {
|
|
2050
|
+
const normalized = normalizeOrigin(origin);
|
|
2051
|
+
const url = new URL(normalized);
|
|
2052
|
+
const privateNetwork = isLoopback(url.hostname) || isPrivateIpv4(url.hostname);
|
|
2053
|
+
if (privateNetwork && !options.allowPrivateNetwork) {
|
|
2054
|
+
throw new Error("Private or loopback network grants must be explicit");
|
|
2055
|
+
}
|
|
2056
|
+
if ((url.protocol === "http:" || url.protocol === "ws:") &&
|
|
2057
|
+
!isLoopback(url.hostname)) {
|
|
2058
|
+
throw new Error("Insecure network grants are allowed only for loopback development");
|
|
2059
|
+
}
|
|
2060
|
+
const grants = (await this.database.getMeta(networkGrantKey(pluginId))) ?? [];
|
|
2061
|
+
const next = [
|
|
2062
|
+
...grants.filter((grant) => grant.origin !== normalized),
|
|
2063
|
+
{
|
|
2064
|
+
origin: normalized,
|
|
2065
|
+
allowPrivateNetwork: options.allowPrivateNetwork ?? false,
|
|
2066
|
+
grantedAt: Date.now(),
|
|
2067
|
+
},
|
|
2068
|
+
].sort((left, right) => left.origin.localeCompare(right.origin));
|
|
2069
|
+
await this.database.setMeta(networkGrantKey(pluginId), next);
|
|
2070
|
+
}
|
|
2071
|
+
async hasNetworkOriginGrant(pluginId, origin) {
|
|
2072
|
+
const normalized = normalizeOrigin(origin);
|
|
2073
|
+
const grants = (await this.database.getMeta(networkGrantKey(pluginId))) ?? [];
|
|
2074
|
+
const grant = grants.find((candidate) => candidate.origin === normalized);
|
|
2075
|
+
if (!grant)
|
|
2076
|
+
return false;
|
|
2077
|
+
try {
|
|
2078
|
+
validateGrantedUrl(new URL(normalized), grant);
|
|
2079
|
+
return true;
|
|
2080
|
+
}
|
|
2081
|
+
catch {
|
|
2082
|
+
return false;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
async revokeNetworkOrigin(pluginId, origin) {
|
|
2086
|
+
const normalized = normalizeOrigin(origin);
|
|
2087
|
+
const grants = (await this.database.getMeta(networkGrantKey(pluginId))) ?? [];
|
|
2088
|
+
await this.database.setMeta(networkGrantKey(pluginId), grants.filter((grant) => grant.origin !== normalized));
|
|
2089
|
+
for (const socket of this.#sockets.get(pluginId) ?? []) {
|
|
2090
|
+
if (new URL(socket.url).origin === normalized)
|
|
2091
|
+
socket.close(1008, "Network grant revoked");
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
revokePlugin(pluginId) {
|
|
2095
|
+
this.lock(pluginId);
|
|
2096
|
+
for (const [key, session] of this.#pairingSessions) {
|
|
2097
|
+
if (session.pluginId !== pluginId)
|
|
2098
|
+
continue;
|
|
2099
|
+
this.#expirePairingSession(session, "cancelled");
|
|
2100
|
+
this.#pairingSessions.delete(key);
|
|
2101
|
+
}
|
|
2102
|
+
for (const request of this.#requests.get(pluginId) ?? []) {
|
|
2103
|
+
request.abort(new DOMException("Plugin capability revoked", "AbortError"));
|
|
2104
|
+
request.dispose();
|
|
2105
|
+
}
|
|
2106
|
+
this.#requests.delete(pluginId);
|
|
2107
|
+
for (const socket of this.#sockets.get(pluginId) ?? []) {
|
|
2108
|
+
socket.close(1008, "Plugin capability revoked");
|
|
2109
|
+
}
|
|
2110
|
+
this.#sockets.delete(pluginId);
|
|
2111
|
+
}
|
|
2112
|
+
network(pluginId, runtimeId, assertGranted = () => undefined, requestOriginGrant) {
|
|
2113
|
+
const findGrant = async (url) => {
|
|
2114
|
+
assertGranted();
|
|
2115
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
2116
|
+
const grants = (await this.database.getMeta(networkGrantKey(pluginId))) ?? [];
|
|
2117
|
+
const grant = grants.find((candidate) => candidate.origin === url.origin);
|
|
2118
|
+
if (!grant)
|
|
2119
|
+
throw new Error(`Network origin is not granted: ${url.origin}`);
|
|
2120
|
+
validateGrantedUrl(url, grant);
|
|
2121
|
+
return grant;
|
|
2122
|
+
};
|
|
2123
|
+
return {
|
|
2124
|
+
requestOriginGrant: async (origin, options) => {
|
|
2125
|
+
assertGranted();
|
|
2126
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
2127
|
+
if (!requestOriginGrant) {
|
|
2128
|
+
throw new Error(`The host has no network grant flow for ${pluginId}`);
|
|
2129
|
+
}
|
|
2130
|
+
await requestOriginGrant(origin, options);
|
|
2131
|
+
},
|
|
2132
|
+
request: async (request) => {
|
|
2133
|
+
let url = new URL(request.url);
|
|
2134
|
+
await findGrant(url);
|
|
2135
|
+
const abort = combineAbort(request.signal, request.timeoutMs ?? 30_000);
|
|
2136
|
+
const requests = this.#requests.get(pluginId) ??
|
|
2137
|
+
new Set();
|
|
2138
|
+
requests.add(abort);
|
|
2139
|
+
this.#requests.set(pluginId, requests);
|
|
2140
|
+
let disposed = false;
|
|
2141
|
+
const disposeRequest = () => {
|
|
2142
|
+
if (disposed)
|
|
2143
|
+
return;
|
|
2144
|
+
disposed = true;
|
|
2145
|
+
abort.dispose();
|
|
2146
|
+
requests.delete(abort);
|
|
2147
|
+
if (requests.size === 0)
|
|
2148
|
+
this.#requests.delete(pluginId);
|
|
2149
|
+
};
|
|
2150
|
+
abort.signal.addEventListener("abort", disposeRequest, { once: true });
|
|
2151
|
+
try {
|
|
2152
|
+
let response;
|
|
2153
|
+
for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
|
|
2154
|
+
response = await this.#fetch(url, {
|
|
2155
|
+
method: request.method ?? "GET",
|
|
2156
|
+
headers: request.headers,
|
|
2157
|
+
body: request.body,
|
|
2158
|
+
credentials: "omit",
|
|
2159
|
+
redirect: "manual",
|
|
2160
|
+
signal: abort.signal,
|
|
2161
|
+
...(request.body instanceof ReadableStream
|
|
2162
|
+
? { duplex: "half" }
|
|
2163
|
+
: {}),
|
|
2164
|
+
});
|
|
2165
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
2166
|
+
break;
|
|
2167
|
+
const location = response.headers.get("location");
|
|
2168
|
+
if (!location)
|
|
2169
|
+
break;
|
|
2170
|
+
if (redirect === MAX_REDIRECTS)
|
|
2171
|
+
throw new Error("Network redirect limit exceeded");
|
|
2172
|
+
await response.body?.cancel();
|
|
2173
|
+
url = new URL(location, url);
|
|
2174
|
+
await findGrant(url);
|
|
2175
|
+
}
|
|
2176
|
+
if (!response)
|
|
2177
|
+
throw new Error("Network request did not produce a response");
|
|
2178
|
+
const headers = Object.fromEntries(response.headers.entries());
|
|
2179
|
+
const body = response.body
|
|
2180
|
+
? keepRequestAlive(response.body, disposeRequest)
|
|
2181
|
+
: null;
|
|
2182
|
+
if (!body)
|
|
2183
|
+
disposeRequest();
|
|
2184
|
+
return {
|
|
2185
|
+
url: response.url || url.href,
|
|
2186
|
+
status: response.status,
|
|
2187
|
+
statusText: response.statusText,
|
|
2188
|
+
headers,
|
|
2189
|
+
body,
|
|
2190
|
+
arrayBuffer: () => new Response(body).arrayBuffer(),
|
|
2191
|
+
text: () => new Response(body).text(),
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
catch (error) {
|
|
2195
|
+
disposeRequest();
|
|
2196
|
+
throw error;
|
|
2197
|
+
}
|
|
2198
|
+
},
|
|
2199
|
+
connectWebSocket: async (input, protocols) => {
|
|
2200
|
+
const url = new URL(input);
|
|
2201
|
+
await findGrant(url);
|
|
2202
|
+
if (!this.#WebSocket)
|
|
2203
|
+
throw new Error("WebSocket is unavailable in this host");
|
|
2204
|
+
const socket = new this.#WebSocket(url, protocols);
|
|
2205
|
+
const sockets = this.#sockets.get(pluginId) ?? new Set();
|
|
2206
|
+
sockets.add(socket);
|
|
2207
|
+
this.#sockets.set(pluginId, sockets);
|
|
2208
|
+
socket.addEventListener("close", () => sockets.delete(socket), {
|
|
2209
|
+
once: true,
|
|
2210
|
+
});
|
|
2211
|
+
const ready = new Promise((resolve, reject) => {
|
|
2212
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
2213
|
+
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {
|
|
2214
|
+
once: true,
|
|
2215
|
+
});
|
|
2216
|
+
});
|
|
2217
|
+
return {
|
|
2218
|
+
get url() {
|
|
2219
|
+
return socket.url;
|
|
2220
|
+
},
|
|
2221
|
+
get readyState() {
|
|
2222
|
+
return socket.readyState;
|
|
2223
|
+
},
|
|
2224
|
+
ready,
|
|
2225
|
+
addEventListener: (type, listener) => socket.addEventListener(type, listener),
|
|
2226
|
+
removeEventListener: (type, listener) => socket.removeEventListener(type, listener),
|
|
2227
|
+
send: (data) => {
|
|
2228
|
+
assertGranted();
|
|
2229
|
+
this.assertRuntime(pluginId, runtimeId);
|
|
2230
|
+
socket.send(data);
|
|
2231
|
+
},
|
|
2232
|
+
close: (code, reason) => socket.close(code, reason),
|
|
2233
|
+
};
|
|
2234
|
+
},
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
}
|