@enbox/agent 0.7.0 → 0.7.1

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 (52) hide show
  1. package/dist/browser.mjs +11 -11
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/agent-session.js +16 -0
  4. package/dist/esm/agent-session.js.map +1 -0
  5. package/dist/esm/dwn-api.js +5 -4
  6. package/dist/esm/dwn-api.js.map +1 -1
  7. package/dist/esm/dwn-encryption.js +11 -4
  8. package/dist/esm/dwn-encryption.js.map +1 -1
  9. package/dist/esm/enbox-connect-protocol.js +376 -207
  10. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/local-key-manager.js.map +1 -1
  14. package/dist/esm/protocol-utils.js +19 -6
  15. package/dist/esm/protocol-utils.js.map +1 -1
  16. package/dist/esm/store-data.js.map +1 -1
  17. package/dist/esm/store-key.js +1 -1
  18. package/dist/esm/store-key.js.map +1 -1
  19. package/dist/esm/sync-engine-level.js +18 -5
  20. package/dist/esm/sync-engine-level.js.map +1 -1
  21. package/dist/esm/types/dwn.js.map +1 -1
  22. package/dist/esm/utils.js +0 -12
  23. package/dist/esm/utils.js.map +1 -1
  24. package/dist/types/agent-session.d.ts +59 -0
  25. package/dist/types/agent-session.d.ts.map +1 -0
  26. package/dist/types/dwn-api.d.ts.map +1 -1
  27. package/dist/types/dwn-encryption.d.ts.map +1 -1
  28. package/dist/types/enbox-connect-protocol.d.ts +34 -3
  29. package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/index.d.ts.map +1 -1
  32. package/dist/types/local-key-manager.d.ts.map +1 -1
  33. package/dist/types/protocol-utils.d.ts.map +1 -1
  34. package/dist/types/sync-engine-level.d.ts +6 -1
  35. package/dist/types/sync-engine-level.d.ts.map +1 -1
  36. package/dist/types/types/dwn.d.ts +10 -1
  37. package/dist/types/types/dwn.d.ts.map +1 -1
  38. package/dist/types/utils.d.ts +0 -2
  39. package/dist/types/utils.d.ts.map +1 -1
  40. package/package.json +6 -6
  41. package/src/agent-session.ts +78 -0
  42. package/src/dwn-api.ts +5 -4
  43. package/src/dwn-encryption.ts +14 -6
  44. package/src/enbox-connect-protocol.ts +466 -256
  45. package/src/index.ts +1 -0
  46. package/src/local-key-manager.ts +7 -3
  47. package/src/protocol-utils.ts +19 -8
  48. package/src/store-data.ts +1 -1
  49. package/src/store-key.ts +1 -1
  50. package/src/sync-engine-level.ts +19 -6
  51. package/src/types/dwn.ts +21 -12
  52. package/src/utils.ts +3 -18
@@ -18,7 +18,7 @@ import type { EnboxPlatformAgent } from './types/agent.js';
18
18
  import type { PrivateKeyJwk } from '@enbox/crypto';
19
19
  import type { RequireOnly } from '@enbox/common';
20
20
  import type { DidDocument, PortableDid } from '@enbox/dids';
21
- import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js';
21
+ import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition, DwnRecordsPermissionScope } from './types/dwn.js';
22
22
 
23
23
  /**
24
24
  * The protocols of permissions requested, along with the definition and permission scopes for each protocol.
@@ -103,7 +103,7 @@ import type {
103
103
  Jwk } from '@enbox/crypto';
104
104
 
105
105
  import { type BearerDid, DidJwk } from '@enbox/dids';
106
- import { Convert, logger } from '@enbox/common';
106
+ import { concatenateUrl, Convert, logger, nowMs, timed } from '@enbox/common';
107
107
  import {
108
108
  CryptoUtils,
109
109
  Ed25519,
@@ -120,7 +120,7 @@ import { getEncryptionKeyInfo } from './dwn-encryption.js';
120
120
  import { isMultiPartyContext } from './protocol-utils.js';
121
121
  import { isRecordPermissionScope } from './dwn-api.js';
122
122
  import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
123
- import { concatenateUrl, mapConcurrent, mapConcurrentSettled } from './utils.js';
123
+ import { mapConcurrent, mapConcurrentSettled } from './utils.js';
124
124
 
125
125
  // ---------------------------------------------------------------------------
126
126
  // Tunables
@@ -151,6 +151,9 @@ const CONNECT_FANOUT_CONCURRENCY = 8;
151
151
  */
152
152
  const CONNECT_REQUEST_TIMEOUT_MS = 10_000;
153
153
 
154
+ /** Log namespace used for wallet-side connect critical-path timings. */
155
+ const CONNECT_PERF_LOG_PREFIX = '[connect.perf]';
156
+
154
157
  // ---------------------------------------------------------------------------
155
158
  // Types
156
159
  // ---------------------------------------------------------------------------
