@enbox/agent 0.6.5 → 0.6.7

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 (113) hide show
  1. package/README.md +18 -5
  2. package/dist/browser.mjs +11 -11
  3. package/dist/browser.mjs.map +4 -4
  4. package/dist/esm/agent-did-resolver-cache.js +5 -5
  5. package/dist/esm/agent-did-resolver-cache.js.map +1 -1
  6. package/dist/esm/crypto-api.js.map +1 -1
  7. package/dist/esm/did-api.js +1 -1
  8. package/dist/esm/did-api.js.map +1 -1
  9. package/dist/esm/dwn-api.js +93 -53
  10. package/dist/esm/dwn-api.js.map +1 -1
  11. package/dist/esm/dwn-discovery-payload.js +7 -4
  12. package/dist/esm/dwn-discovery-payload.js.map +1 -1
  13. package/dist/esm/dwn-key-delivery.js +8 -3
  14. package/dist/esm/dwn-key-delivery.js.map +1 -1
  15. package/dist/esm/enbox-connect-protocol.js +34 -14
  16. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  17. package/dist/esm/enbox-user-agent.js +11 -3
  18. package/dist/esm/enbox-user-agent.js.map +1 -1
  19. package/dist/esm/hd-identity-vault.js +33 -18
  20. package/dist/esm/hd-identity-vault.js.map +1 -1
  21. package/dist/esm/identity-api.js +5 -4
  22. package/dist/esm/identity-api.js.map +1 -1
  23. package/dist/esm/index.js +1 -0
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/local-dwn.js.map +1 -1
  26. package/dist/esm/local-key-manager.js.map +1 -1
  27. package/dist/esm/permissions-api.js +9 -5
  28. package/dist/esm/permissions-api.js.map +1 -1
  29. package/dist/esm/prototyping/crypto/jose/jwe-flattened.js +9 -9
  30. package/dist/esm/prototyping/crypto/jose/jwe-flattened.js.map +1 -1
  31. package/dist/esm/secret-store.js +106 -0
  32. package/dist/esm/secret-store.js.map +1 -0
  33. package/dist/esm/store-data.js +32 -11
  34. package/dist/esm/store-data.js.map +1 -1
  35. package/dist/esm/sync-closure-resolver.js +1 -1
  36. package/dist/esm/sync-closure-resolver.js.map +1 -1
  37. package/dist/esm/sync-engine-level.js +418 -141
  38. package/dist/esm/sync-engine-level.js.map +1 -1
  39. package/dist/esm/sync-replication-ledger.js +25 -0
  40. package/dist/esm/sync-replication-ledger.js.map +1 -1
  41. package/dist/esm/test-harness.js +32 -5
  42. package/dist/esm/test-harness.js.map +1 -1
  43. package/dist/esm/types/sync.js +9 -3
  44. package/dist/esm/types/sync.js.map +1 -1
  45. package/dist/esm/utils.js.map +1 -1
  46. package/dist/types/agent-did-resolver-cache.d.ts +1 -1
  47. package/dist/types/agent-did-resolver-cache.d.ts.map +1 -1
  48. package/dist/types/anonymous-dwn-api.d.ts +2 -2
  49. package/dist/types/anonymous-dwn-api.d.ts.map +1 -1
  50. package/dist/types/crypto-api.d.ts +1 -1
  51. package/dist/types/crypto-api.d.ts.map +1 -1
  52. package/dist/types/did-api.d.ts +2 -2
  53. package/dist/types/did-api.d.ts.map +1 -1
  54. package/dist/types/dwn-api.d.ts +51 -11
  55. package/dist/types/dwn-api.d.ts.map +1 -1
  56. package/dist/types/dwn-key-delivery.d.ts +4 -1
  57. package/dist/types/dwn-key-delivery.d.ts.map +1 -1
  58. package/dist/types/enbox-connect-protocol.d.ts +3 -2
  59. package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
  60. package/dist/types/enbox-user-agent.d.ts +5 -1
  61. package/dist/types/enbox-user-agent.d.ts.map +1 -1
  62. package/dist/types/hd-identity-vault.d.ts +9 -2
  63. package/dist/types/hd-identity-vault.d.ts.map +1 -1
  64. package/dist/types/identity-api.d.ts +1 -1
  65. package/dist/types/identity-api.d.ts.map +1 -1
  66. package/dist/types/index.d.ts +1 -0
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/dist/types/local-dwn.d.ts +3 -3
  69. package/dist/types/local-dwn.d.ts.map +1 -1
  70. package/dist/types/local-key-manager.d.ts +2 -2
  71. package/dist/types/local-key-manager.d.ts.map +1 -1
  72. package/dist/types/permissions-api.d.ts +1 -1
  73. package/dist/types/permissions-api.d.ts.map +1 -1
  74. package/dist/types/secret-store.d.ts +81 -0
  75. package/dist/types/secret-store.d.ts.map +1 -0
  76. package/dist/types/store-data.d.ts +15 -3
  77. package/dist/types/store-data.d.ts.map +1 -1
  78. package/dist/types/sync-engine-level.d.ts +52 -16
  79. package/dist/types/sync-engine-level.d.ts.map +1 -1
  80. package/dist/types/sync-replication-ledger.d.ts +10 -1
  81. package/dist/types/sync-replication-ledger.d.ts.map +1 -1
  82. package/dist/types/test-harness.d.ts +3 -0
  83. package/dist/types/test-harness.d.ts.map +1 -1
  84. package/dist/types/types/agent.d.ts +3 -0
  85. package/dist/types/types/agent.d.ts.map +1 -1
  86. package/dist/types/types/sync.d.ts +27 -4
  87. package/dist/types/types/sync.d.ts.map +1 -1
  88. package/package.json +3 -3
  89. package/src/agent-did-resolver-cache.ts +5 -5
  90. package/src/anonymous-dwn-api.ts +2 -2
  91. package/src/crypto-api.ts +1 -1
  92. package/src/did-api.ts +3 -3
  93. package/src/dwn-api.ts +107 -69
  94. package/src/dwn-discovery-payload.ts +5 -4
  95. package/src/dwn-key-delivery.ts +8 -2
  96. package/src/enbox-connect-protocol.ts +38 -21
  97. package/src/enbox-user-agent.ts +15 -3
  98. package/src/hd-identity-vault.ts +47 -21
  99. package/src/identity-api.ts +6 -5
  100. package/src/index.ts +1 -0
  101. package/src/local-dwn.ts +3 -3
  102. package/src/local-key-manager.ts +2 -2
  103. package/src/permissions-api.ts +12 -8
  104. package/src/prototyping/crypto/jose/jwe-flattened.ts +8 -8
  105. package/src/secret-store.ts +173 -0
  106. package/src/store-data.ts +40 -14
  107. package/src/sync-closure-resolver.ts +2 -2
  108. package/src/sync-engine-level.ts +423 -162
  109. package/src/sync-replication-ledger.ts +26 -1
  110. package/src/test-harness.ts +40 -5
  111. package/src/types/agent.ts +3 -0
  112. package/src/types/sync.ts +35 -7
  113. package/src/utils.ts +1 -1
