@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.4

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.
Files changed (54) hide show
  1. package/dist/_crypto_helpers.d.ts +4 -0
  2. package/dist/bindings/zustand.js +8 -3
  3. package/dist/bindings/zustand.js.map +2 -2
  4. package/dist/cap-mint.d.ts +20 -0
  5. package/dist/cap-mint.js +12 -0
  6. package/dist/cap-mint.js.map +7 -0
  7. package/dist/directory.d.ts +9 -0
  8. package/dist/directory.js +24 -0
  9. package/dist/directory.js.map +7 -0
  10. package/dist/identity.d.ts +4 -82
  11. package/dist/identity.js +2 -354
  12. package/dist/identity.js.map +4 -4
  13. package/dist/index.js +8 -3
  14. package/dist/index.js.map +2 -2
  15. package/dist/keyring.d.ts +6 -0
  16. package/dist/keyring.js +26 -0
  17. package/dist/keyring.js.map +7 -0
  18. package/dist/pairing.d.ts +6 -0
  19. package/dist/pairing.js +26 -0
  20. package/dist/pairing.js.map +7 -0
  21. package/dist/recipients.d.ts +6 -0
  22. package/dist/recipients.js +16 -0
  23. package/dist/recipients.js.map +7 -0
  24. package/dist/types.d.ts +9 -1
  25. package/package.json +2 -2
  26. package/dist/append.d.ts +0 -50
  27. package/dist/background-sync.js +0 -29
  28. package/dist/bindings/broadcast.d.ts +0 -19
  29. package/dist/bindings/broadcast.js +0 -65
  30. package/dist/bindings/react.d.ts +0 -12
  31. package/dist/bindings/react.js +0 -25
  32. package/dist/bindings/suspense.js +0 -49
  33. package/dist/client.js +0 -112
  34. package/dist/config.js +0 -18
  35. package/dist/crypto.js +0 -49
  36. package/dist/debounced-sync.js +0 -120
  37. package/dist/dedup.js +0 -35
  38. package/dist/entitlements.js +0 -41
  39. package/dist/export.js +0 -115
  40. package/dist/group-crypto.d.ts +0 -111
  41. package/dist/group-crypto.js +0 -205
  42. package/dist/group-crypto.js.map +0 -7
  43. package/dist/history.js +0 -61
  44. package/dist/logger.js +0 -80
  45. package/dist/migrate.js +0 -38
  46. package/dist/mobile-lifecycle.js +0 -55
  47. package/dist/multi-store.js +0 -92
  48. package/dist/polling.js +0 -52
  49. package/dist/resolvers.js +0 -223
  50. package/dist/service-worker.js +0 -55
  51. package/dist/storage/indexeddb.js +0 -59
  52. package/dist/sync.js +0 -127
  53. package/dist/types.js +0 -18
  54. package/dist/validate.js +0 -28