@@ -333,13 +336,20 @@ function buildConnectUrl({
333
336
  // JWT signing and verification
334
337
  // ---------------------------------------------------------------------------
335
338
 
336
- /** Signs an object as a JWT using an Ed25519 DID key. */
339
+ /**
340
+ * Signs an object as a JWT using an Ed25519 DID key.
341
+ *
342
+ * `data` is constrained to `object` so callers don't have to widen
343
+ * typed payload shapes (e.g. `EnboxConnectResponse`) to
344
+ * `Record<string, unknown>` at the call site. `Convert.object(data)`
345
+ * stringifies whatever JSON-serializable shape is passed.
346
+ */
337
347
  async function signJwt({
338
348
  did,
339
349
  data,
340
350
  }: {
341
351
  did: BearerDid;
342
- data: Record<string, unknown>;
352
+ data: object;
343
353
  }): Promise<string> {
344
354
  const header = Convert.object({
345
355
  alg : 'EdDSA',
@@ -358,7 +368,17 @@ async function signJwt({
358
368
  return `${header}.${payload}.${signatureBase64Url}`;
359
369
  }
360
370
 
361
- /** Verifies a JWT signature using the DID in the `kid` header. Returns the parsed payload. */
371
+ /**
372
+ * Verifies a JWT signature using the DID in the `kid` header. Returns the
373
+ * parsed payload as an untyped object.
374
+ *
375
+ * The return type is intentionally `Record<string, unknown>` rather than a
376
+ * caller-supplied generic — a JWT payload is bytes from a remote party, and
377
+ * we can't soundly assert its shape without runtime validation. Callers must
378
+ * apply one of the {@link assertConnectRequest} / {@link assertConnectResponse}
379
+ * assertion helpers (or their own type guard) to narrow the payload before
380
+ * accessing fields.
381
+ */
362
382
  async function verifyJwt({ jwt }: { jwt: string }): Promise<Record<string, unknown>> {
363
383
  const [headerB64U, payloadB64U, signatureB64U] = jwt.split('.');
364
384
 
@@ -394,7 +414,129 @@ async function verifyJwt({ jwt }: { jwt: string }): Promise<Record<string, unkno
394
414
  throw new Error('Connect: JWT verification failed — invalid signature.');
395
415
  }
396
416
 
397
- return Convert.base64Url(payloadB64U).toObject() as Record<string, unknown>;
417
+ const decoded: unknown = Convert.base64Url(payloadB64U).toObject();
418
+ if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) {
419
+ throw new Error('Connect: JWT verification failed — payload must be a JSON object.');
420
+ }
421
+ return decoded as Record<string, unknown>;
422
+ }
423
+
424
+ // ─── Field-level validation helpers ─────────────────────────────────────
425
+ //
426
+ // The connect-request / connect-response assertions below describe their
427
+ // expected shape declaratively in terms of these primitive checks. Each
428
+ // helper throws a consistent `Connect: <context> — \`<field>\` <reason>`
429
+ // message on mismatch, so per-field error formatting is centralized here
430
+ // rather than duplicated at every call site.
431
+
432
+ /**
433
+ * Throws a shape-validation error during connect JWT assertion.
434
+ *
435
+ * Deliberately throws plain `Error` rather than `TypeError` even though
436
+ * the failures are runtime type-shape mismatches. The reason is layered
437
+ * error handling: boundary-validation failures need to propagate through
438
+ * the same `try/catch` paths as every other connect-flow error — vault
439
+ * lock failure, JWT signature failure, DWN request failure, etc. —
440
+ * without `catch` blocks having to special-case a `TypeError` subclass.
441
+ *
442
+ * SonarCloud's `typescript:S7786` flags this as "too unspecific for a
443
+ * type check" and prefers `TypeError`. We suppress it at the file level
444
+ * (see `sonar-project.properties`) because every `require*` helper goes
445
+ * through this single throw site.
446
+ */
447
+ function fail(context: string, field: string, reason: string): never {
448
+ throw new Error(`Connect: ${context} — \`${field}\` ${reason}.`);
449
+ }
450
+
451
+ function requireString(payload: Record<string, unknown>, field: string, context: string): void {
452
+ if (typeof payload[field] !== 'string') { fail(context, field, 'must be a string'); }
453
+ }
454
+
455
+ function requireNumber(payload: Record<string, unknown>, field: string, context: string): void {
456
+ if (typeof payload[field] !== 'number') { fail(context, field, 'must be a number'); }
457
+ }
458
+
459
+ function requireArray(payload: Record<string, unknown>, field: string, context: string): void {
460
+ if (!Array.isArray(payload[field])) { fail(context, field, 'must be an array'); }
461
+ }
462
+
463
+ function requireObject(payload: Record<string, unknown>, field: string, context: string): void {
464
+ const value = payload[field];
465
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
466
+ fail(context, field, 'must be an object');
467
+ }
468
+ }
469
+
470
+ function requireLiteral<L extends string | number | boolean>(
471
+ payload: Record<string, unknown>, field: string, expected: L, context: string,
472
+ ): void {
473
+ if (payload[field] !== expected) { fail(context, field, `must be ${JSON.stringify(expected)}`); }
474
+ }
475
+
476
+ function requireStringArray(payload: Record<string, unknown>, field: string, context: string): void {
477
+ const value = payload[field];
478
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
479
+ fail(context, field, 'must be a string[]');
480
+ }
481
+ }
482
+
483
+ function requireOptionalString(payload: Record<string, unknown>, field: string, context: string): void {
484
+ if (payload[field] !== undefined && typeof payload[field] !== 'string') {
485
+ fail(context, field, 'must be a string when present');
486
+ }
487
+ }
488
+
489
+ function requireOptionalArray(payload: Record<string, unknown>, field: string, context: string): void {
490
+ if (payload[field] !== undefined && !Array.isArray(payload[field])) {
491
+ fail(context, field, 'must be an array when present');
492
+ }
493
+ }
494
+
495
+ // ─── Boundary assertions ────────────────────────────────────────────────
496
+ //
497
+ // Each `assertConnect*` describes the shape of its target type as a list
498
+ // of per-field requirements. The structural existence/primitive checks
499
+ // are the only validation done at the JWT-payload boundary; deeper
500
+ // validation of nested arrays/objects (permission scopes, grants,
501
+ // portable DID structure) happens downstream in DWN-aware validators
502
+ // where the richer logic already lives.
503
+
504
+ /**
505
+ * Runtime assertion that a verified JWT payload has the shape of an
506
+ * {@link EnboxConnectRequest}. Use immediately after `verifyJwt()` to narrow
507
+ * a `Record<string, unknown>` payload before accessing fields.
508
+ */
509
+ function assertConnectRequest(payload: Record<string, unknown>): asserts payload is EnboxConnectRequest {
510
+ const ctx = 'invalid connect request';
511
+ requireString(payload, 'clientDid', ctx);
512
+ requireString(payload, 'appName', ctx);
513
+ requireArray(payload, 'permissionRequests', ctx);
514
+ requireString(payload, 'nonce', ctx);
515
+ requireString(payload, 'state', ctx);
516
+ requireString(payload, 'callbackUrl', ctx);
517
+ requireLiteral(payload, 'responseMode', 'direct_post', ctx);
518
+ requireStringArray(payload, 'supportedDidMethods', ctx);
519
+ }
520
+
521
+ /**
522
+ * Runtime assertion that a verified JWT payload has the shape of an
523
+ * {@link EnboxConnectResponse}. Use immediately after `verifyJwt()` to narrow
524
+ * a `Record<string, unknown>` payload before accessing fields.
525
+ */
526
+ function assertConnectResponse(payload: Record<string, unknown>): asserts payload is EnboxConnectResponse {
527
+ const ctx = 'invalid connect response';
528
+ requireString(payload, 'providerDid', ctx);
529
+ requireString(payload, 'delegateDid', ctx);
530
+ requireString(payload, 'aud', ctx);
531
+ requireNumber(payload, 'iat', ctx);
532
+ requireNumber(payload, 'exp', ctx);
533
+ requireOptionalString(payload, 'nonce', ctx);
534
+ requireArray(payload, 'delegateGrants', ctx);
535
+ requireObject(payload, 'delegatePortableDid', ctx);
536
+ requireOptionalArray(payload, 'delegateDecryptionKeys', ctx);
537
+ requireOptionalArray(payload, 'delegateContextKeys', ctx);
538
+ requireOptionalArray(payload, 'delegateMultiPartyProtocols', ctx);
539
+ requireOptionalArray(payload, 'sessionRevocations', ctx);
398
540
  }
399
541
 
400
542
  // ---------------------------------------------------------------------------
@@ -637,7 +779,9 @@ async function getConnectRequest(requestUri: string, encryptionKey: string): Pro
637
779
  const response = await fetch(requestUri, { signal: AbortSignal.timeout(30_000) });
638
780
  const jwe = await response.text();
639
781
  const jwt = await decryptRequest({ jwe, encryptionKey });
640
- return (await verifyJwt({ jwt })) as unknown as EnboxConnectRequest;
782
+ const payload = await verifyJwt({ jwt });
783
+ assertConnectRequest(payload);
784
+ return payload;
641
785
  }
642
786
 
643
787
  // ---------------------------------------------------------------------------
@@ -903,10 +1047,12 @@ async function deriveScopedDecryptionKeys(
903
1047
  DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
904
1048
  ]);
