@enbox/auth 0.6.27 → 0.6.29

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 (44) hide show
  1. package/dist/esm/auth-manager.js +82 -46
  2. package/dist/esm/auth-manager.js.map +1 -1
  3. package/dist/esm/connect/import.js +20 -13
  4. package/dist/esm/connect/import.js.map +1 -1
  5. package/dist/esm/connect/lifecycle.js +356 -68
  6. package/dist/esm/connect/lifecycle.js.map +1 -1
  7. package/dist/esm/connect/local.js +2 -1
  8. package/dist/esm/connect/local.js.map +1 -1
  9. package/dist/esm/connect/restore.js +87 -64
  10. package/dist/esm/connect/restore.js.map +1 -1
  11. package/dist/esm/connect/wallet.js +1 -0
  12. package/dist/esm/connect/wallet.js.map +1 -1
  13. package/dist/esm/discovery.js +2 -1
  14. package/dist/esm/discovery.js.map +1 -1
  15. package/dist/esm/events.js.map +1 -1
  16. package/dist/esm/registration.js +70 -12
  17. package/dist/esm/registration.js.map +1 -1
  18. package/dist/esm/types.js.map +1 -1
  19. package/dist/types/auth-manager.d.ts +26 -15
  20. package/dist/types/auth-manager.d.ts.map +1 -1
  21. package/dist/types/connect/import.d.ts.map +1 -1
  22. package/dist/types/connect/lifecycle.d.ts +60 -1
  23. package/dist/types/connect/lifecycle.d.ts.map +1 -1
  24. package/dist/types/connect/local.d.ts.map +1 -1
  25. package/dist/types/connect/restore.d.ts +8 -0
  26. package/dist/types/connect/restore.d.ts.map +1 -1
  27. package/dist/types/connect/wallet.d.ts.map +1 -1
  28. package/dist/types/events.d.ts +1 -1
  29. package/dist/types/events.d.ts.map +1 -1
  30. package/dist/types/registration.d.ts +28 -3
  31. package/dist/types/registration.d.ts.map +1 -1
  32. package/dist/types/types.d.ts +18 -9
  33. package/dist/types/types.d.ts.map +1 -1
  34. package/package.json +4 -4
  35. package/src/auth-manager.ts +100 -63
  36. package/src/connect/import.ts +24 -19
  37. package/src/connect/lifecycle.ts +360 -74
  38. package/src/connect/local.ts +5 -4
  39. package/src/connect/restore.ts +79 -66
  40. package/src/connect/wallet.ts +2 -1
  41. package/src/discovery.ts +1 -1
  42. package/src/events.ts +1 -1
  43. package/src/registration.ts +82 -15
  44. package/src/types.ts +18 -9
@@ -12,7 +12,7 @@ import type { ImportFromPhraseOptions, ImportFromPortableOptions } from '../type
12
12
 
13
13
  import { DEFAULT_DWN_ENDPOINTS } from '../types.js';
14
14
  import { registerWithDwnEndpoints } from '../registration.js';
15
- import { createDefaultIdentity, ensureVaultReady, finalizeSession, resolveIdentityDids, startSyncIfEnabled } from './lifecycle.js';
15
+ import { createDefaultIdentity, ensureVaultReady, finalizeSession, registerSyncScopeForIdentity, resolveIdentityDids, startSyncIfEnabled } from './lifecycle.js';
16
16
 
17
17
  /**
18
18
  * Import (or recover) an identity from a BIP-39 recovery phrase.
@@ -57,22 +57,27 @@ export async function importFromPhrase(
57
57
  if (ctx.registration) {
58
58
  await registerWithDwnEndpoints(
59
59
  {
60
- userAgent : userAgent,
60
+ userAgent : userAgent,
61
61
  dwnEndpoints,
62
- agentDid : userAgent.agentDid.uri,
62
+ agentDid : userAgent.agentDid.uri,
63
63
  connectedDid,
64
- storage : storage,
64
+ secretStore : userAgent.secrets,
65
+ storage : storage,
65
66
  },
66
67
  ctx.registration,
67
68
  );
68
69
  }
69
70
 
70
- // Register sync for new identities.
71
- if (isNewIdentity && sync !== 'off') {
72
- await userAgent.sync.registerIdentity({
73
- did : connectedDid,
74
- options : { delegateDid, protocols: [] },
75
- });
71
+ // Register sync. For delegate identities, always repair the registration
72
+ // (derive scope from active grants — revoked grants must not remain in a
73
+ // stale registration), regardless of whether the identity was just
74
+ // created or restored from storage. For local identities, register
75
+ // `protocols: 'all'` only on first creation; a pre-existing local
76
+ // identity was already registered during its initial flow.
77
+ if (delegateDid) {
78
+ await registerSyncScopeForIdentity({ userAgent, connectedDid, delegateDid });
79
+ } else if (isNewIdentity && sync !== 'off') {
80
+ await registerSyncScopeForIdentity({ userAgent, connectedDid });
76
81
  }
77
82
 
78
83
  // Start sync.
@@ -115,22 +120,22 @@ export async function importFromPortable(
115
120
  const dwnEndpoints = ctx.defaultDwnEndpoints ?? DEFAULT_DWN_ENDPOINTS;
116
121
  await registerWithDwnEndpoints(
117
122
  {
118
- userAgent : userAgent,
123
+ userAgent : userAgent,
119
124
  dwnEndpoints,
120
- agentDid : userAgent.agentDid.uri,
125
+ agentDid : userAgent.agentDid.uri,
121
126
  connectedDid,
122
- storage : storage,
127
+ secretStore : userAgent.secrets,
128
+ storage : storage,
123
129
  },
124
130
  ctx.registration,
125
131
  );
126
132
  }
127
133
 
128
- // Register and start sync.
129
- if (sync !== 'off') {
130
- await userAgent.sync.registerIdentity({
131
- did : connectedDid,
132
- options : { delegateDid, protocols: [] },
133
- });
134
+ // Register sync. For delegates, derive scope from grants (not 'all').
135
+ if (delegateDid) {
136
+ await registerSyncScopeForIdentity({ userAgent, connectedDid, delegateDid });
137
+ } else if (sync !== 'off') {
138
+ await registerSyncScopeForIdentity({ userAgent, connectedDid });
134
139
  }
135
140
 
136
141
  await startSyncIfEnabled(userAgent, sync);
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { PortableDid } from '@enbox/dids';
18
- import type { BearerIdentity, DelegateContextKey, DelegateDecryptionKey, DwnDataEncodedRecordsWriteMessage, DwnMessagesPermissionScope, DwnRecordsPermissionScope, EnboxUserAgent } from '@enbox/agent';
18
+ import type { BearerIdentity, DelegateContextKey, DelegateDecryptionKey, DwnDataEncodedRecordsWriteMessage, EnboxUserAgent } from '@enbox/agent';
19
19
 
20
20
  import type { AuthEventEmitter } from '../events.js';
21
21
  import type { PasswordProvider } from '../password-provider.js';
@@ -166,6 +166,7 @@ export async function startSyncIfEnabled(
166
166
  return;
167
167
  }
168
168
 
169
+ if (userAgent.sync.hasActiveSubscriptions) { return; } // registerIdentity() hot-adds inline
169
170
  const syncMode = sync === undefined ? 'live' : 'poll';
170
171
  const syncInterval = sync ?? (syncMode === 'live' ? '5m' : '2m');
171
172
 
@@ -247,6 +248,191 @@ export function resolveIdentityDids(
247
248
  return { connectedDid, delegateDid };
248
249
  }
249
250
 
251
+ // ─── deriveSyncScopeFromGrants ──────────────────────────────────
252
+
253
+ /**
254
+ * Derive the sync protocol scope from a set of parsed permission grants.
255
+ *
256
+ * Only `Messages.Read` grants authorize sync operations. Other grant types
257
+ * (Records.Write, Protocols.Query, etc.) are ignored even if they contain a
258
+ * `protocol` field — they do not authorize `MessagesSync`.
259
+ *
260
+ * - Unscoped `Messages.Read` (no `protocol`) → `'all'` (full replica)
261
+ * - Scoped `Messages.Read` grants → collected protocol URIs
262
+ * - No sync-relevant grants → `[]` (caller should unregister)
263
+ *
264
+ * Expired grants are excluded.
265
+ *
266
+ * @internal
267
+ */
268
+ export function deriveSyncScopeFromGrants(grants: DwnPermissionGrant[]): 'all' | string[] {
269
+ const now = new Date().toISOString();
270
+ const protocols = new Set<string>();
271
+
272
+ for (const grant of grants) {
273
+ const scope = grant.scope as any;
274
+
275
+ // Only Messages.Read grants authorize sync.
276
+ if (scope.interface !== 'Messages' || scope.method !== 'Read') {
277
+ continue;
278
+ }
279
+
280
+ // Skip expired grants.
281
+ if (grant.dateExpires && grant.dateExpires <= now) {
282
+ continue;
283
+ }
284
+
285
+ const protocol = scope.protocol as string | undefined;
286
+ if (protocol === undefined) {
287
+ // Unrestricted Messages.Read — delegate can sync all protocols.
288
+ return 'all';
289
+ }
290
+ if (protocol !== PermissionsProtocol.uri) {
291
+ protocols.add(protocol);
292
+ }
293
+ }
294
+
295
+ return [...protocols];
296
+ }
297
+
298
+ /**
299
+ * Query the delegate's stored grants and revocations, filter out revoked
300
+ * and expired grants, and derive the sync protocol scope.
301
+ *
302
+ * Used by both `restoreSession()` and `switchIdentity()` to compute the
303
+ * correct sync registration from persisted grant state.
304
+ *
305
+ * @internal
306
+ */
307
+ export async function deriveActiveSyncScope(
308
+ userAgent: EnboxUserAgent,
309
+ delegateDid: string,
310
+ ): Promise<'all' | string[]> {
311
+ // Query grants and revocations in parallel.
312
+ const [grantResponse, revocationResponse] = await Promise.all([
313
+ userAgent.processDwnRequest({
314
+ author : delegateDid,
315
+ target : delegateDid,
316
+ messageType : DwnInterface.RecordsQuery,
317
+ messageParams : { filter: { protocol: PermissionsProtocol.uri, protocolPath: PermissionsProtocol.grantPath } },
318
+ }),
319
+ userAgent.processDwnRequest({
320
+ author : delegateDid,
321
+ target : delegateDid,
322
+ messageType : DwnInterface.RecordsQuery,
323
+ messageParams : { filter: { protocol: PermissionsProtocol.uri, protocolPath: PermissionsProtocol.revocationPath } },
324
+ }),
325
+ ]);
326
+
327
+ if (grantResponse.reply.status.code !== 200 || !grantResponse.reply.entries) {
328
+ return [];
329
+ }
330
+ // Fail closed: if we can't verify revocations, treat as zero grants.
331
+ if (revocationResponse.reply.status.code !== 200) { return []; }
332
+
333
+ // Build the set of revoked grant IDs from revocation parent context.
334
+ const revokedGrantIds = new Set<string>();
335
+ if (revocationResponse.reply.entries) {
336
+ for (const entry of revocationResponse.reply.entries as DwnDataEncodedRecordsWriteMessage[]) {
337
+ const parentId = (entry as any).descriptor?.parentId ?? (entry as any).parentId;
338
+ if (parentId) { revokedGrantIds.add(parentId); }
339
+ }
340
+ }
341
+
342
+ // Parse grants and filter out revoked ones before deriving scope.
343
+ const grants = (grantResponse.reply.entries as DwnDataEncodedRecordsWriteMessage[])
344
+ .map((entry) => DwnPermissionGrant.parse(entry))
345
+ .filter((grant) => !revokedGrantIds.has(grant.id));
346
+
347
+ return deriveSyncScopeFromGrants(grants);
348
+ }
349
+
350
+ // ─── toSyncIdentityProtocols ────────────────────────────────────
351
+
352
+ /**
353
+ * Narrow a derived sync scope (`'all' | string[]`) to the form required by
354
+ * `SyncIdentityOptions.protocols` (`'all' | [string, ...string[]]`).
355
+ *
356
+ * Returns `undefined` when the scope is an empty array, signalling the
357
+ * caller should unregister the identity rather than register it.
358
+ *
359
+ * @internal
360
+ */
361
+ export function toSyncIdentityProtocols(
362
+ scope: 'all' | string[],
363
+ ): 'all' | [string, ...string[]] | undefined {
364
+ if (scope === 'all') { return 'all'; }
365
+ if (scope.length === 0) { return undefined; }
366
+ return scope as [string, ...string[]];
367
+ }
368
+
369
+ // ─── registerSyncScopeForIdentity ───────────────────────────────
370
+
371
+ /**
372
+ * Register (or update, or clear) the sync registration for an identity based on
373
+ * its derived protocol scope.
374
+ *
375
+ * - For a **delegate session**: queries the delegate's active grants via
376
+ * {@link deriveActiveSyncScope}, then registers with `protocols: 'all'` or a
377
+ * scoped list when grants are present, or unregisters the identity when no
378
+ * sync-relevant grants remain (so revoked protocols stop syncing). The
379
+ * "is not registered" error from unregister is silently tolerated;
380
+ * `"already registered"` from register falls back to `updateIdentityOptions`.
381
+ *
382
+ * - For a **local session** (no `delegateDid`): registers with
383
+ * `protocols: 'all'` (a local identity is a full replica of its own DWN).
384
+ * The `"already registered"` error falls back to `updateIdentityOptions`.
385
+ *
386
+ * @internal
387
+ */
388
+ export async function registerSyncScopeForIdentity(params: {
389
+ userAgent: EnboxUserAgent;
390
+ connectedDid: string;
391
+ delegateDid?: string;
392
+ }): Promise<void> {
393
+ const { userAgent, connectedDid, delegateDid } = params;
394
+
395
+ if (delegateDid !== undefined) {
396
+ const scope = await deriveActiveSyncScope(userAgent, delegateDid);
397
+ const narrowed = toSyncIdentityProtocols(scope);
398
+ if (narrowed !== undefined) {
399
+ const options = { delegateDid, protocols: narrowed };
400
+ try {
401
+ await userAgent.sync.registerIdentity({ did: connectedDid, options });
402
+ } catch (error: unknown) {
403
+ const msg = error instanceof Error ? error.message : '';
404
+ if (msg.includes('already registered')) {
405
+ await userAgent.sync.updateIdentityOptions({ did: connectedDid, options });
406
+ } else {
407
+ throw error;
408
+ }
409
+ }
410
+ } else {
411
+ // Zero grants — clear any stale sync registration so revoked protocols stop syncing.
412
+ try {
413
+ await userAgent.sync.unregisterIdentity(connectedDid);
414
+ } catch (error: unknown) {
415
+ const msg = error instanceof Error ? error.message : '';
416
+ if (!msg.includes('is not registered')) { throw error; }
417
+ }
418
+ }
419
+ return;
420
+ }
421
+
422
+ // Local session — register with full-replica scope.
423
+ const options = { protocols: 'all' as const };
424
+ try {
425
+ await userAgent.sync.registerIdentity({ did: connectedDid, options });
426
+ } catch (error: unknown) {
427
+ const msg = error instanceof Error ? error.message : '';
428
+ if (msg.includes('already registered')) {
429
+ await userAgent.sync.updateIdentityOptions({ did: connectedDid, options });
430
+ } else {
431
+ throw error;
432
+ }
433
+ }
434
+ }
435
+
250
436
  // ─── processConnectedGrants ─────────────────────────────────────