@@ -1,7 +1,8 @@
1
- import type { DidDhtCreateOptions } from '@enbox/dids';
2
1
  import type { Jwk } from '@enbox/crypto';
3
2
  import type { KeyValueStore } from '@enbox/common';
4
3
 
4
+ import type { DidDhtCreateOptions, PortableDid } from '@enbox/dids';
5
+
5
6
  import { HDKey } from 'ed25519-keygen/hdkey';
6
7
  import { wordlist } from '@scure/bip39/wordlists/english';
7
8
  import { BearerDid, DidDht, isPortableDid } from '@enbox/dids';
@@ -142,10 +143,10 @@ export class HdIdentityVault implements IdentityVault<{ InitializeResult: string
142
143
  public crypto = new AgentCryptoApi();
143
144
 
144
145
  /** Determines the computational intensity of the key derivation process. */
145
- private _keyDerivationWorkFactor: number;
146
+ private readonly _keyDerivationWorkFactor: number;
146
147
 
147
148
  /** The underlying key-value store for the vault's encrypted content. */
148
- private _store: KeyValueStore<string, string>;
149
+ private readonly _store: KeyValueStore<string, string>;
149
150
 
150
151
  /** The cryptographic key used to encrypt and decrypt the vault's content securely. */
151
152
  private _contentEncryptionKey: Jwk | undefined;
@@ -158,6 +159,14 @@ export class HdIdentityVault implements IdentityVault<{ InitializeResult: string
158
159
  */
159
160
  private _cachedInitialized: boolean | undefined;
160
161
 
162
+ /**
163
+ * Cached decrypted PortableDid from the last successful {@link getDid} call.
164
+ * Caching the PortableDid (not the BearerDid) avoids the expensive JWE
165
+ * decrypt + LevelDB read on every call, while still returning a fresh
166
+ * BearerDid instance each time so callers cannot mutate shared state.
167
+ */
168
+ private _cachedPortableDid: PortableDid | undefined;
169
+
161
170
  /**
162
171
  * Constructs an instance of `HdIdentityVault`, initializing the key derivation factor and data
163
172
  * store. It sets the default key derivation work factor and initializes the internal data store,
@@ -298,26 +307,34 @@ export class HdIdentityVault implements IdentityVault<{ InitializeResult: string
298
307
  throw new Error(`HdIdentityVault: Vault has not been initialized and unlocked.`);
299
308
  }
300
309
 
301
- // Retrieve the encrypted DID record as compact JWE from the vault store.
302
- const didJwe = await this.getStoredDid();
310
+ // If the decrypted PortableDid is not yet cached, decrypt it from the
311
+ // vault store. This avoids the expensive LevelDB read + JWE decrypt on
312
+ // every call while the vault is unlocked.
313
+ if (!this._cachedPortableDid) {
314
+ const didJwe = await this.getStoredDid();
303
315
 
304
- // Decrypt the compact JWE to obtain the PortableDid as a byte array.
305
- const { plaintext: portableDidBytes } = await CompactJwe.decrypt({
306
- jwe : didJwe,
307
- key : this._contentEncryptionKey!,
308
- crypto : this.crypto,
309
- keyManager : new LocalKeyManager(),
310
- options : { minP2cCount: 1 }, // Vault decrypts its own JWEs; no external-input floor needed.
311
- });
316
+ const { plaintext: portableDidBytes } = await CompactJwe.decrypt({
317
+ jwe : didJwe,
318
+ key : this._contentEncryptionKey!,
319
+ crypto : this.crypto,
320
+ keyManager : new LocalKeyManager(),
321
+ options : { minP2cCount: 1 }, // Vault decrypts its own JWEs; no external-input floor needed.
322
+ });
323
+
324
+ const portableDid = Convert.uint8Array(portableDidBytes).toObject();
325
+ if (!isPortableDid(portableDid)) {
326
+ throw new Error('HdIdentityVault: Unable to decode malformed DID in identity vault');
327
+ }
312
328
 
313
- // Convert the DID from a byte array to PortableDid format.
314
- const portableDid = Convert.uint8Array(portableDidBytes).toObject();
315
- if (!isPortableDid(portableDid)) {
316
- throw new Error('HdIdentityVault: Unable to decode malformed DID in identity vault');
329
+ this._cachedPortableDid = portableDid;
317
330
  }
318
331
 
319
- // Return the DID in Bearer DID format.
320
- return await BearerDid.import({ portableDid });
332
+ // Always return a fresh BearerDid from a deep copy of the cached
333
+ // PortableDid. BearerDid is mutable (document, metadata, keyManager
334
+ // are public) and BearerDid.import() takes document/metadata by
335
+ // reference, so without the clone a caller mutating a returned DID
336
+ // would corrupt the cache for all subsequent getDid() calls.
337
+ return await BearerDid.import({ portableDid: structuredClone(this._cachedPortableDid) });
321
338
  }
322
339
 
323
340
  /**
@@ -661,6 +678,7 @@ export class HdIdentityVault implements IdentityVault<{ InitializeResult: string
661
678
  // Clear the vault content encryption key (CEK), effectively locking the vault.
662
679
  if (this._contentEncryptionKey) {this._contentEncryptionKey.k = '';}
663
680
  this._contentEncryptionKey = undefined;
681
+ this._cachedPortableDid = undefined;
664
682
  }
665
683
 
666
684
  /**
@@ -767,8 +785,16 @@ export class HdIdentityVault implements IdentityVault<{ InitializeResult: string
767
785
  * incorrect.
768
786
  */
769
787
  public async unlock({ password }: { password: string }): Promise<void> {
770
- // Lock the vault.
771
- await this.lock();
788
+ // Verify the vault has been initialized before attempting to unlock.
789
+ if (await this.isInitialized() === false) {
790
+ throw new Error(`HdIdentityVault: Unable to unlock the vault. Vault has not been initialized.`);
791
+ }
792
+
793
+ // Lock the vault if not already locked — avoids an unnecessary
794
+ // redundant isInitialized() store read inside lock().
795
+ if (!this.isLocked()) {
796
+ await this.lock();
797
+ }
772
798
 
773
799
  // Retrieve the content encryption key (CEK) record as a compact JWE from the data store.
774
800
  const cekJwe = await this.getStoredContentEncryptionKey();
@@ -54,7 +54,7 @@ export class AgentIdentityApi<TKeyManager extends AgentKeyManager = AgentKeyMana
54
54
  */
55
55
  private _agent?: EnboxPlatformAgent<TKeyManager>;
56
56
 
57
- private _store: AgentDataStore<IdentityMetadata>;
57
+ private readonly _store: AgentDataStore<IdentityMetadata>;
58
58
 
59
59
  constructor({ agent, store }: IdentityApiParams<TKeyManager> = {}) {
60
60
  this._agent = agent;
@@ -256,11 +256,12 @@ export class AgentIdentityApi<TKeyManager extends AgentKeyManager = AgentKeyMana
256
256
  };
257
257
 
258
258
  // if no other services exist, create a new array with the DWN service
259
- if (!portableDid.document.service) {
260
- portableDid.document.service = [newDwnService];
261
- } else {
262
- // otherwise, push the new DWN service to the existing services
259
+ if (portableDid.document.service) {
260
+ // push the new DWN service to the existing services
263
261
  portableDid.document.service.push(newDwnService);
262
+ } else {
263
+ // no other services exist, create a new array with the DWN service
264
+ portableDid.document.service = [newDwnService];
264
265
  }
265
266
  }
266
267
 
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export * from './identity-api.js';
32
32
  export * from './local-dwn.js';
33
33
  export * from './local-key-manager.js';
34
34
  export * from './permissions-api.js';
35
+ export * from './secret-store.js';
35
36
  export * from './store-data.js';
36
37
  export * from './store-did.js';
37
38
  export * from './store-identity.js';
package/src/local-dwn.ts CHANGED
@@ -62,9 +62,9 @@ export class LocalDwnDiscovery {
62
62
  private _cacheExpiry = 0;
63
63
 
64
64
  constructor(
65
- private _rpcClient: EnboxRpc,
66
- private _cacheTtlMs = 10_000,
67
- private _discoveryFile?: DwnDiscoveryFile,
65
+ private readonly _rpcClient: EnboxRpc,
66
+ private readonly _cacheTtlMs = 10_000,
67
+ private readonly _discoveryFile?: DwnDiscoveryFile,
68
68
  ) {}
69
69
 
70
70
  /**
@@ -177,7 +177,7 @@ export class LocalKeyManager implements AgentKeyManager {
177
177
  * that implements a specific cryptographic algorithm. This map is used to cache and reuse
178
178
  * instances for performance optimization, ensuring that each algorithm is instantiated only once.
179
179
  */
180
- private _algorithmInstances: Map<AlgorithmConstructor, InstanceType<typeof CryptoAlgorithm>> = new Map();
180
+ private readonly _algorithmInstances: Map<AlgorithmConstructor, InstanceType<typeof CryptoAlgorithm>> = new Map();
181
181
 
182
182
  /**
183
183
  * The `_keyStore` private variable in `LocalKeyManager` is a {@link AgentDataStore} instance used
@@ -187,7 +187,7 @@ export class LocalKeyManager implements AgentKeyManager {
187
187
  * persistent storage, providing flexibility in key management according to the application's
188
188
  * requirements.
189
189
  */
190
- private _keyStore: AgentDataStore<Jwk>;
190
+ private readonly _keyStore: AgentDataStore<Jwk>;
191
191
 
192
192
  constructor({ agent, keyStore }: LocalKmsParams = {}) {
193
193
  this._agent = agent;
@@ -1,6 +1,6 @@
1
1
  import type { EnboxAgent } from './types/agent.js';
2
2
  import type { CreateGrantParams, CreateRequestParams, CreateRevocationParams, FetchPermissionRequestParams, FetchPermissionsParams, GetPermissionParams, IsGrantRevokedParams, PermissionGrantEntry, PermissionRequestEntry, PermissionRevocationEntry, PermissionsApi } from './types/permissions.js';
3
- import type { DwnDataEncodedRecordsWriteMessage, DwnMessageParams, DwnMessagesPermissionScope, DwnPermissionScope, DwnProtocolPermissionScope, DwnRecordsPermissionScope, ProcessDwnRequest } from './types/dwn.js';
3
+ import type { DwnDataEncodedRecordsWriteMessage, DwnMessageParams, DwnPermissionScope, DwnRecordsPermissionScope, ProcessDwnRequest } from './types/dwn.js';
4
4
  import type { PermissionGrant, PermissionGrantData, PermissionRequestData, PermissionRevocationData } from '@enbox/dwn-sdk-js';
5
5
 
6
6
  import { isRecordsType } from './dwn-api.js';
@@ -11,7 +11,7 @@ import { DwnInterface, DwnPermissionGrant, DwnPermissionRequest } from './types/
11
11
  export class AgentPermissionsApi implements PermissionsApi {
12
12
 
13
13
  /** cache for fetching a permission {@link PermissionGrant}, keyed by a specific MessageType and protocol */
14
- private _cachedPermissions: TtlCache<string, PermissionGrantEntry> = new TtlCache({ ttl: 60 * 1000 });
14
+ private readonly _cachedPermissions: TtlCache<string, PermissionGrantEntry> = new TtlCache({ ttl: 60 * 1000 });
15
15
 
16
16
  private _agent?: EnboxAgent;
17
17
 
@@ -445,11 +445,15 @@ export class AgentPermissionsApi implements PermissionsApi {
445
445
  const scope = grant.scope;
446
446
  const scopeMessageType = scope.interface + scope.method;
447
447
 
448
- // Messages.Read is a unified scope that covers Messages.Read, Messages.Sync, and Messages.Subscribe.
449
- // When looking for a MessagesSync or MessagesSubscribe grant, also accept a MessagesRead grant.
450
- const isMessagesScopeMatch = scopeMessageType === messageType
451
- || (scopeMessageType === DwnInterface.MessagesRead
452
- && (messageType === DwnInterface.MessagesSync || messageType === DwnInterface.MessagesSubscribe));
448
+ // Messages.Read is the only valid Messages scope and covers Read, Sync, and Subscribe operations.
449
+ // Defensively require method === Read so malformed/legacy grants with method Sync/Subscribe
450
+ // are rejected rather than treated as valid scopes.
451
+ const isMessagesScopeMatch = scope.interface === 'Messages'
452
+ ? scope.method === 'Read'
453
+ && (messageType === DwnInterface.MessagesRead
454
+ || messageType === DwnInterface.MessagesSync
455
+ || messageType === DwnInterface.MessagesSubscribe)
456
+ : scopeMessageType === messageType;
453
457
 
454
458
  if (isMessagesScopeMatch) {
455
459
  if (isRecordsType(messageType)) {
@@ -474,7 +478,7 @@ export class AgentPermissionsApi implements PermissionsApi {
474
478
  return true;
475
479
  }
476
480
  } else {
477
- const messagesScope = scope as DwnMessagesPermissionScope | DwnProtocolPermissionScope;
481
+ const messagesScope = scope;
478
482
  // Checks for unrestricted protocol scope, if no protocol is defined in the scope it is unrestricted
479
483
  if (messagesScope.protocol === undefined) {
480
484
  return true;
@@ -301,24 +301,24 @@ export class FlattenedJwe {
301
301
  const tag = decodeHeaderParam('tag', jwe.tag);
302
302
 
303
303
  // Decode the JWE Ciphertext to a byte array, and if present, append the Authentication Tag.
304
- const ciphertext = tag !== undefined
305
- ? new Uint8Array([
304
+ const ciphertext = tag === undefined
305
+ ? Convert.base64Url(jwe.ciphertext).toUint8Array()
306
+ : new Uint8Array([
306
307
  ...Convert.base64Url(jwe.ciphertext).toUint8Array(),
307
308
  ...(tag ?? [])
308
- ])
309
- : Convert.base64Url(jwe.ciphertext).toUint8Array();
309
+ ]);
310
310
 
311
311
  // If the JWE Additional Authenticated Data (AAD) is present, the Additional Authenticated Data
312
312
  // input to the Content Encryption Algorithm is
313
313
  // ASCII(Encoded Protected Header || '.' || BASE64URL(JWE AAD)). If the JWE AAD is absent, the
314
314
  // Additional Authenticated Data is ASCII(BASE64URL(UTF8(JWE Protected Header))).
315
- const additionalData = jwe.aad !== undefined
316
- ? new Uint8Array([
315
+ const additionalData = jwe.aad === undefined
316
+ ? Convert.string(jwe.protected ?? '').toUint8Array()
317
+ : new Uint8Array([
317
318
  ...Convert.string(jwe.protected ?? '').toUint8Array(),
318
319
  ...Convert.string('.').toUint8Array(),
319
320
  ...Convert.string(jwe.aad).toUint8Array()
320
- ])
321
- : Convert.string(jwe.protected ?? '').toUint8Array();
321
+ ]);
322
322
 
323
323
  // Decrypt the JWE using the Content Encryption Key (CEK) with:
324
324
  // - Key Manager: If the CEK is a Key Identifier.
@@ -0,0 +1,173 @@
1
+ import type { KeyValueStore } from '@enbox/common';
2
+
3
+ import type { IdentityVault } from './types/identity-vault.js';
4
+
5
+ /**
6
+ * Options for storing a secret.
7
+ */
8
+ export type SecretStorePutOptions = {
9
+ /**
10
+ * ISO-8601 timestamp after which the secret is considered expired.
11
+ *
12
+ * Expired secrets are silently deleted on the next {@link SecretStore.get}
13
+ * call and `undefined` is returned.
14
+ */
15
+ expiresAt?: string;
16
+ };
17
+
18
+ /**
19
+ * Platform-agnostic interface for storing classified secrets encrypted at rest
20
+ * by the agent vault.
21
+ *
22
+ * Implementations must ensure that plaintext secret material is never persisted
23
+ * to browser-accessible storage such as `localStorage` or `sessionStorage`.
24
+ */
25
+ export interface SecretStore {
26
+ /**
27
+ * Store a secret, encrypted at rest with the vault key.
28
+ *
29
+ * @param key - Unique identifier for the secret.
30
+ * @param value - The secret bytes to store.
31
+ * @param options - Optional metadata (e.g. TTL).
32
+ * @throws If the vault is locked.
33
+ */
34
+ put(key: string, value: Uint8Array, options?: SecretStorePutOptions): Promise<void>;
35
+
36
+ /**
37
+ * Retrieve and decrypt a secret.
38
+ *
39
+ * Returns `undefined` if the key does not exist or the secret has expired.
40
+ *
41
+ * @param key - The identifier used when the secret was stored.
42
+ * @throws If the vault is locked.
43
+ */
44
+ get(key: string): Promise<Uint8Array | undefined>;
45
+
46
+ /**
47
+ * Delete a secret.
48
+ *
49
+ * @param key - The identifier used when the secret was stored.
50
+ * @returns `true` if the key existed and was removed, `false` otherwise.
51
+ */
52
+ delete(key: string): Promise<boolean>;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Internal envelope persisted in the backing KeyValueStore.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** @internal */
60
+ type SecretEnvelope = {
61
+ /** Compact JWE encrypted by the vault's content encryption key. */
62
+ jwe: string;
63
+
64
+ /** Optional ISO-8601 expiry timestamp. */
65
+ expiresAt?: string;
66
+ };
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // VaultBackedSecretStore
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Vault-backed implementation of {@link SecretStore}.
74
+ *
75
+ * Secrets are encrypted with the vault's content encryption key (AES-256-GCM
76
+ * via `dir` algorithm) before being persisted to the backing
77
+ * {@link KeyValueStore}. The vault must be unlocked for {@link put} and
78
+ * {@link get} operations; {@link delete} does not require the vault to be
79
+ * unlocked since it only removes the encrypted envelope.
80
+ */
81
+ export class VaultBackedSecretStore implements SecretStore {
82
+ private readonly _vault: IdentityVault;
83
+ private readonly _store: KeyValueStore<string, string>;
84
+
85
+ constructor({ vault, store }: {
86
+ vault : IdentityVault;
87
+ store : KeyValueStore<string, string>;
88
+ }) {
89
+ this._vault = vault;
90
+ this._store = store;
91
+ }
92
+
93
+ public async put(
94
+ key: string,
95
+ value: Uint8Array,
96
+ options?: SecretStorePutOptions,
97
+ ): Promise<void> {
98
+ // vault.encryptData() throws if the vault is locked.
99
+ const jwe = await this._vault.encryptData({ plaintext: value });
100
+
101
+ const envelope: SecretEnvelope = { jwe };
102
+ if (options?.expiresAt !== undefined) {
103
+ envelope.expiresAt = options.expiresAt;
104
+ }
105
+
106
+ await this._store.set(key, JSON.stringify(envelope));
107
+ }
108
+
109
+ public async get(key: string): Promise<Uint8Array | undefined> {
110
+ const raw = await this._store.get(key);
111
+ if (raw === undefined) { return undefined; }
112
+
113
+ const envelope = JSON.parse(raw) as SecretEnvelope;
114
+
115
+ // Purge expired secrets.
116
+ if (envelope.expiresAt !== undefined && new Date(envelope.expiresAt).getTime() <= Date.now()) {
117
+ await this._store.delete(key);
118
+ return undefined;
119
+ }
120
+
121
+ // vault.decryptData() throws if the vault is locked.
122
+ return this._vault.decryptData({ jwe: envelope.jwe });
123
+ }
124
+
125
+ public async delete(key: string): Promise<boolean> {
126
+ const existing = await this._store.get(key);
127
+ if (existing === undefined) { return false; }
128
+ await this._store.delete(key);
129
+ return true;
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // InMemorySecretStore
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * In-memory implementation of {@link SecretStore} for testing.
139
+ *
140
+ * Secrets are stored as plain `Uint8Array` values in a `Map` — no encryption,
141
+ * no persistence. This is suitable only for unit tests.
142
+ */
143
+ export class InMemorySecretStore implements SecretStore {
144
+ private readonly _store = new Map<string, { value: Uint8Array; expiresAt?: string }>();
145
+
146
+ public async put(
147
+ key: string,
148
+ value: Uint8Array,
149
+ options?: SecretStorePutOptions,
150
+ ): Promise<void> {
151
+ this._store.set(key, {
152
+ value : new Uint8Array(value),
153
+ expiresAt : options?.expiresAt,
154
+ });
155
+ }
156
+
157
+ public async get(key: string): Promise<Uint8Array | undefined> {
158
+ const entry = this._store.get(key);
159
+ if (entry === undefined) { return undefined; }
160
+
161
+ // Purge expired secrets.
162
+ if (entry.expiresAt !== undefined && new Date(entry.expiresAt).getTime() <= Date.now()) {
163
+ this._store.delete(key);
164
+ return undefined;
165
+ }
166
+
167
+ return new Uint8Array(entry.value);
168
+ }
169
+
170
+ public async delete(key: string): Promise<boolean> {
171
+ return this._store.delete(key);
172
+ }
173
+ }
package/src/store-data.ts CHANGED
@@ -69,9 +69,14 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
69
69
  * Cache of tenant DIDs that have been initialized with the protocol.
70
70
  * This is used to avoid redundant protocol initialization requests.
71
71
  *
72
- * Since these are default protocols and unlikely to change, we can use a long TTL.
72
+ * Uses TtlCache with a 1-hour TTL rather than a permanent Set so that
73
+ * protocol state changes by another agent/process (protocol upgrade,
74
+ * reinstall, or tenant clear) are eventually re-detected. Both this
75
+ * and `_tenantEncryptionActive` must share the same TTL so they expire
76
+ * together — otherwise `initialize()` could return early while the
77
+ * encryption state is stale.
73
78
  */
74
- protected _protocolInitializedCache: TtlCache<string, boolean> = new TtlCache({ ttl: ms('21 days'), max: 1000 });
79
+ protected _protocolInitializedCache: TtlCache<string, boolean> = new TtlCache({ ttl: ms('1 hour'), max: 1000 });
75
80
 
76
81
  /**
77
82
  * The protocol assigned to this storage instance.
@@ -84,8 +89,15 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
84
89
  * When any type in `_recordProtocolDefinition` has `encryptionRequired: true`,
85
90
  * this will always be `true` after successful initialization (since
86
91
  * installation fails if encryption is not possible).
92
+ *
93
+ * Uses the same 1-hour TTL as `_protocolInitializedCache` so both
94
+ * caches expire and re-derive together. See comment above.
87
95
  */
88
- private _tenantEncryptionActive: TtlCache<string, boolean> = new TtlCache({ ttl: ms('21 days'), max: 1000 });
96
+ private readonly _tenantEncryptionActive: TtlCache<string, boolean> = new TtlCache({ ttl: ms('1 hour'), max: 1000 });
97
+
98
+ /** Cached result of the `encryptionRequired` check on the protocol definition.
99
+ * Computed lazily on first access — the definition is immutable after assignment. */
100
+ private _encryptionRequired: boolean | undefined;
89
101
 
90
102
  /**
91
103
  * Properties to use when writing and querying records with the DWN store.
@@ -218,7 +230,11 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
218
230
  */
219
231
  public async initialize({ tenant, agent }: DataStoreTenantParams): Promise<void> {
220
232
  const tenantDid = await getDataStoreTenant({ agent, tenant });
221
- if (this._protocolInitializedCache.has(tenantDid)) {
233
+ // Short-circuit only when both caches are present. If the encryption
234
+ // cache has expired or was cleared while the init cache survived,
235
+ // re-derive instead of silently dropping encryption.
236
+ if (this._protocolInitializedCache.has(tenantDid)
237
+ && (!this.encryptionRequired || this._tenantEncryptionActive.has(tenantDid))) {
222
238
  return;
223
239
  }
224
240
 
@@ -237,27 +253,37 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
237
253
  throw new Error(`Failed to query for protocols: ${status.code} - ${status.detail}`);
238
254
  }
239
255
 
256
+ let encryptionActive = false;
257
+
240
258
  if (entries?.length === 0) {
241
259
  // protocol is not installed, install it
242
- await this.installProtocol(tenantDid, agent);
243
- } else if (this.encryptionRequired && !this._tenantEncryptionActive.has(tenantDid)) {
260
+ const installed = await this.installProtocol(tenantDid, agent);
261
+ encryptionActive = installed.encryptionActive;
262
+ } else if (this.encryptionRequired) {
244
263
  // Protocol already installed — determine if it has $encryption keys
245
264
  // by inspecting the installed definition.
246
265
  const definition = entries![0].descriptor?.definition;
247
266
  const firstType = Object.keys(definition?.structure ?? {})[0];
248
- const hasEncryption = !!(firstType && definition?.structure[firstType]?.$encryption);
249
- this._tenantEncryptionActive.set(tenantDid, hasEncryption);
267
+ encryptionActive = !!(firstType && definition?.structure[firstType]?.$encryption);
250
268
  }
251
269
 
270
+ // Set both caches atomically so they always expire together. This
271
+ // prevents a stale-encryption window where one cache expires before
272
+ // the other and initialize() short-circuits on the surviving entry.
273
+ this._tenantEncryptionActive.set(tenantDid, encryptionActive);
252
274
  this._protocolInitializedCache.set(tenantDid, true);
253
275
  }
254
276
 
255
277
  /**
256
278
  * Returns `true` if any type in the protocol definition has `encryptionRequired: true`.
279
+ * Cached after first computation since the protocol definition is immutable.
257
280
  */
258
281
  private get encryptionRequired(): boolean {
259
- return Object.values(this._recordProtocolDefinition.types)
260
- .some((type): boolean => type.encryptionRequired === true);
282
+ if (this._encryptionRequired === undefined) {
283
+ this._encryptionRequired = Object.values(this._recordProtocolDefinition.types)
284
+ .some((type): boolean => type.encryptionRequired === true);
285
+ }
286
+ return this._encryptionRequired;
261
287
  }
262
288
 
263
289
  /**
@@ -328,7 +354,7 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
328
354
  * If the tenant DID lacks an X25519 keyAgreement key, the error propagates
329
355
  * — plaintext fallback is not allowed.
330
356
  */
331
- private async installProtocol(tenant: string, agent: EnboxPlatformAgent): Promise<void> {
357
+ private async installProtocol(tenant: string, agent: EnboxPlatformAgent): Promise<{ encryptionActive: boolean }> {
332
358
  let definition = this._recordProtocolDefinition;
333
359
  let encryptionActive = false;
334
360
 
@@ -342,8 +368,6 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
342
368
  encryptionActive = true;
343
369
  }
344
370
 
345
- this._tenantEncryptionActive.set(tenant, encryptionActive);
346
-
347
371
  const { reply : { status } } = await agent.dwn.processRequest({
348
372
  author : tenant,
349
373
  target : tenant,
@@ -354,6 +378,8 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
354
378
  if (status.code !== 202) {
355
379
  throw new Error(`Failed to install protocol: ${status.code} - ${status.detail}`);
356
380
  }
381
+
382
+ return { encryptionActive };
357
383
  }
358
384
 
359
385
  private async lookupRecordId({ id, tenantDid, agent }: {
@@ -403,7 +429,7 @@ export class InMemoryDataStore<TStoreObject extends Record<string, any> = Jwk> i
403
429
  /**
404
430
  * A private field that contains the Map used as the in-memory data store.
405
431
  */
406
- private store: Map<string, TStoreObject> = new Map();
432
+ private readonly store: Map<string, TStoreObject> = new Map();
407
433
 
408
434
  public async delete({ id, agent, tenant }: DataStoreDeleteParams): Promise<boolean> {
409
435
  // Determine the tenant identifier (DID) for the delete operation.
@@ -366,7 +366,7 @@ function extractProtocolAwareDeps(
366
366
  Buffer.from(payload, 'base64url').toString('utf-8')
367
367
  );
368
368
  const protocolRole = decoded.protocolRole as string | undefined;
369
- if (protocolRole && protocolRole.includes(':')) {
369
+ if (protocolRole?.includes(':')) {
370
370
  // Cross-protocol role: "alias:protocolPath"
371
371
  const roleColonIdx = protocolRole.indexOf(':');
372
372
  const roleAlias = protocolRole.substring(0, roleColonIdx);
@@ -518,7 +518,7 @@ export async function evaluateClosure(
518
518
  const currentProtocol = currentDesc.protocol as string | undefined;
519
519
  if (currentProtocol) {
520
520
  const cachedProtocolMsg = context.protocolCache.get(currentProtocol);
521
- const protocolDef = (cachedProtocolMsg?.descriptor as any)?.definition;
521
+ const protocolDef = cachedProtocolMsg?.descriptor?.definition;
522
522
  if (protocolDef) {
523
523
  const protoAwareEdges = extractProtocolAwareDeps(current, protocolDef, context.isDelegateSession);
524
524
  const protoResult = await resolveEdges(