905
1049
 
906
- // Collect read-like scopes only.
1050
+ // Collect read-like scopes only. `isRecordPermissionScope` narrows to
1051
+ // `DwnRecordsPermissionScope`, which declares `protocolPath?: string`
1052
+ // and `contextId?: string` — no `as any` needed for those reads below.
907
1053
  const readScopes = scopes.filter(
908
- (s): s is DwnPermissionScope & { method: string } =>
909
- isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1054
+ (s): s is DwnRecordsPermissionScope =>
1055
+ isRecordPermissionScope(s) && readMethods.has(s.method),
910
1056
  );
911
1057
 
912
1058
  if (readScopes.length === 0) {
@@ -915,7 +1061,7 @@ async function deriveScopedDecryptionKeys(
915
1061
 
916
1062
  // Fail closed: reject contextId-scoped encrypted reads.
917
1063
  for (const scope of readScopes) {
918
- if ('contextId' in scope && (scope as any).contextId) {
1064
+ if (scope.contextId) {
919
1065
  throw new Error(
920
1066
  `Encrypted delegate access scoped by contextId is not supported ` +
921
1067
  `yet; use protocol-wide permissions for protocol '${protocolUri}'.`,
@@ -936,9 +1082,7 @@ async function deriveScopedDecryptionKeys(
936
1082
  }
937
1083
 
938
1084
  // Check if any scope is protocol-wide (no protocolPath).
939
- const hasProtocolWideRead = readScopes.some(
940
- (s) => !('protocolPath' in s) || !(s as any).protocolPath,
941
- );
1085
+ const hasProtocolWideRead = readScopes.some((s) => !s.protocolPath);
942
1086
 
943
1087
  const { keyId, keyUri } = await getEncryptionKeyInfo(agent, ownerDid);
944
1088
 
@@ -967,8 +1111,7 @@ async function deriveScopedDecryptionKeys(
967
1111
  // Emit one exact-path key per unique protocolPath.
968
1112
  const uniquePaths = new Set<string>();
969
1113
  for (const scope of readScopes) {
970
- const pp = (scope as any).protocolPath as string | undefined;
971
- if (pp) { uniquePaths.add(pp); }
1114
+ if (scope.protocolPath) { uniquePaths.add(scope.protocolPath); }
972
1115
  }
973
1116
 
974
1117
  const keys: DelegateDecryptionKey[] = [];
@@ -1058,9 +1201,12 @@ async function deriveContextKeysForDelegate(
1058
1201
  DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
1059
1202
  ]);
1060
1203
 
1204
+ // `isRecordPermissionScope` narrows to `DwnRecordsPermissionScope`,
1205
+ // which declares `protocolPath?: string` and `contextId?: string` —
1206
+ // no `as any` needed for the field reads below.
1061
1207
  const readScopes = scopes.filter(
1062
- (s): s is DwnPermissionScope & { method: string } =>
1063
- isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1208
+ (s): s is DwnRecordsPermissionScope =>
1209
+ isRecordPermissionScope(s) && readMethods.has(s.method),
1064
1210
  );
1065
1211
 
1066
1212
  if (readScopes.length === 0) {
@@ -1069,7 +1215,7 @@ async function deriveContextKeysForDelegate(
1069
1215
 
1070
1216
  // Fail closed: reject contextId-scoped reads.
1071
1217
  for (const scope of readScopes) {
1072
- if ('contextId' in scope && (scope as any).contextId) {
1218
+ if (scope.contextId) {
1073
1219
  throw new Error(
1074
1220
  `Encrypted delegate access scoped by contextId is not supported ` +
1075
1221
  `yet; use protocol-wide permissions for protocol ` +
@@ -1080,7 +1226,7 @@ async function deriveContextKeysForDelegate(
1080
1226
 
1081
1227
  // Fail closed: reject protocolPath-scoped reads on multi-party protocols.
1082
1228
  for (const scope of readScopes) {
1083
- if ('protocolPath' in scope && (scope as any).protocolPath) {
1229
+ if (scope.protocolPath) {
1084
1230
  throw new Error(
1085
1231
  `Encrypted delegate access scoped by protocolPath on multi-party ` +
1086
1232
  `protocols is not supported yet; use protocol-wide permissions for ` +
@@ -1113,8 +1259,7 @@ async function deriveContextKeysForDelegate(
1113
1259
  });
1114
1260
 
1115
1261
  for (const entry of reply.entries ?? []) {
1116
- const rootContextId = (entry as any).contextId?.split('/')[0]
1117
- || (entry as any).recordId;
1262
+ const rootContextId = entry.contextId?.split('/')[0] ?? entry.recordId;
1118
1263
 
1119
1264
  if (!rootContextId || seenContextIds.has(rootContextId)) { continue; }
1120
1265
  seenContextIds.add(rootContextId);
@@ -1166,261 +1311,324 @@ async function submitConnectResponse(
1166
1311
  pin: string | undefined,
1167
1312
  agent: EnboxPlatformAgent
1168
1313
  ): Promise<void> {
1169
- const delegateBearerDid = await DidJwk.create();
1170
- const delegatePortableDid = await delegateBearerDid.export();
1171
-
1172
- // Add X25519 key derived from the delegate's Ed25519 key.
1173
- // did:jwk only supports one verification method, but DWN encryption
1174
- // requires X25519 for key agreement. Including the derived X25519
1175
- // private key in the PortableDid ensures the delegate agent's KMS
1176
- // has both keys after import. The Ed25519→X25519 conversion is a
1177
- // standard cryptographic operation (RFC 8032 / libsodium).
1178
- const delegateEdPrivateKey = delegatePortableDid.privateKeys![0];
1179
- const delegateX25519PrivateKey = await Ed25519.convertPrivateKeyToX25519({
1180
- privateKey: delegateEdPrivateKey,
1181
- });
1182
- delegatePortableDid.privateKeys!.push(delegateX25519PrivateKey);
1183
-
1184
- // Derive the delegate's key-delivery ProtocolPath leaf public key.
1185
- // This is the pre-derived key that the owner will use later when writing
1186
- // contextKey records addressed to this delegate. The owner cannot derive
1187
- // this from the delegate's root public key alone (HKDF needs the private
1188
- // key), so we compute it now while we have temporary access to the
1189
- // delegate's private key material.
1190
- const delegateX25519PrivateKeyBytes = await X25519.privateKeyToBytes({
1191
- privateKey: delegateX25519PrivateKey,
1192
- });
1193
- const keyDeliveryDerivationPath = [
1194
- KeyDerivationScheme.ProtocolPath,
1195
- KeyDeliveryProtocolDefinition.protocol,
1196
- 'contextKey',
1197
- ];
1198
- const delegateLeafPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(
1199
- delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath,
1314
+ const submitStart = nowMs();
1315
+ const numProtocols = connectRequest.permissionRequests.length;
1316
+ const numScopes = connectRequest.permissionRequests.reduce(
1317
+ (sum, req) => sum + req.permissionScopes.length,
1318
+ 0,
1319
+ );
1320
+ // Tracked across the try/finally so the aggregate `submitConnectResponse.total`
1321
+ // log emits on both success and failure paths operators bisecting wall-time
1322
+ // from wallet debug logs need the total even when a phase throws.
1323
+ let sessionGrantCount = 0;
1324
+ let outcome: 'ok' | 'fail' = 'ok';
1325
+ logger.log(
1326
+ `${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.start `
1327
+ + `(protocols=${numProtocols}, scopes=${numScopes})`,
1200
1328
  );
1201
- const delegateLeafPrivateKeyJwk = await X25519.bytesToPrivateKey({
1202
- privateKeyBytes: delegateLeafPrivateKeyBytes,
1203
- });
1204
- const delegateKeyDeliveryLeafPublicKey = await X25519.getPublicKey({
1205
- key: delegateLeafPrivateKeyJwk,
1206
- });
1207
1329
 
1208
- // The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
1209
- // For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
1210
- // this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
1211
- // `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
1212
- // the same id which they do because both derive from verificationMethod.id
1213
- // of the keyAgreement relationship.
1214
- const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod![0].id;
1215
- const delegateKeyDeliveryData = {
1216
- rootKeyId : delegateKeyAgreementVmId,
1217
- publicKeyJwk : delegateKeyDeliveryLeafPublicKey,
1218
- };
1330
+ try {
1331
+ const delegateBearerDid = await timed(`${CONNECT_PERF_LOG_PREFIX} delegateDid.create`, () => DidJwk.create());
1332
+ const delegatePortableDid = await delegateBearerDid.export();
1333
+
1334
+ // Add X25519 key derived from the delegate's Ed25519 key.
1335
+ // did:jwk only supports one verification method, but DWN encryption
1336
+ // requires X25519 for key agreement. Including the derived X25519
1337
+ // private key in the PortableDid ensures the delegate agent's KMS
1338
+ // has both keys after import. The Ed25519→X25519 conversion is a
1339
+ // standard cryptographic operation (RFC 8032 / libsodium).
1340
+ const delegateEdPrivateKey = delegatePortableDid.privateKeys![0];
1341
+ const delegateX25519PrivateKey = await Ed25519.convertPrivateKeyToX25519({
1342
+ privateKey: delegateEdPrivateKey,
1343
+ });
1344
+ delegatePortableDid.privateKeys!.push(delegateX25519PrivateKey);
1345
+
1346
+ // Derive the delegate's key-delivery ProtocolPath leaf public key.
1347
+ // This is the pre-derived key that the owner will use later when writing
1348
+ // contextKey records addressed to this delegate. The owner cannot derive
1349
+ // this from the delegate's root public key alone (HKDF needs the private
1350
+ // key), so we compute it now while we have temporary access to the
1351
+ // delegate's private key material.
1352
+ const delegateX25519PrivateKeyBytes = await X25519.privateKeyToBytes({
1353
+ privateKey: delegateX25519PrivateKey,
1354
+ });
1355
+ const keyDeliveryDerivationPath = [
1356
+ KeyDerivationScheme.ProtocolPath,
1357
+ KeyDeliveryProtocolDefinition.protocol,
1358
+ 'contextKey',
1359
+ ];
1360
+ const delegateLeafPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(
1361
+ delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath,
1362
+ );
1363
+ const delegateLeafPrivateKeyJwk = await X25519.bytesToPrivateKey({
1364
+ privateKeyBytes: delegateLeafPrivateKeyBytes,
1365
+ });
1366
+ const delegateKeyDeliveryLeafPublicKey = await X25519.getPublicKey({
1367
+ key: delegateLeafPrivateKeyJwk,
1368
+ });
1219
1369
 
1220
- // Derive scope-aware decryption keys for encrypted protocols.
1221
- // Single-party: ProtocolPath keys (protocol-wide or exact-path).
1222
- // Multi-party: ProtocolContext keys (per rootContextId).
1223
- // Write-only delegates receive no decryption capability.
1224
- const delegateDecryptionKeys: DelegateDecryptionKey[] = [];
1225
- const delegateContextKeys: DelegateContextKey[] = [];
1226
- const delegateMultiPartyProtocols: string[] = [];
1370
+ // The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
1371
+ // For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
1372
+ // this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
1373
+ // `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
1374
+ // the same id — which they do because both derive from verificationMethod.id
1375
+ // of the keyAgreement relationship.
1376
+ const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod![0].id;
1377
+ const delegateKeyDeliveryData = {
1378
+ rootKeyId : delegateKeyAgreementVmId,
1379
+ publicKeyJwk : delegateKeyDeliveryLeafPublicKey,
1380
+ };
1381
+
1382
+ // Derive scope-aware decryption keys for encrypted protocols.
1383
+ // Single-party: ProtocolPath keys (protocol-wide or exact-path).
1384
+ // Multi-party: ProtocolContext keys (per rootContextId).
1385
+ // Write-only delegates receive no decryption capability.
1386
+ const delegateDecryptionKeys: DelegateDecryptionKey[] = [];
1387
+ const delegateContextKeys: DelegateContextKey[] = [];
1388
+ const delegateMultiPartyProtocols: string[] = [];
1389
+
1390
+ const delegateGrants = await timed(
1391
+ `${CONNECT_PERF_LOG_PREFIX} permissionGrants.fanout (protocols=${numProtocols})`,
1392
+ async () => {
1393
+ const delegateGrantPromises = connectRequest.permissionRequests.map(
1394
+ async (permissionRequest) => {
1395
+ const { protocolDefinition, permissionScopes } = permissionRequest;
1396
+
1397
+ const grantsMatchProtocolUri = permissionScopes.every(
1398
+ scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol
1399
+ );
1400
+ if (!grantsMatchProtocolUri) {
1401
+ throw new Error('All permission scopes must match the protocol URI they are provided with.');
1402
+ }
1403
+
1404
+ await prepareProtocol(selectedDid, agent, protocolDefinition);
1405
+
1406
+ const hasEncryptedTypes = Object.values(protocolDefinition.types ?? {})
1407
+ .some((type: any) => type?.encryptionRequired === true);
1408
+
1409
+ if (hasEncryptedTypes) {
1410
+ const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
1411
+
1412
+ if (multiParty.length > 0 && singleParty.length > 0) {
1413
+ // Mixed protocol: some roots are multi-party, others single-party.
1414
+ // We cannot safely model this with either key type alone.
1415
+ throw new Error(
1416
+ `Encrypted delegate access for protocols with mixed single-party ` +
1417
+ `and multi-party roots is not supported yet. ` +
1418
+ `Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
1419
+ `[${multiParty.join(', ')}] and single-party roots ` +
1420
+ `[${singleParty.join(', ')}].`,
1421
+ );
1422
+ }
1423
+
1424
+ if (multiParty.length > 0) {
1425
+ // Pure multi-party: derive per-context keys for existing contexts.
1426
+ // Unsupported scope shapes (protocolPath, contextId) throw.
1427
+ const ctxKeys = await deriveContextKeysForDelegate(
1428
+ agent, selectedDid, protocolDefinition, permissionScopes,
1429
+ );
1430
+ delegateContextKeys.push(...ctxKeys);
1431
+
1432
+ // Only register the protocol for post-connect delivery if the
1433
+ // delegate has at least one read-like scope. Write-only delegates
1434
+ // must NOT receive context keys — they have no decryption need.
1435
+ const readMethods = new Set([
1436
+ DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
1437
+ ]);
1438
+ const hasReadLikeScope = permissionScopes.some(
1439
+ (s): boolean => isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1440
+ );
1441
+ if (hasReadLikeScope) {
1442
+ delegateMultiPartyProtocols.push(protocolDefinition.protocol);
1443
+ }
1444
+ } else {
1445
+ // Pure single-party: derive ProtocolPath keys.
1446
+ // Unsupported scope shapes (contextId) throw.
1447
+ const keys = await deriveScopedDecryptionKeys(
1448
+ agent, selectedDid, protocolDefinition.protocol,
1449
+ permissionScopes, protocolDefinition,
1450
+ );
1451
+ delegateDecryptionKeys.push(...keys);
1452
+ }
1453
+ }
1454
+
1455
+ return EnboxConnectProtocol.createPermissionGrants(
1456
+ selectedDid,
1457
+ delegateBearerDid,
1458
+ agent,
1459
+ permissionScopes,
1460
+ delegateKeyDeliveryData,
1461
+ );
1462
+ }
1463
+ );
1227
1464
 
1228
- const delegateGrantPromises = connectRequest.permissionRequests.map(
1229
- async (permissionRequest) => {
1230
- const { protocolDefinition, permissionScopes } = permissionRequest;
1465
+ return (await Promise.all(delegateGrantPromises)).flat();
1466
+ },
1467
+ );
1231
1468
 
1232
- const grantsMatchProtocolUri = permissionScopes.every(
1233
- scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol
1234
- );
1235
- if (!grantsMatchProtocolUri) {
1236
- throw new Error('All permission scopes must match the protocol URI they are provided with.');
1237
- }
1469
+ // Create per-grant contextId-scoped revocation grants.
1470
+ // Each revocation grant authorizes the delegate to write a revocation
1471
+ // ONLY for the specific session grant it corresponds to.
1472
+ const permissionsApi = new AgentPermissionsApi({ agent });
1473
+ const sessionRevocations: { grantId: string; revocationGrantId: string }[] = [];
1474
+ let revGrantEndpoints: string[] = [];
1475
+ try {
1476
+ revGrantEndpoints = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
1477
+ } catch {
1478
+ // Endpoint resolution failure — revocation grants will be local-only until sync.
1479
+ }
1238
1480
 
1239
- await prepareProtocol(selectedDid, agent, protocolDefinition);
1481
+ // Snapshot the current length — revocation grants are appended to delegateGrants
1482
+ // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1483
+ sessionGrantCount = delegateGrants.length;
1484
+
1485
+ // Phase 1: create all revocation grants locally with bounded concurrency.
1486
+ // createGrant is local-only (storage + signing) so it's cheap, but we still
1487
+ // cap parallelism to avoid head-of-line blocking when sessionGrantCount is
1488
+ // large (e.g. dapp requesting many scopes at once).
1489
+ const revGrantResults = await timed(
1490
+ `${CONNECT_PERF_LOG_PREFIX} revocationGrants.create (n=${sessionGrantCount})`,
1491
+ () => mapConcurrent(
1492
+ delegateGrants.slice(0, sessionGrantCount),
1493
+ CONNECT_FANOUT_CONCURRENCY,
1494
+ (grantMessage) =>
1495
+ permissionsApi.createGrant({
1496
+ delegated : true,
1497
+ store : true,
1498
+ grantedTo : delegateBearerDid.uri,
1499
+ scope : {
1500
+ interface : DwnInterfaceName.Records,
1501
+ method : DwnMethodName.Write,
1502
+ protocol : PermissionsProtocol.uri,
1503
+ contextId : grantMessage.recordId,
1504
+ },
1505
+ dateExpires : '2040-06-25T16:09:16.693356Z',
1506
+ author : selectedDid,
1507
+ }).then((revGrant) => ({ grantMessage, revGrant })),
1508
+ ),
1509
+ );
1240
1510
 
1241
- const hasEncryptedTypes = Object.values(protocolDefinition.types ?? {})
1242
- .some((type: any) => type?.encryptionRequired === true);
1511
+ // Phase 2: fan out every revocation grant to every owner DWN endpoint with
1512
+ // a single global concurrency cap so that (grants × endpoints) cannot blow
1513
+ // up. This is best-effort (sync delivers eventually) so individual failures
1514
+ // are tolerated by `mapConcurrentSettled`.
1515
+ const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
1516
+ sessionRevocations.push({
1517
+ grantId : grantMessage.recordId,
1518
+ revocationGrantId : revGrant.message.recordId,
1519
+ });
1243
1520
 
1244
- if (hasEncryptedTypes) {
1245
- const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
1521
+ const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
1522
+ const revData = Uint8Array.from(Convert.base64Url(revEncoded).toUint8Array());
1246
1523
 
1247
- if (multiParty.length > 0 && singleParty.length > 0) {
1248
- // Mixed protocol: some roots are multi-party, others single-party.
1249
- // We cannot safely model this with either key type alone.
1250
- throw new Error(
1251
- `Encrypted delegate access for protocols with mixed single-party ` +
1252
- `and multi-party roots is not supported yet. ` +
1253
- `Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
1254
- `[${multiParty.join(', ')}] and single-party roots ` +
1255
- `[${singleParty.join(', ')}].`,
1256
- );
1257
- }
1524
+ // Include the revocation grant in the delegate grants for distribution.
1525
+ delegateGrants.push(revGrant.message);
1258
1526
 
1259
- if (multiParty.length > 0) {
1260
- // Pure multi-party: derive per-context keys for existing contexts.
1261
- // Unsupported scope shapes (protocolPath, contextId) throw.
1262
- const ctxKeys = await deriveContextKeysForDelegate(
1263
- agent, selectedDid, protocolDefinition, permissionScopes,
1264
- );
1265
- delegateContextKeys.push(...ctxKeys);
1266
-
1267
- // Only register the protocol for post-connect delivery if the
1268
- // delegate has at least one read-like scope. Write-only delegates
1269
- // must NOT receive context keys — they have no decryption need.
1270
- const readMethods = new Set([
1271
- DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
1272
- ]);
1273
- const hasReadLikeScope = permissionScopes.some(
1274
- (s): boolean => isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1275
- );
1276
- if (hasReadLikeScope) {
1277
- delegateMultiPartyProtocols.push(protocolDefinition.protocol);
1278
- }
1279
- } else {
1280
- // Pure single-party: derive ProtocolPath keys.
1281
- // Unsupported scope shapes (contextId) throw.
1282
- const keys = await deriveScopedDecryptionKeys(
1283
- agent, selectedDid, protocolDefinition.protocol,
1284
- permissionScopes, protocolDefinition,
1285
- );
1286
- delegateDecryptionKeys.push(...keys);
1287
- }
1288
- }
1527
+ return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
1528
+ });
1289
1529
 
1290
- return EnboxConnectProtocol.createPermissionGrants(
1291
- selectedDid,
1292
- delegateBearerDid,
1293
- agent,
1294
- permissionScopes,
1295
- delegateKeyDeliveryData,
1530
+ if (revSendTasks.length > 0) {
1531
+ await timed(
1532
+ `${CONNECT_PERF_LOG_PREFIX} revocationGrants.fanout (sends=${revSendTasks.length}, endpoints=${revGrantEndpoints.length})`,
1533
+ () => mapConcurrentSettled(
1534
+ revSendTasks,
1535
+ CONNECT_FANOUT_CONCURRENCY,
1536
+ ({ revRawMessage, revData, dwnUrl }) =>
1537
+ agent.rpc.sendDwnRequest({
1538
+ dwnUrl,
1539
+ targetDid : selectedDid,
1540
+ message : revRawMessage,
1541
+ data : new Blob([revData]),
1542
+ signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
1543
+ }),
1544
+ ),
1296
1545
  );
1297
1546
  }
1298
- );
1299
-
1300
- const delegateGrants = (await Promise.all(delegateGrantPromises)).flat();
1301
1547
 
1302
- // Create per-grant contextId-scoped revocation grants.
1303
- // Each revocation grant authorizes the delegate to write a revocation
1304
- // ONLY for the specific session grant it corresponds to.
1305
- const permissionsApi = new AgentPermissionsApi({ agent });
1306
- const sessionRevocations: { grantId: string; revocationGrantId: string }[] = [];
1307
- let revGrantEndpoints: string[] = [];
1308
- try {
1309
- revGrantEndpoints = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
1310
- } catch {
1311
- // Endpoint resolution failure revocation grants will be local-only until sync.
1312
- }
1313
-
1314
- // Snapshot the current length revocation grants are appended to delegateGrants
1315
- // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1316
- const sessionGrantCount = delegateGrants.length;
1548
+ const responseObject = await timed(
1549
+ `${CONNECT_PERF_LOG_PREFIX} response.build`,
1550
+ () => EnboxConnectProtocol.createConnectResponse({
1551
+ providerDid : selectedDid,
1552
+ delegateDid : delegateBearerDid.uri,
1553
+ aud : connectRequest.clientDid,
1554
+ nonce : connectRequest.nonce,
1555
+ delegateGrants,
1556
+ delegatePortableDid,
1557
+ delegateDecryptionKeys : delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
1558
+ delegateContextKeys : delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
1559
+ delegateMultiPartyProtocols : delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
1560
+ sessionRevocations : sessionRevocations.length > 0 ? sessionRevocations : undefined,
1561
+ }),
1562
+ );
1317
1563
 
1318
- // Phase 1: create all revocation grants locally with bounded concurrency.
1319
- // createGrant is local-only (storage + signing) so it's cheap, but we still
1320
- // cap parallelism to avoid head-of-line blocking when sessionGrantCount is
1321
- // large (e.g. dapp requesting many scopes at once).
1322
- const revGrantResults = await mapConcurrent(
1323
- delegateGrants.slice(0, sessionGrantCount),
1324
- CONNECT_FANOUT_CONCURRENCY,
1325
- (grantMessage) =>
1326
- permissionsApi.createGrant({
1327
- delegated : true,
1328
- store : true,
1329
- grantedTo : delegateBearerDid.uri,
1330
- scope : {
1331
- interface : DwnInterfaceName.Records,
1332
- method : DwnMethodName.Write,
1333
- protocol : PermissionsProtocol.uri,
1334
- contextId : grantMessage.recordId,
1335
- },
1336
- dateExpires : '2040-06-25T16:09:16.693356Z',
1337
- author : selectedDid,
1338
- }).then((revGrant) => ({ grantMessage, revGrant })),
1339
- );
1564
+ const responseObjectJwt = await timed(
1565
+ `${CONNECT_PERF_LOG_PREFIX} response.sign`,
1566
+ () => EnboxConnectProtocol.signJwt({
1567
+ did : delegateBearerDid,
1568
+ data : responseObject,
1569
+ }),
1570
+ );
1340
1571
 
1341
- // Phase 2: fan out every revocation grant to every owner DWN endpoint with
1342
- // a single global concurrency cap so that (grants × endpoints) cannot blow
1343
- // up. This is best-effort (sync delivers eventually) so individual failures
1344
- // are tolerated by `mapConcurrentSettled`.
1345
- const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
1346
- sessionRevocations.push({
1347
- grantId : grantMessage.recordId,
1348
- revocationGrantId : revGrant.message.recordId,
1349
- });
1572
+ const clientDid = await timed(
1573
+ `${CONNECT_PERF_LOG_PREFIX} clientDid.resolve`,
1574
+ () => DidJwk.resolve(connectRequest.clientDid),
1575
+ );
1350
1576
 
1351
- const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
1352
- const revData = Convert.base64Url(revEncoded).toUint8Array();
1577
+ const sharedKey = await timed(
1578
+ `${CONNECT_PERF_LOG_PREFIX} response.deriveSharedKey`,
1579
+ () => EnboxConnectProtocol.deriveSharedKey(
1580
+ delegateBearerDid,
1581
+ clientDid?.didDocument!,
1582
+ ),
1583
+ );
1353
1584
 
1354
- // Include the revocation grant in the delegate grants for distribution.
1355
- delegateGrants.push(revGrant.message);
1585
+ const encryptedResponse = await timed(
1586
+ `${CONNECT_PERF_LOG_PREFIX} response.encrypt`,
1587
+ () => EnboxConnectProtocol.encryptResponse({
1588
+ jwt : responseObjectJwt,
1589
+ encryptionKey : sharedKey,
1590
+ delegatePublicKeyJwk : delegateBearerDid.document.verificationMethod![0].publicKeyJwk!,
1591
+ pin,
1592
+ }),
1593
+ );
1356
1594
 
1357
- return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
1358
- });
1595
+ const formEncodedRequest = new URLSearchParams({
1596
+ id_token : encryptedResponse,
1597
+ state : connectRequest.state,
1598
+ }).toString();
1599
+
1600
+ await timed(
1601
+ `${CONNECT_PERF_LOG_PREFIX} response.callbackPost`,
1602
+ async () => {
1603
+ const response = await fetch(connectRequest.callbackUrl, {
1604
+ body : formEncodedRequest,
1605
+ method : 'POST',
1606
+ headers : {
1607
+ 'Content-Type': 'application/x-www-form-urlencoded',
1608
+ },
1609
+ signal: AbortSignal.timeout(30_000),
1610
+ });
1611
+
1612
+ if (!response.ok) {
1613
+ // NOTE: delegate grants have already been written/fanned out by this
1614
+ // point, so callers may observe partial owner-side state when the
1615
+ // callback server rejects the final response delivery.
1616
+ throw new Error(`Connect: callback POST failed with HTTP ${response.status}.`);
1617
+ }
1359
1618
 
1360
- if (revSendTasks.length > 0) {
1361
- await mapConcurrentSettled(
1362
- revSendTasks,
1363
- CONNECT_FANOUT_CONCURRENCY,
1364
- ({ revRawMessage, revData, dwnUrl }) =>
1365
- agent.rpc.sendDwnRequest({
1366
- dwnUrl,
1367
- targetDid : selectedDid,
1368
- message : revRawMessage,
1369
- data : new Blob([revData as BlobPart]),
1370
- signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
1371
- }),
1619
+ return response;
1620
+ },
1621
+ );
1622
+ } catch (err) {
1623
+ outcome = 'fail';
1624
+ throw err;
1625
+ } finally {
1626
+ const totalElapsed = nowMs() - submitStart;
1627
+ logger.log(
1628
+ `${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.total ${outcome} in ${totalElapsed.toFixed(1)}ms `
1629
+ + `(protocols=${numProtocols}, scopes=${numScopes}, sessionGrants=${sessionGrantCount})`,
1372
1630
  );
1373
1631
  }
1374
-
1375
- logger.log('Building connect response...');
1376
- const responseObject = await EnboxConnectProtocol.createConnectResponse({
1377
- providerDid : selectedDid,
1378
- delegateDid : delegateBearerDid.uri,
1379
- aud : connectRequest.clientDid,
1380
- nonce : connectRequest.nonce,
1381
- delegateGrants,
1382
- delegatePortableDid,
1383
- delegateDecryptionKeys : delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
1384
- delegateContextKeys : delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
1385
- delegateMultiPartyProtocols : delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
1386
- sessionRevocations : sessionRevocations.length > 0 ? sessionRevocations : undefined,
1387
- });
1388
-
1389
- logger.log('Signing connect response...');
1390
- const responseObjectJwt = await EnboxConnectProtocol.signJwt({
1391
- did : delegateBearerDid,
1392
- data : responseObject as unknown as Record<string, unknown>,
1393
- });
1394
-
1395
- const clientDid = await DidJwk.resolve(connectRequest.clientDid);
1396
-
1397
- const sharedKey = await EnboxConnectProtocol.deriveSharedKey(
1398
- delegateBearerDid,
1399
- clientDid?.didDocument!
1400
- );
1401
-
1402
- logger.log('Encrypting connect response...');
1403
- const encryptedResponse = await EnboxConnectProtocol.encryptResponse({
1404
- jwt : responseObjectJwt,
1405
- encryptionKey : sharedKey,
1406
- delegatePublicKeyJwk : delegateBearerDid.document.verificationMethod![0].publicKeyJwk!,
1407
- pin,
1408
- });
1409
-
1410
- const formEncodedRequest = new URLSearchParams({
1411
- id_token : encryptedResponse,
1412
- state : connectRequest.state,
1413
- }).toString();
1414
-
1415
- logger.log(`Sending connect response to: ${connectRequest.callbackUrl}`);
1416
- await fetch(connectRequest.callbackUrl, {
1417
- body : formEncodedRequest,
1418
- method : 'POST',
1419
- headers : {
1420
- 'Content-Type': 'application/x-www-form-urlencoded',
1421
- },
1422
- signal: AbortSignal.timeout(30_000),
1423
- });
1424
1632
  }
1425
1633
 
1426
1634
  // ---------------------------------------------------------------------------
@@ -1431,6 +1639,8 @@ export const EnboxConnectProtocol = {
1431
1639
  buildConnectUrl,
1432
1640
  signJwt,
1433
1641
  verifyJwt,
1642
+ assertConnectRequest,
1643
+ assertConnectResponse,
1434
1644
  encryptRequest,
1435
1645
  decryptRequest,
1436
1646
  encryptResponse,