@abraca/dabra 0.9.0 → 1.0.0

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.
@@ -14,6 +14,7 @@
14
14
  import * as ed from "@noble/ed25519";
15
15
  import { hkdf } from "@noble/hashes/hkdf";
16
16
  import { sha256 } from "@noble/hashes/sha256";
17
+ import { ed25519 as nobleEd25519Curves } from "@noble/curves/ed25519.js";
17
18
 
18
19
  // ── Types ────────────────────────────────────────────────────────────────────
19
20
 
@@ -125,7 +126,7 @@ export class CryptoIdentityKeystore {
125
126
  * @param rpId - WebAuthn relying party ID (e.g. "example.com").
126
127
  * @param rpName - Human-readable relying party name.
127
128
  */
128
- async register(username: string, rpId: string, rpName: string): Promise<string> {
129
+ async register(username: string, rpId: string, rpName: string): Promise<{publicKey: string; x25519PublicKey: string}> {
129
130
  // 1. Generate Ed25519 keypair
130
131
  const privateKey = ed.utils.randomPrivateKey();
131
132
  const publicKey = await ed.getPublicKeyAsync(privateKey);
@@ -194,7 +195,8 @@ export class CryptoIdentityKeystore {
194
195
  });
195
196
  db.close();
196
197
 
197
- return toBase64url(publicKey);
198
+ const x25519Pub = nobleEd25519Curves.utils.toMontgomery(publicKey);
199
+ return { publicKey: toBase64url(publicKey), x25519PublicKey: toBase64url(x25519Pub) };
198
200
  }
199
201
 
200
202
  /**
@@ -303,4 +305,65 @@ export class CryptoIdentityKeystore {
303
305
  await dbDelete(db);
304
306
  db.close();
305
307
  }
308
+
309
+ /**
310
+ * Returns the X25519 public key derived from the stored Ed25519 private key.
311
+ * Does NOT require WebAuthn — computed from the stored encrypted key... actually
312
+ * we derive from the Ed25519 public key directly (Montgomery form), no decryption needed
313
+ * since nobleEd25519Curves.utils.toMontgomery only needs the public key.
314
+ * Returns null if no identity is stored.
315
+ */
316
+ async getX25519PublicKey(): Promise<Uint8Array | null> {
317
+ const db = await openDb();
318
+ const stored = await dbGet(db);
319
+ db.close();
320
+ if (!stored) return null;
321
+ const edPub = fromBase64url(stored.publicKey);
322
+ return nobleEd25519Curves.utils.toMontgomery(edPub);
323
+ }
324
+
325
+ /**
326
+ * Returns the X25519 private key derived from the stored Ed25519 private key.
327
+ * Requires WebAuthn assertion to decrypt the private key.
328
+ * The caller MUST wipe the returned Uint8Array after use.
329
+ */
330
+ async getX25519PrivateKey(): Promise<Uint8Array> {
331
+ const db = await openDb();
332
+ const stored = await dbGet(db);
333
+ db.close();
334
+
335
+ if (!stored) {
336
+ throw new Error("No identity stored. Call register() first.");
337
+ }
338
+
339
+ const assertion = await navigator.credentials.get({
340
+ publicKey: {
341
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
342
+ allowCredentials: [{ id: stored.credentialId, type: "public-key" }],
343
+ userVerification: "required",
344
+ extensions: {
345
+ prf: { eval: { first: stored.salt.buffer } },
346
+ } as AuthenticationExtensionsClientInputs,
347
+ },
348
+ }) as PublicKeyCredential | null;
349
+
350
+ if (!assertion) throw new Error("WebAuthn assertion failed");
351
+
352
+ const extResults = assertion.getClientExtensionResults() as {
353
+ prf?: { results?: { first?: ArrayBuffer } };
354
+ };
355
+ const prfOutput = extResults?.prf?.results?.first;
356
+ if (!prfOutput) throw new Error("PRF output not available from authenticator");
357
+
358
+ const aesKey = await deriveAesKey(prfOutput, stored.salt);
359
+ const privateKeyBytes = await crypto.subtle.decrypt(
360
+ { name: "AES-GCM", iv: stored.iv },
361
+ aesKey,
362
+ stored.encryptedPrivateKey,
363
+ );
364
+ const edPrivKey = new Uint8Array(privateKeyBytes);
365
+ const x25519Priv = nobleEd25519Curves.utils.toMontgomerySecret(edPrivKey);
366
+ edPrivKey.fill(0);
367
+ return x25519Priv;
368
+ }
306
369
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * DocKeyManager
3
+ *
4
+ * Manages AES-256-GCM document keys for CSE and E2E encrypted documents.
5
+ * Keys are wrapped per-user using X25519 ECDH + HKDF-SHA256 + AES-256-GCM.
6
+ */
7
+
8
+ import { x25519 } from "@noble/curves/ed25519.js";
9
+ import { hkdf } from "@noble/hashes/hkdf";
10
+ import { sha256 } from "@noble/hashes/sha256";
11
+ import type { AbracadabraClient } from "./AbracadabraClient.ts";
12
+ import type { CryptoIdentityKeystore } from "./CryptoIdentityKeystore.ts";
13
+
14
+ const HKDF_INFO = new TextEncoder().encode("abracadabra-dockey-v1");
15
+
16
+ function fromBase64(b64: string): Uint8Array {
17
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
18
+ }
19
+
20
+ export class DocKeyManager {
21
+ private cache = new Map<string, { key: CryptoKey; epoch: number }>();
22
+
23
+ /** Generate a new random AES-256-GCM document key. */
24
+ static async generateDocKey(): Promise<CryptoKey> {
25
+ return crypto.subtle.generateKey(
26
+ { name: "AES-GCM", length: 256 },
27
+ true,
28
+ ["encrypt", "decrypt"],
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Get (or fetch) the DocKey for a document.
34
+ * Returns null if no envelope exists (user not provisioned).
35
+ */
36
+ async getDocKey(
37
+ docId: string,
38
+ client: AbracadabraClient,
39
+ keystore: CryptoIdentityKeystore,
40
+ ): Promise<CryptoKey | null> {
41
+ const cached = this.cache.get(docId);
42
+ if (cached) return cached.key;
43
+
44
+ const envelope = await client.getMyKeyEnvelope(docId);
45
+ if (!envelope) return null;
46
+
47
+ const x25519PrivKey = await keystore.getX25519PrivateKey();
48
+ try {
49
+ const wrapped = fromBase64(envelope.encrypted_key);
50
+ const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
51
+ this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch });
52
+ return docKey;
53
+ } finally {
54
+ x25519PrivKey.fill(0);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Wrap a DocKey for a recipient.
60
+ * Output: [ephemeralPub(32) || nonce(12) || ciphertext(~48)] bytes
61
+ */
62
+ async wrapKeyForRecipient(
63
+ docKey: CryptoKey,
64
+ recipientX25519PubKey: Uint8Array,
65
+ docId: string,
66
+ ): Promise<Uint8Array> {
67
+ const ephemeralPriv = crypto.getRandomValues(new Uint8Array(32));
68
+ const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
69
+ const sharedSecret = x25519.getSharedSecret(ephemeralPriv, recipientX25519PubKey);
70
+
71
+ const salt = new TextEncoder().encode(docId);
72
+ const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
73
+ const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]);
74
+
75
+ const rawDocKey = await crypto.subtle.exportKey("raw", docKey);
76
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
77
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, wrapKey, rawDocKey));
78
+
79
+ const result = new Uint8Array(32 + 12 + ciphertext.length);
80
+ result.set(ephemeralPub, 0);
81
+ result.set(nonce, 32);
82
+ result.set(ciphertext, 44);
83
+ return result;
84
+ }
85
+
86
+ private async _unwrapKey(wrapped: Uint8Array, recipientX25519PrivKey: Uint8Array, docId: string): Promise<CryptoKey> {
87
+ const ephemeralPub = wrapped.slice(0, 32);
88
+ const nonce = wrapped.slice(32, 44);
89
+ const ciphertext = wrapped.slice(44);
90
+
91
+ const sharedSecret = x25519.getSharedSecret(recipientX25519PrivKey, ephemeralPub);
92
+ const salt = new TextEncoder().encode(docId);
93
+ const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
94
+ const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
95
+ const rawDocKey = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, wrapKey, ciphertext);
96
+ return crypto.subtle.importKey("raw", rawDocKey, { name: "AES-GCM" }, true, ["encrypt", "decrypt"]);
97
+ }
98
+
99
+ /** Clear the cached key for a document (or all if docId omitted). */
100
+ clearCache(docId?: string): void {
101
+ if (docId) {
102
+ this.cache.delete(docId);
103
+ } else {
104
+ this.cache.clear();
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * E2EAbracadabraProvider
3
+ *
4
+ * Extends AbracadabraProvider with full E2E encryption support.
5
+ *
6
+ * Differences from standard provider:
7
+ * - startSync() sends a stateless e2e_sync_request instead of SYNC_STEP1
8
+ * - On receiving e2e_ready, fetches all blobs via REST, decrypts, applies to Y.Doc
9
+ * - Local updates are AES-GCM encrypted before sending over WebSocket
10
+ * - Incoming e2e_update stateless messages are decrypted and applied
11
+ *
12
+ * Phase 2 offline support:
13
+ * - After the doc key is obtained, an E2EOfflineStore is created for this doc.
14
+ * - The stored encrypted snapshot is applied before fetching server updates.
15
+ * - The last-seen sequence number (e2e_seq) is persisted so only delta updates
16
+ * are fetched on subsequent connects.
17
+ * - After sync, a fresh encrypted snapshot is saved.
18
+ *
19
+ * Key availability limitation: if the user's WebAuthn key is not in
20
+ * DocKeyManager's in-memory cache and there is no network, E2E docs show
21
+ * empty — the key fetch requires either a cached in-memory key or network.
22
+ */
23
+
24
+ import * as Y from "yjs";
25
+ import { AbracadabraProvider } from "./AbracadabraProvider.ts";
26
+ import type { AbracadabraProviderConfiguration } from "./AbracadabraProvider.ts";
27
+ import type { DocKeyManager } from "./DocKeyManager.ts";
28
+ import type { CryptoIdentityKeystore } from "./CryptoIdentityKeystore.ts";
29
+ import type { AbracadabraClient } from "./AbracadabraClient.ts";
30
+ import { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
31
+ import { encryptField, decryptField } from "./EncryptedY.ts";
32
+ import { E2EOfflineStore } from "./E2EOfflineStore.ts";
33
+
34
+ function fromBase64(b64: string): Uint8Array {
35
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
36
+ }
37
+
38
+ export interface E2EAbracadabraProviderConfiguration extends AbracadabraProviderConfiguration {
39
+ docKeyManager: DocKeyManager;
40
+ keystore: CryptoIdentityKeystore;
41
+ client: AbracadabraClient;
42
+ }
43
+
44
+ export class E2EAbracadabraProvider extends AbracadabraProvider {
45
+ private readonly docKeyManager: DocKeyManager;
46
+ private readonly keystore: CryptoIdentityKeystore;
47
+ private readonly e2eClient: AbracadabraClient;
48
+ private docKey: CryptoKey | null = null;
49
+ private lastSeq = -1;
50
+
51
+ /** E2E-encrypted offline store; created after the doc key is available. */
52
+ private e2eStore: E2EOfflineStore | null = null;
53
+ private readonly e2eServerOrigin: string | undefined;
54
+
55
+ constructor(configuration: E2EAbracadabraProviderConfiguration) {
56
+ // Disable the parent's offline store — E2E uses its own encrypted store.
57
+ super({ ...configuration, disableOfflineStore: true });
58
+ this.docKeyManager = configuration.docKeyManager;
59
+ this.keystore = configuration.keystore;
60
+ this.e2eClient = configuration.client;
61
+
62
+ // Derive server origin for E2EOfflineStore namespacing
63
+ this.e2eServerOrigin = E2EAbracadabraProvider.deriveServerOrigin(
64
+ configuration,
65
+ configuration.client,
66
+ );
67
+ }
68
+
69
+ /** Fetch the doc key from the server (requires WebAuthn if not cached). */
70
+ private async ensureDocKey(): Promise<CryptoKey | null> {
71
+ if (this.docKey) return this.docKey;
72
+ this.docKey = await this.docKeyManager.getDocKey(
73
+ this.configuration.name,
74
+ this.e2eClient,
75
+ this.keystore,
76
+ );
77
+
78
+ // Create the encrypted offline store now that we have the key
79
+ if (this.docKey && !this.e2eStore) {
80
+ this.e2eStore = new E2EOfflineStore(
81
+ this.configuration.name,
82
+ this.e2eServerOrigin,
83
+ this.docKey,
84
+ );
85
+ }
86
+
87
+ return this.docKey;
88
+ }
89
+
90
+ /** Handle stateless messages including e2e_ready and e2e_update. */
91
+ override receiveStateless(payload: string): void {
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(payload);
95
+ } catch {
96
+ super.receiveStateless(payload);
97
+ return;
98
+ }
99
+
100
+ const msg = parsed as { type?: string; seq?: number; data?: string; doc_id?: string };
101
+
102
+ if (msg.type === "e2e_ready") {
103
+ // Fetch all blobs and hydrate doc
104
+ this._fetchAndApplyAllBlobs().catch((e) => {
105
+ console.error("[E2EAbracadabraProvider] failed to fetch e2e blobs:", e);
106
+ });
107
+ return;
108
+ }
109
+
110
+ if (msg.type === "e2e_update" && msg.data !== undefined && msg.seq !== undefined) {
111
+ if (msg.seq <= this.lastSeq) return; // Already applied
112
+ this._decryptAndApply(fromBase64(msg.data), msg.seq).catch((e) => {
113
+ console.error("[E2EAbracadabraProvider] failed to apply e2e_update:", e);
114
+ });
115
+ return;
116
+ }
117
+
118
+ super.receiveStateless(payload);
119
+ }
120
+
121
+ private async _fetchAndApplyAllBlobs(): Promise<void> {
122
+ const key = await this.ensureDocKey();
123
+ if (!key) {
124
+ console.warn("[E2EAbracadabraProvider] no doc key available, cannot decrypt blobs");
125
+ this.synced = true;
126
+ return;
127
+ }
128
+
129
+ // Phase 2: load stored snapshot + lastSeq before fetching from server
130
+ if (this.e2eStore) {
131
+ const storedSeqStr = await this.e2eStore.getMeta("e2e_seq").catch(() => null);
132
+ if (storedSeqStr !== null) {
133
+ this.lastSeq = parseInt(storedSeqStr, 10);
134
+ }
135
+
136
+ const snapshot = await this.e2eStore.getDocSnapshot().catch(() => null);
137
+ if (snapshot) {
138
+ Y.applyUpdate(this.document, snapshot, this.e2eStore);
139
+ }
140
+ }
141
+
142
+ // Only fetch updates newer than lastSeq
143
+ const updates = await this.e2eClient.getE2EUpdatesSince(
144
+ this.configuration.name,
145
+ this.lastSeq,
146
+ );
147
+ for (const { seq, data } of updates) {
148
+ await this._decryptAndApply(data, seq);
149
+ }
150
+
151
+ this.synced = true;
152
+
153
+ // Phase 2: persist updated snapshot and lastSeq
154
+ if (this.e2eStore) {
155
+ const snapshot = Y.encodeStateAsUpdate(this.document);
156
+ await this.e2eStore.saveDocSnapshot(snapshot).catch(() => null);
157
+ await this.e2eStore.setMeta("e2e_seq", String(this.lastSeq)).catch(() => null);
158
+ }
159
+ }
160
+
161
+ private async _decryptAndApply(encryptedData: Uint8Array, seq: number): Promise<void> {
162
+ const key = await this.ensureDocKey();
163
+ if (!key) return;
164
+ try {
165
+ const plaintext = await decryptField(encryptedData, key);
166
+ Y.applyUpdate(this.document, plaintext, this);
167
+ this.lastSeq = Math.max(this.lastSeq, seq);
168
+ } catch (e) {
169
+ console.error("[E2EAbracadabraProvider] decryption failed for seq", seq, e);
170
+ }
171
+ }
172
+
173
+ /** Encrypt local updates before sending over WebSocket. */
174
+ override documentUpdateHandler(update: Uint8Array, origin: unknown): void {
175
+ if (origin === this) return;
176
+ // Don't call super (which would send plaintext); encrypt first
177
+ this._encryptAndSend(update).catch((e) => {
178
+ console.error("[E2EAbracadabraProvider] failed to encrypt update:", e);
179
+ });
180
+ }
181
+
182
+ private async _encryptAndSend(update: Uint8Array): Promise<void> {
183
+ const key = await this.ensureDocKey();
184
+ if (!key) {
185
+ console.warn("[E2EAbracadabraProvider] no doc key, dropping update");
186
+ return;
187
+ }
188
+ const encrypted = await encryptField(update, key);
189
+ this.send(UpdateMessage, {
190
+ update: encrypted,
191
+ documentName: this.configuration.name,
192
+ });
193
+ }
194
+
195
+ override destroy(): void {
196
+ this.e2eStore?.destroy();
197
+ this.e2eStore = null;
198
+ super.destroy();
199
+ }
200
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * E2EOfflineStore
3
+ *
4
+ * Extends OfflineStore with transparent AES-GCM encryption of the document
5
+ * snapshot, ensuring that even if the device is compromised, the local
6
+ * IndexedDB data cannot be read without the doc key.
7
+ *
8
+ * Only the snapshot is encrypted (not the meta store entries, which contain
9
+ * non-sensitive data like sequence numbers).
10
+ *
11
+ * Key availability limitation: if the user's WebAuthn key is not in
12
+ * DocKeyManager's in-memory cache and there is no network, E2E docs show
13
+ * empty — this is expected Phase 2 behavior.
14
+ */
15
+
16
+ import { OfflineStore } from "./OfflineStore.ts";
17
+ import { encryptField, decryptField } from "./EncryptedY.ts";
18
+
19
+ export class E2EOfflineStore extends OfflineStore {
20
+ private readonly docKey: CryptoKey;
21
+
22
+ /**
23
+ * @param docId The document UUID.
24
+ * @param serverOrigin Hostname of the server (namespaces the IDB key).
25
+ * @param docKey AES-GCM CryptoKey used to encrypt/decrypt the snapshot.
26
+ */
27
+ constructor(docId: string, serverOrigin: string | undefined, docKey: CryptoKey) {
28
+ super(docId, serverOrigin);
29
+ this.docKey = docKey;
30
+ }
31
+
32
+ /**
33
+ * Encrypt the snapshot before storing it.
34
+ */
35
+ override async saveDocSnapshot(snapshot: Uint8Array): Promise<void> {
36
+ const encrypted = await encryptField(snapshot, this.docKey);
37
+ await super.saveDocSnapshot(encrypted);
38
+ }
39
+
40
+ /**
41
+ * Decrypt the snapshot after loading it.
42
+ * Returns null if decryption fails (e.g. key mismatch or corrupt data).
43
+ */
44
+ override async getDocSnapshot(): Promise<Uint8Array | null> {
45
+ const encrypted = await super.getDocSnapshot();
46
+ if (!encrypted) return null;
47
+ try {
48
+ return await decryptField(encrypted, this.docKey);
49
+ } catch {
50
+ // Key mismatch or tampered data — return null so the provider falls
51
+ // back to fetching from the server.
52
+ return null;
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * EncryptedY - CSE (Client-Side Encryption) primitives for Yjs documents.
3
+ *
4
+ * Wire format for encrypted fields: [nonce(12) || AES-GCM ciphertext]
5
+ *
6
+ * Limitations (documented):
7
+ * - Concurrent writes to the same Y.Map key produce last-write-wins semantics
8
+ * (not character-level CRDT merge), because encrypted values are opaque to Yrs.
9
+ */
10
+
11
+ import * as Y from "yjs";
12
+
13
+ // ── Field-level encryption ─────────────────────────────────────────────────────
14
+
15
+ /** Encrypt a field value with AES-256-GCM. Returns [nonce(12) || ciphertext]. */
16
+ export async function encryptField(value: Uint8Array, docKey: CryptoKey): Promise<Uint8Array> {
17
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
18
+ const ciphertext = new Uint8Array(
19
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, docKey, value),
20
+ );
21
+ const result = new Uint8Array(12 + ciphertext.length);
22
+ result.set(nonce, 0);
23
+ result.set(ciphertext, 12);
24
+ return result;
25
+ }
26
+
27
+ /** Decrypt a field value from [nonce(12) || ciphertext] format. */
28
+ export async function decryptField(ciphertext: Uint8Array, docKey: CryptoKey): Promise<Uint8Array> {
29
+ const nonce = ciphertext.slice(0, 12);
30
+ const ct = ciphertext.slice(12);
31
+ return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, docKey, ct));
32
+ }
33
+
34
+ // ── EncryptedYMap ─────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * A proxy wrapper around a Y.Map<Uint8Array> that transparently encrypts
38
+ * values on .set() and decrypts on .get().
39
+ *
40
+ * NOTE: Concurrent writes to the same key produce last-write-wins (LWW)
41
+ * because encrypted values are opaque blobs from Yrs's perspective.
42
+ */
43
+ export class EncryptedYMap {
44
+ constructor(
45
+ private readonly ymap: Y.Map<Uint8Array>,
46
+ private readonly docKey: CryptoKey,
47
+ ) {}
48
+
49
+ async set(key: string, value: Uint8Array): Promise<void> {
50
+ const encrypted = await encryptField(value, this.docKey);
51
+ this.ymap.set(key, encrypted);
52
+ }
53
+
54
+ async get(key: string): Promise<Uint8Array | null> {
55
+ const encrypted = this.ymap.get(key);
56
+ if (!encrypted) return null;
57
+ try {
58
+ return await decryptField(encrypted, this.docKey);
59
+ } catch {
60
+ return null; // Decryption failed (wrong key or corrupted)
61
+ }
62
+ }
63
+
64
+ has(key: string): boolean {
65
+ return this.ymap.has(key);
66
+ }
67
+
68
+ delete(key: string): void {
69
+ this.ymap.delete(key);
70
+ }
71
+
72
+ get size(): number {
73
+ return this.ymap.size;
74
+ }
75
+
76
+ /** Get all keys (encrypted values remain opaque until .get() is called). */
77
+ keys(): string[] {
78
+ return Array.from(this.ymap.keys());
79
+ }
80
+
81
+ /** Observe changes on the underlying Y.Map. */
82
+ observe(f: (event: Y.YMapEvent<Uint8Array>) => void): void {
83
+ this.ymap.observe(f);
84
+ }
85
+
86
+ unobserve(f: (event: Y.YMapEvent<Uint8Array>) => void): void {
87
+ this.ymap.unobserve(f);
88
+ }
89
+ }
90
+
91
+ /** Create an EncryptedYMap wrapping a Y.Map<Uint8Array>. */
92
+ export function makeEncryptedYMap(ymap: Y.Map<Uint8Array>, docKey: CryptoKey): EncryptedYMap {
93
+ return new EncryptedYMap(ymap, docKey);
94
+ }
95
+
96
+ // ── EncryptedYText ────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Stores full text as a single encrypted blob in a Y.Map<Uint8Array> under a
100
+ * fixed field name. This is last-write-wins for the entire text field.
101
+ *
102
+ * NOTE: This does NOT preserve character-level CRDT merge for concurrent edits.
103
+ * It trades CRDT merge for confidentiality.
104
+ */
105
+ export class EncryptedYText {
106
+ private readonly ymap: Y.Map<Uint8Array>;
107
+
108
+ constructor(
109
+ ydoc: Y.Doc,
110
+ fieldName: string,
111
+ private readonly docKey: CryptoKey,
112
+ ) {
113
+ this.ymap = ydoc.getMap(`_encrypted_${fieldName}`);
114
+ }
115
+
116
+ async set(text: string): Promise<void> {
117
+ const encoded = new TextEncoder().encode(text);
118
+ const encrypted = await encryptField(encoded, this.docKey);
119
+ this.ymap.set("v", encrypted);
120
+ }
121
+
122
+ async get(): Promise<string | null> {
123
+ const encrypted = this.ymap.get("v");
124
+ if (!encrypted) return null;
125
+ try {
126
+ const decoded = await decryptField(encrypted, this.docKey);
127
+ return new TextDecoder().decode(decoded);
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ observe(f: (event: Y.YMapEvent<Uint8Array>) => void): void {
134
+ this.ymap.observe(f);
135
+ }
136
+
137
+ unobserve(f: (event: Y.YMapEvent<Uint8Array>) => void): void {
138
+ this.ymap.unobserve(f);
139
+ }
140
+ }
141
+
142
+ /** Create an EncryptedYText for a field in a Y.Doc. */
143
+ export function makeEncryptedYText(ydoc: Y.Doc, fieldName: string, docKey: CryptoKey): EncryptedYText {
144
+ return new EncryptedYText(ydoc, fieldName, docKey);
145
+ }
@@ -226,6 +226,29 @@ export class OfflineStore {
226
226
  );
227
227
  }
228
228
 
229
+ // ── Generic meta accessors ────────────────────────────────────────────────
230
+
231
+ async getMeta(key: string): Promise<string | null> {
232
+ const db = await this.getDb();
233
+ if (!db) return null;
234
+ const tx = db.transaction("meta", "readonly");
235
+ const result = await txPromise<string | undefined>(
236
+ tx.objectStore("meta"),
237
+ tx.objectStore("meta").get(key),
238
+ );
239
+ return result ?? null;
240
+ }
241
+
242
+ async setMeta(key: string, value: string): Promise<void> {
243
+ const db = await this.getDb();
244
+ if (!db) return;
245
+ const tx = db.transaction("meta", "readwrite");
246
+ await txPromise(
247
+ tx.objectStore("meta"),
248
+ tx.objectStore("meta").put(value, key),
249
+ );
250
+ }
251
+
229
252
  // ── Lifecycle ─────────────────────────────────────────────────────────────
230
253
 
231
254
  destroy() {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * TreeTimestamps
3
+ *
4
+ * Attaches an afterUpdate observer on a child Y.Doc so that whenever a
5
+ * non-offline update is applied, the `updatedAt` timestamp on the
6
+ * corresponding entry in the root doc's `doc-tree` map is written.
7
+ *
8
+ * This propagates "last edited" timestamps to all peers via the root CRDT,
9
+ * without requiring any server-side changes.
10
+ *
11
+ * Limitation: at least one client must have the child doc open after an edit
12
+ * for the timestamp to propagate (eventually consistent).
13
+ */
14
+
15
+ import * as Y from "yjs";
16
+ import type { OfflineStore } from "./OfflineStore.ts";
17
+
18
+ /**
19
+ * Attach an observer that writes `updatedAt: Date.now()` to the root
20
+ * doc-tree entry for `childDocId` whenever the child doc receives a
21
+ * non-offline update.
22
+ *
23
+ * @param treeMap The root doc's "doc-tree" Y.Map.
24
+ * @param childDocId The child document's UUID (key in treeMap).
25
+ * @param childDoc The child Y.Doc to observe.
26
+ * @param offlineStore The child provider's OfflineStore (used to detect
27
+ * offline-replay origins and skip them). Pass null when
28
+ * the offline store is disabled.
29
+ * @returns Cleanup function — call on provider destroy.
30
+ */
31
+ export function attachUpdatedAtObserver(
32
+ treeMap: Y.Map<any>,
33
+ childDocId: string,
34
+ childDoc: Y.Doc,
35
+ offlineStore: OfflineStore | null,
36
+ ): () => void {
37
+ function handler(update: Uint8Array, origin: unknown): void {
38
+ // Skip updates replayed from the local offline store — they represent
39
+ // content that was already "seen" and shouldn't advance updatedAt.
40
+ if (offlineStore !== null && origin === offlineStore) return;
41
+
42
+ // Update the root tree entry (no-op if the entry doesn't exist).
43
+ const entry = treeMap.get(childDocId);
44
+ if (!entry) return;
45
+
46
+ treeMap.set(childDocId, { ...entry, updatedAt: Date.now() });
47
+ }
48
+
49
+ childDoc.on("update", handler);
50
+ return () => childDoc.off("update", handler);
51
+ }
package/src/index.ts CHANGED
@@ -13,3 +13,13 @@ export { DocumentCache } from "./DocumentCache.ts";
13
13
  export type { DocumentCacheOptions } from "./DocumentCache.ts";
14
14
  export { SearchIndex } from "./SearchIndex.ts";
15
15
  export { FileBlobStore } from "./FileBlobStore.ts";
16
+ export { DocKeyManager } from "./DocKeyManager.ts";
17
+ export { E2EAbracadabraProvider } from "./E2EAbracadabraProvider.ts";
18
+ export { E2EOfflineStore } from "./E2EOfflineStore.ts";
19
+ export * from "./EncryptedY.ts";
20
+ export type { DocEncryptionInfo } from "./types.ts";
21
+ export { attachUpdatedAtObserver } from "./TreeTimestamps.ts";
22
+ export { BackgroundSyncManager } from "./BackgroundSyncManager.ts";
23
+ export type { BackgroundSyncManagerOptions } from "./BackgroundSyncManager.ts";
24
+ export { BackgroundSyncPersistence } from "./BackgroundSyncPersistence.ts";
25
+ export type { DocSyncState } from "./BackgroundSyncPersistence.ts";