@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
@@ -33,7 +33,7 @@ var __rest = (this && this.__rest) || function (s, e) {
33
33
  return t;
34
34
  };
35
35
  import { DidJwk } from '@enbox/dids';
36
- import { Convert, logger } from '@enbox/common';
36
+ import { concatenateUrl, Convert, logger, nowMs, timed } from '@enbox/common';
37
37
  import { CryptoUtils, Ed25519, EdDsaAlgorithm, Hkdf, X25519, XChaCha20Poly1305, } from '@enbox/crypto';
38
38
  import { DwnInterfaceName, DwnMethodName, HdKey, KeyDerivationScheme, PermissionsProtocol } from '@enbox/dwn-sdk-js';
39
39
  import { AgentPermissionsApi } from './permissions-api.js';
@@ -42,7 +42,7 @@ import { getEncryptionKeyInfo } from './dwn-encryption.js';
42
42
  import { isMultiPartyContext } from './protocol-utils.js';
43
43
  import { isRecordPermissionScope } from './dwn-api.js';
44
44
  import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
45
- import { concatenateUrl, mapConcurrent, mapConcurrentSettled } from './utils.js';
45
+ import { mapConcurrent, mapConcurrentSettled } from './utils.js';
46
46
  // ---------------------------------------------------------------------------
47
47
  // Tunables
48
48
  // ---------------------------------------------------------------------------
@@ -69,6 +69,8 @@ const CONNECT_FANOUT_CONCURRENCY = 8;
69
69
  * the connect-flow fan-outs are best-effort and tolerate per-task failure.
70
70
  */
71
71
  const CONNECT_REQUEST_TIMEOUT_MS = 10000;
72
+ /** Log namespace used for wallet-side connect critical-path timings. */
73
+ const CONNECT_PERF_LOG_PREFIX = '[connect.perf]';
72
74
  // ---------------------------------------------------------------------------
73
75
  // URL building
74
76
  // ---------------------------------------------------------------------------