@@ -1,120 +0,0 @@
1
- // ── Implementation ────────────────────────────────────────────────────────────
2
- const DEFAULT_DELAY_MS = 2000;
3
- const DEFAULT_WARN_BYTES = 900 * 1024; // 900 KB
4
- const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MB
5
- /** Returns true if the push should be blocked. */
6
- function checkPayloadSize(doc, opts) {
7
- // Estimate encrypted payload size. AES-GCM output is similar to input size;
8
- // base64 encoding adds ~33% overhead, plus a small IV/tag overhead.
9
- const estimatedBytes = Math.ceil(JSON.stringify(doc).length * 1.34);
10
- if (estimatedBytes > opts.maxBytes) {
11
- if (opts.onSizeExceeded) {
12
- opts.onSizeExceeded(estimatedBytes);
13
- }
14
- else {
15
- console.error(`[starfish] Push blocked: estimated payload ${(estimatedBytes / 1024).toFixed(0)} KB ` +
16
- `exceeds limit of ${(opts.maxBytes / 1024).toFixed(0)} KB. Prune your data before syncing.`);
17
- }
18
- return true;
19
- }
20
- if (estimatedBytes > opts.warnBytes) {
21
- if (opts.onSizeWarning) {
22
- opts.onSizeWarning(estimatedBytes);
23
- }
24
- else {
25
- console.warn(`[starfish] Payload approaching limit: estimated ${(estimatedBytes / 1024).toFixed(0)} KB ` +
26
- `(warn threshold: ${(opts.warnBytes / 1024).toFixed(0)} KB).`);
27
- }
28
- }
29
- return false;
30
- }
31
- /**
32
- * Creates a debounced push helper that coalesces rapid mutations into a single sync.
33
- *
34
- * Designed to be called on every domain store mutation (e.g., every keystroke).
35
- * The push is delayed by `delayMs` after the **last** call, so typing quickly
36
- * results in one push, not one per character.
37
- *
38
- * Also estimates the encrypted payload size before pushing and warns / blocks
39
- * if it approaches the server's body size limit.
40
- *
41
- * ```ts
42
- * const { notify } = createDebouncedSync(starfishStore, {
43
- * serialize: () => ({ tasks: taskStore.getState().tasks }),
44
- * })
45
- *
46
- * // Call on every domain store mutation:
47
- * taskStore.subscribe(() => notify())
48
- * ```
49
- */
50
- export function createDebouncedSync(store, options = {}) {
51
- const { delayMs = DEFAULT_DELAY_MS, warnBytes = DEFAULT_WARN_BYTES, maxBytes = DEFAULT_MAX_BYTES, serialize, onSizeWarning, onSizeExceeded, } = options;
52
- let timer = null;
53
- function cancel() {
54
- if (timer !== null) {
55
- clearTimeout(timer);
56
- timer = null;
57
- }
58
- }
59
- function notify() {
60
- cancel();
61
- timer = setTimeout(() => {
62
- timer = null;
63
- const current = store.getState().data;
64
- const doc = serialize ? serialize(current) : current;
65
- if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded }))
66
- return;
67
- store.getState().set(() => doc);
68
- }, delayMs);
69
- }
70
- return { notify, cancel };
71
- }
72
- /**
73
- * Creates a debounced push helper that calls `syncManager.push()` directly,
74
- * without requiring a Zustand store.
75
- *
76
- * Use this for one-way publishing workflows: public pages, derived snapshots,
77
- * or any case where you want to push data without a full `createStarfishStore` setup.
78
- *
79
- * ```ts
80
- * const syncManager = new SyncManager({ client, pullPath, pushPath })
81
- *
82
- * const { notify, cancel } = createDebouncedPush(syncManager, {
83
- * serialize: () => buildPublicPageDocument(),
84
- * })
85
- *
86
- * // Push after every relevant store mutation:
87
- * planningStore.subscribe(() => notify())
88
- *
89
- * // Clean up on teardown:
90
- * cancel()
91
- * ```
92
- */
93
- export function createDebouncedPush(syncManager, options) {
94
- const { delayMs = DEFAULT_DELAY_MS, warnBytes = DEFAULT_WARN_BYTES, maxBytes = DEFAULT_MAX_BYTES, serialize, onSizeWarning, onSizeExceeded, onError, } = options;
95
- let timer = null;
96
- function cancel() {
97
- if (timer !== null) {
98
- clearTimeout(timer);
99
- timer = null;
100
- }
101
- }
102
- function notify() {
103
- cancel();
104
- timer = setTimeout(() => {
105
- timer = null;
106
- const doc = serialize();
107
- if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded }))
108
- return;
109
- syncManager.push(doc).catch((err) => {
110
- if (onError) {
111
- onError(err);
112
- }
113
- else {
114
- console.warn("[starfish] Push failed:", err);
115
- }
116
- });
117
- }, delayMs);
118
- }
119
- return { notify, cancel };
120
- }
package/dist/dedup.js DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * Request deduplication: prevents multiple concurrent identical GET requests.
3
- * If a GET request is in-flight for a URL, subsequent identical GET requests
4
- * return the same Promise. POST/PUT/DELETE/PATCH are never deduped.
5
- */
6
- export function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
7
- const inflightGets = new Map();
8
- return (async (input, init) => {
9
- const method = (init?.method ?? "GET").toUpperCase();
10
- // Only dedup GET requests
11
- if (method !== "GET") {
12
- return baseFetch(input, init);
13
- }
14
- const url = typeof input === "string"
15
- ? input
16
- : input instanceof URL
17
- ? input.toString()
18
- : input.url;
19
- const existing = inflightGets.get(url);
20
- if (existing) {
21
- // Return a clone — the original is reserved for cloning only
22
- return existing.then((res) => res.clone());
23
- }
24
- // Store a promise that resolves to a response we keep solely for cloning.
25
- // The first caller also gets a clone, ensuring the "master" body is never consumed.
26
- const promise = baseFetch(input, init)
27
- .then((res) => res)
28
- .finally(() => {
29
- inflightGets.delete(url);
30
- });
31
- inflightGets.set(url, promise);
32
- // First caller also gets a clone so the cached response body stays unconsumed
33
- return promise.then((res) => res.clone());
34
- });
35
- }
@@ -1,41 +0,0 @@
1
- import { StarfishHttpError } from "./types.js";
2
- /**
3
- * Fetches the list of feature slugs from a user's entitlement document.
4
- *
5
- * Returns an empty array if the document does not exist yet or the features
6
- * field is absent — so callers never need to handle a 404.
7
- *
8
- * ```ts
9
- * import { pullEntitlements } from "@drakkar.software/starfish-client"
10
- *
11
- * const features = await pullEntitlements(client, userId)
12
- * // e.g. ["premium-package-1", "paid-cloud-sync"]
13
- *
14
- * if (features.includes("paid-cloud-sync")) {
15
- * // unlock cloud sync UI
16
- * }
17
- * ```
18
- *
19
- * The path template must match the server-side collection's `storagePath`.
20
- * With the recommended default config:
21
- * ```ts
22
- * { storagePath: "users/{identity}/entitlements" }
23
- * // → path: "/pull/users/{userId}/entitlements" (default)
24
- * ```
25
- */
26
- export async function pullEntitlements(client, userId, opts) {
27
- const path = (opts?.path ?? "/pull/users/{userId}/entitlements").replace("{userId}", userId);
28
- const field = opts?.field ?? "features";
29
- try {
30
- const result = await client.pull(path);
31
- const list = result.data?.[field];
32
- if (!Array.isArray(list))
33
- return [];
34
- return list.filter((s) => typeof s === "string");
35
- }
36
- catch (err) {
37
- if (err instanceof StarfishHttpError && err.status === 404)
38
- return [];
39
- throw err;
40
- }
41
- }
package/dist/export.js DELETED
@@ -1,115 +0,0 @@
1
- /**
2
- * Data export/import helpers for Starfish sync data.
3
- * Supports JSON and CSV formats.
4
- */
5
- /**
6
- * Export data to a string representation.
7
- * JSON: serializes the full object.
8
- * CSV: flattens top-level keys into columns. Array values are JSON-encoded.
9
- */
10
- export function exportData(data, opts) {
11
- const format = opts?.format ?? "json";
12
- if (format === "json") {
13
- return opts?.pretty
14
- ? JSON.stringify(data, null, 2)
15
- : JSON.stringify(data);
16
- }
17
- // CSV export: each top-level key becomes a column
18
- return toCsv(data);
19
- }
20
- /**
21
- * Import data from a string representation.
22
- */
23
- export function importData(raw, format = "json") {
24
- if (format === "json") {
25
- const parsed = JSON.parse(raw);
26
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
27
- throw new Error("Expected a JSON object");
28
- }
29
- return parsed;
30
- }
31
- return fromCsv(raw);
32
- }
33
- /**
34
- * Export data to a Blob suitable for download.
35
- */
36
- export function exportToBlob(data, opts) {
37
- const format = opts?.format ?? "json";
38
- const content = exportData(data, opts);
39
- const mimeType = format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8";
40
- return new Blob([content], { type: mimeType });
41
- }
42
- function toCsv(data) {
43
- const keys = Object.keys(data);
44
- const header = keys.map(escapeCsvField).join(",");
45
- const values = keys.map((k) => {
46
- const v = data[k];
47
- if (v === null || v === undefined)
48
- return "";
49
- if (typeof v === "object")
50
- return escapeCsvField(JSON.stringify(v));
51
- return escapeCsvField(String(v));
52
- });
53
- return `${header}\n${values.join(",")}`;
54
- }
55
- function fromCsv(raw) {
56
- const lines = raw.trim().split("\n");
57
- if (lines.length < 2) {
58
- throw new Error("CSV must have at least a header row and a data row");
59
- }
60
- const headers = parseCsvLine(lines[0]);
61
- const values = parseCsvLine(lines[1]);
62
- const result = {};
63
- for (let i = 0; i < headers.length; i++) {
64
- const key = headers[i];
65
- const val = values[i] ?? "";
66
- // Try to parse JSON values
67
- try {
68
- result[key] = JSON.parse(val);
69
- }
70
- catch {
71
- result[key] = val;
72
- }
73
- }
74
- return result;
75
- }
76
- function escapeCsvField(field) {
77
- if (field.includes(",") || field.includes('"') || field.includes("\n")) {
78
- return `"${field.replace(/"/g, '""')}"`;
79
- }
80
- return field;
81
- }
82
- function parseCsvLine(line) {
83
- const result = [];
84
- let current = "";
85
- let inQuotes = false;
86
- for (let i = 0; i < line.length; i++) {
87
- const ch = line[i];
88
- if (inQuotes) {
89
- if (ch === '"' && line[i + 1] === '"') {
90
- current += '"';
91
- i++;
92
- }
93
- else if (ch === '"') {
94
- inQuotes = false;
95
- }
96
- else {
97
- current += ch;
98
- }
99
- }
100
- else {
101
- if (ch === '"') {
102
- inQuotes = true;
103
- }
104
- else if (ch === ",") {
105
- result.push(current);
106
- current = "";
107
- }
108
- else {
109
- current += ch;
110
- }
111
- }
112
- }
113
- result.push(current);
114
- return result;
115
- }
@@ -1,111 +0,0 @@
1
- /**
2
- * Group encryption utilities for Starfish.
3
- *
4
- * Enables multiple users to share a common encrypted collection without sharing
5
- * a passphrase. Each member holds their own credentials; a Group Encryption Key
6
- * (GEK) is distributed per-member using X25519 ECDH key agreement.
7
- *
8
- * Typical flow:
9
- * 1. Each user calls `deriveCredentials(passphrase)` — now includes groupPublicKey / groupPrivateKey.
10
- * 2. Admin calls `createGroupKeyring(...)` to create a keyring document.
11
- * 3. Members call `createGroupEncryptor(keyringData, myIdentity, myPrivateKey)` to get an Encryptor.
12
- * 4. The Encryptor is passed to SyncManager via the `encryptor` option.
13
- */
14
- import type { Encryptor } from "./crypto.js";
15
- /** An ECDH key pair used for group encryption. Hex-encoded for easy serialization. */
16
- export interface GroupKeyPair {
17
- /** Hex-encoded X25519 private key (32 bytes). Keep secret — never store on server. */
18
- privateKey: string;
19
- /** Hex-encoded X25519 public key (32 bytes). Safe to publish. */
20
- publicKey: string;
21
- }
22
- /** One epoch's wrapped keys: each member's GEK encrypted to their public key. */
23
- export interface EpochKeyring {
24
- /** The admin's hex-encoded X25519 public key (used for ECDH by members). */
25
- adminPublicKey: string;
26
- /** Map from member identity (userId) → base64(IV || AES-GCM(GEK)) */
27
- wrappedKeys: Record<string, string>;
28
- }
29
- /** The full keyring document stored in a Starfish collection. Push this with any SyncManager. */
30
- export interface GroupKeyring {
31
- /** The epoch number currently used for new encryptions. */
32
- currentEpoch: number;
33
- /** All epochs. Members unwrap the GEK for whichever epoch a document was encrypted with. */
34
- epochs: Record<string, EpochKeyring>;
35
- }
36
- /**
37
- * Derives a deterministic X25519 key pair from a passphrase + userId.
38
- *
39
- * The derivation uses SHA-256 with a fixed domain separator so it is distinct
40
- * from the auth token and encryption key derivations. Same passphrase + userId
41
- * always produces the same key pair on any device (stateless).
42
- */
43
- export declare function deriveGroupKeyPair(passphrase: string, userId: string): Promise<GroupKeyPair>;
44
- /** Generates a random 256-bit Group Encryption Key as a hex string. */
45
- export declare function generateGroupKey(): string;
46
- /**
47
- * Wraps a GEK for a specific member using ECDH key agreement.
48
- *
49
- * The wrapper (admin) and member each have an X25519 key pair. ECDH between
50
- * `wrapperPrivateKey` and `memberPublicKey` produces a shared secret, which is
51
- * used to derive an AES-256-GCM key that encrypts the GEK.
52
- *
53
- * @returns base64(IV || AES-GCM-ciphertext)
54
- */
55
- export declare function wrapGroupKey(gek: string, memberPublicKey: string, wrapperPrivateKey: string): Promise<string>;
56
- /**
57
- * Unwraps a GEK using the member's own private key and the admin's public key.
58
- *
59
- * ECDH between `memberPrivateKey` and `adminPublicKey` yields the same shared
60
- * secret as the wrapping step, so the same AES key is derived and the GEK is
61
- * recovered.
62
- *
63
- * @returns GEK as a hex string
64
- */
65
- export declare function unwrapGroupKey(wrapped: string, memberPrivateKey: string, adminPublicKey: string): Promise<string>;
66
- /**
67
- * Creates a new group keyring document with epoch 1.
68
- *
69
- * @param adminKeyPair The admin's key pair (from `deriveGroupKeyPair` or `deriveCredentials`)
70
- * @param members Map from member identity (userId) → hex public key
71
- * @param gek Optional GEK to use; generated randomly if omitted
72
- * @returns The keyring document and the raw GEK (admin keeps the GEK to add future members)
73
- */
74
- export declare function createGroupKeyring(adminKeyPair: GroupKeyPair, members: Record<string, string>, gek?: string): Promise<{
75
- keyring: GroupKeyring;
76
- gek: string;
77
- }>;
78
- /**
79
- * Adds a new member to the current epoch of an existing keyring.
80
- *
81
- * The admin supplies the current GEK (returned by `createGroupKeyring` or
82
- * `rotateGroupKey`) and their key pair to wrap it for the new member.
83
- * This does NOT rotate the GEK — the new member can read all existing
84
- * documents encrypted with the current epoch key.
85
- *
86
- * Only the admin (whose `publicKey` matches `epochKeyring.adminPublicKey`) can
87
- * add members, because all wrapped entries must use the same ECDH key pair.
88
- */
89
- export declare function addGroupMember(keyring: GroupKeyring, adminKeyPair: GroupKeyPair, currentGek: string, newMemberId: string, newMemberPublicKey: string): Promise<GroupKeyring>;
90
- /**
91
- * Rotates the group key, creating a new epoch.
92
- *
93
- * Used when removing a member. The removed member retains their old epoch key
94
- * (and can still read old documents), but cannot read new documents.
95
- *
96
- * @param remainingMembers Map from identity → hex public key for members who keep access
97
- */
98
- export declare function rotateGroupKey(keyring: GroupKeyring, adminKeyPair: GroupKeyPair, remainingMembers: Record<string, string>, newGek?: string): Promise<{
99
- keyring: GroupKeyring;
100
- gek: string;
101
- }>;
102
- /**
103
- * Creates an Encryptor that can decrypt any epoch and encrypts with the current epoch.
104
- *
105
- * Wire format: `{ _encrypted: "base64(IV || ciphertext)", _epoch: N }`
106
- *
107
- * @param keyring The keyring document fetched from Starfish
108
- * @param myIdentity The caller's userId (to locate their wrapped key in each epoch)
109
- * @param myPrivateKey The caller's hex-encoded X25519 private key
110
- */
111
- export declare function createGroupEncryptor(keyring: GroupKeyring, myIdentity: string, myPrivateKey: string): Promise<Encryptor>;
@@ -1,205 +0,0 @@
1
- // src/group-crypto.ts
2
- import { x25519 } from "@noble/curves/ed25519.js";
3
- import { getCrypto as getCrypto2, getBase64 as getBase642, IV_BYTES as IV_BYTES2, deriveKey as deriveKey2 } from "@drakkar.software/starfish-protocol";
4
-
5
- // src/crypto.ts
6
- import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
7
- var ALGO = "AES-GCM";
8
- function createEncryptor(secret, salt, info = "starfish-e2e") {
9
- if (!secret) throw new Error("encryptionSecret must not be empty");
10
- if (!salt) throw new Error("encryptionSalt must not be empty");
11
- const keyPromise = deriveKey(secret, salt, info);
12
- return {
13
- async encrypt(data) {
14
- const key = await keyPromise;
15
- const c = getCrypto();
16
- const b64 = getBase64();
17
- const plaintext = new TextEncoder().encode(JSON.stringify(data));
18
- const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
19
- const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
20
- const combined = new Uint8Array(iv.length + ciphertext.byteLength);
21
- combined.set(iv);
22
- combined.set(new Uint8Array(ciphertext), iv.length);
23
- return { [ENCRYPTED_KEY]: b64.encode(combined) };
24
- },
25
- async decrypt(wrapper) {
26
- const encoded = wrapper[ENCRYPTED_KEY];
27
- if (typeof encoded !== "string") {
28
- throw new Error("Expected encrypted data but received unencrypted document");
29
- }
30
- const key = await keyPromise;
31
- const c = getCrypto();
32
- const b64 = getBase64();
33
- const combined = b64.decode(encoded);
34
- if (combined.length < IV_BYTES) {
35
- throw new Error("Encrypted data is too short");
36
- }
37
- const iv = combined.slice(0, IV_BYTES);
38
- const ciphertext = combined.slice(IV_BYTES);
39
- try {
40
- const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
41
- return JSON.parse(new TextDecoder().decode(plaintext));
42
- } catch (err) {
43
- throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
44
- }
45
- }
46
- };
47
- }
48
-
49
- // src/group-crypto.ts
50
- function bytesToHex(bytes) {
51
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
52
- }
53
- function hexToBytes(hex) {
54
- const bytes = new Uint8Array(hex.length / 2);
55
- for (let i = 0; i < bytes.length; i++) {
56
- bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
57
- }
58
- return bytes;
59
- }
60
- var ALGO2 = "AES-GCM";
61
- var GROUP_WRAP_SALT = "starfish-group-wrap";
62
- var GROUP_WRAP_INFO = "starfish-group-wrap";
63
- var GROUP_ECDH_DOMAIN = "starfish-group-ecdh";
64
- var GROUP_DATA_INFO = "starfish-group";
65
- var GEK_BYTES = 32;
66
- async function deriveGroupKeyPair(passphrase, userId) {
67
- const c = getCrypto2();
68
- const enc = new TextEncoder();
69
- const input = enc.encode(`${passphrase}:${userId}:${GROUP_ECDH_DOMAIN}`);
70
- const hash = await c.subtle.digest("SHA-256", input);
71
- const privateKeyBytes = new Uint8Array(hash);
72
- const publicKeyBytes = x25519.getPublicKey(privateKeyBytes);
73
- return { privateKey: bytesToHex(privateKeyBytes), publicKey: bytesToHex(publicKeyBytes) };
74
- }
75
- function generateGroupKey() {
76
- const c = getCrypto2();
77
- return bytesToHex(c.getRandomValues(new Uint8Array(GEK_BYTES)));
78
- }
79
- async function wrapGroupKey(gek, memberPublicKey, wrapperPrivateKey) {
80
- const sharedSecret = x25519.getSharedSecret(hexToBytes(wrapperPrivateKey), hexToBytes(memberPublicKey));
81
- const wrappingKey = await deriveKey2(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO);
82
- const c = getCrypto2();
83
- const b64 = getBase642();
84
- const iv = c.getRandomValues(new Uint8Array(IV_BYTES2));
85
- const encrypted = await c.subtle.encrypt({ name: ALGO2, iv }, wrappingKey, hexToBytes(gek).buffer);
86
- const combined = new Uint8Array(IV_BYTES2 + encrypted.byteLength);
87
- combined.set(iv);
88
- combined.set(new Uint8Array(encrypted), IV_BYTES2);
89
- return b64.encode(combined);
90
- }
91
- async function unwrapGroupKey(wrapped, memberPrivateKey, adminPublicKey) {
92
- const sharedSecret = x25519.getSharedSecret(hexToBytes(memberPrivateKey), hexToBytes(adminPublicKey));
93
- const wrappingKey = await deriveKey2(bytesToHex(sharedSecret), GROUP_WRAP_SALT, GROUP_WRAP_INFO);
94
- const b64 = getBase642();
95
- const c = getCrypto2();
96
- const combined = b64.decode(wrapped);
97
- const iv = combined.slice(0, IV_BYTES2);
98
- const ciphertext = combined.slice(IV_BYTES2);
99
- try {
100
- const decrypted = await c.subtle.decrypt({ name: ALGO2, iv }, wrappingKey, ciphertext);
101
- return bytesToHex(new Uint8Array(decrypted));
102
- } catch {
103
- throw new Error("Failed to unwrap group key: decryption failed (wrong keys or corrupted data)");
104
- }
105
- }
106
- async function createGroupKeyring(adminKeyPair, members, gek) {
107
- const resolvedGek = gek ?? generateGroupKey();
108
- const wrappedKeys = {};
109
- for (const [memberId, memberPublicKey] of Object.entries(members)) {
110
- wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey);
111
- }
112
- const keyring = {
113
- currentEpoch: 1,
114
- epochs: {
115
- "1": { adminPublicKey: adminKeyPair.publicKey, wrappedKeys }
116
- }
117
- };
118
- return { keyring, gek: resolvedGek };
119
- }
120
- async function addGroupMember(keyring, adminKeyPair, currentGek, newMemberId, newMemberPublicKey) {
121
- const epochKey = String(keyring.currentEpoch);
122
- const epochKeyring = keyring.epochs[epochKey];
123
- if (!epochKeyring) throw new Error(`Epoch ${keyring.currentEpoch} not found in keyring`);
124
- if (epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {
125
- throw new Error(`Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`);
126
- }
127
- const wrapped = await wrapGroupKey(currentGek, newMemberPublicKey, adminKeyPair.privateKey);
128
- return {
129
- ...keyring,
130
- epochs: {
131
- ...keyring.epochs,
132
- [epochKey]: {
133
- ...epochKeyring,
134
- wrappedKeys: { ...epochKeyring.wrappedKeys, [newMemberId]: wrapped }
135
- }
136
- }
137
- };
138
- }
139
- async function rotateGroupKey(keyring, adminKeyPair, remainingMembers, newGek) {
140
- const epochKey = String(keyring.currentEpoch);
141
- const epochKeyring = keyring.epochs[epochKey];
142
- if (epochKeyring && epochKeyring.adminPublicKey !== adminKeyPair.publicKey) {
143
- throw new Error(
144
- `Provided key pair does not match the admin public key stored in epoch ${keyring.currentEpoch}`
145
- );
146
- }
147
- const resolvedGek = newGek ?? generateGroupKey();
148
- const newEpoch = keyring.currentEpoch + 1;
149
- const wrappedKeys = {};
150
- for (const [memberId, memberPublicKey] of Object.entries(remainingMembers)) {
151
- wrappedKeys[memberId] = await wrapGroupKey(resolvedGek, memberPublicKey, adminKeyPair.privateKey);
152
- }
153
- const newKeyring = {
154
- currentEpoch: newEpoch,
155
- epochs: {
156
- ...keyring.epochs,
157
- [String(newEpoch)]: { adminPublicKey: adminKeyPair.publicKey, wrappedKeys }
158
- }
159
- };
160
- return { keyring: newKeyring, gek: resolvedGek };
161
- }
162
- async function createGroupEncryptor(keyring, myIdentity, myPrivateKey) {
163
- const epochEncryptors = /* @__PURE__ */ new Map();
164
- for (const [epochStr, epochKeyring] of Object.entries(keyring.epochs)) {
165
- const epoch = parseInt(epochStr, 10);
166
- const wrapped = epochKeyring.wrappedKeys[myIdentity];
167
- if (!wrapped) continue;
168
- const gek = await unwrapGroupKey(wrapped, myPrivateKey, epochKeyring.adminPublicKey);
169
- epochEncryptors.set(epoch, createEncryptor(gek, `epoch-${epoch}`, GROUP_DATA_INFO));
170
- }
171
- const currentEpoch = keyring.currentEpoch;
172
- const currentEncryptor = epochEncryptors.get(currentEpoch);
173
- if (!currentEncryptor) {
174
- throw new Error(
175
- `No wrapped key found for identity "${myIdentity}" in epoch ${currentEpoch}. Ensure the admin has added this member to the keyring.`
176
- );
177
- }
178
- return {
179
- async encrypt(data) {
180
- const encrypted = await currentEncryptor.encrypt(data);
181
- return { ...encrypted, _epoch: currentEpoch };
182
- },
183
- async decrypt(wrapper) {
184
- const epoch = typeof wrapper._epoch === "number" ? wrapper._epoch : currentEpoch;
185
- const encryptor = epochEncryptors.get(epoch);
186
- if (!encryptor) {
187
- throw new Error(
188
- `No key available for epoch ${epoch}. This document was encrypted in a different epoch. Ensure your keyring is up to date.`
189
- );
190
- }
191
- return encryptor.decrypt(wrapper);
192
- }
193
- };
194
- }
195
- export {
196
- addGroupMember,
197
- createGroupEncryptor,
198
- createGroupKeyring,
199
- deriveGroupKeyPair,
200
- generateGroupKey,
201
- rotateGroupKey,
202
- unwrapGroupKey,
203
- wrapGroupKey
204
- };
205
- //# sourceMappingURL=group-crypto.js.map