holosphere 2.0.0-alpha13 → 2.0.0-alpha15

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 (50) hide show
  1. package/dist/2019-ATLjawsU.cjs +8 -0
  2. package/dist/{2019-Cp3uYhyY.cjs.map → 2019-ATLjawsU.cjs.map} +1 -1
  3. package/dist/{2019-CLMqIAfQ.js → 2019-BfjzDRje.js} +1667 -1721
  4. package/dist/{2019-CLMqIAfQ.js.map → 2019-BfjzDRje.js.map} +1 -1
  5. package/dist/{browser-nUQt1cnB.js → browser-CKTczilW.js} +2 -2
  6. package/dist/{browser-nUQt1cnB.js.map → browser-CKTczilW.js.map} +1 -1
  7. package/dist/{browser-D6cNVl0v.cjs → browser-GOg6KKOV.cjs} +2 -2
  8. package/dist/{browser-D6cNVl0v.cjs.map → browser-GOg6KKOV.cjs.map} +1 -1
  9. package/dist/cjs/holosphere.cjs +1 -1
  10. package/dist/esm/holosphere.js +25 -21
  11. package/dist/{index-CoAjtqsD.js → index-BdnrGafX.js} +2 -2
  12. package/dist/{index-CoAjtqsD.js.map → index-BdnrGafX.js.map} +1 -1
  13. package/dist/{index-BN_uoxQK.js → index-C3Cag0SV.js} +2558 -495
  14. package/dist/index-C3Cag0SV.js.map +1 -0
  15. package/dist/index-ChpSfdYS.cjs +29 -0
  16. package/dist/index-ChpSfdYS.cjs.map +1 -0
  17. package/dist/{index-DJjGSwXG.cjs → index-D_QecZNu.cjs} +2 -2
  18. package/dist/{index-DJjGSwXG.cjs.map → index-D_QecZNu.cjs.map} +1 -1
  19. package/dist/{index-Z5TstN1e.js → index-nMC3dWZ5.js} +2 -2
  20. package/dist/{index-Z5TstN1e.js.map → index-nMC3dWZ5.js.map} +1 -1
  21. package/dist/{index-Cp3tI53z.cjs → index-z5HWfWMu.cjs} +2 -2
  22. package/dist/{index-Cp3tI53z.cjs.map → index-z5HWfWMu.cjs.map} +1 -1
  23. package/dist/{indexeddb-storage-CZK5A7XH.cjs → indexeddb-storage-DWSeL-YF.cjs} +2 -2
  24. package/dist/{indexeddb-storage-CZK5A7XH.cjs.map → indexeddb-storage-DWSeL-YF.cjs.map} +1 -1
  25. package/dist/{indexeddb-storage-bpA01pAU.js → indexeddb-storage-dx01N0ET.js} +2 -2
  26. package/dist/{indexeddb-storage-bpA01pAU.js.map → indexeddb-storage-dx01N0ET.js.map} +1 -1
  27. package/dist/{memory-storage-BqhmytP_.js → memory-storage-BPIfkpcf.js} +2 -2
  28. package/dist/{memory-storage-BqhmytP_.js.map → memory-storage-BPIfkpcf.js.map} +1 -1
  29. package/dist/{memory-storage-B1k8Jszd.cjs → memory-storage-CKUGDq2d.cjs} +2 -2
  30. package/dist/{memory-storage-B1k8Jszd.cjs.map → memory-storage-CKUGDq2d.cjs.map} +1 -1
  31. package/package.json +3 -1
  32. package/scripts/test-ndk-direct.js +104 -0
  33. package/src/crypto/key-store.js +356 -0
  34. package/src/crypto/lens-keys.js +205 -0
  35. package/src/crypto/secp256k1.js +181 -18
  36. package/src/federation/handshake.js +317 -23
  37. package/src/federation/hologram.js +25 -17
  38. package/src/federation/registry.js +779 -59
  39. package/src/index.js +416 -27
  40. package/src/lib/federation-methods.js +308 -4
  41. package/src/storage/nostr-async.js +144 -86
  42. package/src/storage/nostr-client.js +77 -18
  43. package/src/storage/nostr-wrapper.js +4 -1
  44. package/src/storage/unified-storage.js +5 -4
  45. package/src/subscriptions/manager.js +1 -1
  46. package/vitest.config.js +6 -1
  47. package/dist/2019-Cp3uYhyY.cjs +0 -8
  48. package/dist/index-BN_uoxQK.js.map +0 -1
  49. package/dist/index-V8EHMYEY.cjs +0 -29
  50. package/dist/index-V8EHMYEY.cjs.map +0 -1
