@tinycloud/sdk-services 1.2.0 → 1.5.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.
@@ -0,0 +1,975 @@
1
+ /**
2
+ * DataVaultService - Encrypted key-value storage service implementation.
3
+ *
4
+ * Platform-agnostic encrypted KV service that wraps KVService internally.
5
+ * Uses dependency injection via VaultCrypto for WASM crypto operations
6
+ * and DataVaultServiceConfig for platform dependencies.
7
+ *
8
+ * Architecture:
9
+ * - Extends BaseService (not KVService)
10
+ * - Wraps two KV instances: dataKV (prefix "vault/") and keyKV (prefix "keys/")
11
+ * - Master key and encryption identity live in memory only (cleared on lock)
12
+ */
13
+ import { BaseService } from "../base/BaseService";
14
+ import { ok, } from "../types";
15
+ import { VaultHeaders, } from "./types";
16
+ // =============================================================================
17
+ // Helper Functions
18
+ // =============================================================================
19
+ /** Convert a caught value to an Error. WASM throws plain objects, not Error instances. */
20
+ function toError(error) {
21
+ if (error instanceof Error)
22
+ return error;
23
+ if (typeof error === "object" && error !== null) {
24
+ return new Error(JSON.stringify(error));
25
+ }
26
+ return new Error(String(error));
27
+ }
28
+ function toBytes(str) {
29
+ return new TextEncoder().encode(str);
30
+ }
31
+ function fromBytes(bytes) {
32
+ return new TextDecoder().decode(bytes);
33
+ }
34
+ function hexEncode(bytes) {
35
+ return Array.from(bytes)
36
+ .map((b) => b.toString(16).padStart(2, "0"))
37
+ .join("");
38
+ }
39
+ function concatBytes(...arrays) {
40
+ const total = arrays.reduce((acc, arr) => acc + arr.length, 0);
41
+ const result = new Uint8Array(total);
42
+ let offset = 0;
43
+ for (const arr of arrays) {
44
+ result.set(arr, offset);
45
+ offset += arr.length;
46
+ }
47
+ return result;
48
+ }
49
+ function base64Encode(bytes) {
50
+ let binary = "";
51
+ for (let i = 0; i < bytes.length; i++) {
52
+ binary += String.fromCharCode(bytes[i]);
53
+ }
54
+ return btoa(binary);
55
+ }
56
+ function base64Decode(str) {
57
+ const binary = atob(str);
58
+ const bytes = new Uint8Array(binary.length);
59
+ for (let i = 0; i < binary.length; i++) {
60
+ bytes[i] = binary.charCodeAt(i);
61
+ }
62
+ return bytes;
63
+ }
64
+ function defaultVaultMessage(input) {
65
+ switch (input.code) {
66
+ case "DECRYPTION_FAILED": return input.message ?? "Decryption failed";
67
+ case "KEY_NOT_FOUND": return input.message ?? `Key not found: ${input.key}`;
68
+ case "INTEGRITY_ERROR": return input.message ?? "Integrity check failed";
69
+ case "GRANT_NOT_FOUND": return input.message ?? `Grant not found: ${input.grantor} / ${input.key}`;
70
+ case "VAULT_LOCKED": return input.message ?? "Vault is locked";
71
+ case "PUBLIC_KEY_NOT_FOUND": return input.message ?? `Public key not found for ${input.did}`;
72
+ case "STORAGE_ERROR": return input.message ?? input.cause.message;
73
+ }
74
+ }
75
+ function vaultError(input) {
76
+ const error = {
77
+ ...input,
78
+ service: "vault",
79
+ message: defaultVaultMessage(input),
80
+ };
81
+ return { ok: false, error };
82
+ }
83
+ // =============================================================================
84
+ // DataVaultService
85
+ // =============================================================================
86
+ /**
87
+ * Data Vault service implementation.
88
+ *
89
+ * Provides encrypted key-value storage with client-side encryption,
90
+ * key management, and sharing via X25519 grants.
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * // Unlock the vault
95
+ * await vault.unlock(signer);
96
+ *
97
+ * // Store encrypted data
98
+ * await vault.put('secret/notes', { content: 'Hello' });
99
+ *
100
+ * // Retrieve and decrypt
101
+ * const entry = await vault.get<{ content: string }>('secret/notes');
102
+ * if (entry.ok) {
103
+ * console.log(entry.data.value.content); // 'Hello'
104
+ * }
105
+ *
106
+ * // Share with another user
107
+ * await vault.grant('secret/notes', recipientDID);
108
+ * ```
109
+ */
110
+ export class DataVaultService extends BaseService {
111
+ /**
112
+ * Create a new DataVaultService instance.
113
+ *
114
+ * @param config - Service configuration including crypto and tc references
115
+ */
116
+ constructor(config) {
117
+ super();
118
+ this.masterKey = null;
119
+ this.encryptionIdentity = null;
120
+ this._isUnlocked = false;
121
+ this.vaultConfig = config;
122
+ this._config = config;
123
+ }
124
+ /**
125
+ * Get the service configuration.
126
+ */
127
+ get config() {
128
+ return this._config;
129
+ }
130
+ /**
131
+ * Whether the vault is currently unlocked.
132
+ */
133
+ get isUnlocked() {
134
+ return this._isUnlocked;
135
+ }
136
+ /**
137
+ * The vault's public encryption key (X25519).
138
+ * Throws if vault is locked.
139
+ */
140
+ get publicKey() {
141
+ if (!this.encryptionIdentity) {
142
+ throw new Error("Vault is locked");
143
+ }
144
+ return this.encryptionIdentity.publicKey;
145
+ }
146
+ /**
147
+ * Convenience accessor for crypto operations.
148
+ */
149
+ get crypto() {
150
+ return this.vaultConfig.crypto;
151
+ }
152
+ /**
153
+ * Convenience accessor for TinyCloud instance.
154
+ */
155
+ get tc() {
156
+ return this.vaultConfig.tc;
157
+ }
158
+ /**
159
+ * Get the host URL.
160
+ */
161
+ get host() {
162
+ return this.tc.hosts[0];
163
+ }
164
+ // =========================================================================
165
+ // Phase 1: Core Operations
166
+ // =========================================================================
167
+ /**
168
+ * Unlock the vault. Triggers wallet signatures to derive keys.
169
+ *
170
+ * 1. Signs a deterministic message to derive the master key
171
+ * 2. Signs a second message to derive the X25519 encryption identity
172
+ * 3. Ensures public space exists and publishes the public key
173
+ *
174
+ * @param signer - Object with signMessage method (wallet/key signer)
175
+ */
176
+ async unlock(signer) {
177
+ return this.withTelemetry("unlock", undefined, async () => {
178
+ const s = signer;
179
+ const spaceId = this.vaultConfig.spaceId;
180
+ try {
181
+ // Step 1: Derive master key from deterministic signature
182
+ const masterSig = await s.signMessage(`tinycloud-vault-master-v1:${spaceId}`);
183
+ const masterSigBytes = toBytes(masterSig);
184
+ this.masterKey = this.crypto.deriveKey(masterSigBytes, this.crypto.sha256(toBytes(spaceId)), toBytes("vault-master"));
185
+ // Step 2: Derive encryption identity from second signature
186
+ const identitySig = await s.signMessage("tinycloud-encryption-identity-v1");
187
+ const identitySigBytes = toBytes(identitySig);
188
+ const seed = this.crypto.deriveKey(identitySigBytes, toBytes("tinycloud-x25519"), toBytes("encryption-identity"));
189
+ this.encryptionIdentity = this.crypto.x25519FromSeed(seed);
190
+ // Step 3: Publish vault metadata to public space (best-effort)
191
+ // This enables other users to discover our public key and vault location.
192
+ // Non-fatal: key derivation succeeded, publishing is optional.
193
+ try {
194
+ // Try to ensure public space exists (may fail if delegation lacks space/info action)
195
+ await this.tc.ensurePublicSpace();
196
+ // Publish regardless — the space may already exist even if ensurePublicSpace failed
197
+ const pubKeyB64 = base64Encode(this.encryptionIdentity.publicKey);
198
+ await this.tc.publicKV.put(".well-known/vault-pubkey", pubKeyB64);
199
+ await this.tc.publicKV.put(".well-known/vault-version", "1");
200
+ // Publish vault space ID so getShared() can find grants and data
201
+ await this.tc.publicKV.put(".well-known/vault-space", this.vaultConfig.spaceId);
202
+ }
203
+ catch {
204
+ // Public key publishing failed — vault still usable
205
+ }
206
+ this._isUnlocked = true;
207
+ return ok(undefined);
208
+ }
209
+ catch (error) {
210
+ // Clear key material on failure
211
+ this.masterKey = null;
212
+ this.encryptionIdentity = null;
213
+ return vaultError({
214
+ code: "STORAGE_ERROR",
215
+ cause: toError(error),
216
+ });
217
+ }
218
+ });
219
+ }
220
+ /**
221
+ * Lock the vault, clearing all key material from memory.
222
+ */
223
+ lock() {
224
+ this.masterKey = null;
225
+ this.encryptionIdentity = null;
226
+ this._isUnlocked = false;
227
+ }
228
+ /**
229
+ * Called when SDK signs out. Locks the vault and aborts operations.
230
+ */
231
+ onSignOut() {
232
+ this.lock();
233
+ super.onSignOut();
234
+ }
235
+ /**
236
+ * Encrypt and store a value at the given key.
237
+ *
238
+ * @param key - The key to store under
239
+ * @param value - The value to encrypt and store
240
+ * @param options - Optional put configuration
241
+ */
242
+ async put(key, value, options) {
243
+ return this.withTelemetry("put", key, async () => {
244
+ if (!this._isUnlocked || !this.masterKey) {
245
+ return vaultError({
246
+ code: "VAULT_LOCKED",
247
+ message: "Vault must be unlocked before storing data",
248
+ });
249
+ }
250
+ if (!this.requireAuth()) {
251
+ return vaultError({
252
+ code: "VAULT_LOCKED",
253
+ message: "Authentication required",
254
+ });
255
+ }
256
+ try {
257
+ // Serialize value
258
+ let plaintext;
259
+ if (value instanceof Uint8Array) {
260
+ plaintext = value;
261
+ }
262
+ else if (options?.serialize) {
263
+ plaintext = options.serialize(value);
264
+ }
265
+ else if (typeof value === "string") {
266
+ plaintext = toBytes(value);
267
+ }
268
+ else {
269
+ plaintext = toBytes(JSON.stringify(value));
270
+ }
271
+ const contentType = options?.contentType ??
272
+ (value instanceof Uint8Array
273
+ ? "application/octet-stream"
274
+ : "application/json");
275
+ // Generate per-entry key
276
+ const entryKey = this.crypto.randomBytes(32);
277
+ const keyId = hexEncode(this.crypto.sha256(entryKey)).slice(0, 16);
278
+ // Encrypt value with entry key
279
+ const encrypted = this.crypto.encrypt(entryKey, plaintext);
280
+ // Encrypt entry key with master key
281
+ const keyBlob = this.crypto.encrypt(this.masterKey, entryKey);
282
+ // Build metadata
283
+ const metadata = {
284
+ [VaultHeaders.VERSION]: "1",
285
+ [VaultHeaders.CIPHER]: "aes-256-gcm",
286
+ [VaultHeaders.KEY_ID]: keyId,
287
+ [VaultHeaders.CONTENT_TYPE]: contentType,
288
+ [VaultHeaders.KDF]: "hkdf-sha256",
289
+ [VaultHeaders.KEY_ROTATION]: this.vaultConfig.keyRotation ?? "per-write",
290
+ ...(options?.metadata ?? {}),
291
+ };
292
+ // Store encrypted entry key in key space
293
+ const keyMetadata = JSON.stringify({
294
+ keyId,
295
+ contentType,
296
+ ...metadata,
297
+ });
298
+ const keyPayload = JSON.stringify({
299
+ key: base64Encode(keyBlob),
300
+ metadata: keyMetadata,
301
+ });
302
+ const keyPutResult = await this.tc.kv.put(`keys/${key}`, keyPayload);
303
+ if (!keyPutResult.ok) {
304
+ return vaultError({
305
+ code: "STORAGE_ERROR",
306
+ cause: new Error(`Failed to store key blob: ${keyPutResult.error.message}`),
307
+ });
308
+ }
309
+ // Store encrypted value in data space
310
+ const valuePayload = JSON.stringify({
311
+ data: base64Encode(encrypted),
312
+ metadata,
313
+ });
314
+ const valuePutResult = await this.tc.kv.put(`vault/${key}`, valuePayload);
315
+ if (!valuePutResult.ok) {
316
+ return vaultError({
317
+ code: "STORAGE_ERROR",
318
+ cause: new Error(`Failed to store encrypted value: ${valuePutResult.error.message}`),
319
+ });
320
+ }
321
+ return ok(undefined);
322
+ }
323
+ catch (error) {
324
+ return vaultError({
325
+ code: "STORAGE_ERROR",
326
+ cause: toError(error),
327
+ });
328
+ }
329
+ });
330
+ }
331
+ /**
332
+ * Retrieve and decrypt a value by key.
333
+ *
334
+ * @param key - The key to retrieve
335
+ * @param options - Optional get configuration
336
+ * @returns Result with the decrypted entry
337
+ */
338
+ async get(key, options) {
339
+ return this.withTelemetry("get", key, async () => {
340
+ if (!this._isUnlocked || !this.masterKey) {
341
+ return vaultError({
342
+ code: "VAULT_LOCKED",
343
+ message: "Vault must be unlocked before reading data",
344
+ });
345
+ }
346
+ if (!this.requireAuth()) {
347
+ return vaultError({
348
+ code: "VAULT_LOCKED",
349
+ message: "Authentication required",
350
+ });
351
+ }
352
+ try {
353
+ // Fetch encrypted entry key from key space
354
+ const keyResult = await this.tc.kv.get(`keys/${key}`, {
355
+ raw: true,
356
+ });
357
+ if (!keyResult.ok) {
358
+ return vaultError({ code: "KEY_NOT_FOUND", key });
359
+ }
360
+ const keyEnvelope = JSON.parse(keyResult.data.data);
361
+ const keyBlobBytes = base64Decode(keyEnvelope.key);
362
+ const entryKey = this.crypto.decrypt(this.masterKey, keyBlobBytes);
363
+ // Fetch encrypted value from data space
364
+ const valueResult = await this.tc.kv.get(`vault/${key}`, {
365
+ raw: true,
366
+ });
367
+ if (!valueResult.ok) {
368
+ return vaultError({ code: "KEY_NOT_FOUND", key });
369
+ }
370
+ const valueEnvelope = JSON.parse(valueResult.data.data);
371
+ const encryptedBytes = base64Decode(valueEnvelope.data);
372
+ const plaintext = this.crypto.decrypt(entryKey, encryptedBytes);
373
+ // Read metadata
374
+ const metadata = valueEnvelope.metadata ?? {};
375
+ const contentType = metadata[VaultHeaders.CONTENT_TYPE] ?? "application/json";
376
+ const keyId = metadata[VaultHeaders.KEY_ID] ?? "";
377
+ // Deserialize
378
+ let value;
379
+ if (options?.raw) {
380
+ value = plaintext;
381
+ }
382
+ else if (options?.deserialize) {
383
+ value = options.deserialize(plaintext);
384
+ }
385
+ else if (contentType === "application/json") {
386
+ value = JSON.parse(fromBytes(plaintext));
387
+ }
388
+ else {
389
+ value = plaintext;
390
+ }
391
+ return ok({ value, metadata, keyId });
392
+ }
393
+ catch (error) {
394
+ if (error instanceof Error &&
395
+ error.message.includes("decryption")) {
396
+ return vaultError({
397
+ code: "DECRYPTION_FAILED",
398
+ message: error.message,
399
+ });
400
+ }
401
+ return vaultError({
402
+ code: "STORAGE_ERROR",
403
+ cause: toError(error),
404
+ });
405
+ }
406
+ });
407
+ }
408
+ /**
409
+ * Delete an encrypted key.
410
+ * Removes both the encrypted value and the key blob.
411
+ *
412
+ * @param key - The key to delete
413
+ */
414
+ async delete(key) {
415
+ return this.withTelemetry("delete", key, async () => {
416
+ if (!this._isUnlocked) {
417
+ return vaultError({
418
+ code: "VAULT_LOCKED",
419
+ message: "Vault must be unlocked before deleting data",
420
+ });
421
+ }
422
+ if (!this.requireAuth()) {
423
+ return vaultError({
424
+ code: "VAULT_LOCKED",
425
+ message: "Authentication required",
426
+ });
427
+ }
428
+ try {
429
+ // Delete from both key space and data space
430
+ const [keyDelResult, valueDelResult] = await Promise.all([
431
+ this.tc.kv.delete(`keys/${key}`),
432
+ this.tc.kv.delete(`vault/${key}`),
433
+ ]);
434
+ if (!keyDelResult.ok && !valueDelResult.ok) {
435
+ return vaultError({ code: "KEY_NOT_FOUND", key });
436
+ }
437
+ return ok(undefined);
438
+ }
439
+ catch (error) {
440
+ return vaultError({
441
+ code: "STORAGE_ERROR",
442
+ cause: toError(error),
443
+ });
444
+ }
445
+ });
446
+ }
447
+ /**
448
+ * List vault keys with optional prefix filtering.
449
+ *
450
+ * @param options - Optional list configuration
451
+ * @returns Result with array of key names (vault/ prefix stripped)
452
+ */
453
+ async list(options) {
454
+ return this.withTelemetry("list", options?.prefix, async () => {
455
+ if (!this._isUnlocked) {
456
+ return vaultError({
457
+ code: "VAULT_LOCKED",
458
+ message: "Vault must be unlocked before listing data",
459
+ });
460
+ }
461
+ if (!this.requireAuth()) {
462
+ return vaultError({
463
+ code: "VAULT_LOCKED",
464
+ message: "Authentication required",
465
+ });
466
+ }
467
+ try {
468
+ const listPrefix = options?.prefix
469
+ ? `vault/${options.prefix}`
470
+ : "vault/";
471
+ const listResult = await this.tc.kv.list({
472
+ prefix: listPrefix,
473
+ removePrefix: true,
474
+ });
475
+ if (!listResult.ok) {
476
+ return vaultError({
477
+ code: "STORAGE_ERROR",
478
+ cause: new Error(`Failed to list vault keys: ${listResult.error.message}`),
479
+ });
480
+ }
481
+ // Keys are already stripped of the "vault/" prefix by removePrefix
482
+ let keys = listResult.data.keys;
483
+ // If a user prefix was provided, strip it too if requested
484
+ if (options?.removePrefix && options.prefix) {
485
+ const userPrefix = options.prefix.endsWith("/")
486
+ ? options.prefix
487
+ : `${options.prefix}/`;
488
+ keys = keys.map((k) => k.startsWith(userPrefix) ? k.slice(userPrefix.length) : k);
489
+ }
490
+ return ok(keys);
491
+ }
492
+ catch (error) {
493
+ return vaultError({
494
+ code: "STORAGE_ERROR",
495
+ cause: toError(error),
496
+ });
497
+ }
498
+ });
499
+ }
500
+ /**
501
+ * Get envelope metadata for a key without decrypting the value.
502
+ *
503
+ * @param key - The key to inspect
504
+ * @returns Result with metadata headers
505
+ */
506
+ async head(key) {
507
+ return this.withTelemetry("head", key, async () => {
508
+ if (!this._isUnlocked) {
509
+ return vaultError({
510
+ code: "VAULT_LOCKED",
511
+ message: "Vault must be unlocked before reading metadata",
512
+ });
513
+ }
514
+ if (!this.requireAuth()) {
515
+ return vaultError({
516
+ code: "VAULT_LOCKED",
517
+ message: "Authentication required",
518
+ });
519
+ }
520
+ try {
521
+ // Fetch envelope without decrypting
522
+ const valueResult = await this.tc.kv.get(`vault/${key}`, {
523
+ raw: true,
524
+ });
525
+ if (!valueResult.ok) {
526
+ return vaultError({ code: "KEY_NOT_FOUND", key });
527
+ }
528
+ const valueEnvelope = JSON.parse(valueResult.data.data);
529
+ const metadata = valueEnvelope.metadata ?? {};
530
+ return ok(metadata);
531
+ }
532
+ catch (error) {
533
+ return vaultError({
534
+ code: "STORAGE_ERROR",
535
+ cause: toError(error),
536
+ });
537
+ }
538
+ });
539
+ }
540
+ // =========================================================================
541
+ // Batch Operations
542
+ // =========================================================================
543
+ /**
544
+ * Encrypt and store multiple entries.
545
+ *
546
+ * @param entries - Array of key/value pairs with optional per-entry options
547
+ * @returns Array of results, one per entry
548
+ */
549
+ async putMany(entries) {
550
+ return Promise.all(entries.map((entry) => this.put(entry.key, entry.value, entry.options)));
551
+ }
552
+ /**
553
+ * Retrieve and decrypt multiple keys.
554
+ *
555
+ * @param keys - Array of keys to retrieve
556
+ * @param options - Optional get configuration applied to all entries
557
+ * @returns Array of results, one per key
558
+ */
559
+ async getMany(keys, options) {
560
+ return Promise.all(keys.map((key) => this.get(key, options)));
561
+ }
562
+ // =========================================================================
563
+ // Phase 2: Sharing
564
+ // =========================================================================
565
+ /**
566
+ * Re-encrypt a vault key for another user (renamed from grant).
567
+ * Re-encrypts the data key to the recipient's public key via X25519 DH.
568
+ *
569
+ * @param key - The key to share
570
+ * @param recipientDID - The recipient's primary DID (did:pkh:...)
571
+ * @param options - Optional grant configuration
572
+ */
573
+ async reencrypt(key, recipientDID, options) {
574
+ return this.withTelemetry("reencrypt", key, async () => {
575
+ if (!this._isUnlocked || !this.masterKey) {
576
+ return vaultError({
577
+ code: "VAULT_LOCKED",
578
+ message: "Vault must be unlocked before granting access",
579
+ });
580
+ }
581
+ if (!this.requireAuth()) {
582
+ return vaultError({
583
+ code: "VAULT_LOCKED",
584
+ message: "Authentication required",
585
+ });
586
+ }
587
+ try {
588
+ // Step 1: Resolve recipient's public key
589
+ const pubKeyResult = await this.resolvePublicKey(recipientDID);
590
+ if (!pubKeyResult.ok) {
591
+ return pubKeyResult;
592
+ }
593
+ const bobPubKey = pubKeyResult.data;
594
+ // Step 2: Fetch and decrypt entry key from key space
595
+ const keyResult = await this.tc.kv.get(`keys/${key}`, {
596
+ raw: true,
597
+ });
598
+ if (!keyResult.ok) {
599
+ return vaultError({ code: "KEY_NOT_FOUND", key });
600
+ }
601
+ const keyEnvelope = JSON.parse(keyResult.data.data);
602
+ const keyBlobBytes = base64Decode(keyEnvelope.key);
603
+ const entryKey = this.crypto.decrypt(this.masterKey, keyBlobBytes);
604
+ // Step 3: Create ephemeral X25519 key pair
605
+ const ephemeralSeed = this.crypto.randomBytes(32);
606
+ const ephemeralKeyPair = this.crypto.x25519FromSeed(ephemeralSeed);
607
+ // Step 4: Compute shared secret via DH
608
+ const sharedSecret = this.crypto.x25519Dh(ephemeralKeyPair.privateKey, bobPubKey);
609
+ // Step 5: Derive encryption key from shared secret
610
+ const encryptionKey = this.crypto.deriveKey(sharedSecret, toBytes("tinycloud-x25519"), toBytes("vault-grant"));
611
+ // Step 6: Encrypt entry key with derived key
612
+ const encryptedGrant = this.crypto.encrypt(encryptionKey, entryKey);
613
+ // Step 7: Concatenate ephemeral public key + encrypted grant
614
+ const grantBlob = concatBytes(ephemeralKeyPair.publicKey, encryptedGrant);
615
+ // Step 8: Store grant in key space
616
+ const grantPayload = JSON.stringify({
617
+ grant: base64Encode(grantBlob),
618
+ spaceId: this.vaultConfig.spaceId,
619
+ metadata: {
620
+ [VaultHeaders.GRANT_VERSION]: "1",
621
+ [VaultHeaders.GRANTOR]: this.tc.did,
622
+ ...(options?.metadata ?? {}),
623
+ },
624
+ });
625
+ // Store grant in the vault's space (main space)
626
+ const grantPutResult = await this.tc.kv.put(`grants/${recipientDID}/${key}`, grantPayload);
627
+ if (!grantPutResult.ok) {
628
+ return vaultError({
629
+ code: "STORAGE_ERROR",
630
+ cause: new Error(`Failed to store grant: ${grantPutResult.error.message}`),
631
+ });
632
+ }
633
+ return ok(undefined);
634
+ }
635
+ catch (error) {
636
+ return vaultError({
637
+ code: "STORAGE_ERROR",
638
+ cause: toError(error),
639
+ });
640
+ }
641
+ });
642
+ }
643
+ /**
644
+ * @deprecated Use reencrypt() instead.
645
+ */
646
+ async grant(key, recipientDID, options) {
647
+ return this.reencrypt(key, recipientDID, options);
648
+ }
649
+ /**
650
+ * Retrieve and decrypt a value shared by another user.
651
+ *
652
+ * @param grantorDID - The DID of the user who shared the data
653
+ * @param key - The key that was shared
654
+ * @param options - Optional get configuration
655
+ * @returns Result with the decrypted entry
656
+ */
657
+ async getShared(grantorDID, key, options) {
658
+ return this.withTelemetry("getShared", key, async () => {
659
+ if (!this._isUnlocked ||
660
+ !this.masterKey ||
661
+ !this.encryptionIdentity) {
662
+ return vaultError({
663
+ code: "VAULT_LOCKED",
664
+ message: "Vault must be unlocked before reading shared data",
665
+ });
666
+ }
667
+ if (!this.requireAuth()) {
668
+ return vaultError({
669
+ code: "VAULT_LOCKED",
670
+ message: "Authentication required",
671
+ });
672
+ }
673
+ try {
674
+ const myDID = this.tc.did;
675
+ const grantorKV = options?.kv;
676
+ if (!grantorKV) {
677
+ return vaultError({
678
+ code: "STORAGE_ERROR",
679
+ cause: new Error("getShared requires a delegated KV service via options.kv. " +
680
+ "Use useDelegation() to get delegated access, then pass { kv: access.kv }."),
681
+ });
682
+ }
683
+ // Step 1: Fetch grant from grantor's space via delegated KV
684
+ const grantResult = await grantorKV.get(`grants/${myDID}/${key}`, {
685
+ raw: true,
686
+ });
687
+ if (!grantResult.ok) {
688
+ return vaultError({
689
+ code: "GRANT_NOT_FOUND",
690
+ grantor: grantorDID,
691
+ key,
692
+ });
693
+ }
694
+ const grantEnvelope = typeof grantResult.data?.data === "string"
695
+ ? JSON.parse(grantResult.data.data)
696
+ : grantResult.data?.data;
697
+ const grantBlobBytes = base64Decode(grantEnvelope.grant);
698
+ // Step 2: Extract ephemeral public key and encrypted grant
699
+ const ephemeralPubKey = grantBlobBytes.slice(0, 32);
700
+ const encryptedGrant = grantBlobBytes.slice(32);
701
+ // Step 3: Compute shared secret using our private key
702
+ const sharedSecret = this.crypto.x25519Dh(this.encryptionIdentity.privateKey, ephemeralPubKey);
703
+ // Step 4: Derive decryption key
704
+ const encryptionKey = this.crypto.deriveKey(sharedSecret, toBytes("tinycloud-x25519"), toBytes("vault-grant"));
705
+ // Step 5: Decrypt entry key
706
+ const entryKey = this.crypto.decrypt(encryptionKey, encryptedGrant);
707
+ // Step 6: Fetch encrypted value from grantor's space via delegated KV
708
+ const valueResult = await grantorKV.get(`vault/${key}`, {
709
+ raw: true,
710
+ });
711
+ if (!valueResult.ok) {
712
+ return vaultError({
713
+ code: "KEY_NOT_FOUND",
714
+ key,
715
+ });
716
+ }
717
+ const valueEnvelope = typeof valueResult.data?.data === "string"
718
+ ? JSON.parse(valueResult.data.data)
719
+ : valueResult.data?.data;
720
+ const encryptedBytes = base64Decode(valueEnvelope.data);
721
+ const plaintext = this.crypto.decrypt(entryKey, encryptedBytes);
722
+ // Read metadata
723
+ const metadata = valueEnvelope.metadata ?? {};
724
+ const contentType = metadata[VaultHeaders.CONTENT_TYPE] ?? "application/json";
725
+ const keyId = metadata[VaultHeaders.KEY_ID] ?? "";
726
+ // Deserialize
727
+ let value;
728
+ if (options?.raw) {
729
+ value = plaintext;
730
+ }
731
+ else if (options?.deserialize) {
732
+ value = options.deserialize(plaintext);
733
+ }
734
+ else if (contentType === "application/json") {
735
+ value = JSON.parse(fromBytes(plaintext));
736
+ }
737
+ else {
738
+ value = plaintext;
739
+ }
740
+ return ok({ value, metadata, keyId });
741
+ }
742
+ catch (error) {
743
+ if (error instanceof Error &&
744
+ error.message.includes("decryption")) {
745
+ return vaultError({
746
+ code: "DECRYPTION_FAILED",
747
+ message: error.message,
748
+ });
749
+ }
750
+ return vaultError({
751
+ code: "STORAGE_ERROR",
752
+ cause: toError(error),
753
+ });
754
+ }
755
+ });
756
+ }
757
+ /**
758
+ * Resolve another user's public encryption key from their DID.
759
+ *
760
+ * @param did - The DID to resolve (did:pkh:eip155:{chainId}:{address})
761
+ * @returns Result with the public key bytes
762
+ */
763
+ async resolvePublicKey(did) {
764
+ try {
765
+ const parts = this.parseDID(did);
766
+ if (!parts) {
767
+ return vaultError({ code: "PUBLIC_KEY_NOT_FOUND", did });
768
+ }
769
+ const spaceId = this.tc.makePublicSpaceId(parts.address, parts.chainId);
770
+ const result = await this.tc.readPublicSpace(this.host, spaceId, ".well-known/vault-pubkey");
771
+ if (!result.ok) {
772
+ return vaultError({ code: "PUBLIC_KEY_NOT_FOUND", did });
773
+ }
774
+ const pubKeyBytes = base64Decode(result.data);
775
+ return { ok: true, data: pubKeyBytes };
776
+ }
777
+ catch (error) {
778
+ return vaultError({ code: "PUBLIC_KEY_NOT_FOUND", did });
779
+ }
780
+ }
781
+ /**
782
+ * List DIDs that have been granted access to a key.
783
+ *
784
+ * @param key - The key to list grants for
785
+ * @returns Result with array of recipient DIDs
786
+ */
787
+ async listGrants(key) {
788
+ return this.withTelemetry("listGrants", key, async () => {
789
+ if (!this._isUnlocked) {
790
+ return vaultError({
791
+ code: "VAULT_LOCKED",
792
+ message: "Vault must be unlocked before listing grants",
793
+ });
794
+ }
795
+ if (!this.requireAuth()) {
796
+ return vaultError({
797
+ code: "VAULT_LOCKED",
798
+ message: "Authentication required",
799
+ });
800
+ }
801
+ try {
802
+ const listResult = await this.tc.kv.list({
803
+ prefix: "grants/",
804
+ removePrefix: true,
805
+ });
806
+ if (!listResult.ok) {
807
+ return vaultError({
808
+ code: "STORAGE_ERROR",
809
+ cause: new Error(`Failed to list grants: ${listResult.error.message}`),
810
+ });
811
+ }
812
+ // Grant paths are: {recipientDID}/{key}
813
+ // Filter for the specific key and extract DIDs
814
+ const dids = [];
815
+ for (const grantPath of listResult.data.keys) {
816
+ // Path format: {recipientDID}/{key}
817
+ // The key may contain slashes, so we need to match the suffix
818
+ if (grantPath.endsWith(`/${key}`)) {
819
+ const did = grantPath.slice(0, grantPath.length - key.length - 1);
820
+ if (did) {
821
+ dids.push(did);
822
+ }
823
+ }
824
+ }
825
+ return ok(dids);
826
+ }
827
+ catch (error) {
828
+ return vaultError({
829
+ code: "STORAGE_ERROR",
830
+ cause: toError(error),
831
+ });
832
+ }
833
+ });
834
+ }
835
+ // =========================================================================
836
+ // Phase 3: Key Rotation / Revocation
837
+ // =========================================================================
838
+ /**
839
+ * Revoke a previously issued grant.
840
+ *
841
+ * This performs a full key rotation:
842
+ * 1. Lists current grantees
843
+ * 2. Removes the revoked recipient
844
+ * 3. Re-encrypts the value with a new entry key
845
+ * 4. Re-issues grants to remaining recipients
846
+ *
847
+ * @param key - The key to revoke access to
848
+ * @param recipientDID - The recipient whose access to revoke
849
+ */
850
+ async revoke(key, recipientDID) {
851
+ return this.withTelemetry("revoke", key, async () => {
852
+ if (!this._isUnlocked || !this.masterKey) {
853
+ return vaultError({
854
+ code: "VAULT_LOCKED",
855
+ message: "Vault must be unlocked before revoking access",
856
+ });
857
+ }
858
+ if (!this.requireAuth()) {
859
+ return vaultError({
860
+ code: "VAULT_LOCKED",
861
+ message: "Authentication required",
862
+ });
863
+ }
864
+ try {
865
+ // Step 1: List all current grantees
866
+ const granteesResult = await this.listGrants(key);
867
+ if (!granteesResult.ok) {
868
+ return granteesResult;
869
+ }
870
+ const remainingGrantees = granteesResult.data.filter((did) => did !== recipientDID);
871
+ // Step 2: Delete the grant for the revoked recipient
872
+ const deleteGrantResult = await this.tc.kv.delete(`grants/${recipientDID}/${key}`);
873
+ // Grant may already be deleted, that's fine
874
+ // Step 3: Fetch and decrypt current value
875
+ const getResult = await this.get(key);
876
+ if (!getResult.ok) {
877
+ return getResult;
878
+ }
879
+ const currentEntry = getResult.data;
880
+ // Step 4: Generate new entry key
881
+ const newEntryKey = this.crypto.randomBytes(32);
882
+ const newKeyId = hexEncode(this.crypto.sha256(newEntryKey)).slice(0, 16);
883
+ // Step 5: Re-serialize and re-encrypt value with new key
884
+ let plaintext;
885
+ if (currentEntry.value instanceof Uint8Array) {
886
+ plaintext = currentEntry.value;
887
+ }
888
+ else {
889
+ plaintext = toBytes(JSON.stringify(currentEntry.value));
890
+ }
891
+ const encrypted = this.crypto.encrypt(newEntryKey, plaintext);
892
+ // Step 6: Encrypt new entry key with master key
893
+ const newKeyBlob = this.crypto.encrypt(this.masterKey, newEntryKey);
894
+ // Step 7: Update metadata with new key ID
895
+ const metadata = {
896
+ ...currentEntry.metadata,
897
+ [VaultHeaders.KEY_ID]: newKeyId,
898
+ };
899
+ // Step 8: Store updated key blob
900
+ const keyPayload = JSON.stringify({
901
+ key: base64Encode(newKeyBlob),
902
+ metadata: JSON.stringify({
903
+ keyId: newKeyId,
904
+ ...metadata,
905
+ }),
906
+ });
907
+ const keyPutResult = await this.tc.kv.put(`keys/${key}`, keyPayload);
908
+ if (!keyPutResult.ok) {
909
+ return vaultError({
910
+ code: "STORAGE_ERROR",
911
+ cause: new Error(`Failed to store rotated key blob: ${keyPutResult.error.message}`),
912
+ });
913
+ }
914
+ // Step 9: Store re-encrypted value
915
+ const valuePayload = JSON.stringify({
916
+ data: base64Encode(encrypted),
917
+ metadata,
918
+ });
919
+ const valuePutResult = await this.tc.kv.put(`vault/${key}`, valuePayload);
920
+ if (!valuePutResult.ok) {
921
+ return vaultError({
922
+ code: "STORAGE_ERROR",
923
+ cause: new Error(`Failed to store re-encrypted value: ${valuePutResult.error.message}`),
924
+ });
925
+ }
926
+ // Step 10: Re-issue grants to remaining recipients
927
+ for (const did of remainingGrantees) {
928
+ const grantResult = await this.reencrypt(key, did);
929
+ if (!grantResult.ok) {
930
+ // Continue re-issuing to other recipients even if one fails
931
+ // The failed grant can be re-issued manually
932
+ }
933
+ }
934
+ return ok(undefined);
935
+ }
936
+ catch (error) {
937
+ return vaultError({
938
+ code: "STORAGE_ERROR",
939
+ cause: toError(error),
940
+ });
941
+ }
942
+ });
943
+ }
944
+ // =========================================================================
945
+ // Internal Helpers
946
+ // =========================================================================
947
+ /**
948
+ * Parse a DID string to extract address and chainId.
949
+ * Expected format: did:pkh:eip155:{chainId}:{address}
950
+ *
951
+ * @param did - The DID to parse
952
+ * @returns Parsed address and chainId, or null if invalid
953
+ */
954
+ parseDID(did) {
955
+ // did:pkh:eip155:{chainId}:{address}
956
+ const parts = did.split(":");
957
+ if (parts.length !== 5 ||
958
+ parts[0] !== "did" ||
959
+ parts[1] !== "pkh" ||
960
+ parts[2] !== "eip155") {
961
+ return null;
962
+ }
963
+ const chainId = parseInt(parts[3], 10);
964
+ const address = parts[4];
965
+ if (isNaN(chainId) || !address) {
966
+ return null;
967
+ }
968
+ return { address, chainId };
969
+ }
970
+ }
971
+ /**
972
+ * Service identifier for registration.
973
+ */
974
+ DataVaultService.serviceName = "vault";
975
+ //# sourceMappingURL=DataVaultService.js.map