251
437
 
252
438
  /**
@@ -263,19 +449,40 @@ export async function processConnectedGrants(params: {
263
449
  connectedDid: string;
264
450
  delegateDid: string;
265
451
  grants: DwnDataEncodedRecordsWriteMessage[];
266
- }): Promise<string[]> {
452
+ }): Promise<'all' | string[]> {
267
453
  const { agent, connectedDid, delegateDid, grants } = params;
268
- const connectedProtocols = new Set<string>();
269
454
 
270
- for (const grantMessage of grants) {
455
+ // Two-phase write strategy:
456
+ //
457
+ // Phase 1 — delegate partition (rollbackable): write all grants into
458
+ // the delegateDid's partition using the delegate's signing key. If any
459
+ // write fails, delete the ones that succeeded and throw.
460
+ //
461
+ // Phase 2 — connected partition (not rollbackable): write grants into
462
+ // the connectedDid's partition using processRawMessage (the delegate
463
+ // agent doesn't hold the connectedDid's signing key, so it cannot
464
+ // create a signed RecordsDelete to roll these back). Phase 2 only
465
+ // runs after all phase-1 writes succeed, minimizing the orphan window.
466
+ //
467
+ // Both phases process grants concurrently with allSettled so a single
468
+ // failure doesn't leave other writes racing against cleanup.
469
+
470
+ // Prepare decoded grant data for both phases.
471
+ const parsed = grants.map((grantMessage) => {
271
472
  const grant = DwnPermissionGrant.parse(grantMessage);
272
-
273
473
  const { encodedData, ...rawMessage } = grantMessage;
274
- const dataStream = new Blob([Convert.base64Url(encodedData).toUint8Array() as BlobPart]);
474
+ return { grant, rawMessage, encodedData };
475
+ });
476
+
477
+ // ── Phase 1: delegate partition ───────────────────────────────────
275
478
 
276
- // Store the grant in the delegateDid's partition so the permissions
277
- // API can look it up when building delegate-signed requests.
278
- const { reply: delegateReply } = await agent.processDwnRequest({
479
+ // Track which grants were actually created (202) vs already existed (409).
480
+ // Only newly created grants should be rolled back on failure.
481
+ const createdInPhase1 = new Set<number>();
482
+
483
+ const delegateResults = await Promise.allSettled(parsed.map(async ({ rawMessage, encodedData }, index) => {
484
+ const dataStream = new Blob([Convert.base64Url(encodedData).toUint8Array() as BlobPart]);
485
+ const { reply } = await agent.processDwnRequest({
279
486
  store : true,
280
487
  author : delegateDid,
281
488
  target : delegateDid,
@@ -285,21 +492,39 @@ export async function processConnectedGrants(params: {
285
492
  dataStream,
286
493
  });
287
494
 
288
- if (delegateReply.status.code !== 202) {
495
+ if (reply.status.code === 202) {
496
+ createdInPhase1.add(index);
497
+ } else if (reply.status.code !== 409) {
289
498
  throw new Error(
290
- `[@enbox/auth] Failed to store grant in delegate partition: ${delegateReply.status.detail}`
499
+ `[@enbox/auth] Failed to store grant in delegate partition: ${reply.status.detail}`
291
500
  );
292
501
  }
502
+ }));
503
+
504
+ const delegateFailure = delegateResults.find(
505
+ (r): r is PromiseRejectedResult => r.status === 'rejected',
506
+ );
507
+ if (delegateFailure) {
508
+ // Roll back only the grants we actually created (202), not
509
+ // pre-existing ones that returned 409.
510
+ await Promise.allSettled(parsed.map(async ({ rawMessage }, i) => {
511
+ if (createdInPhase1.has(i)) {
512
+ try {
513
+ await agent.processDwnRequest({
514
+ author : delegateDid,
515
+ target : delegateDid,
516
+ messageType : DwnInterface.RecordsDelete,
517
+ messageParams : { recordId: rawMessage.recordId },
518
+ });
519
+ } catch { /* best-effort rollback */ }
520
+ }
521
+ }));
522
+ throw delegateFailure.reason;
523
+ }
524
+
525
+ // ── Phase 2: connected partition ──────────────────────────────────
293
526
 