@@ -0,0 +1,356 @@
1
+ /**
2
+ * @fileoverview Lens Key Store for Encrypted Federation
3
+ *
4
+ * Manages symmetric keys for encrypted lenses. Each HoloSphere instance
5
+ * has its own key store that tracks:
6
+ * - Keys we own (generated for our own lenses)
7
+ * - Keys we received (shared by federation partners)
8
+ *
9
+ * Keys are stored in memory and can be persisted to Nostr for recovery.
10
+ *
11
+ * @module crypto/key-store
12
+ */
13
+
14
+ import {
15
+ generateLensKey,
16
+ wrapKeyForRecipient,
17
+ unwrapKey,
18
+ serializeLensKey,
19
+ deserializeLensKey
20
+ } from './lens-keys.js';
21
+
22
+ /**
23
+ * In-memory key store for lens symmetric keys.
24
+ * Each HoloSphere instance should have its own LensKeyStore.
25
+ *
26
+ * @class LensKeyStore
27
+ */
28
+ export class LensKeyStore {
29
+ /**
30
+ * Creates a new LensKeyStore instance.
31
+ */
32
+ constructor() {
33
+ /**
34
+ * Keys we own (generated for our lenses).
35
+ * Map: lensPath -> { key: Uint8Array, wrappedForSelf: string, createdAt: number }
36
+ * @type {Map<string, Object>}
37
+ */
38
+ this.ownKeys = new Map();
39
+
40
+ /**
41
+ * Keys we received from federation partners.
42
+ * Map: lensPath -> { key: Uint8Array, fromPubKey: string, receivedAt: number }
43
+ * @type {Map<string, Object>}
44
+ */
45
+ this.receivedKeys = new Map();
46
+
47
+ /**
48
+ * Wrapped keys for partners (for sharing during federation).
49
+ * Map: lensPath -> Map<partnerPubKey, wrappedKey>
50
+ * @type {Map<string, Map<string, string>>}
51
+ */
52
+ this.partnerWrappedKeys = new Map();
53
+ }
54
+
55
+ /**
56
+ * Store a lens key we own (we generated it).
57
+ *
58
+ * @param {string} lensPath - Path identifying the lens (appName/holonId/lensName)
59
+ * @param {Uint8Array} key - 32-byte symmetric key
60
+ * @param {string} wrappedForSelf - NIP-44 wrapped key for self-recovery
61
+ */
62
+ storeOwnKey(lensPath, key, wrappedForSelf) {
63
+ this.ownKeys.set(lensPath, {
64
+ key,
65
+ wrappedForSelf,
66
+ createdAt: Date.now()
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Store a lens key we received from a federation partner.
72
+ *
73
+ * @param {string} lensPath - Path identifying the lens
74
+ * @param {Uint8Array} key - 32-byte symmetric key
75
+ * @param {string} fromPubKey - Public key of the partner who shared the key
76
+ */
77
+ storeReceivedKey(lensPath, key, fromPubKey) {
78
+ this.receivedKeys.set(lensPath, {
79
+ key,
80
+ fromPubKey,
81
+ receivedAt: Date.now()
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Get the key for a lens (own or received).
87
+ * Own keys take precedence over received keys.
88
+ *
89
+ * @param {string} lensPath - Path identifying the lens
90
+ * @returns {Uint8Array|null} 32-byte symmetric key or null if not found
91
+ */
92
+ getKey(lensPath) {
93
+ const own = this.ownKeys.get(lensPath);
94
+ if (own) return own.key;
95
+
96
+ const received = this.receivedKeys.get(lensPath);
97
+ if (received) return received.key;
98
+
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Check if we have a key for a lens.
104
+ *
105
+ * @param {string} lensPath - Path identifying the lens
106
+ * @returns {boolean} True if key exists
107
+ */
108
+ hasKey(lensPath) {
109
+ return this.ownKeys.has(lensPath) || this.receivedKeys.has(lensPath);
110
+ }
111
+
112
+ /**
113
+ * Check if we own the key for a lens (vs received it).
114
+ *
115
+ * @param {string} lensPath - Path identifying the lens
116
+ * @returns {boolean} True if we own the key
117
+ */
118
+ isOwnKey(lensPath) {
119
+ return this.ownKeys.has(lensPath);
120
+ }
121
+
122
+ /**
123
+ * Get or create a key for a lens.
124
+ * If key doesn't exist, generates a new one.
125
+ *
126
+ * @param {string} lensPath - Path identifying the lens
127
+ * @param {string} ownerPrivKey - Owner's private key for wrapping
128
+ * @param {string} ownerPubKey - Owner's public key
129
+ * @returns {Object} { key: Uint8Array, isNew: boolean, wrappedForSelf: string }
130
+ */
131
+ getOrCreateKey(lensPath, ownerPrivKey, ownerPubKey) {
132
+ // Check if key already exists
133
+ const existing = this.ownKeys.get(lensPath);
134
+ if (existing) {
135
+ return {
136
+ key: existing.key,
137
+ isNew: false,
138
+ wrappedForSelf: existing.wrappedForSelf
139
+ };
140
+ }
141
+
142
+ // Also check received keys (shouldn't generate if we received one)
143
+ if (this.receivedKeys.has(lensPath)) {
144
+ const received = this.receivedKeys.get(lensPath);
145
+ return {
146
+ key: received.key,
147
+ isNew: false,
148
+ wrappedForSelf: null // We don't have wrapped version for received keys
149
+ };
150
+ }
151
+
152
+ // Generate new key
153
+ const key = generateLensKey();
154
+ const wrappedForSelf = wrapKeyForRecipient(key, ownerPrivKey, ownerPubKey);
155
+
156
+ // Store it
157
+ this.storeOwnKey(lensPath, key, wrappedForSelf);
158
+
159
+ return { key, isNew: true, wrappedForSelf };
160
+ }
161
+
162
+ /**
163
+ * Wrap a lens key for a partner (for federation sharing).
164
+ *
165
+ * @param {string} lensPath - Path identifying the lens
166
+ * @param {string} ownerPrivKey - Owner's private key
167
+ * @param {string} partnerPubKey - Partner's public key
168
+ * @returns {string|null} Wrapped key for partner or null if no key exists
169
+ */
170
+ wrapKeyForPartner(lensPath, ownerPrivKey, partnerPubKey) {
171
+ const key = this.getKey(lensPath);
172
+ if (!key) return null;
173
+
174
+ const wrappedKey = wrapKeyForRecipient(key, ownerPrivKey, partnerPubKey);
175
+
176
+ // Cache the wrapped key for this partner
177
+ if (!this.partnerWrappedKeys.has(lensPath)) {
178
+ this.partnerWrappedKeys.set(lensPath, new Map());
179
+ }
180
+ this.partnerWrappedKeys.get(lensPath).set(partnerPubKey, wrappedKey);
181
+
182
+ return wrappedKey;
183
+ }
184
+
185
+ /**
186
+ * Get all partners who have been given wrapped keys for a lens.
187
+ *
188
+ * @param {string} lensPath - Path identifying the lens
189
+ * @returns {string[]} Array of partner public keys
190
+ */
191
+ getPartnersWithAccess(lensPath) {
192
+ const partners = this.partnerWrappedKeys.get(lensPath);
193
+ return partners ? Array.from(partners.keys()) : [];
194
+ }
195
+
196
+ /**
197
+ * Revoke a partner's access by removing their wrapped key.
198
+ * Note: This doesn't prevent them from using their cached key.
199
+ * For true revocation, you need to re-key the lens.
200
+ *
201
+ * @param {string} lensPath - Path identifying the lens
202
+ * @param {string} partnerPubKey - Partner's public key
203
+ * @returns {boolean} True if partner was found and removed
204
+ */
205
+ revokePartnerAccess(lensPath, partnerPubKey) {
206
+ const partners = this.partnerWrappedKeys.get(lensPath);
207
+ if (partners) {
208
+ return partners.delete(partnerPubKey);
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * Re-key a lens (generate new key and re-wrap for remaining partners).
215
+ * Used for key rotation or access revocation.
216
+ *
217
+ * @param {string} lensPath - Path identifying the lens
218
+ * @param {string} ownerPrivKey - Owner's private key
219
+ * @param {string} ownerPubKey - Owner's public key
220
+ * @param {string[]} [excludePartners=[]] - Partners to exclude from re-wrapping
221
+ * @returns {Object} { newKey, wrappedForSelf, partnerKeys: Map<pubKey, wrappedKey> }
222
+ */
223
+ rekeyLens(lensPath, ownerPrivKey, ownerPubKey, excludePartners = []) {
224
+ // Get current partners
225
+ const currentPartners = this.getPartnersWithAccess(lensPath);
226
+ const remainingPartners = currentPartners.filter(p => !excludePartners.includes(p));
227
+
228
+ // Generate new key
229
+ const newKey = generateLensKey();
230
+ const wrappedForSelf = wrapKeyForRecipient(newKey, ownerPrivKey, ownerPubKey);
231
+
232
+ // Store new key
233
+ this.storeOwnKey(lensPath, newKey, wrappedForSelf);
234
+
235
+ // Re-wrap for remaining partners
236
+ const partnerKeys = new Map();
237
+ this.partnerWrappedKeys.set(lensPath, new Map());
238
+
239
+ for (const partnerPubKey of remainingPartners) {
240
+ const wrappedKey = wrapKeyForRecipient(newKey, ownerPrivKey, partnerPubKey);
241
+ this.partnerWrappedKeys.get(lensPath).set(partnerPubKey, wrappedKey);
242
+ partnerKeys.set(partnerPubKey, wrappedKey);
243
+ }
244
+
245
+ return { newKey, wrappedForSelf, partnerKeys };
246
+ }
247
+
248
+ /**
249
+ * Receive and unwrap a key from a partner.
250
+ *
251
+ * @param {string} lensPath - Path identifying the lens
252
+ * @param {string} wrappedKey - NIP-44 wrapped key from partner
253
+ * @param {string} recipientPrivKey - Our private key
254
+ * @param {string} senderPubKey - Partner's public key
255
+ * @returns {Uint8Array} Unwrapped 32-byte symmetric key
256
+ */
257
+ receiveKey(lensPath, wrappedKey, recipientPrivKey, senderPubKey) {
258
+ const key = unwrapKey(wrappedKey, recipientPrivKey, senderPubKey);
259
+ this.storeReceivedKey(lensPath, key, senderPubKey);
260
+ return key;
261
+ }
262
+
263
+ /**
264
+ * Export own keys for persistent storage.
265
+ * Returns serializable format that can be stored in Nostr.
266
+ *
267
+ * @returns {Object[]} Array of { lensPath, wrappedForSelf, createdAt }
268
+ */
269
+ exportOwnKeys() {
270
+ const exported = [];
271
+ for (const [lensPath, data] of this.ownKeys.entries()) {
272
+ exported.push({
273
+ lensPath,
274
+ wrappedForSelf: data.wrappedForSelf,
275
+ createdAt: data.createdAt
276
+ });
277
+ }
278
+ return exported;
279
+ }
280
+
281
+ /**
282
+ * Import own keys from persistent storage.
283
+ *
284
+ * @param {Object[]} keyData - Array of exported key data
285
+ * @param {string} ownerPrivKey - Owner's private key for unwrapping
286
+ * @param {string} ownerPubKey - Owner's public key
287
+ */
288
+ importOwnKeys(keyData, ownerPrivKey, ownerPubKey) {
289
+ for (const data of keyData) {
290
+ const key = unwrapKey(data.wrappedForSelf, ownerPrivKey, ownerPubKey);
291
+ this.ownKeys.set(data.lensPath, {
292
+ key,
293
+ wrappedForSelf: data.wrappedForSelf,
294
+ createdAt: data.createdAt
295
+ });
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Clear all keys from the store.
301
+ * Use with caution - this will remove all access to encrypted data.
302
+ */
303
+ clear() {
304
+ this.ownKeys.clear();
305
+ this.receivedKeys.clear();
306
+ this.partnerWrappedKeys.clear();
307
+ }
308
+
309
+ /**
310
+ * Get statistics about the key store.
311
+ *
312
+ * @returns {Object} { ownKeyCount, receivedKeyCount, totalPartnerGrants }
313
+ */
314
+ getStats() {
315
+ let totalPartnerGrants = 0;
316
+ for (const partners of this.partnerWrappedKeys.values()) {
317
+ totalPartnerGrants += partners.size;
318
+ }
319
+
320
+ return {
321
+ ownKeyCount: this.ownKeys.size,
322
+ receivedKeyCount: this.receivedKeys.size,
323
+ totalPartnerGrants
324
+ };
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Build a lens path from components.
330
+ *
331
+ * @param {string} appName - Application namespace
332
+ * @param {string} holonId - Holon identifier
333
+ * @param {string} lensName - Lens name
334
+ * @returns {string} Lens path (appName/holonId/lensName)
335
+ */
336
+ export function buildLensPath(appName, holonId, lensName) {
337
+ return `${appName}/${holonId}/${lensName}`;
338
+ }
339
+
340
+ /**
341
+ * Parse a lens path into components.
342
+ *
343
+ * @param {string} lensPath - Lens path to parse
344
+ * @returns {Object} { appName, holonId, lensName }
345
+ */
346
+ export function parseLensPath(lensPath) {
347
+ const parts = lensPath.split('/');
348
+ if (parts.length < 3) {
349
+ throw new Error(`Invalid lens path: ${lensPath}`);
350
+ }
351
+ return {
352
+ appName: parts[0],
353
+ holonId: parts[1],
354
+ lensName: parts[2]
355
+ };
356
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @fileoverview Symmetric Key Management for Encrypted Federation
3
+ *
4
+ * Provides symmetric key generation, data encryption/decryption using AES-256-GCM,
5
+ * and key wrapping/unwrapping using NIP-44 for secure key exchange.
6
+ *
7
+ * Key Principles:
8
+ * - Each lens has its own symmetric key (256-bit AES key)
9
+ * - Data is encrypted once with the symmetric key (single source of truth)
10
+ * - Key = Access: Having the symmetric key means you can read the data
11
+ * - Key exchange happens during federation approval via NIP-44 encrypted DMs
12
+ *
13
+ * Browser-compatible: Uses @noble/ciphers for AES-GCM encryption.
14
+ *
15
+ * @module crypto/lens-keys
16
+ */
17
+
18
+ import { gcm } from '@noble/ciphers/aes';
19
+ import { randomBytes } from '@noble/ciphers/webcrypto';
20
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
21
+ import { encryptNIP44, decryptNIP44 } from './nostr-utils.js';
22
+
23
+ /**
24
+ * Generate a 256-bit symmetric key for a lens.
25
+ * Uses cryptographically secure random bytes.
26
+ *
27
+ * @returns {Uint8Array} 32-byte (256-bit) symmetric key
28
+ */
29
+ export function generateLensKey() {
30
+ return randomBytes(32);
31
+ }
32
+
33
+ /**
34
+ * Convert Uint8Array to base64 string.
35
+ * @param {Uint8Array} bytes
36
+ * @returns {string}
37
+ */
38
+ function uint8ToBase64(bytes) {
39
+ // Browser-compatible base64 encoding
40
+ if (typeof btoa === 'function') {
41
+ return btoa(String.fromCharCode(...bytes));
42
+ }
43
+ // Node.js fallback
44
+ return Buffer.from(bytes).toString('base64');
45
+ }
46
+
47
+ /**
48
+ * Convert base64 string to Uint8Array.
49
+ * @param {string} base64
50
+ * @returns {Uint8Array}
51
+ */
52
+ function base64ToUint8(base64) {
53
+ // Browser-compatible base64 decoding
54
+ if (typeof atob === 'function') {
55
+ const binary = atob(base64);
56
+ const bytes = new Uint8Array(binary.length);
57
+ for (let i = 0; i < binary.length; i++) {
58
+ bytes[i] = binary.charCodeAt(i);
59
+ }
60
+ return bytes;
61
+ }
62
+ // Node.js fallback
63
+ return new Uint8Array(Buffer.from(base64, 'base64'));
64
+ }
65
+
66
+ /**
67
+ * Encrypt data with symmetric key using AES-256-GCM.
68
+ * GCM provides both confidentiality and authenticity.
69
+ *
70
+ * Format: base64(iv[12] + ciphertext_with_tag)
71
+ *
72
+ * @param {Uint8Array} key - 32-byte symmetric key
73
+ * @param {string} plaintext - Data to encrypt (will be UTF-8 encoded)
74
+ * @returns {string} Base64-encoded encrypted data (iv + ciphertext + authTag)
75
+ */
76
+ export function encryptWithKey(key, plaintext) {
77
+ // Generate random 12-byte IV (recommended size for GCM)
78
+ const iv = randomBytes(12);
79
+
80
+ // Create cipher and encrypt (GCM tag is appended automatically)
81
+ const aes = gcm(key, iv);
82
+ const plaintextBytes = new TextEncoder().encode(plaintext);
83
+ const ciphertext = aes.encrypt(plaintextBytes);
84
+
85
+ // Concatenate: iv (12) + ciphertext (includes 16-byte auth tag at end)
86
+ const combined = new Uint8Array(iv.length + ciphertext.length);
87
+ combined.set(iv, 0);
88
+ combined.set(ciphertext, iv.length);
89
+
90
+ return uint8ToBase64(combined);
91
+ }
92
+
93
+ /**
94
+ * Decrypt data with symmetric key using AES-256-GCM.
95
+ *
96
+ * @param {Uint8Array} key - 32-byte symmetric key
97
+ * @param {string} ciphertext - Base64-encoded encrypted data
98
+ * @returns {string} Decrypted plaintext (UTF-8)
99
+ * @throws {Error} If decryption fails (wrong key or tampered data)
100
+ */
101
+ export function decryptWithKey(key, ciphertext) {
102
+ // Decode base64
103
+ const data = base64ToUint8(ciphertext);
104
+
105
+ // Extract components
106
+ const iv = data.slice(0, 12);
107
+ const encrypted = data.slice(12); // ciphertext + authTag
108
+
109
+ // Create cipher and decrypt
110
+ const aes = gcm(key, iv);
111
+ const decrypted = aes.decrypt(encrypted);
112
+
113
+ return new TextDecoder().decode(decrypted);
114
+ }
115
+
116
+ /**
117
+ * Wrap a symmetric key for a recipient using NIP-44 encryption.
118
+ * This allows secure key exchange via Nostr DMs.
119
+ *
120
+ * @param {Uint8Array} lensKey - 32-byte symmetric key to wrap
121
+ * @param {string} senderPrivKey - Sender's hex private key
122
+ * @param {string} recipientPubKey - Recipient's hex public key
123
+ * @returns {string} NIP-44 encrypted wrapped key
124
+ */
125
+ export function wrapKeyForRecipient(lensKey, senderPrivKey, recipientPubKey) {
126
+ // Convert key to base64 for NIP-44 encryption
127
+ const keyBase64 = uint8ToBase64(lensKey);
128
+ return encryptNIP44(senderPrivKey, recipientPubKey, keyBase64);
129
+ }
130
+
131
+ /**
132
+ * Unwrap a symmetric key received from a sender using NIP-44 decryption.
133
+ *
134
+ * @param {string} wrappedKey - NIP-44 encrypted wrapped key
135
+ * @param {string} recipientPrivKey - Recipient's hex private key
136
+ * @param {string} senderPubKey - Sender's hex public key
137
+ * @returns {Uint8Array} 32-byte symmetric key
138
+ */
139
+ export function unwrapKey(wrappedKey, recipientPrivKey, senderPubKey) {
140
+ const keyBase64 = decryptNIP44(recipientPrivKey, senderPubKey, wrappedKey);
141
+ return base64ToUint8(keyBase64);
142
+ }
143
+
144
+ /**
145
+ * Serialize a lens key for persistent storage.
146
+ * The key is wrapped for the owner using NIP-44.
147
+ *
148
+ * @param {Uint8Array} lensKey - 32-byte symmetric key
149
+ * @param {string} ownerPrivKey - Owner's hex private key
150
+ * @param {string} ownerPubKey - Owner's hex public key
151
+ * @returns {Object} Serializable key data { wrappedKey, algorithm, version }
152
+ */
153
+ export function serializeLensKey(lensKey, ownerPrivKey, ownerPubKey) {
154
+ return {
155
+ wrappedKey: wrapKeyForRecipient(lensKey, ownerPrivKey, ownerPubKey),
156
+ algorithm: 'aes-256-gcm',
157
+ keyWrap: 'nip44',
158
+ version: '1.0'
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Deserialize a lens key from persistent storage.
164
+ *
165
+ * @param {Object} keyData - Serialized key data
166
+ * @param {string} ownerPrivKey - Owner's hex private key
167
+ * @param {string} ownerPubKey - Owner's hex public key
168
+ * @returns {Uint8Array} 32-byte symmetric key
169
+ */
170
+ export function deserializeLensKey(keyData, ownerPrivKey, ownerPubKey) {
171
+ if (keyData.version !== '1.0') {
172
+ throw new Error(`Unsupported lens key version: ${keyData.version}`);
173
+ }
174
+ return unwrapKey(keyData.wrappedKey, ownerPrivKey, ownerPubKey);
175
+ }
176
+
177
+ /**
178
+ * Check if content is encrypted (base64-encoded AES-GCM format).
179
+ * Encrypted content starts with a valid base64 string of at least 28 bytes
180
+ * (12 IV + 16 authTag) and is not valid JSON.
181
+ *
182
+ * @param {string} content - Content string to check
183
+ * @returns {boolean} True if content appears to be encrypted
184
+ */
185
+ export function isEncrypted(content) {
186
+ if (!content || typeof content !== 'string') return false;
187
+
188
+ // Quick check: if it starts with { or [, it's likely JSON
189
+ const trimmed = content.trim();
190
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
191
+ return false;
192
+ }
193
+
194
+ // Check if it's valid base64 with minimum length for encrypted data
195
+ // 12 (IV) + 16 (authTag) = 28 bytes minimum = ~38 base64 chars
196
+ if (content.length < 38) return false;
197
+
198
+ try {
199
+ const decoded = base64ToUint8(content);
200
+ // Minimum size check: IV (12) + authTag (16) + at least 1 byte of data
201
+ return decoded.length >= 29;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }