@drakkar.software/starfish-client 3.0.0-alpha.14 → 3.0.0-alpha.18
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/README.md +16 -0
- package/dist/append.d.ts +50 -0
- package/dist/bindings/broadcast.d.ts +19 -0
- package/dist/bindings/broadcast.js +65 -0
- package/dist/bindings/react.d.ts +12 -0
- package/dist/bindings/react.js +25 -0
- package/dist/bindings/zustand.d.ts +27 -1
- package/dist/bindings/zustand.js +126 -8
- package/dist/bindings/zustand.js.map +2 -2
- package/dist/client.d.ts +27 -0
- package/dist/client.js +37 -316
- package/dist/crypto.js +49 -0
- package/dist/entitlements.js +41 -0
- package/dist/group-crypto.d.ts +111 -0
- package/dist/group-crypto.js +205 -0
- package/dist/group-crypto.js.map +7 -0
- package/dist/identity.d.ts +82 -4
- package/dist/identity.js +354 -2
- package/dist/identity.js.map +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +109 -4
- package/dist/index.js.map +2 -2
- package/dist/mobile-lifecycle.js +2 -41
- package/dist/polling.js +2 -2
- package/dist/sync.d.ts +18 -0
- package/dist/sync.js +14 -68
- package/dist/types.d.ts +43 -0
- package/package.json +2 -2
- package/dist/_crypto_helpers.d.ts +0 -4
- package/dist/append-log.js +0 -267
- package/dist/cap-mint.d.ts +0 -20
- package/dist/cap-mint.js +0 -12
- package/dist/cap-mint.js.map +0 -7
- package/dist/directory.d.ts +0 -9
- package/dist/directory.js +0 -24
- package/dist/directory.js.map +0 -7
- package/dist/keyring.d.ts +0 -6
- package/dist/keyring.js +0 -26
- package/dist/keyring.js.map +0 -7
- package/dist/pairing.d.ts +0 -6
- package/dist/pairing.js +0 -26
- package/dist/pairing.js.map +0 -7
- package/dist/recipients.d.ts +0 -6
- package/dist/recipients.js +0 -16
- package/dist/recipients.js.map +0 -7
package/dist/identity.js
CHANGED
|
@@ -1,6 +1,358 @@
|
|
|
1
1
|
// src/identity.ts
|
|
2
|
-
import {
|
|
2
|
+
import { getCrypto as getCrypto3, getBase64 as getBase643 } from "@drakkar.software/starfish-protocol";
|
|
3
|
+
|
|
4
|
+
// src/group-crypto.ts
|
|
5
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
6
|
+
import { getCrypto as getCrypto2, getBase64 as getBase642, IV_BYTES as IV_BYTES2, deriveKey as deriveKey2 } from "@drakkar.software/starfish-protocol";
|
|
7
|
+
|
|
8
|
+
// src/crypto.ts
|
|
9
|
+
import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
|
|
10
|
+
|
|
11
|
+
// src/group-crypto.ts
|
|
12
|
+
function bytesToHex(bytes) {
|
|
13
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
14
|
+
}
|
|
15
|
+
var GROUP_ECDH_DOMAIN = "starfish-group-ecdh";
|
|
16
|
+
async function deriveGroupKeyPair(passphrase, userId) {
|
|
17
|
+
const c = getCrypto2();
|
|
18
|
+
const enc = new TextEncoder();
|
|
19
|
+
const input = enc.encode(`${passphrase}:${userId}:${GROUP_ECDH_DOMAIN}`);
|
|
20
|
+
const hash = await c.subtle.digest("SHA-256", input);
|
|
21
|
+
const privateKeyBytes = new Uint8Array(hash);
|
|
22
|
+
const publicKeyBytes = x25519.getPublicKey(privateKeyBytes);
|
|
23
|
+
return { privateKey: bytesToHex(privateKeyBytes), publicKey: bytesToHex(publicKeyBytes) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/identity.ts
|
|
27
|
+
var WORDLIST = [
|
|
28
|
+
"able",
|
|
29
|
+
"acid",
|
|
30
|
+
"aged",
|
|
31
|
+
"also",
|
|
32
|
+
"area",
|
|
33
|
+
"army",
|
|
34
|
+
"away",
|
|
35
|
+
"back",
|
|
36
|
+
"ball",
|
|
37
|
+
"band",
|
|
38
|
+
"bank",
|
|
39
|
+
"base",
|
|
40
|
+
"bath",
|
|
41
|
+
"bean",
|
|
42
|
+
"bear",
|
|
43
|
+
"beat",
|
|
44
|
+
"bell",
|
|
45
|
+
"best",
|
|
46
|
+
"bird",
|
|
47
|
+
"blue",
|
|
48
|
+
"boat",
|
|
49
|
+
"bold",
|
|
50
|
+
"bolt",
|
|
51
|
+
"bone",
|
|
52
|
+
"born",
|
|
53
|
+
"bowl",
|
|
54
|
+
"burn",
|
|
55
|
+
"calm",
|
|
56
|
+
"call",
|
|
57
|
+
"camp",
|
|
58
|
+
"card",
|
|
59
|
+
"care",
|
|
60
|
+
"cash",
|
|
61
|
+
"cast",
|
|
62
|
+
"cave",
|
|
63
|
+
"city",
|
|
64
|
+
"clam",
|
|
65
|
+
"clay",
|
|
66
|
+
"clip",
|
|
67
|
+
"coal",
|
|
68
|
+
"coat",
|
|
69
|
+
"coin",
|
|
70
|
+
"cold",
|
|
71
|
+
"cook",
|
|
72
|
+
"cool",
|
|
73
|
+
"corn",
|
|
74
|
+
"cost",
|
|
75
|
+
"cozy",
|
|
76
|
+
"dark",
|
|
77
|
+
"data",
|
|
78
|
+
"dawn",
|
|
79
|
+
"dead",
|
|
80
|
+
"deal",
|
|
81
|
+
"deck",
|
|
82
|
+
"deep",
|
|
83
|
+
"dew",
|
|
84
|
+
"dish",
|
|
85
|
+
"dome",
|
|
86
|
+
"door",
|
|
87
|
+
"down",
|
|
88
|
+
"draw",
|
|
89
|
+
"drop",
|
|
90
|
+
"drum",
|
|
91
|
+
"dusk",
|
|
92
|
+
"dust",
|
|
93
|
+
"each",
|
|
94
|
+
"earn",
|
|
95
|
+
"east",
|
|
96
|
+
"edge",
|
|
97
|
+
"even",
|
|
98
|
+
"ever",
|
|
99
|
+
"face",
|
|
100
|
+
"fact",
|
|
101
|
+
"fair",
|
|
102
|
+
"fall",
|
|
103
|
+
"fame",
|
|
104
|
+
"farm",
|
|
105
|
+
"fast",
|
|
106
|
+
"felt",
|
|
107
|
+
"file",
|
|
108
|
+
"fill",
|
|
109
|
+
"fire",
|
|
110
|
+
"fish",
|
|
111
|
+
"fist",
|
|
112
|
+
"flag",
|
|
113
|
+
"flat",
|
|
114
|
+
"flew",
|
|
115
|
+
"flow",
|
|
116
|
+
"foam",
|
|
117
|
+
"fold",
|
|
118
|
+
"fond",
|
|
119
|
+
"food",
|
|
120
|
+
"foot",
|
|
121
|
+
"form",
|
|
122
|
+
"frog",
|
|
123
|
+
"fuel",
|
|
124
|
+
"full",
|
|
125
|
+
"gain",
|
|
126
|
+
"game",
|
|
127
|
+
"gate",
|
|
128
|
+
"gave",
|
|
129
|
+
"gaze",
|
|
130
|
+
"gift",
|
|
131
|
+
"glad",
|
|
132
|
+
"glow",
|
|
133
|
+
"glue",
|
|
134
|
+
"goal",
|
|
135
|
+
"good",
|
|
136
|
+
"grab",
|
|
137
|
+
"gray",
|
|
138
|
+
"grip",
|
|
139
|
+
"grow",
|
|
140
|
+
"gulf",
|
|
141
|
+
"gust",
|
|
142
|
+
"half",
|
|
143
|
+
"hall",
|
|
144
|
+
"hand",
|
|
145
|
+
"hard",
|
|
146
|
+
"harm",
|
|
147
|
+
"have",
|
|
148
|
+
"hawk",
|
|
149
|
+
"head",
|
|
150
|
+
"heal",
|
|
151
|
+
"heap",
|
|
152
|
+
"heat",
|
|
153
|
+
"held",
|
|
154
|
+
"helm",
|
|
155
|
+
"help",
|
|
156
|
+
"herb",
|
|
157
|
+
"here",
|
|
158
|
+
"hero",
|
|
159
|
+
"high",
|
|
160
|
+
"hill",
|
|
161
|
+
"hint",
|
|
162
|
+
"hold",
|
|
163
|
+
"hole",
|
|
164
|
+
"home",
|
|
165
|
+
"hope",
|
|
166
|
+
"horn",
|
|
167
|
+
"hour",
|
|
168
|
+
"huge",
|
|
169
|
+
"hunt",
|
|
170
|
+
"idea",
|
|
171
|
+
"inch",
|
|
172
|
+
"into",
|
|
173
|
+
"iris",
|
|
174
|
+
"isle",
|
|
175
|
+
"jade",
|
|
176
|
+
"jail",
|
|
177
|
+
"join",
|
|
178
|
+
"jump",
|
|
179
|
+
"just",
|
|
180
|
+
"keep",
|
|
181
|
+
"kind",
|
|
182
|
+
"king",
|
|
183
|
+
"knot",
|
|
184
|
+
"know",
|
|
185
|
+
"lack",
|
|
186
|
+
"lake",
|
|
187
|
+
"land",
|
|
188
|
+
"lane",
|
|
189
|
+
"last",
|
|
190
|
+
"late",
|
|
191
|
+
"lawn",
|
|
192
|
+
"lead",
|
|
193
|
+
"leaf",
|
|
194
|
+
"lean",
|
|
195
|
+
"leap",
|
|
196
|
+
"left",
|
|
197
|
+
"lend",
|
|
198
|
+
"less",
|
|
199
|
+
"life",
|
|
200
|
+
"lift",
|
|
201
|
+
"like",
|
|
202
|
+
"lime",
|
|
203
|
+
"line",
|
|
204
|
+
"lion",
|
|
205
|
+
"list",
|
|
206
|
+
"live",
|
|
207
|
+
"load",
|
|
208
|
+
"lock",
|
|
209
|
+
"loft",
|
|
210
|
+
"long",
|
|
211
|
+
"look",
|
|
212
|
+
"loop",
|
|
213
|
+
"loud",
|
|
214
|
+
"love",
|
|
215
|
+
"luck",
|
|
216
|
+
"lung",
|
|
217
|
+
"made",
|
|
218
|
+
"main",
|
|
219
|
+
"mark",
|
|
220
|
+
"mast",
|
|
221
|
+
"math",
|
|
222
|
+
"maze",
|
|
223
|
+
"meal",
|
|
224
|
+
"meet",
|
|
225
|
+
"melt",
|
|
226
|
+
"mild",
|
|
227
|
+
"mind",
|
|
228
|
+
"mint",
|
|
229
|
+
"mist",
|
|
230
|
+
"mode",
|
|
231
|
+
"moon",
|
|
232
|
+
"more",
|
|
233
|
+
"most",
|
|
234
|
+
"move",
|
|
235
|
+
"much",
|
|
236
|
+
"must",
|
|
237
|
+
"name",
|
|
238
|
+
"near",
|
|
239
|
+
"neck",
|
|
240
|
+
"need",
|
|
241
|
+
"next",
|
|
242
|
+
"nice",
|
|
243
|
+
"nine",
|
|
244
|
+
"none",
|
|
245
|
+
"noon",
|
|
246
|
+
"note",
|
|
247
|
+
"noun",
|
|
248
|
+
"oath",
|
|
249
|
+
"once",
|
|
250
|
+
"open",
|
|
251
|
+
"oval",
|
|
252
|
+
"over",
|
|
253
|
+
"pack",
|
|
254
|
+
"page",
|
|
255
|
+
"paid",
|
|
256
|
+
"pain",
|
|
257
|
+
"pale",
|
|
258
|
+
"palm",
|
|
259
|
+
"park",
|
|
260
|
+
"part",
|
|
261
|
+
"path",
|
|
262
|
+
"pave",
|
|
263
|
+
"peak",
|
|
264
|
+
"pier",
|
|
265
|
+
"pile",
|
|
266
|
+
"pine",
|
|
267
|
+
"pipe",
|
|
268
|
+
"plan",
|
|
269
|
+
"plum",
|
|
270
|
+
"poem",
|
|
271
|
+
"pole",
|
|
272
|
+
"pool",
|
|
273
|
+
"port",
|
|
274
|
+
"pose",
|
|
275
|
+
"post",
|
|
276
|
+
"pray",
|
|
277
|
+
"prey",
|
|
278
|
+
"pull",
|
|
279
|
+
"pure",
|
|
280
|
+
"push",
|
|
281
|
+
"quit",
|
|
282
|
+
"race",
|
|
283
|
+
"rack"
|
|
284
|
+
];
|
|
285
|
+
function bytesToHex2(bytes) {
|
|
286
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
287
|
+
}
|
|
288
|
+
async function sha256Hex(input) {
|
|
289
|
+
const c = getCrypto3();
|
|
290
|
+
const encoded = new TextEncoder().encode(input);
|
|
291
|
+
const hash = await c.subtle.digest("SHA-256", encoded);
|
|
292
|
+
return bytesToHex2(new Uint8Array(hash));
|
|
293
|
+
}
|
|
294
|
+
function base64UrlEncode(data) {
|
|
295
|
+
const b64 = getBase643();
|
|
296
|
+
return b64.encode(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
297
|
+
}
|
|
298
|
+
function base64UrlDecode(encoded) {
|
|
299
|
+
const b64 = getBase643();
|
|
300
|
+
const padded = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
301
|
+
const rem = padded.length % 4;
|
|
302
|
+
const padded2 = rem === 0 ? padded : padded + "=".repeat(4 - rem);
|
|
303
|
+
return b64.decode(padded2);
|
|
304
|
+
}
|
|
305
|
+
function generatePassphrase(wordCount = 12, wordlist = WORDLIST) {
|
|
306
|
+
if (wordlist.length !== 256) {
|
|
307
|
+
throw new Error(`Word list must have exactly 256 entries, got ${wordlist.length}`);
|
|
308
|
+
}
|
|
309
|
+
const c = getCrypto3();
|
|
310
|
+
const bytes = c.getRandomValues(new Uint8Array(wordCount));
|
|
311
|
+
return Array.from(bytes, (b) => wordlist[b]).join(" ");
|
|
312
|
+
}
|
|
313
|
+
async function deriveCredentials(passphrase) {
|
|
314
|
+
if (!passphrase.trim()) throw new Error("Passphrase must not be empty");
|
|
315
|
+
const authToken = await sha256Hex(passphrase);
|
|
316
|
+
const userId = authToken.slice(0, 16);
|
|
317
|
+
const encryptionSecret = await sha256Hex(`${passphrase}:${userId}`);
|
|
318
|
+
const { publicKey: groupPublicKey, privateKey: groupPrivateKey } = await deriveGroupKeyPair(
|
|
319
|
+
passphrase,
|
|
320
|
+
userId
|
|
321
|
+
);
|
|
322
|
+
return {
|
|
323
|
+
authToken,
|
|
324
|
+
userId,
|
|
325
|
+
encryptionSecret,
|
|
326
|
+
encryptionSalt: userId,
|
|
327
|
+
groupPublicKey,
|
|
328
|
+
groupPrivateKey
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function buildInviteUrl(baseUrl, payload) {
|
|
332
|
+
const json = JSON.stringify(payload);
|
|
333
|
+
const bytes = new TextEncoder().encode(json);
|
|
334
|
+
const token = base64UrlEncode(bytes);
|
|
335
|
+
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
336
|
+
return `${baseUrl}${separator}t=${token}`;
|
|
337
|
+
}
|
|
338
|
+
function parseInviteUrl(url) {
|
|
339
|
+
try {
|
|
340
|
+
const tokenMatch = url.match(/[?&]t=([^&]+)/);
|
|
341
|
+
if (!tokenMatch?.[1]) return null;
|
|
342
|
+
const bytes = base64UrlDecode(tokenMatch[1]);
|
|
343
|
+
const json = new TextDecoder().decode(bytes);
|
|
344
|
+
const parsed = JSON.parse(json);
|
|
345
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
346
|
+
return parsed;
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
3
351
|
export {
|
|
4
|
-
|
|
352
|
+
WORDLIST as DEFAULT_WORDLIST,
|
|
353
|
+
buildInviteUrl,
|
|
354
|
+
deriveCredentials,
|
|
355
|
+
generatePassphrase,
|
|
356
|
+
parseInviteUrl
|
|
5
357
|
};
|
|
6
358
|
//# sourceMappingURL=identity.js.map
|
package/dist/identity.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/identity.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Phase-2 transitional shim. The implementation lives in\n * `@drakkar.software/starfish-identities`. Removed in Phase 3.\n */\nexport { deriveRootIdentity } from \"@drakkar.software/starfish-identities\"\nexport type { RootIdentity, RootKeyPair } from \"@drakkar.software/starfish-identities\"\n"],
|
|
5
|
-
"mappings": ";
|
|
6
|
-
"names": []
|
|
3
|
+
"sources": ["../src/identity.ts", "../src/group-crypto.ts", "../src/crypto.ts"],
|
|
4
|
+
"sourcesContent": ["import { getCrypto, getBase64 } from \"@drakkar.software/starfish-protocol\"\nimport { deriveGroupKeyPair } from \"./group-crypto.js\"\n\n// \u2500\u2500 Word list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// 256 common English words, one per index (0-255). One byte of entropy per word.\n// 12 words = 96 bits of entropy (stronger than a random UUID).\n\nconst WORDLIST: string[] = [\n \"able\", \"acid\", \"aged\", \"also\", \"area\", \"army\", \"away\", \"back\",\n \"ball\", \"band\", \"bank\", \"base\", \"bath\", \"bean\", \"bear\", \"beat\",\n \"bell\", \"best\", \"bird\", \"blue\", \"boat\", \"bold\", \"bolt\", \"bone\",\n \"born\", \"bowl\", \"burn\", \"calm\", \"call\", \"camp\", \"card\", \"care\",\n \"cash\", \"cast\", \"cave\", \"city\", \"clam\", \"clay\", \"clip\", \"coal\",\n \"coat\", \"coin\", \"cold\", \"cook\", \"cool\", \"corn\", \"cost\", \"cozy\",\n \"dark\", \"data\", \"dawn\", \"dead\", \"deal\", \"deck\", \"deep\", \"dew\",\n \"dish\", \"dome\", \"door\", \"down\", \"draw\", \"drop\", \"drum\", \"dusk\",\n \"dust\", \"each\", \"earn\", \"east\", \"edge\", \"even\", \"ever\", \"face\",\n \"fact\", \"fair\", \"fall\", \"fame\", \"farm\", \"fast\", \"felt\", \"file\",\n \"fill\", \"fire\", \"fish\", \"fist\", \"flag\", \"flat\", \"flew\", \"flow\",\n \"foam\", \"fold\", \"fond\", \"food\", \"foot\", \"form\", \"frog\", \"fuel\",\n \"full\", \"gain\", \"game\", \"gate\", \"gave\", \"gaze\", \"gift\", \"glad\",\n \"glow\", \"glue\", \"goal\", \"good\", \"grab\", \"gray\", \"grip\", \"grow\",\n \"gulf\", \"gust\", \"half\", \"hall\", \"hand\", \"hard\", \"harm\", \"have\",\n \"hawk\", \"head\", \"heal\", \"heap\", \"heat\", \"held\", \"helm\", \"help\",\n \"herb\", \"here\", \"hero\", \"high\", \"hill\", \"hint\", \"hold\", \"hole\",\n \"home\", \"hope\", \"horn\", \"hour\", \"huge\", \"hunt\", \"idea\", \"inch\",\n \"into\", \"iris\", \"isle\", \"jade\", \"jail\", \"join\", \"jump\", \"just\",\n \"keep\", \"kind\", \"king\", \"knot\", \"know\", \"lack\", \"lake\", \"land\",\n \"lane\", \"last\", \"late\", \"lawn\", \"lead\", \"leaf\", \"lean\", \"leap\",\n \"left\", \"lend\", \"less\", \"life\", \"lift\", \"like\", \"lime\", \"line\",\n \"lion\", \"list\", \"live\", \"load\", \"lock\", \"loft\", \"long\", \"look\",\n \"loop\", \"loud\", \"love\", \"luck\", \"lung\", \"made\", \"main\", \"mark\",\n \"mast\", \"math\", \"maze\", \"meal\", \"meet\", \"melt\", \"mild\", \"mind\",\n \"mint\", \"mist\", \"mode\", \"moon\", \"more\", \"most\", \"move\", \"much\",\n \"must\", \"name\", \"near\", \"neck\", \"need\", \"next\", \"nice\", \"nine\",\n \"none\", \"noon\", \"note\", \"noun\", \"oath\", \"once\", \"open\", \"oval\",\n \"over\", \"pack\", \"page\", \"paid\", \"pain\", \"pale\", \"palm\", \"park\",\n \"part\", \"path\", \"pave\", \"peak\", \"pier\", \"pile\", \"pine\", \"pipe\",\n \"plan\", \"plum\", \"poem\", \"pole\", \"pool\", \"port\", \"pose\", \"post\",\n \"pray\", \"prey\", \"pull\", \"pure\", \"push\", \"quit\", \"race\", \"rack\",\n]\n\n// \u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Credentials derived from a passphrase. Pass directly to SyncManager / StarfishClient. */\nexport interface DerivedCredentials {\n /** Hex-encoded auth token. Use as `Bearer ${authToken}` in request headers. */\n authToken: string\n /**\n * Short identifier derived from the auth token (first 16 hex chars = 8 bytes).\n * Used as the user/namespace segment in collection paths.\n */\n userId: string\n /**\n * Hex-encoded key suitable as `encryptionSecret` for SyncManager.\n * Combined with `encryptionSalt` to derive the AES-256-GCM key via HKDF.\n */\n encryptionSecret: string\n /**\n * Value suitable as `encryptionSalt` for SyncManager. Equals `userId`.\n * Using a per-identity salt ensures that even if two users share a passphrase,\n * their encryption keys are different.\n */\n encryptionSalt: string\n /**\n * Hex-encoded X25519 public key for group encryption.\n * Publish this so group admins can wrap the Group Encryption Key (GEK) for you.\n * Safe to store in a public Starfish document.\n */\n groupPublicKey: string\n /**\n * Hex-encoded X25519 private key for group encryption.\n * Used to unwrap the GEK from a keyring document.\n * Never transmit this \u2014 keep it in memory or derive it from the passphrase.\n */\n groupPrivateKey: string\n}\n\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction bytesToHex(bytes: Uint8Array): string {\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\")\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const c = getCrypto()\n const encoded = new TextEncoder().encode(input)\n const hash = await c.subtle.digest(\"SHA-256\", encoded)\n return bytesToHex(new Uint8Array(hash))\n}\n\n// URL-safe base64 (RFC 4648 \u00A75): replaces + \u2192 -, / \u2192 _, strips trailing =\nfunction base64UrlEncode(data: Uint8Array): string {\n const b64 = getBase64()\n return b64\n .encode(data)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\")\n}\n\nfunction base64UrlDecode(encoded: string): Uint8Array {\n const b64 = getBase64()\n // Restore standard base64 padding\n const padded = encoded.replace(/-/g, \"+\").replace(/_/g, \"/\")\n const rem = padded.length % 4\n const padded2 = rem === 0 ? padded : padded + \"=\".repeat(4 - rem)\n return b64.decode(padded2)\n}\n\n// \u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Generates a cryptographically random passphrase from the built-in 256-word list.\n *\n * Each word represents one byte of entropy (256 words = one byte per word, zero modulo bias).\n * A 12-word passphrase gives 96 bits of entropy \u2014 stronger than a random UUID.\n *\n * @param wordCount Number of words (default: 12).\n * @param wordlist Custom word list (must have exactly 256 entries).\n */\nexport function generatePassphrase(wordCount = 12, wordlist: string[] = WORDLIST): string {\n if (wordlist.length !== 256) {\n throw new Error(`Word list must have exactly 256 entries, got ${wordlist.length}`)\n }\n const c = getCrypto()\n const bytes = c.getRandomValues(new Uint8Array(wordCount))\n return Array.from(bytes, (b) => wordlist[b]).join(\" \")\n}\n\n/**\n * Derives auth credentials from a passphrase.\n *\n * All derivations are deterministic \u2014 the same passphrase always produces the same credentials.\n * Sharing the passphrase grants access on any device.\n *\n * The returned values map directly to Starfish options:\n * ```ts\n * const creds = await deriveCredentials(passphrase)\n *\n * const client = new StarfishClient({\n * baseUrl: serverUrl,\n * auth: () => ({ Authorization: `Bearer ${creds.authToken}` }),\n * })\n * const syncManager = new SyncManager({\n * client,\n * pullPath: `/pull/${creds.userId}/wedding`,\n * pushPath: `/push/${creds.userId}/wedding`,\n * encryptionSecret: creds.encryptionSecret,\n * encryptionSalt: creds.encryptionSalt,\n * })\n * ```\n */\nexport async function deriveCredentials(passphrase: string): Promise<DerivedCredentials> {\n if (!passphrase.trim()) throw new Error(\"Passphrase must not be empty\")\n\n // authToken = SHA-256(passphrase) \u2014 used as Bearer token\n const authToken = await sha256Hex(passphrase)\n\n // userId = first 16 hex chars of authToken (8 bytes)\n const userId = authToken.slice(0, 16)\n\n // encryptionSecret = SHA-256(passphrase + \":\" + userId)\n // Domain separation from authToken ensures they cannot be recovered from each other.\n const encryptionSecret = await sha256Hex(`${passphrase}:${userId}`)\n\n // X25519 key pair for group encryption \u2014 deterministically derived from passphrase.\n const { publicKey: groupPublicKey, privateKey: groupPrivateKey } = await deriveGroupKeyPair(\n passphrase,\n userId,\n )\n\n return {\n authToken,\n userId,\n encryptionSecret,\n encryptionSalt: userId,\n groupPublicKey,\n groupPrivateKey,\n }\n}\n\n/**\n * Encodes an invite payload as a URL-safe token and appends it as `?t=<token>`.\n *\n * ```ts\n * const url = buildInviteUrl(\"myapp://join\", { name: \"Alice & Bob\", p: passphrase })\n * // \u2192 \"myapp://join?t=eyJuYW1lIjoiQWxpY2UgJiBCb2IifQ\"\n * ```\n */\nexport function buildInviteUrl(baseUrl: string, payload: Record<string, unknown>): string {\n const json = JSON.stringify(payload)\n const bytes = new TextEncoder().encode(json)\n const token = base64UrlEncode(bytes)\n const separator = baseUrl.includes(\"?\") ? \"&\" : \"?\"\n return `${baseUrl}${separator}t=${token}`\n}\n\n/**\n * Decodes an invite URL produced by `buildInviteUrl`.\n *\n * Returns the decoded payload, or `null` if the URL is missing or malformed.\n */\nexport function parseInviteUrl(url: string): Record<string, unknown> | null {\n try {\n const tokenMatch = url.match(/[?&]t=([^&]+)/)\n if (!tokenMatch?.[1]) return null\n const bytes = base64UrlDecode(tokenMatch[1])\n const json = new TextDecoder().decode(bytes)\n const parsed: unknown = JSON.parse(json)\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) return null\n return parsed as Record<string, unknown>\n } catch {\n return null\n }\n}\n\n// Export word list for consumers that want to provide localized alternatives\nexport { WORDLIST as DEFAULT_WORDLIST }\n", "/**\n * Group encryption utilities for Starfish.\n *\n * Enables multiple users to share a common encrypted collection without sharing\n * a passphrase. Each member holds their own credentials; a Group Encryption Key\n * (GEK) is distributed per-member using X25519 ECDH key agreement.\n *\n * Typical flow:\n * 1. Each user calls `deriveCredentials(passphrase)` \u2014 now includes groupPublicKey / groupPrivateKey.\n * 2. Admin calls `createGroupKeyring(...)` to create a keyring document.\n * 3. Members call `createGroupEncryptor(keyringData, myIdentity, myPrivateKey)` to get an Encryptor.\n * 4. The Encryptor is passed to SyncManager via the `encryptor` option.\n */\n\nimport { x25519 } from \"@noble/curves/ed25519.js\"\nimport { getCrypto, getBase64, IV_BYTES, deriveKey } from \"@drakkar.software/starfish-protocol\"\nimport type { Encryptor } from \"./crypto.js\"\nimport { createEncryptor } from \"./crypto.js\"\n\n// \u2500\u2500 Internal helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction bytesToHex(bytes: Uint8Array): string {\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\")\n}\n\nfunction hexToBytes(hex: string): Uint8Array {\n const bytes = new Uint8Array(hex.length / 2)\n for (let i = 0; i < bytes.length; i++) {\n bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)\n }\n return bytes\n}\n\nconst ALGO = \"AES-GCM\"\nconst GROUP_WRAP_SALT = \"starfish-group-wrap\"\nconst GROUP_WRAP_INFO = \"starfish-group-wrap\"\nconst GROUP_ECDH_DOMAIN = \"starfish-group-ecdh\"\nconst GROUP_DATA_INFO = \"starfish-group\"\nconst GEK_BYTES = 32\n\n// \u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** An ECDH key pair used for group encryption. Hex-encoded for easy serialization. */\nexport interface GroupKeyPair {\n /** Hex-encoded X25519 private key (32 bytes). Keep secret \u2014 never store on server. */\n privateKey: string\n /** Hex-encoded X25519 public key (32 bytes). Safe to publish. */\n publicKey: string\n}\n\n/** One epoch's wrapped keys: each member's GEK encrypted to their public key. */\nexport interface EpochKeyring {\n /** The admin's hex-encoded X25519 public key (used for ECDH by members). */\n adminPublicKey: string\n /** Map from member identity (userId) \u2192 base64(IV || AES-GCM(GEK)) */\n wrappedKeys: Record<string, string>\n}\n\n/** The full keyring document stored in a Starfish collection. Push this with any SyncManager. */\nexport interface GroupKeyring {\n /** The epoch number currently used for new encryptions. */\n currentEpoch: number\n /** All epochs. Members unwrap the GEK for whichever epoch a document was encrypted with. */\n epochs: Record<string, EpochKeyring>\n}\n\n// \u2500\u2500 Key derivation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Derives a deterministic X25519 key pair from a passphrase + userId.\n *\n * The derivation uses SHA-256 with a fixed domain separator so it is distinct\n * from the auth token and encryption key derivations. Same passphrase + userId\n * always produces the same key pair on any device (stateless).\n */\nexport async function deriveGroupKeyPair(passphrase: string, userId: string): Promise<GroupKeyPair> {\n const c = getCrypto()\n const enc = new TextEncoder()\n const input = enc.encode(`${passphrase}:${userId}:${GROUP_ECDH_DOMAIN}`)\n const hash = await c.subtle.digest(\"SHA-256\", input)\n const privateKeyBytes = new Uint8Array(hash)\n const publicKeyBytes = x25519.getPublicKey(privateKeyBytes)\n return { privateKey: bytesToHex(privateKeyBytes), publicKey: bytesToHex(publicKeyBytes) }\n}\n\n// \u2500\u2500 GEK generation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Generates a random 256-bit Group Encryption Key as a hex string. */\nexport function generateGroupKey(): string {\n const c = getCrypto()\n return bytesToHex(c.getRandomValues(new Uint8Array(GEK_BYTES)))\n}\n\n// \u2500\u2500 Key wrapping / unwrapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Wraps a GEK for a specific member using ECDH key agreement.\n *\n * The wrapper (admin) and member each have an X25519 key pair. ECDH between\n * `wrapperPrivateKey` and `memberPublicKey` produces a shared secret, which is\n * used to derive an AES-256-GCM key that encrypts the GEK.\n *\n * @returns base64(IV || AES-GCM-ciphertext)\n */\nexport async function wrapGroupKey(\n gek: string,\n memberPublicKey: string,\n wrapperPrivateKey: string,\n): Promise<string> {\n const sharedSecret = x25519.getSharedSecret(hexToBytes(wrapperPrivateKey), hexToBytes(memberPublicKey))\n const wrappingKey = await deriveKey(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO)\n\n const c = getCrypto()\n const b64 = getBase64()\n const iv = c.getRandomValues(new Uint8Array(IV_BYTES))\n const encrypted = await c.subtle.encrypt({ name: ALGO, iv }, wrappingKey, hexToBytes(gek).buffer as ArrayBuffer)\n\n const combined = new Uint8Array(IV_BYTES + encrypted.byteLength)\n combined.set(iv)\n combined.set(new Uint8Array(encrypted), IV_BYTES)\n return b64.encode(combined)\n}\n\n/**\n * Unwraps a GEK using the member's own private key and the admin's public key.\n *\n * ECDH between `memberPrivateKey` and `adminPublicKey` yields the same shared\n * secret as the wrapping step, so the same AES key is derived and the GEK is\n * recovered.\n *\n * @returns GEK as a hex string\n */\nexport async function unwrapGroupKey(\n wrapped: string,\n memberPrivateKey: string,\n adminPublicKey: string,\n): Promise<string> {\n const sharedSecret = x25519.getSharedSecret(hexToBytes(memberPrivateKey), hexToBytes(adminPublicKey))\n const wrappingKey = await deriveKey(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO)\n\n const b64 = getBase64()\n const c = getCrypto()\n const combined = b64.decode(wrapped)\n const iv = combined.slice(0, IV_BYTES)\n const ciphertext = combined.slice(IV_BYTES)\n try {\n const decrypted = await c.subtle.decrypt({ name: ALGO, iv }, wrappingKey, ciphertext)\n return bytesToHex(new Uint8Array(decrypted))\n } catch {\n throw new Error(\"Failed to unwrap group key: decryption failed (wrong keys or corrupted data)\")\n }\n}\n\n// \u2500\u2500 Keyring management \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Creates a new group keyring document with epoch 1.\n *\n * @param adminKeyPair The admin's key pair (from `deriveGroupKeyPair` or `deriveCredentials`)\n * @param members Map from member identity (userId) \u2192 hex public key\n * @param gek Optional GEK to use; generated randomly if omitted\n * @returns The keyring document and the raw GEK (admin keeps the GEK to add future members)\n */\nexport async function createGroupKeyring(\n adminKeyPair: GroupKeyPair,\n members: Record<string, string>,\n gek?: string,\n): Promise<{ keyring: GroupKeyring; gek: string }> {\n const resolvedGek = gek ?? generateGroupKey()\n const wrappedKeys: Record<string, string> = {}\n for (const [memberId, memberPublicKey] of Object.entries(members)) {\n wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey)\n }\n const keyring: GroupKeyring = {\n currentEpoch: 1,\n epochs: {\n \"1\": { adminPublicKey: adminKeyPair.publicKey, wrappedKeys },\n },\n }\n return { keyring, gek: resolvedGek }\n}\n\n/**\n * Adds a new member to the current epoch of an existing keyring.\n *\n * The admin supplies the current GEK (returned by `createGroupKeyring` or\n * `rotateGroupKey`) and their key pair to wrap it for the new member.\n * This does NOT rotate the GEK \u2014 the new member can read all existing\n * documents encrypted with the current epoch key.\n *\n * Only the admin (whose `publicKey` matches `epochKeyring.adminPublicKey`) can\n * add members, because all wrapped entries must use the same ECDH key pair.\n */\nexport async function addGroupMember(\n keyring: GroupKeyring,\n adminKeyPair: GroupKeyPair,\n currentGek: string,\n newMemberId: string,\n newMemberPublicKey: string,\n): Promise<GroupKeyring> {\n const epochKey = String(keyring.currentEpoch)\n const epochKeyring = keyring.epochs[epochKey]\n if (!epochKeyring) throw new Error(`Epoch ${keyring.currentEpoch} not found in keyring`)\n if (epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {\n throw new Error(`Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`)\n }\n\n const wrapped = await wrapGroupKey(currentGek, newMemberPublicKey, adminKeyPair.privateKey)\n\n return {\n ...keyring,\n epochs: {\n ...keyring.epochs,\n [epochKey]: {\n ...epochKeyring,\n wrappedKeys: { ...epochKeyring.wrappedKeys, [newMemberId]: wrapped },\n },\n },\n }\n}\n\n/**\n * Rotates the group key, creating a new epoch.\n *\n * Used when removing a member. The removed member retains their old epoch key\n * (and can still read old documents), but cannot read new documents.\n *\n * @param remainingMembers Map from identity \u2192 hex public key for members who keep access\n */\nexport async function rotateGroupKey(\n keyring: GroupKeyring,\n adminKeyPair: GroupKeyPair,\n remainingMembers: Record<string, string>,\n newGek?: string,\n): Promise<{ keyring: GroupKeyring; gek: string }> {\n const epochKey = String(keyring.currentEpoch)\n const epochKeyring = keyring.epochs[epochKey]\n if (epochKeyring && epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {\n throw new Error(\n `Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`,\n )\n }\n const resolvedGek = newGek ?? generateGroupKey()\n const newEpoch = keyring.currentEpoch + 1\n const wrappedKeys: Record<string, string> = {}\n for (const [memberId, memberPublicKey] of Object.entries(remainingMembers)) {\n wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey)\n }\n const newKeyring: GroupKeyring = {\n currentEpoch: newEpoch,\n epochs: {\n ...keyring.epochs,\n [String(newEpoch)]: { adminPublicKey: adminKeyPair.publicKey, wrappedKeys },\n },\n }\n return { keyring: newKeyring, gek: resolvedGek }\n}\n\n// \u2500\u2500 Encryptor factory \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Creates an Encryptor that can decrypt any epoch and encrypts with the current epoch.\n *\n * Wire format: `{ _encrypted: \"base64(IV || ciphertext)\", _epoch: N }`\n *\n * @param keyring The keyring document fetched from Starfish\n * @param myIdentity The caller's userId (to locate their wrapped key in each epoch)\n * @param myPrivateKey The caller's hex-encoded X25519 private key\n */\nexport async function createGroupEncryptor(\n keyring: GroupKeyring,\n myIdentity: string,\n myPrivateKey: string,\n): Promise<Encryptor> {\n // Unwrap GEK for each epoch we have a key for\n const epochEncryptors = new Map<number, Encryptor>()\n for (const [epochStr, epochKeyring] of Object.entries(keyring.epochs)) {\n const epoch = parseInt(epochStr, 10)\n const wrapped = epochKeyring.wrappedKeys[myIdentity]\n if (!wrapped) continue\n const gek = await unwrapGroupKey(wrapped, myPrivateKey, epochKeyring.adminPublicKey)\n epochEncryptors.set(epoch, createEncryptor(gek, `epoch-${epoch}`, GROUP_DATA_INFO))\n }\n\n const currentEpoch = keyring.currentEpoch\n const currentEncryptor = epochEncryptors.get(currentEpoch)\n if (!currentEncryptor) {\n throw new Error(\n `No wrapped key found for identity \"${myIdentity}\" in epoch ${currentEpoch}. ` +\n `Ensure the admin has added this member to the keyring.`,\n )\n }\n\n return {\n async encrypt(data: Record<string, unknown>): Promise<Record<string, unknown>> {\n const encrypted = await currentEncryptor.encrypt(data)\n return { ...encrypted, _epoch: currentEpoch }\n },\n\n async decrypt(wrapper: Record<string, unknown>): Promise<Record<string, unknown>> {\n const epoch = typeof wrapper._epoch === \"number\" ? wrapper._epoch : currentEpoch\n const encryptor = epochEncryptors.get(epoch)\n if (!encryptor) {\n throw new Error(\n `No key available for epoch ${epoch}. ` +\n `This document was encrypted in a different epoch. ` +\n `Ensure your keyring is up to date.`,\n )\n }\n return encryptor.decrypt(wrapper)\n },\n }\n}\n", "import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from \"@drakkar.software/starfish-protocol\"\n\nconst ALGO = \"AES-GCM\"\n\nexport { ENCRYPTED_KEY }\n\n/** Encrypt/decrypt interface for client-side E2E encryption. */\nexport interface Encryptor {\n encrypt(data: Record<string, unknown>): Promise<Record<string, unknown>>\n decrypt(wrapper: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n/**\n * Creates an Encryptor that uses AES-256-GCM with HKDF-derived keys.\n */\nexport function createEncryptor(secret: string, salt: string, info: string = \"starfish-e2e\"): Encryptor {\n if (!secret) throw new Error(\"encryptionSecret must not be empty\")\n if (!salt) throw new Error(\"encryptionSalt must not be empty\")\n const keyPromise = deriveKey(secret, salt, info)\n\n return {\n async encrypt(data: Record<string, unknown>): Promise<Record<string, unknown>> {\n const key = await keyPromise\n const c = getCrypto()\n const b64 = getBase64()\n const plaintext = new TextEncoder().encode(JSON.stringify(data))\n const iv = c.getRandomValues(new Uint8Array(IV_BYTES))\n const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext)\n\n const combined = new Uint8Array(iv.length + ciphertext.byteLength)\n combined.set(iv)\n combined.set(new Uint8Array(ciphertext), iv.length)\n\n return { [ENCRYPTED_KEY]: b64.encode(combined) }\n },\n\n async decrypt(wrapper: Record<string, unknown>): Promise<Record<string, unknown>> {\n const encoded = wrapper[ENCRYPTED_KEY]\n if (typeof encoded !== \"string\") {\n throw new Error(\"Expected encrypted data but received unencrypted document\")\n }\n\n const key = await keyPromise\n const c = getCrypto()\n const b64 = getBase64()\n const combined = b64.decode(encoded)\n if (combined.length < IV_BYTES) {\n throw new Error(\"Encrypted data is too short\")\n }\n const iv = combined.slice(0, IV_BYTES)\n const ciphertext = combined.slice(IV_BYTES)\n try {\n const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext)\n return JSON.parse(new TextDecoder().decode(plaintext))\n } catch (err) {\n throw new Error(\"Decryption failed: data may be tampered or key is incorrect\", { cause: err })\n }\n },\n }\n}\n"],
|
|
5
|
+
"mappings": ";AAAA,SAAS,aAAAA,YAAW,aAAAC,kBAAiB;;;ACcrC,SAAS,cAAc;AACvB,SAAS,aAAAC,YAAW,aAAAC,YAAW,YAAAC,WAAU,aAAAC,kBAAiB;;;ACf1D,SAAS,WAAW,WAAW,UAAU,eAAe,iBAAiB;;;ADqBzE,SAAS,WAAW,OAA2B;AAC7C,SAAO,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AAC1E;AAaA,IAAM,oBAAoB;AAuC1B,eAAsB,mBAAmB,YAAoB,QAAuC;AAClG,QAAM,IAAIC,WAAU;AACpB,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,QAAQ,IAAI,OAAO,GAAG,UAAU,IAAI,MAAM,IAAI,iBAAiB,EAAE;AACvE,QAAM,OAAO,MAAM,EAAE,OAAO,OAAO,WAAW,KAAK;AACnD,QAAM,kBAAkB,IAAI,WAAW,IAAI;AAC3C,QAAM,iBAAiB,OAAO,aAAa,eAAe;AAC1D,SAAO,EAAE,YAAY,WAAW,eAAe,GAAG,WAAW,WAAW,cAAc,EAAE;AAC1F;;;AD5EA,IAAM,WAAqB;AAAA,EACzB;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EACxD;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAQ;AAA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",
|
|
6
|
+
"names": ["getCrypto", "getBase64", "getCrypto", "getBase64", "IV_BYTES", "deriveKey", "getCrypto", "bytesToHex", "getCrypto", "getBase64"]
|
|
7
7
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { stableStringify, computeHash } from "@drakkar.software/starfish-protoco
|
|
|
4
4
|
export { buildRevocationList, revocationListCanonicalSigningInput } from "@drakkar.software/starfish-protocol";
|
|
5
5
|
export type { RevocationList, RevocationEntry, RevokedSubject, BuildRevocationListOpts, } from "@drakkar.software/starfish-protocol";
|
|
6
6
|
export type { PullResult, PushSuccess, PullKeyringProjection } from "@drakkar.software/starfish-protocol";
|
|
7
|
-
export { StarfishClient } from "./client.js";
|
|
7
|
+
export { StarfishClient, pullWasFromCache } from "./client.js";
|
|
8
8
|
export type { BlobPullResult, BlobPushResult, AppendPullOptions, PullOptions, BatchPullOptions, BatchPullResult, BatchPullEntry, } from "./client.js";
|
|
9
9
|
export { SyncManager, AbortError } from "./sync.js";
|
|
10
10
|
export type { SyncManagerOptions, SyncSigner } from "./sync.js";
|
|
@@ -13,7 +13,7 @@ export type { AppendLogCursorOptions, AppendElement, AuthorVerifier, ElementErro
|
|
|
13
13
|
export { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
|
|
14
14
|
export type { Encryptor } from "@drakkar.software/starfish-protocol";
|
|
15
15
|
export { ConflictError, StarfishHttpError, } from "./types.js";
|
|
16
|
-
export type { StarfishClientOptions, StarfishCapProvider, ConflictResolver, ClientPlugin, } from "./types.js";
|
|
16
|
+
export type { StarfishClientOptions, StarfishCapProvider, PullCache, ConflictResolver, ClientPlugin, } from "./types.js";
|
|
17
17
|
export { consoleSyncLogger, noopSyncLogger, createMetricsCollector } from "./logger.js";
|
|
18
18
|
export type { SyncLogger, SyncMetrics, MetricsCollector } from "./logger.js";
|
|
19
19
|
export { createMigrator } from "./migrate.js";
|
package/dist/index.js
CHANGED
|
@@ -41,6 +41,13 @@ var StarfishHttpError = class extends Error {
|
|
|
41
41
|
|
|
42
42
|
// src/client.ts
|
|
43
43
|
var APPEND_DEFAULT_FIELD = "items";
|
|
44
|
+
function pullCacheKey(pathAndQuery) {
|
|
45
|
+
const q = pathAndQuery.indexOf("?");
|
|
46
|
+
return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
|
|
47
|
+
}
|
|
48
|
+
function pullWasFromCache(result) {
|
|
49
|
+
return result.fromCache === true;
|
|
50
|
+
}
|
|
44
51
|
function stripPushPrefix(path) {
|
|
45
52
|
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
46
53
|
}
|
|
@@ -58,6 +65,8 @@ var StarfishClient = class {
|
|
|
58
65
|
namespace;
|
|
59
66
|
capProvider;
|
|
60
67
|
fetch;
|
|
68
|
+
cache;
|
|
69
|
+
cacheMaxAgeMs;
|
|
61
70
|
/**
|
|
62
71
|
* Installed client-side plugins. Currently stored as inert data; no
|
|
63
72
|
* hooks fire yet. Extensions can inspect this list if needed.
|
|
@@ -68,8 +77,19 @@ var StarfishClient = class {
|
|
|
68
77
|
this.namespace = options.namespace || void 0;
|
|
69
78
|
this.capProvider = options.capProvider;
|
|
70
79
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
80
|
+
this.cache = options.cache;
|
|
81
|
+
this.cacheMaxAgeMs = options.cacheMaxAgeMs;
|
|
71
82
|
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
72
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Mark a `PullResult` as having been served from the offline read-through
|
|
86
|
+
* cache (transport was unreachable). Non-enumerable so it doesn't leak into
|
|
87
|
+
* JSON / equality / re-caching; read via {@link pullWasFromCache}.
|
|
88
|
+
*/
|
|
89
|
+
tagFromCache(result) {
|
|
90
|
+
Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
73
93
|
/**
|
|
74
94
|
* Resolve the host portion of the URL the client will send to. The host
|
|
75
95
|
* is folded into the signed canonical input as the `h` field so the
|
|
@@ -189,10 +209,20 @@ var StarfishClient = class {
|
|
|
189
209
|
}
|
|
190
210
|
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
191
211
|
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
212
|
+
const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
|
|
213
|
+
let res;
|
|
214
|
+
try {
|
|
215
|
+
res = await this.fetch(url, {
|
|
216
|
+
method: "GET",
|
|
217
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (cacheKey) {
|
|
221
|
+
const cached = await this.readCache(cacheKey);
|
|
222
|
+
if (cached) return cached;
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
196
226
|
if (!res.ok) {
|
|
197
227
|
throw new StarfishHttpError(res.status, await res.text());
|
|
198
228
|
}
|
|
@@ -201,8 +231,46 @@ var StarfishClient = class {
|
|
|
201
231
|
const list = result.data?.[appendField];
|
|
202
232
|
return Array.isArray(list) ? list : [];
|
|
203
233
|
}
|
|
234
|
+
if (cacheKey) {
|
|
235
|
+
const snapshot = {
|
|
236
|
+
data: result.data,
|
|
237
|
+
hash: result.hash,
|
|
238
|
+
timestamp: result.timestamp,
|
|
239
|
+
cachedAt: Date.now()
|
|
240
|
+
};
|
|
241
|
+
void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
|
|
242
|
+
});
|
|
243
|
+
}
|
|
204
244
|
return result;
|
|
205
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Read the cached snapshot for a document `path` WITHOUT hitting the network —
|
|
248
|
+
* the basis for cache-first paint (seed the UI from the last-synced snapshot,
|
|
249
|
+
* then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
|
|
250
|
+
* or null when no cache is configured / there's no entry. Namespacing matches
|
|
251
|
+
* {@link pull}, so the key lines up with whatever `pull` wrote.
|
|
252
|
+
*/
|
|
253
|
+
async peekCache(path) {
|
|
254
|
+
if (!this.cache) return null;
|
|
255
|
+
return this.readCache(pullCacheKey(this.applyNamespace(path)));
|
|
256
|
+
}
|
|
257
|
+
/** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
|
|
258
|
+
* null on a miss or an unparseable blob (never throws — a corrupt cache entry
|
|
259
|
+
* must not break a pull, just miss). */
|
|
260
|
+
async readCache(cacheKey) {
|
|
261
|
+
try {
|
|
262
|
+
const raw = await this.cache.get(cacheKey);
|
|
263
|
+
if (!raw) return null;
|
|
264
|
+
const parsed = JSON.parse(raw);
|
|
265
|
+
if (!parsed || typeof parsed.hash !== "string") return null;
|
|
266
|
+
if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
206
274
|
/**
|
|
207
275
|
* Pull several documents in one round-trip via `/batch/pull`. `collections` is
|
|
208
276
|
* the list of distinct collection names; `opts.params` supplies, per collection,
|
|
@@ -430,6 +498,7 @@ var SyncManager = class {
|
|
|
430
498
|
lastCheckpoint = 0;
|
|
431
499
|
localData = {};
|
|
432
500
|
aborted = false;
|
|
501
|
+
lastFromCache = false;
|
|
433
502
|
constructor(options) {
|
|
434
503
|
this.client = options.client;
|
|
435
504
|
this.pullPath = options.pullPath;
|
|
@@ -470,6 +539,40 @@ var SyncManager = class {
|
|
|
470
539
|
setHash(hash) {
|
|
471
540
|
this.lastHash = hash;
|
|
472
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* Whether the most recent {@link pull} (or {@link seedFromCache}) was served
|
|
544
|
+
* from the client's offline read-through cache rather than a live server
|
|
545
|
+
* response. The binding surfaces this as a `stale` flag so the UI can show an
|
|
546
|
+
* offline indicator without treating a cache hit as "reachable". Reset to
|
|
547
|
+
* false by the next successful network pull.
|
|
548
|
+
*/
|
|
549
|
+
getLastPullFromCache() {
|
|
550
|
+
return this.lastFromCache;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Cache-first paint: seed `localData` from the client's read-through cache
|
|
554
|
+
* WITHOUT touching the network, decrypting in memory for E2E collections.
|
|
555
|
+
* Returns whether anything was seeded (false on a miss, an expired entry, or
|
|
556
|
+
* a decrypt failure — e.g. keyring skew). Call once on store creation before
|
|
557
|
+
* the initial live {@link pull}, which then supersedes the seeded snapshot.
|
|
558
|
+
* Requires the client to have been built with a `cache`.
|
|
559
|
+
*/
|
|
560
|
+
async seedFromCache() {
|
|
561
|
+
if (this.aborted) return false;
|
|
562
|
+
const cached = await this.client.peekCache(this.pullPath);
|
|
563
|
+
if (!cached) return false;
|
|
564
|
+
let data;
|
|
565
|
+
try {
|
|
566
|
+
data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
|
|
567
|
+
} catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
if (this.aborted) return false;
|
|
571
|
+
this.localData = data;
|
|
572
|
+
this.lastHash = cached.hash;
|
|
573
|
+
this.lastFromCache = true;
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
473
576
|
getCheckpoint() {
|
|
474
577
|
return this.lastCheckpoint;
|
|
475
578
|
}
|
|
@@ -480,6 +583,7 @@ var SyncManager = class {
|
|
|
480
583
|
try {
|
|
481
584
|
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
482
585
|
if (this.aborted) throw new AbortError();
|
|
586
|
+
this.lastFromCache = pullWasFromCache(result);
|
|
483
587
|
if (this.encryptor) {
|
|
484
588
|
const decrypted = await this.encryptor.decrypt(result.data);
|
|
485
589
|
if (this.aborted) throw new AbortError();
|
|
@@ -1671,6 +1775,7 @@ export {
|
|
|
1671
1775
|
isServiceWorkerSupported,
|
|
1672
1776
|
noopSyncLogger,
|
|
1673
1777
|
pruneTombstones,
|
|
1778
|
+
pullWasFromCache,
|
|
1674
1779
|
registerBackgroundSync,
|
|
1675
1780
|
registerServiceWorker,
|
|
1676
1781
|
revocationListCanonicalSigningInput,
|