@@ -103,7 +105,14 @@ function buildConnectUrl({ baseURL, endpoint, authParam, tokenParam, }) {
103
105
  // ---------------------------------------------------------------------------
104
106
  // JWT signing and verification
105
107
  // ---------------------------------------------------------------------------
106
- /** Signs an object as a JWT using an Ed25519 DID key. */
108
+ /**
109
+ * Signs an object as a JWT using an Ed25519 DID key.
110
+ *
111
+ * `data` is constrained to `object` so callers don't have to widen
112
+ * typed payload shapes (e.g. `EnboxConnectResponse`) to
113
+ * `Record<string, unknown>` at the call site. `Convert.object(data)`
114
+ * stringifies whatever JSON-serializable shape is passed.
115
+ */
107
116
  function signJwt(_a) {
108
117
  return __awaiter(this, arguments, void 0, function* ({ did, data, }) {
109
118
  const header = Convert.object({
@@ -120,7 +129,17 @@ function signJwt(_a) {
120
129
  return `${header}.${payload}.${signatureBase64Url}`;
121
130
  });
122
131
  }
123
- /** Verifies a JWT signature using the DID in the `kid` header. Returns the parsed payload. */
132
+ /**
133
+ * Verifies a JWT signature using the DID in the `kid` header. Returns the
134
+ * parsed payload as an untyped object.
135
+ *
136
+ * The return type is intentionally `Record<string, unknown>` rather than a
137
+ * caller-supplied generic — a JWT payload is bytes from a remote party, and
138
+ * we can't soundly assert its shape without runtime validation. Callers must
139
+ * apply one of the {@link assertConnectRequest} / {@link assertConnectResponse}
140
+ * assertion helpers (or their own type guard) to narrow the payload before
141
+ * accessing fields.
142
+ */
124
143
  function verifyJwt(_a) {
125
144
  return __awaiter(this, arguments, void 0, function* ({ jwt }) {
126
145
  var _b, _c;
@@ -148,9 +167,124 @@ function verifyJwt(_a) {
148
167
  if (!isValid) {
149
168
  throw new Error('Connect: JWT verification failed — invalid signature.');
150
169
  }
151
- return Convert.base64Url(payloadB64U).toObject();
170
+ const decoded = Convert.base64Url(payloadB64U).toObject();
171
+ if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) {
172
+ throw new Error('Connect: JWT verification failed — payload must be a JSON object.');
173
+ }
174
+ return decoded;
152
175
  });
153
176
  }
177
+ // ─── Field-level validation helpers ─────────────────────────────────────
178
+ //
179
+ // The connect-request / connect-response assertions below describe their
180
+ // expected shape declaratively in terms of these primitive checks. Each
181
+ // helper throws a consistent `Connect: <context> — \`<field>\` <reason>`
182
+ // message on mismatch, so per-field error formatting is centralized here
183
+ // rather than duplicated at every call site.
184
+ /**
185
+ * Throws a shape-validation error during connect JWT assertion.
186
+ *
187
+ * Deliberately throws plain `Error` rather than `TypeError` even though
188
+ * the failures are runtime type-shape mismatches. The reason is layered
189
+ * error handling: boundary-validation failures need to propagate through
190
+ * the same `try/catch` paths as every other connect-flow error — vault
191
+ * lock failure, JWT signature failure, DWN request failure, etc. —
192
+ * without `catch` blocks having to special-case a `TypeError` subclass.
193
+ *
194
+ * SonarCloud's `typescript:S7786` flags this as "too unspecific for a
195
+ * type check" and prefers `TypeError`. We suppress it at the file level
196
+ * (see `sonar-project.properties`) because every `require*` helper goes
197
+ * through this single throw site.
198
+ */
199
+ function fail(context, field, reason) {
200
+ throw new Error(`Connect: ${context} — \`${field}\` ${reason}.`);
201
+ }
202
+ function requireString(payload, field, context) {
203
+ if (typeof payload[field] !== 'string') {
204
+ fail(context, field, 'must be a string');
205
+ }
206
+ }
207
+ function requireNumber(payload, field, context) {
208
+ if (typeof payload[field] !== 'number') {
209
+ fail(context, field, 'must be a number');
210
+ }
211
+ }
212
+ function requireArray(payload, field, context) {
213
+ if (!Array.isArray(payload[field])) {
214
+ fail(context, field, 'must be an array');
215
+ }
216
+ }
217
+ function requireObject(payload, field, context) {
218
+ const value = payload[field];
219
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
220
+ fail(context, field, 'must be an object');
221
+ }
222
+ }
223
+ function requireLiteral(payload, field, expected, context) {
224
+ if (payload[field] !== expected) {
225
+ fail(context, field, `must be ${JSON.stringify(expected)}`);
226
+ }
227
+ }
228
+ function requireStringArray(payload, field, context) {
229
+ const value = payload[field];
230
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
231
+ fail(context, field, 'must be a string[]');
232
+ }
233
+ }
234
+ function requireOptionalString(payload, field, context) {
235
+ if (payload[field] !== undefined && typeof payload[field] !== 'string') {
236
+ fail(context, field, 'must be a string when present');
237
+ }
238
+ }
239
+ function requireOptionalArray(payload, field, context) {
240
+ if (payload[field] !== undefined && !Array.isArray(payload[field])) {
241
+ fail(context, field, 'must be an array when present');
242
+ }
243
+ }
244
+ // ─── Boundary assertions ────────────────────────────────────────────────
245
+ //
246
+ // Each `assertConnect*` describes the shape of its target type as a list
247
+ // of per-field requirements. The structural existence/primitive checks
248
+ // are the only validation done at the JWT-payload boundary; deeper
249
+ // validation of nested arrays/objects (permission scopes, grants,
250
+ // portable DID structure) happens downstream in DWN-aware validators
251
+ // where the richer logic already lives.
252
+ /**
253
+ * Runtime assertion that a verified JWT payload has the shape of an
254
+ * {@link EnboxConnectRequest}. Use immediately after `verifyJwt()` to narrow
255
+ * a `Record<string, unknown>` payload before accessing fields.
256
+ */
257
+ function assertConnectRequest(payload) {
258
+ const ctx = 'invalid connect request';
259
+ requireString(payload, 'clientDid', ctx);
260
+ requireString(payload, 'appName', ctx);
261
+ requireArray(payload, 'permissionRequests', ctx);
262
+ requireString(payload, 'nonce', ctx);
263
+ requireString(payload, 'state', ctx);
264
+ requireString(payload, 'callbackUrl', ctx);
265
+ requireLiteral(payload, 'responseMode', 'direct_post', ctx);
266
+ requireStringArray(payload, 'supportedDidMethods', ctx);
267
+ }
268
+ /**
269
+ * Runtime assertion that a verified JWT payload has the shape of an
270
+ * {@link EnboxConnectResponse}. Use immediately after `verifyJwt()` to narrow
271
+ * a `Record<string, unknown>` payload before accessing fields.
272
+ */
273
+ function assertConnectResponse(payload) {
274
+ const ctx = 'invalid connect response';
275
+ requireString(payload, 'providerDid', ctx);
276
+ requireString(payload, 'delegateDid', ctx);
277
+ requireString(payload, 'aud', ctx);
278
+ requireNumber(payload, 'iat', ctx);
279
+ requireNumber(payload, 'exp', ctx);
280
+ requireOptionalString(payload, 'nonce', ctx);
281
+ requireArray(payload, 'delegateGrants', ctx);
282
+ requireObject(payload, 'delegatePortableDid', ctx);
283
+ requireOptionalArray(payload, 'delegateDecryptionKeys', ctx);
284
+ requireOptionalArray(payload, 'delegateContextKeys', ctx);
285
+ requireOptionalArray(payload, 'delegateMultiPartyProtocols', ctx);
286
+ requireOptionalArray(payload, 'sessionRevocations', ctx);
287
+ }
154
288
  // ---------------------------------------------------------------------------
155
289
  // Encryption: request (symmetric key via QR/deep link)
156
290
  // ---------------------------------------------------------------------------
@@ -323,7 +457,9 @@ function getConnectRequest(requestUri, encryptionKey) {
323
457
  const response = yield fetch(requestUri, { signal: AbortSignal.timeout(30000) });
324
458
  const jwe = yield response.text();
325
459
  const jwt = yield decryptRequest({ jwe, encryptionKey });
326
- return (yield verifyJwt({ jwt }));
460
+ const payload = yield verifyJwt({ jwt });
461
+ assertConnectRequest(payload);
462
+ return payload;
327
463
  });
328
464
  }