294
- // Also store the grant in the connectedDid's local DWN partition.
295
- // When the sync engine (or any delegate-authorized operation) processes
296
- // a request against the connectedDid's tenant, the DWN needs to find
297
- // the grant record there to authorize the delegate.
298
- //
299
- // We use processRawMessage because the delegate agent does not hold the
300
- // connectedDid's private keys — we cannot re-sign the message. The
301
- // rawMessage already carries valid authorization from the connectedDid
302
- // (the wallet signed it), so we pass it directly to the local DWN.
527
+ const connectedResults = await Promise.allSettled(parsed.map(async ({ rawMessage, encodedData }) => {
303
528
  const connectedReply = await agent.dwn.processRawMessage(
304
529
  connectedDid,
305
530
  rawMessage as GenericMessage,
@@ -311,18 +536,35 @@ export async function processConnectedGrants(params: {
311
536
  `[@enbox/auth] Failed to store grant in connected partition: ${connectedReply.status.detail}`
312
537
  );
313
538
  }
314
-
315
- const protocol = (grant.scope as DwnMessagesPermissionScope | DwnRecordsPermissionScope).protocol;
316
- // Exclude the permissions protocol — revocation grants are scoped to it
317
- // but the sync engine must not attempt to sync it separately. Permission
318
- // records are already included in each protocol's sync stream via
319
- // PermissionsProtocol.constructAdditionalMessageFilter().
320
- if (protocol && protocol !== PermissionsProtocol.uri) {
321
- connectedProtocols.add(protocol);
322
- }
539
+ }));
540
+
541
+ const connectedFailure = connectedResults.find(
542
+ (r): r is PromiseRejectedResult => r.status === 'rejected',
543
+ );
544
+ if (connectedFailure) {
545
+ // Connected-partition grants cannot be rolled back (the delegate
546
+ // agent cannot sign RecordsDelete as connectedDid). The orphaned
547
+ // records are harmless: the connect flow will throw, the imported
548
+ // identity is cleaned up by importDelegateAndSetupSync(), and
549
+ // without a registered sync identity the grants are never used.
550
+ // Roll back only the delegate-partition grants we actually created.
551
+ await Promise.allSettled(parsed.map(async ({ rawMessage }, i) => {
552
+ if (!createdInPhase1.has(i)) { return; }
553
+ try {
554
+ await agent.processDwnRequest({
555
+ author : delegateDid,
556
+ target : delegateDid,
557
+ messageType : DwnInterface.RecordsDelete,
558
+ messageParams : { recordId: rawMessage.recordId },
559
+ });
560
+ } catch { /* best-effort rollback */ }
561
+ }));
562
+ throw connectedFailure.reason;
323
563
  }
324
564
 
325
- return [...connectedProtocols];
565
+ // ── Derive sync scope from the processed grants ──────────────────
566
+
567
+ return deriveSyncScopeFromGrants(parsed.map((p) => p.grant));
326
568
  }
