@enbox/auth 0.6.28 → 0.6.30
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.
- package/dist/esm/auth-manager.js +82 -46
- package/dist/esm/auth-manager.js.map +1 -1
- package/dist/esm/connect/import.js +20 -13
- package/dist/esm/connect/import.js.map +1 -1
- package/dist/esm/connect/lifecycle.js +356 -68
- package/dist/esm/connect/lifecycle.js.map +1 -1
- package/dist/esm/connect/local.js +2 -1
- package/dist/esm/connect/local.js.map +1 -1
- package/dist/esm/connect/restore.js +87 -64
- package/dist/esm/connect/restore.js.map +1 -1
- package/dist/esm/connect/wallet.js +1 -0
- package/dist/esm/connect/wallet.js.map +1 -1
- package/dist/esm/discovery.js +2 -1
- package/dist/esm/discovery.js.map +1 -1
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/registration.js +70 -12
- package/dist/esm/registration.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/types/auth-manager.d.ts +26 -15
- package/dist/types/auth-manager.d.ts.map +1 -1
- package/dist/types/connect/import.d.ts.map +1 -1
- package/dist/types/connect/lifecycle.d.ts +60 -1
- package/dist/types/connect/lifecycle.d.ts.map +1 -1
- package/dist/types/connect/local.d.ts.map +1 -1
- package/dist/types/connect/restore.d.ts +8 -0
- package/dist/types/connect/restore.d.ts.map +1 -1
- package/dist/types/connect/wallet.d.ts.map +1 -1
- package/dist/types/events.d.ts +1 -1
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/registration.d.ts +28 -3
- package/dist/types/registration.d.ts.map +1 -1
- package/dist/types/types.d.ts +18 -9
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/auth-manager.ts +100 -63
- package/src/connect/import.ts +24 -19
- package/src/connect/lifecycle.ts +360 -74
- package/src/connect/local.ts +5 -4
- package/src/connect/restore.ts +79 -66
- package/src/connect/wallet.ts +2 -1
- package/src/discovery.ts +1 -1
- package/src/events.ts +1 -1
- package/src/registration.ts +82 -15
- package/src/types.ts +18 -9
package/src/connect/import.ts
CHANGED
|
@@ -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
|
|
60
|
+
userAgent : userAgent,
|
|
61
61
|
dwnEndpoints,
|
|
62
|
-
agentDid
|
|
62
|
+
agentDid : userAgent.agentDid.uri,
|
|
63
63
|
connectedDid,
|
|
64
|
-
|
|
64
|
+
secretStore : userAgent.secrets,
|
|
65
|
+
storage : storage,
|
|
65
66
|
},
|
|
66
67
|
ctx.registration,
|
|
67
68
|
);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
// Register sync
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
123
|
+
userAgent : userAgent,
|
|
119
124
|
dwnEndpoints,
|
|
120
|
-
agentDid
|
|
125
|
+
agentDid : userAgent.agentDid.uri,
|
|
121
126
|
connectedDid,
|
|
122
|
-
|
|
127
|
+
secretStore : userAgent.secrets,
|
|
128
|
+
storage : storage,
|
|
123
129
|
},
|
|
124
130
|
ctx.registration,
|
|
125
131
|
);
|
|
126
132
|
}
|
|
127
133
|
|
|
128
|
-
// Register
|
|
129
|
-
if (
|
|
130
|
-
await
|
|
131
|
-
|
|
132
|
-
|
|
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);
|
package/src/connect/lifecycle.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { PortableDid } from '@enbox/dids';
|
|
18
|
-
import type { BearerIdentity, DelegateContextKey, DelegateDecryptionKey, DwnDataEncodedRecordsWriteMessage,
|
|
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
|
-
|
|
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
|
-
|
|
474
|
+
return { grant, rawMessage, encodedData };
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ── Phase 1: delegate partition ───────────────────────────────────
|
|
275
478
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 (
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
527
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/connect/local.ts
CHANGED
|
@@ -81,11 +81,12 @@ export async function localConnect(
|
|
|
81
81
|
if (ctx.registration) {
|
|
82
82
|
await registerWithDwnEndpoints(
|
|
83
83
|
{
|
|
84
|
-
userAgent
|
|
84
|
+
userAgent : userAgent,
|
|
85
85
|
dwnEndpoints,
|
|
86
|
-
agentDid
|
|
86
|
+
agentDid : userAgent.agentDid.uri,
|
|
87
87
|
connectedDid,
|
|
88
|
-
|
|
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
|
|