329
465
  // ---------------------------------------------------------------------------
@@ -538,14 +674,16 @@ function deriveScopedDecryptionKeys(agent, ownerDid, protocolUri, scopes, protoc
538
674
  const readMethods = new Set([
539
675
  DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
540
676
  ]);
541
- // Collect read-like scopes only.
677
+ // Collect read-like scopes only. `isRecordPermissionScope` narrows to
678
+ // `DwnRecordsPermissionScope`, which declares `protocolPath?: string`
679
+ // and `contextId?: string` — no `as any` needed for those reads below.
542
680
  const readScopes = scopes.filter((s) => isRecordPermissionScope(s) && readMethods.has(s.method));
543
681
  if (readScopes.length === 0) {
544
682
  return []; // write/delete only → no decryption keys
545
683
  }
546
684
  // Fail closed: reject contextId-scoped encrypted reads.
547
685
  for (const scope of readScopes) {
548
- if ('contextId' in scope && scope.contextId) {
686
+ if (scope.contextId) {
549
687
  throw new Error(`Encrypted delegate access scoped by contextId is not supported ` +
550
688
  `yet; use protocol-wide permissions for protocol '${protocolUri}'.`);
551
689
  }
@@ -560,7 +698,7 @@ function deriveScopedDecryptionKeys(agent, ownerDid, protocolUri, scopes, protoc
560
698
  `for multi-party protocols.`);
561
699
  }
562
700
  // Check if any scope is protocol-wide (no protocolPath).
563
- const hasProtocolWideRead = readScopes.some((s) => !('protocolPath' in s) || !s.protocolPath);
701
+ const hasProtocolWideRead = readScopes.some((s) => !s.protocolPath);
564
702
  const { keyId, keyUri } = yield getEncryptionKeyInfo(agent, ownerDid);
565
703
  // If any unrestricted read scope exists, emit one protocol-wide key
566
704
  // and skip narrower keys (the protocol-wide key subsumes them).
@@ -585,9 +723,8 @@ function deriveScopedDecryptionKeys(agent, ownerDid, protocolUri, scopes, protoc
585
723
  // Emit one exact-path key per unique protocolPath.
586
724
  const uniquePaths = new Set();
587
725
  for (const scope of readScopes) {
588
- const pp = scope.protocolPath;
589
- if (pp) {
590
- uniquePaths.add(pp);
726
+ if (scope.protocolPath) {
727
+ uniquePaths.add(scope.protocolPath);
591
728
  }
592
729
  }
593
730
  const keys = [];
@@ -666,17 +803,20 @@ function classifyProtocolRoots(definition) {
666
803
  */
667
804
  function deriveContextKeysForDelegate(agent, ownerDid, protocolDefinition, scopes) {
668
805
  return __awaiter(this, void 0, void 0, function* () {
669
- var _a, _b, _c;
806
+ var _a, _b, _c, _d;
670
807
  const readMethods = new Set([
671
808
  DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
672
809
  ]);
810
+ // `isRecordPermissionScope` narrows to `DwnRecordsPermissionScope`,
811
+ // which declares `protocolPath?: string` and `contextId?: string` —
812
+ // no `as any` needed for the field reads below.
673
813
  const readScopes = scopes.filter((s) => isRecordPermissionScope(s) && readMethods.has(s.method));
674
814
  if (readScopes.length === 0) {
675
815
  return []; // write-only → no context keys
676
816
  }
677
817
  // Fail closed: reject contextId-scoped reads.
678
818
  for (const scope of readScopes) {
679
- if ('contextId' in scope && scope.contextId) {
819
+ if (scope.contextId) {
680
820
  throw new Error(`Encrypted delegate access scoped by contextId is not supported ` +
681
821
  `yet; use protocol-wide permissions for protocol ` +
682
822
  `'${protocolDefinition.protocol}'.`);
@@ -684,7 +824,7 @@ function deriveContextKeysForDelegate(agent, ownerDid, protocolDefinition, scope
684
824
  }
685
825
  // Fail closed: reject protocolPath-scoped reads on multi-party protocols.
686
826
  for (const scope of readScopes) {
687
- if ('protocolPath' in scope && scope.protocolPath) {
827
+ if (scope.protocolPath) {
688
828
  throw new Error(`Encrypted delegate access scoped by protocolPath on multi-party ` +
689
829
  `protocols is not supported yet; use protocol-wide permissions for ` +
690
830
  `protocol '${protocolDefinition.protocol}'.`);
@@ -710,8 +850,7 @@ function deriveContextKeysForDelegate(agent, ownerDid, protocolDefinition, scope
710
850
  },
711
851
  });
712
852
  for (const entry of (_b = reply.entries) !== null && _b !== void 0 ? _b : []) {
713
- const rootContextId = ((_c = entry.contextId) === null || _c === void 0 ? void 0 : _c.split('/')[0])
714
- || entry.recordId;
853
+ const rootContextId = (_d = (_c = entry.contextId) === null || _c === void 0 ? void 0 : _c.split('/')[0]) !== null && _d !== void 0 ? _d : entry.recordId;
715
854
  if (!rootContextId || seenContextIds.has(rootContextId)) {
716
855
  continue;
717
856
  }
@@ -756,201 +895,229 @@ function deriveContextKeysForDelegate(agent, ownerDid, protocolDefinition, scope
756
895
  */
757
896
  function submitConnectResponse(selectedDid, connectRequest, pin, agent) {
758
897
  return __awaiter(this, void 0, void 0, function* () {
759
- const delegateBearerDid = yield DidJwk.create();
760
- const delegatePortableDid = yield delegateBearerDid.export();
761
- // Add X25519 key derived from the delegate's Ed25519 key.
762
- // did:jwk only supports one verification method, but DWN encryption
763
- // requires X25519 for key agreement. Including the derived X25519
764
- // private key in the PortableDid ensures the delegate agent's KMS
765
- // has both keys after import. The Ed25519→X25519 conversion is a
766
- // standard cryptographic operation (RFC 8032 / libsodium).
767
- const delegateEdPrivateKey = delegatePortableDid.privateKeys[0];
768
- const delegateX25519PrivateKey = yield Ed25519.convertPrivateKeyToX25519({
769
- privateKey: delegateEdPrivateKey,
770
- });
771
- delegatePortableDid.privateKeys.push(delegateX25519PrivateKey);
772
- // Derive the delegate's key-delivery ProtocolPath leaf public key.
773
- // This is the pre-derived key that the owner will use later when writing
774
- // contextKey records addressed to this delegate. The owner cannot derive
775
- // this from the delegate's root public key alone (HKDF needs the private
776
- // key), so we compute it now while we have temporary access to the
777
- // delegate's private key material.
778
- const delegateX25519PrivateKeyBytes = yield X25519.privateKeyToBytes({
779
- privateKey: delegateX25519PrivateKey,
780
- });
781
- const keyDeliveryDerivationPath = [
782
- KeyDerivationScheme.ProtocolPath,
783
- KeyDeliveryProtocolDefinition.protocol,
784
- 'contextKey',
785
- ];
786
- const delegateLeafPrivateKeyBytes = yield HdKey.derivePrivateKeyBytes(delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath);
787
- const delegateLeafPrivateKeyJwk = yield X25519.bytesToPrivateKey({
788
- privateKeyBytes: delegateLeafPrivateKeyBytes,
789
- });
790
- const delegateKeyDeliveryLeafPublicKey = yield X25519.getPublicKey({
791
- key: delegateLeafPrivateKeyJwk,
792
- });
793
- // The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
794
- // For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
795
- // this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
796
- // `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
797
- // the same id which they do because both derive from verificationMethod.id
798
- // of the keyAgreement relationship.
799
- const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod[0].id;
800
- const delegateKeyDeliveryData = {
801
- rootKeyId: delegateKeyAgreementVmId,
802
- publicKeyJwk: delegateKeyDeliveryLeafPublicKey,
803
- };
804
- // Derive scope-aware decryption keys for encrypted protocols.
805
- // Single-party: ProtocolPath keys (protocol-wide or exact-path).
806
- // Multi-party: ProtocolContext keys (per rootContextId).
807
- // Write-only delegates receive no decryption capability.
808
- const delegateDecryptionKeys = [];
809
- const delegateContextKeys = [];
810
- const delegateMultiPartyProtocols = [];
811
- const delegateGrantPromises = connectRequest.permissionRequests.map((permissionRequest) => __awaiter(this, void 0, void 0, function* () {
812
- var _a;
813
- const { protocolDefinition, permissionScopes } = permissionRequest;
814
- const grantsMatchProtocolUri = permissionScopes.every(scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol);
815
- if (!grantsMatchProtocolUri) {
816
- throw new Error('All permission scopes must match the protocol URI they are provided with.');
817
- }
818
- yield prepareProtocol(selectedDid, agent, protocolDefinition);
819
- const hasEncryptedTypes = Object.values((_a = protocolDefinition.types) !== null && _a !== void 0 ? _a : {})
820
- .some((type) => (type === null || type === void 0 ? void 0 : type.encryptionRequired) === true);
821
- if (hasEncryptedTypes) {
822
- const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
823
- if (multiParty.length > 0 && singleParty.length > 0) {
824
- // Mixed protocol: some roots are multi-party, others single-party.
825
- // We cannot safely model this with either key type alone.
826
- throw new Error(`Encrypted delegate access for protocols with mixed single-party ` +
827
- `and multi-party roots is not supported yet. ` +
828
- `Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
829
- `[${multiParty.join(', ')}] and single-party roots ` +
830
- `[${singleParty.join(', ')}].`);
831
- }
832
- if (multiParty.length > 0) {
833
- // Pure multi-party: derive per-context keys for existing contexts.
834
- // Unsupported scope shapes (protocolPath, contextId) throw.
835
- const ctxKeys = yield deriveContextKeysForDelegate(agent, selectedDid, protocolDefinition, permissionScopes);
836
- delegateContextKeys.push(...ctxKeys);
837
- // Only register the protocol for post-connect delivery if the
838
- // delegate has at least one read-like scope. Write-only delegates
839
- // must NOT receive context keys — they have no decryption need.
840
- const readMethods = new Set([
841
- DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
842
- ]);
843
- const hasReadLikeScope = permissionScopes.some((s) => isRecordPermissionScope(s) && readMethods.has(s.method));
844
- if (hasReadLikeScope) {
845
- delegateMultiPartyProtocols.push(protocolDefinition.protocol);
898
+ const submitStart = nowMs();
899
+ const numProtocols = connectRequest.permissionRequests.length;
900
+ const numScopes = connectRequest.permissionRequests.reduce((sum, req) => sum + req.permissionScopes.length, 0);
901
+ // Tracked across the try/finally so the aggregate `submitConnectResponse.total`
902
+ // log emits on both success and failure paths — operators bisecting wall-time
903
+ // from wallet debug logs need the total even when a phase throws.
904
+ let sessionGrantCount = 0;
905
+ let outcome = 'ok';
906
+ logger.log(`${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.start `
907
+ + `(protocols=${numProtocols}, scopes=${numScopes})`);
908
+ try {
909
+ const delegateBearerDid = yield timed(`${CONNECT_PERF_LOG_PREFIX} delegateDid.create`, () => DidJwk.create());
910
+ const delegatePortableDid = yield delegateBearerDid.export();
911
+ // Add X25519 key derived from the delegate's Ed25519 key.
912
+ // did:jwk only supports one verification method, but DWN encryption
913
+ // requires X25519 for key agreement. Including the derived X25519
914
+ // private key in the PortableDid ensures the delegate agent's KMS
915
+ // has both keys after import. The Ed25519→X25519 conversion is a
916
+ // standard cryptographic operation (RFC 8032 / libsodium).
917
+ const delegateEdPrivateKey = delegatePortableDid.privateKeys[0];
918
+ const delegateX25519PrivateKey = yield Ed25519.convertPrivateKeyToX25519({
919
+ privateKey: delegateEdPrivateKey,
920
+ });
921
+ delegatePortableDid.privateKeys.push(delegateX25519PrivateKey);
922
+ // Derive the delegate's key-delivery ProtocolPath leaf public key.
923
+ // This is the pre-derived key that the owner will use later when writing
924
+ // contextKey records addressed to this delegate. The owner cannot derive
925
+ // this from the delegate's root public key alone (HKDF needs the private
926
+ // key), so we compute it now while we have temporary access to the
927
+ // delegate's private key material.
928
+ const delegateX25519PrivateKeyBytes = yield X25519.privateKeyToBytes({
929
+ privateKey: delegateX25519PrivateKey,
930
+ });
931
+ const keyDeliveryDerivationPath = [
932
+ KeyDerivationScheme.ProtocolPath,
933
+ KeyDeliveryProtocolDefinition.protocol,
934
+ 'contextKey',
935
+ ];
936
+ const delegateLeafPrivateKeyBytes = yield HdKey.derivePrivateKeyBytes(delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath);
937
+ const delegateLeafPrivateKeyJwk = yield X25519.bytesToPrivateKey({
938
+ privateKeyBytes: delegateLeafPrivateKeyBytes,
939
+ });
940
+ const delegateKeyDeliveryLeafPublicKey = yield X25519.getPublicKey({
941
+ key: delegateLeafPrivateKeyJwk,
942
+ });
943
+ // The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
944
+ // For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
945
+ // this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
946
+ // `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
947
+ // the same id — which they do because both derive from verificationMethod.id
948
+ // of the keyAgreement relationship.
949
+ const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod[0].id;
950
+ const delegateKeyDeliveryData = {
951
+ rootKeyId: delegateKeyAgreementVmId,
952
+ publicKeyJwk: delegateKeyDeliveryLeafPublicKey,
953
+ };
954
+ // Derive scope-aware decryption keys for encrypted protocols.
955
+ // Single-party: ProtocolPath keys (protocol-wide or exact-path).
956
+ // Multi-party: ProtocolContext keys (per rootContextId).
957
+ // Write-only delegates receive no decryption capability.
958
+ const delegateDecryptionKeys = [];
959
+ const delegateContextKeys = [];
960
+ const delegateMultiPartyProtocols = [];
961
+ const delegateGrants = yield timed(`${CONNECT_PERF_LOG_PREFIX} permissionGrants.fanout (protocols=${numProtocols})`, () => __awaiter(this, void 0, void 0, function* () {
962
+ const delegateGrantPromises = connectRequest.permissionRequests.map((permissionRequest) => __awaiter(this, void 0, void 0, function* () {
963
+ var _a;
964
+ const { protocolDefinition, permissionScopes } = permissionRequest;
965
+ const grantsMatchProtocolUri = permissionScopes.every(scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol);
966
+ if (!grantsMatchProtocolUri) {
967
+ throw new Error('All permission scopes must match the protocol URI they are provided with.');
846
968
  }
847
- }
848
- else {
849
- // Pure single-party: derive ProtocolPath keys.
850
- // Unsupported scope shapes (contextId) throw.
851
- const keys = yield deriveScopedDecryptionKeys(agent, selectedDid, protocolDefinition.protocol, permissionScopes, protocolDefinition);
852
- delegateDecryptionKeys.push(...keys);
853
- }
969
+ yield prepareProtocol(selectedDid, agent, protocolDefinition);
970
+ const hasEncryptedTypes = Object.values((_a = protocolDefinition.types) !== null && _a !== void 0 ? _a : {})
971
+ .some((type) => (type === null || type === void 0 ? void 0 : type.encryptionRequired) === true);
972
+ if (hasEncryptedTypes) {
973
+ const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
974
+ if (multiParty.length > 0 && singleParty.length > 0) {
975
+ // Mixed protocol: some roots are multi-party, others single-party.
976
+ // We cannot safely model this with either key type alone.
977
+ throw new Error(`Encrypted delegate access for protocols with mixed single-party ` +
978
+ `and multi-party roots is not supported yet. ` +
979
+ `Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
980
+ `[${multiParty.join(', ')}] and single-party roots ` +
981
+ `[${singleParty.join(', ')}].`);
982
+ }
983
+ if (multiParty.length > 0) {
984
+ // Pure multi-party: derive per-context keys for existing contexts.
985
+ // Unsupported scope shapes (protocolPath, contextId) throw.
986
+ const ctxKeys = yield deriveContextKeysForDelegate(agent, selectedDid, protocolDefinition, permissionScopes);
987
+ delegateContextKeys.push(...ctxKeys);
988
+ // Only register the protocol for post-connect delivery if the
989
+ // delegate has at least one read-like scope. Write-only delegates
990
+ // must NOT receive context keys — they have no decryption need.
991
+ const readMethods = new Set([
992
+ DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
993
+ ]);
994
+ const hasReadLikeScope = permissionScopes.some((s) => isRecordPermissionScope(s) && readMethods.has(s.method));
995
+ if (hasReadLikeScope) {
996
+ delegateMultiPartyProtocols.push(protocolDefinition.protocol);
997
+ }
998
+ }
999
+ else {
1000
+ // Pure single-party: derive ProtocolPath keys.
1001
+ // Unsupported scope shapes (contextId) throw.
1002
+ const keys = yield deriveScopedDecryptionKeys(agent, selectedDid, protocolDefinition.protocol, permissionScopes, protocolDefinition);
1003
+ delegateDecryptionKeys.push(...keys);
1004
+ }
1005
+ }
1006
+ return EnboxConnectProtocol.createPermissionGrants(selectedDid, delegateBearerDid, agent, permissionScopes, delegateKeyDeliveryData);
1007
+ }));
1008
+ return (yield Promise.all(delegateGrantPromises)).flat();
1009
+ }));
1010
+ // Create per-grant contextId-scoped revocation grants.
1011
+ // Each revocation grant authorizes the delegate to write a revocation
1012
+ // ONLY for the specific session grant it corresponds to.
1013
+ const permissionsApi = new AgentPermissionsApi({ agent });
1014
+ const sessionRevocations = [];
1015
+ let revGrantEndpoints = [];
1016
+ try {
1017
+ revGrantEndpoints = yield agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
854
1018
  }
855
- return EnboxConnectProtocol.createPermissionGrants(selectedDid, delegateBearerDid, agent, permissionScopes, delegateKeyDeliveryData);
856
- }));
857
- const delegateGrants = (yield Promise.all(delegateGrantPromises)).flat();
858
- // Create per-grant contextId-scoped revocation grants.
859
- // Each revocation grant authorizes the delegate to write a revocation
860
- // ONLY for the specific session grant it corresponds to.
861
- const permissionsApi = new AgentPermissionsApi({ agent });
862
- const sessionRevocations = [];
863
- let revGrantEndpoints = [];
864
- try {
865
- revGrantEndpoints = yield agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
866
- }
867
- catch (_a) {
868
- // Endpoint resolution failure — revocation grants will be local-only until sync.
869
- }
870
- // Snapshot the current length — revocation grants are appended to delegateGrants
871
- // below, but we must NOT iterate over them (they are meta-grants, not session grants).
872
- const sessionGrantCount = delegateGrants.length;
873
- // Phase 1: create all revocation grants locally with bounded concurrency.
874
- // createGrant is local-only (storage + signing) so it's cheap, but we still
875
- // cap parallelism to avoid head-of-line blocking when sessionGrantCount is
876
- // large (e.g. dapp requesting many scopes at once).
877
- const revGrantResults = yield mapConcurrent(delegateGrants.slice(0, sessionGrantCount), CONNECT_FANOUT_CONCURRENCY, (grantMessage) => permissionsApi.createGrant({
878
- delegated: true,
879
- store: true,
880
- grantedTo: delegateBearerDid.uri,
881
- scope: {
882
- interface: DwnInterfaceName.Records,
883
- method: DwnMethodName.Write,
884
- protocol: PermissionsProtocol.uri,
885
- contextId: grantMessage.recordId,
886
- },
887
- dateExpires: '2040-06-25T16:09:16.693356Z',
888
- author: selectedDid,
889
- }).then((revGrant) => ({ grantMessage, revGrant })));
890
- // Phase 2: fan out every revocation grant to every owner DWN endpoint with
891
- // a single global concurrency cap so that (grants × endpoints) cannot blow
892
- // up. This is best-effort (sync delivers eventually) so individual failures
893
- // are tolerated by `mapConcurrentSettled`.
894
- const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
895
- sessionRevocations.push({
896
- grantId: grantMessage.recordId,
897
- revocationGrantId: revGrant.message.recordId,
1019
+ catch (_a) {
1020
+ // Endpoint resolution failure — revocation grants will be local-only until sync.
1021
+ }
1022
+ // Snapshot the current length — revocation grants are appended to delegateGrants
1023
+ // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1024
+ sessionGrantCount = delegateGrants.length;
1025
+ // Phase 1: create all revocation grants locally with bounded concurrency.
1026
+ // createGrant is local-only (storage + signing) so it's cheap, but we still
1027
+ // cap parallelism to avoid head-of-line blocking when sessionGrantCount is
1028
+ // large (e.g. dapp requesting many scopes at once).
1029
+ const revGrantResults = yield timed(`${CONNECT_PERF_LOG_PREFIX} revocationGrants.create (n=${sessionGrantCount})`, () => mapConcurrent(delegateGrants.slice(0, sessionGrantCount), CONNECT_FANOUT_CONCURRENCY, (grantMessage) => permissionsApi.createGrant({
1030
+ delegated: true,
1031
+ store: true,
1032
+ grantedTo: delegateBearerDid.uri,
1033
+ scope: {
1034
+ interface: DwnInterfaceName.Records,
1035
+ method: DwnMethodName.Write,
1036
+ protocol: PermissionsProtocol.uri,
1037
+ contextId: grantMessage.recordId,
1038
+ },
1039
+ dateExpires: '2040-06-25T16:09:16.693356Z',
1040
+ author: selectedDid,
1041
+ }).then((revGrant) => ({ grantMessage, revGrant }))));
1042
+ // Phase 2: fan out every revocation grant to every owner DWN endpoint with
1043
+ // a single global concurrency cap so that (grants × endpoints) cannot blow
1044
+ // up. This is best-effort (sync delivers eventually) so individual failures
1045
+ // are tolerated by `mapConcurrentSettled`.
1046
+ const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
1047
+ sessionRevocations.push({
1048
+ grantId: grantMessage.recordId,
1049
+ revocationGrantId: revGrant.message.recordId,
1050
+ });
1051
+ const _a = revGrant.message, { encodedData: revEncoded } = _a, revRawMessage = __rest(_a, ["encodedData"]);
1052
+ const revData = Uint8Array.from(Convert.base64Url(revEncoded).toUint8Array());
1053
+ // Include the revocation grant in the delegate grants for distribution.
1054
+ delegateGrants.push(revGrant.message);
1055
+ return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
898
1056
  });
899
- const _a = revGrant.message, { encodedData: revEncoded } = _a, revRawMessage = __rest(_a, ["encodedData"]);
900
- const revData = Convert.base64Url(revEncoded).toUint8Array();
901
- // Include the revocation grant in the delegate grants for distribution.
902
- delegateGrants.push(revGrant.message);
903
- return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
904
- });
905
- if (revSendTasks.length > 0) {
906
- yield mapConcurrentSettled(revSendTasks, CONNECT_FANOUT_CONCURRENCY, ({ revRawMessage, revData, dwnUrl }) => agent.rpc.sendDwnRequest({
907
- dwnUrl,
908
- targetDid: selectedDid,
909
- message: revRawMessage,
910
- data: new Blob([revData]),
911
- signal: AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
1057
+ if (revSendTasks.length > 0) {
1058
+ yield timed(`${CONNECT_PERF_LOG_PREFIX} revocationGrants.fanout (sends=${revSendTasks.length}, endpoints=${revGrantEndpoints.length})`, () => mapConcurrentSettled(revSendTasks, CONNECT_FANOUT_CONCURRENCY, ({ revRawMessage, revData, dwnUrl }) => agent.rpc.sendDwnRequest({
1059
+ dwnUrl,
1060
+ targetDid: selectedDid,
1061
+ message: revRawMessage,
1062
+ data: new Blob([revData]),
1063
+ signal: AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
1064
+ })));
1065
+ }
1066
+ const responseObject = yield timed(`${CONNECT_PERF_LOG_PREFIX} response.build`, () => EnboxConnectProtocol.createConnectResponse({
1067
+ providerDid: selectedDid,
1068
+ delegateDid: delegateBearerDid.uri,
1069
+ aud: connectRequest.clientDid,
1070
+ nonce: connectRequest.nonce,
1071
+ delegateGrants,
1072
+ delegatePortableDid,
1073
+ delegateDecryptionKeys: delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
1074
+ delegateContextKeys: delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
1075
+ delegateMultiPartyProtocols: delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
1076
+ sessionRevocations: sessionRevocations.length > 0 ? sessionRevocations : undefined,
1077
+ }));
1078
+ const responseObjectJwt = yield timed(`${CONNECT_PERF_LOG_PREFIX} response.sign`, () => EnboxConnectProtocol.signJwt({
1079
+ did: delegateBearerDid,
1080
+ data: responseObject,
1081
+ }));
1082
+ const clientDid = yield timed(`${CONNECT_PERF_LOG_PREFIX} clientDid.resolve`, () => DidJwk.resolve(connectRequest.clientDid));
1083
+ const sharedKey = yield timed(`${CONNECT_PERF_LOG_PREFIX} response.deriveSharedKey`, () => EnboxConnectProtocol.deriveSharedKey(delegateBearerDid, clientDid === null || clientDid === void 0 ? void 0 : clientDid.didDocument));
1084
+ const encryptedResponse = yield timed(`${CONNECT_PERF_LOG_PREFIX} response.encrypt`, () => EnboxConnectProtocol.encryptResponse({
1085
+ jwt: responseObjectJwt,
1086
+ encryptionKey: sharedKey,
1087
+ delegatePublicKeyJwk: delegateBearerDid.document.verificationMethod[0].publicKeyJwk,
1088
+ pin,
1089
+ }));
1090
+ const formEncodedRequest = new URLSearchParams({
1091
+ id_token: encryptedResponse,
1092
+ state: connectRequest.state,
1093
+ }).toString();
1094
+ yield timed(`${CONNECT_PERF_LOG_PREFIX} response.callbackPost`, () => __awaiter(this, void 0, void 0, function* () {
1095
+ const response = yield fetch(connectRequest.callbackUrl, {
1096
+ body: formEncodedRequest,
1097
+ method: 'POST',
1098
+ headers: {
1099
+ 'Content-Type': 'application/x-www-form-urlencoded',
1100
+ },
1101
+ signal: AbortSignal.timeout(30000),
1102
+ });
1103
+ if (!response.ok) {
1104
+ // NOTE: delegate grants have already been written/fanned out by this
1105
+ // point, so callers may observe partial owner-side state when the
1106
+ // callback server rejects the final response delivery.
1107
+ throw new Error(`Connect: callback POST failed with HTTP ${response.status}.`);
1108
+ }
1109
+ return response;
912
1110
  }));
913
1111
  }
914
- logger.log('Building connect response...');
915
- const responseObject = yield EnboxConnectProtocol.createConnectResponse({
916
- providerDid: selectedDid,
917
- delegateDid: delegateBearerDid.uri,
918
- aud: connectRequest.clientDid,
919
- nonce: connectRequest.nonce,
920
- delegateGrants,
921
- delegatePortableDid,
922
- delegateDecryptionKeys: delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
923
- delegateContextKeys: delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
924
- delegateMultiPartyProtocols: delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
925
- sessionRevocations: sessionRevocations.length > 0 ? sessionRevocations : undefined,
926
- });
927
- logger.log('Signing connect response...');
928
- const responseObjectJwt = yield EnboxConnectProtocol.signJwt({
929
- did: delegateBearerDid,
930
- data: responseObject,
931
- });
932
- const clientDid = yield DidJwk.resolve(connectRequest.clientDid);
933
- const sharedKey = yield EnboxConnectProtocol.deriveSharedKey(delegateBearerDid, clientDid === null || clientDid === void 0 ? void 0 : clientDid.didDocument);
934
- logger.log('Encrypting connect response...');
935
- const encryptedResponse = yield EnboxConnectProtocol.encryptResponse({
936
- jwt: responseObjectJwt,
937
- encryptionKey: sharedKey,
938
- delegatePublicKeyJwk: delegateBearerDid.document.verificationMethod[0].publicKeyJwk,
939
- pin,
940
- });
941
- const formEncodedRequest = new URLSearchParams({
942
- id_token: encryptedResponse,
943
- state: connectRequest.state,
944
- }).toString();
945
- logger.log(`Sending connect response to: ${connectRequest.callbackUrl}`);
946
- yield fetch(connectRequest.callbackUrl, {
947
- body: formEncodedRequest,
948
- method: 'POST',
949
- headers: {
950
- 'Content-Type': 'application/x-www-form-urlencoded',
951
- },
952
- signal: AbortSignal.timeout(30000),
953
- });
1112
+ catch (err) {
1113
+ outcome = 'fail';
1114
+ throw err;
1115
+ }
1116
+ finally {
1117
+ const totalElapsed = nowMs() - submitStart;
1118
+ logger.log(`${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.total ${outcome} in ${totalElapsed.toFixed(1)}ms `
1119
+ + `(protocols=${numProtocols}, scopes=${numScopes}, sessionGrants=${sessionGrantCount})`);
1120
+ }
954
1121
  });
955
1122
  }
956
1123
  // ---------------------------------------------------------------------------
@@ -960,6 +1127,8 @@ export const EnboxConnectProtocol = {
960
1127
  buildConnectUrl,
961
1128
  signJwt,
962
1129
  verifyJwt,
1130
+ assertConnectRequest,
1131
+ assertConnectResponse,
963
1132
  encryptRequest,
964
1133
  decryptRequest,
965
1134
  encryptResponse,