327
569
 
328
570
  // ─── importDelegateAndSetupSync ─────────────────────────────────
@@ -409,21 +651,30 @@ export async function importDelegateAndSetupSync(params: {
409
651
  // Register (or update) the identity for protocol-scoped sync.
410
652
  // If the identity is already registered from a prior session, update
411
653
  // the protocol list so it matches the new grants — otherwise a stale
412
- // `protocols: []` (global sync) would remain and the sync engine
413
- // would try to sync every protocol including the DWN permissions
414
- // protocol, which the delegate has no grant for.
415
- const syncOptions = {
416
- delegateDid : delegatePortableDid.uri,
417
- protocols : connectedProtocols,
418
- };
419
- try {
420
- await userAgent.sync.registerIdentity({ did: connectedDid, options: syncOptions });
421
- } catch (error: unknown) {
422
- const msg = error instanceof Error ? error.message : '';
423
- if (msg.includes('already registered')) {
424
- await userAgent.sync.updateIdentityOptions({ did: connectedDid, options: syncOptions });
425
- } else {
426
- throw error;
654
+ // registration would remain.
655
+ const narrowedProtocols = toSyncIdentityProtocols(connectedProtocols);
656
+ if (narrowedProtocols !== undefined) {
657
+ const syncOptions = {
658
+ delegateDid : delegatePortableDid.uri,
659
+ protocols : narrowedProtocols,
660
+ };
661
+ try {
662
+ await userAgent.sync.registerIdentity({ did: connectedDid, options: syncOptions });
663
+ } catch (error: unknown) {
664
+ const msg = error instanceof Error ? error.message : '';
665
+ if (msg.includes('already registered')) {
666
+ await userAgent.sync.updateIdentityOptions({ did: connectedDid, options: syncOptions });
667
+ } else {
668
+ throw error;
669
+ }
670
+ }
671
+ } else {
672
+ // Zero grants — remove any stale sync registration so revoked protocols stop syncing.
673
+ try {
674
+ await userAgent.sync.unregisterIdentity(connectedDid);
675
+ } catch (error: unknown) {
676
+ const msg = error instanceof Error ? error.message : '';
677
+ if (!msg.includes('is not registered')) { throw error; }
427
678
  }
428
679
  }
429
680
 
@@ -490,31 +741,18 @@ export async function finalizeDelegateSession(params: {
490
741
  await startSyncIfEnabled(userAgent, sync);
491
742
 
492
743
  // Persist protocol path keys alongside the delegate session markers
493
- // so they survive agent restarts.
494
- const delegateDecryptionKeys = (identity as any)._delegateDecryptionKeys as DelegateDecryptionKey[] | undefined;
744
+ // so they survive agent restarts. Delegate keys are stored in the
745
+ // vault-backed SecretStore (encrypted at rest), while non-secret
746
+ // session markers go into the plaintext StorageAdapter.
495
747
  const extraStorageKeys: Record<string, string> = {
496
748
  [STORAGE_KEYS.DELEGATE_DID] : delegateDid,
497
749
  [STORAGE_KEYS.CONNECTED_DID] : connectedDid,
498
750
  };
499
- if (delegateDecryptionKeys && delegateDecryptionKeys.length > 0) {
500
- const plaintext = Convert.string(JSON.stringify(delegateDecryptionKeys)).toUint8Array();
501
- const jwe = await userAgent.vault.encryptData({ plaintext });
502
- extraStorageKeys[STORAGE_KEYS.DELEGATE_DECRYPTION_KEYS] = jwe;
503
- }
504
- const delegateContextKeys = (identity as any)._delegateContextKeys as DelegateContextKey[] | undefined;
505
- if (delegateContextKeys && delegateContextKeys.length > 0) {
506
- const plaintext = Convert.string(JSON.stringify(delegateContextKeys)).toUint8Array();
507
- const jwe = await userAgent.vault.encryptData({ plaintext });
508
- extraStorageKeys[STORAGE_KEYS.DELEGATE_CONTEXT_KEYS] = jwe;
509
- }
510
- const delegateMultiPartyProtocols = (identity as any)._delegateMultiPartyProtocols as string[] | undefined;
511
- if (delegateMultiPartyProtocols && delegateMultiPartyProtocols.length > 0) {
512
- extraStorageKeys[STORAGE_KEYS.DELEGATE_MULTI_PARTY_PROTOCOLS] = JSON.stringify(delegateMultiPartyProtocols);
513
- }
514
- const sessionRevocations = (identity as any)._sessionRevocations as { grantId: string; revocationGrantId: string }[] | undefined;
515
- if (sessionRevocations && sessionRevocations.length > 0) {
516
- extraStorageKeys[STORAGE_KEYS.SESSION_REVOCATIONS] = JSON.stringify(sessionRevocations);
517
- }
751
+
752
+ // Persist or clear delegate keys/revocations. Clearing stale values
753
+ // from prior sessions prevents a reconnect with fewer capabilities
754
+ // from retaining old decryption material.
755
+ await persistOrClearDelegateSecrets(userAgent, storage, identity, extraStorageKeys);
518
756
 
519
757
  // Wire post-connect context key persistence: when the owner creates a
520
758
  // new multi-party context, the agent injects the key into the delegate
@@ -523,9 +761,8 @@ export async function finalizeDelegateSession(params: {
523
761
  if (changedDelegateDid !== delegateDid) { return; }
524
762
  try {
525
763
  const keys = userAgent.dwn.exportDelegateContextKeys(delegateDid);
526
- const pt = Convert.string(JSON.stringify(keys)).toUint8Array();
527
- const encrypted = await userAgent.vault.encryptData({ plaintext: pt });
528
- await storage.set(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS, encrypted);
764
+ const bytes = Convert.string(JSON.stringify(keys)).toUint8Array();
765
+ await userAgent.secrets.put(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS, bytes);
529
766
  } catch { /* best effort — keys will be re-derived on next connect */ }
530
767
  };
531
768
 
@@ -587,15 +824,17 @@ export async function finalizeSession(params: {
587
824
  extraStorageKeys,
588
825
  } = params;
589
826
 
590
- // Persist session markers.
591
- await storage.set(STORAGE_KEYS.PREVIOUSLY_CONNECTED, 'true');
592
- await storage.set(STORAGE_KEYS.ACTIVE_IDENTITY, connectedDid);
593
-
827
+ // Persist all session markers concurrently — all writes are independent.
828
+ const storageWrites: Promise<void>[] = [
829
+ storage.set(STORAGE_KEYS.PREVIOUSLY_CONNECTED, 'true'),
830
+ storage.set(STORAGE_KEYS.ACTIVE_IDENTITY, connectedDid),
831
+ ];
594
832
  if (extraStorageKeys) {
595
833
  for (const [key, value] of Object.entries(extraStorageKeys)) {
596
- await storage.set(key, value);
834
+ storageWrites.push(storage.set(key, value));
597
835
  }
598
836
  }
837
+ await Promise.all(storageWrites);
599
838
 
600
839
  // When identityName is undefined, no user identity exists (agent-only session).
601
840
  // Build an IdentityInfo with the agent DID as a fallback.
@@ -623,3 +862,50 @@ export async function finalizeSession(params: {
623
862
 
624
863
  return session;
625
864
  }
865
+
866
+ // ─── persistOrClearDelegateSecrets ──────────────────────────────
867
+
868
+ /** @internal */
869
+ async function persistOrClearDelegateSecrets(
870
+ userAgent: EnboxUserAgent,
871
+ storage: StorageAdapter,
872
+ identity: BearerIdentity,
873
+ extraStorageKeys: Record<string, string>,
874
+ ): Promise<void> {
875
+ const delegateDecryptionKeys = (identity as any)._delegateDecryptionKeys as DelegateDecryptionKey[] | undefined;
876
+ const delegateContextKeys = (identity as any)._delegateContextKeys as DelegateContextKey[] | undefined;
877
+
878
+ // Persist or clear keys in the SecretStore + legacy StorageAdapter.
879
+ const secretWrites: Promise<void>[] = [];
880
+ const putOrDelete = (key: string, data: unknown[] | undefined): void => {
881
+ if (data?.length) {
882
+ secretWrites.push(userAgent.secrets.put(key, Convert.string(JSON.stringify(data)).toUint8Array()));
883
+ } else {
884
+ secretWrites.push(userAgent.secrets.delete(key).then(() => {}).catch(() => {}));
885
+ secretWrites.push(storage.remove(key).catch(() => {}));
886
+ }
887
+ };
888
+ putOrDelete(STORAGE_KEYS.DELEGATE_DECRYPTION_KEYS, delegateDecryptionKeys);
889
+ putOrDelete(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS, delegateContextKeys);
890
+ await Promise.all(secretWrites);
891
+
892
+ // Best-effort cleanup of legacy plaintext copies when new keys were written.
893
+ if (delegateDecryptionKeys?.length || delegateContextKeys?.length) {
894
+ try { await storage.remove(STORAGE_KEYS.DELEGATE_DECRYPTION_KEYS); } catch { /* best-effort */ }
895
+ try { await storage.remove(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS); } catch { /* best-effort */ }
896
+ }
897
+
898
+ const delegateMultiPartyProtocols = (identity as any)._delegateMultiPartyProtocols as string[] | undefined;
899
+ if (delegateMultiPartyProtocols?.length) {
900
+ extraStorageKeys[STORAGE_KEYS.DELEGATE_MULTI_PARTY_PROTOCOLS] = JSON.stringify(delegateMultiPartyProtocols);
901
+ } else {
902
+ try { await storage.remove(STORAGE_KEYS.DELEGATE_MULTI_PARTY_PROTOCOLS); } catch { /* best-effort */ }
903
+ }
904
+
905
+ const sessionRevocations = (identity as any)._sessionRevocations as { grantId: string; revocationGrantId: string }[] | undefined;
906
+ if (sessionRevocations?.length) {
907
+ extraStorageKeys[STORAGE_KEYS.SESSION_REVOCATIONS] = JSON.stringify(sessionRevocations);
908
+ } else {
909
+ try { await storage.remove(STORAGE_KEYS.SESSION_REVOCATIONS); } catch { /* best-effort */ }
910
+ }
911
+ }
@@ -81,11 +81,12 @@ export async function localConnect(
81
81
  if (ctx.registration) {
82
82
  await registerWithDwnEndpoints(
83
83
  {
84
- userAgent : userAgent,
84
+ userAgent : userAgent,
85
85
  dwnEndpoints,
86
- agentDid : userAgent.agentDid.uri,
86
+ agentDid : userAgent.agentDid.uri,
87
87
  connectedDid,
88
- storage : storage,
88
+ secretStore : userAgent.secrets,
89
+ storage : storage,
89
90
  },
90
91
  ctx.registration,
91
92
  );
@@ -95,7 +96,7 @@ export async function localConnect(
95
96
  if (isNewIdentity && sync !== 'off') {
96
97
  await userAgent.sync.registerIdentity({
97
98
  did : connectedDid,
98
- options : { delegateDid, protocols: [] },
99
+ options : { delegateDid, protocols: 'all' },
99
100
  });
100
101
  }
101
102