@enbox/agent 0.3.0 → 0.4.0

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 (57) hide show
  1. package/dist/browser.mjs +12 -30
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/connect.js +22 -30
  4. package/dist/esm/connect.js.map +1 -1
  5. package/dist/esm/dwn-api.js +149 -22
  6. package/dist/esm/dwn-api.js.map +1 -1
  7. package/dist/esm/dwn-discovery-file.js +1 -1
  8. package/dist/esm/dwn-discovery-payload.js +20 -21
  9. package/dist/esm/dwn-discovery-payload.js.map +1 -1
  10. package/dist/esm/dwn-key-delivery.js.map +1 -1
  11. package/dist/esm/{oidc.js → enbox-connect-protocol.js} +236 -248
  12. package/dist/esm/enbox-connect-protocol.js.map +1 -0
  13. package/dist/esm/enbox-user-agent.js +18 -5
  14. package/dist/esm/enbox-user-agent.js.map +1 -1
  15. package/dist/esm/index.js +4 -2
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/local-dwn.js +21 -51
  18. package/dist/esm/local-dwn.js.map +1 -1
  19. package/dist/esm/permissions-api.js.map +1 -1
  20. package/dist/esm/store-data.js.map +1 -1
  21. package/dist/esm/sync-engine-level.js +1 -1
  22. package/dist/esm/sync-engine-level.js.map +1 -1
  23. package/dist/esm/sync-messages.js +1 -1
  24. package/dist/esm/sync-messages.js.map +1 -1
  25. package/dist/types/connect.d.ts +15 -19
  26. package/dist/types/connect.d.ts.map +1 -1
  27. package/dist/types/dwn-api.d.ts +46 -6
  28. package/dist/types/dwn-api.d.ts.map +1 -1
  29. package/dist/types/dwn-discovery-file.d.ts +1 -1
  30. package/dist/types/dwn-discovery-payload.d.ts +18 -19
  31. package/dist/types/dwn-discovery-payload.d.ts.map +1 -1
  32. package/dist/types/enbox-connect-protocol.d.ts +220 -0
  33. package/dist/types/enbox-connect-protocol.d.ts.map +1 -0
  34. package/dist/types/enbox-user-agent.d.ts +10 -1
  35. package/dist/types/enbox-user-agent.d.ts.map +1 -1
  36. package/dist/types/index.d.ts +1 -2
  37. package/dist/types/index.d.ts.map +1 -1
  38. package/dist/types/local-dwn.d.ts +16 -32
  39. package/dist/types/local-dwn.d.ts.map +1 -1
  40. package/package.json +9 -11
  41. package/src/connect.ts +40 -54
  42. package/src/dwn-api.ts +175 -29
  43. package/src/dwn-discovery-file.ts +1 -1
  44. package/src/dwn-discovery-payload.ts +23 -24
  45. package/src/dwn-key-delivery.ts +1 -1
  46. package/src/enbox-connect-protocol.ts +778 -0
  47. package/src/enbox-user-agent.ts +27 -4
  48. package/src/index.ts +4 -2
  49. package/src/local-dwn.ts +22 -53
  50. package/src/permissions-api.ts +3 -3
  51. package/src/store-data.ts +1 -1
  52. package/src/sync-engine-level.ts +1 -1
  53. package/src/sync-messages.ts +1 -1
  54. package/dist/esm/oidc.js.map +0 -1
  55. package/dist/types/oidc.d.ts +0 -250
  56. package/dist/types/oidc.d.ts.map +0 -1
  57. package/src/oidc.ts +0 -864
@@ -0,0 +1,778 @@
1
+ /**
2
+ * Enbox Connect Protocol
3
+ *
4
+ * A capability delegation protocol for DWN access. Enables apps to request
5
+ * scoped permission grants from a wallet (provider), receiving a delegate DID
6
+ * with the granted permissions.
7
+ *
8
+ * Two transport modes:
9
+ * - Local (`dwn://connect`): same-device, direct HTTP against the local DWN
10
+ * - Remote (`enbox://connect`): cross-device, relay-mediated with QR/deep link
11
+ *
12
+ * The protocol uses JWTs for signing, JWE (XChaCha20-Poly1305) for encryption,
13
+ * and ECDH (Ed25519 → X25519 + HKDF) for key agreement.
14
+ */
15
+
16
+ import type { ConnectPermissionRequest } from './connect.js';
17
+ import type { EnboxAgent } from './types/agent.js';
18
+ import type { RequireOnly } from '@enbox/common';
19
+ import type { DidDocument, PortableDid } from '@enbox/dids';
20
+ import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js';
21
+ import type {
22
+ JoseHeaderParams,
23
+ Jwk } from '@enbox/crypto';
24
+
25
+ import { type BearerDid, DidJwk } from '@enbox/dids';
26
+ import { Convert, logger } from '@enbox/common';
27
+ import {
28
+ CryptoUtils,
29
+ Ed25519,
30
+ EdDsaAlgorithm,
31
+ Hkdf,
32
+ X25519,
33
+ XChaCha20Poly1305,
34
+ } from '@enbox/crypto';
35
+ import { DwnInterfaceName, DwnMethodName } from '@enbox/dwn-sdk-js';
36
+
37
+ import { AgentPermissionsApi } from './permissions-api.js';
38
+ import { concatenateUrl } from './utils.js';
39
+ import { DwnInterface } from './types/dwn.js';
40
+ import { isRecordPermissionScope } from './dwn-api.js';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Types
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Pushed to the connect server so the wallet can retrieve it later.
48
+ * The request is encrypted (JWE) before being pushed.
49
+ *
50
+ * Inspired by RFC 9126 (Pushed Authorization Requests).
51
+ */
52
+ export type ConnectPushedRequest = {
53
+ /** The encrypted JWE containing the signed {@link EnboxConnectRequest} JWT. */
54
+ request: string;
55
+ };
56
+
57
+ /**
58
+ * Returned by the connect server after a {@link ConnectPushedRequest}.
59
+ * Contains a URI the wallet uses to fetch the encrypted request,
60
+ * and the TTL before it expires.
61
+ */
62
+ export type ConnectPushedResponse = {
63
+ /** URI where the wallet can fetch the encrypted auth request. */
64
+ request_uri: string;
65
+ /** Seconds until the request expires. */
66
+ expires_in: number;
67
+ };
68
+
69
+ /**
70
+ * A connect request from an app to a wallet, asking for DWN permissions.
71
+ *
72
+ * The app creates this, signs it as a JWT, encrypts it as a JWE, and pushes
73
+ * it to the connect server. The wallet retrieves, decrypts, verifies, and
74
+ * displays it in a consent UI.
75
+ */
76
+ export type EnboxConnectRequest = {
77
+ /** Ephemeral DID (did:jwk) used for ECDH key agreement and request signing. */
78
+ clientDid: string;
79
+
80
+ /** Human-readable name of the requesting application, shown in the consent UI. */
81
+ appName: string;
82
+
83
+ /** DWN protocols and permission scopes being requested. */
84
+ permissionRequests: ConnectPermissionRequest[];
85
+
86
+ /** Anti-replay nonce (random base64url). */
87
+ nonce: string;
88
+
89
+ /** State correlator for matching request to response (random base64url). */
90
+ state: string;
91
+
92
+ /** URL where the wallet should POST the encrypted response. */
93
+ callbackUrl: string;
94
+
95
+ /** Response mode — always `direct_post` (wallet POSTs response to callbackUrl). */
96
+ responseMode: 'direct_post';
97
+
98
+ /** Supported DID methods for the connected identity. */
99
+ supportedDidMethods: string[];
100
+ };
101
+
102
+ /**
103
+ * A connect response from a wallet, granting DWN permissions.
104
+ *
105
+ * The wallet creates this after user consent, signs it as a JWT with the
106
+ * delegate DID, encrypts it via ECDH, and POSTs it to the connect server.
107
+ * The app retrieves, decrypts (using ECDH + optional PIN), and verifies it.
108
+ */
109
+ export type EnboxConnectResponse = {
110
+ /** The wallet owner's real DID that authorised the delegation. */
111
+ providerDid: string;
112
+
113
+ /** The newly created delegate DID identifier. */
114
+ delegateDid: string;
115
+
116
+ /** Audience — must match the `clientDid` from the request. */
117
+ aud: string;
118
+
119
+ /** Issued-at timestamp (Unix seconds). */
120
+ iat: number;
121
+
122
+ /** Expiration timestamp (Unix seconds). */
123
+ exp: number;
124
+
125
+ /** Echo of the request nonce. */
126
+ nonce?: string;
127
+
128
+ /** DWN permission grant messages (serialised RecordsWrite with encoded data). */
129
+ delegateGrants: DwnDataEncodedRecordsWriteMessage[];
130
+
131
+ /** The delegate DID's full portable form, including private keys. */
132
+ delegatePortableDid: PortableDid;
133
+ };
134
+
135
+ /** The connect server endpoint types. */
136
+ export type ConnectEndpoint =
137
+ | 'pushedAuthorizationRequest'
138
+ | 'authorize'
139
+ | 'callback'
140
+ | 'token';
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Deprecated type aliases — kept temporarily for migration
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** @deprecated Use {@link EnboxConnectRequest} instead. */
147
+ export type EnboxConnectAuthRequest = EnboxConnectRequest;
148
+
149
+ /** @deprecated Use {@link EnboxConnectResponse} instead. */
150
+ export type EnboxConnectAuthResponse = EnboxConnectResponse;
151
+
152
+ /** @deprecated Use {@link ConnectPushedRequest} instead. */
153
+ export type PushedAuthRequest = ConnectPushedRequest;
154
+
155
+ /** @deprecated Use {@link ConnectPushedResponse} instead. */
156
+ export type PushedAuthResponse = ConnectPushedResponse;
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // URL building
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Builds the URL for a connect server endpoint.
164
+ *
165
+ * @param options.baseURL - The connect server base URL (e.g. `http://localhost:3000/connect/`)
166
+ * @param options.endpoint - The endpoint type
167
+ * @param options.authParam - Required for `authorize` endpoint (the request ID)
168
+ * @param options.tokenParam - Required for `token` endpoint (the state value)
169
+ */
170
+ function buildConnectUrl({
171
+ baseURL,
172
+ endpoint,
173
+ authParam,
174
+ tokenParam,
175
+ }: {
176
+ baseURL: string;
177
+ endpoint: ConnectEndpoint;
178
+ authParam?: string;
179
+ tokenParam?: string;
180
+ }): string {
181
+ switch (endpoint) {
182
+ case 'pushedAuthorizationRequest':
183
+ return concatenateUrl(baseURL, 'par');
184
+ case 'authorize':
185
+ if (!authParam) {
186
+ throw new Error('authParam must be provided when building an authorize URL');
187
+ }
188
+ return concatenateUrl(baseURL, `authorize/${authParam}.jwt`);
189
+ case 'callback':
190
+ return concatenateUrl(baseURL, 'callback');
191
+ case 'token':
192
+ if (!tokenParam) {
193
+ throw new Error('tokenParam must be provided when building a token URL');
194
+ }
195
+ return concatenateUrl(baseURL, `token/${tokenParam}.jwt`);
196
+ default:
197
+ throw new Error(`Unknown connect endpoint: ${endpoint}`);
198
+ }
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // JWT signing and verification
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** Signs an object as a JWT using an Ed25519 DID key. */
206
+ async function signJwt({
207
+ did,
208
+ data,
209
+ }: {
210
+ did: BearerDid;
211
+ data: Record<string, unknown>;
212
+ }): Promise<string> {
213
+ const header = Convert.object({
214
+ alg : 'EdDSA',
215
+ kid : did.document.verificationMethod![0].id,
216
+ typ : 'JWT',
217
+ }).toBase64Url();
218
+
219
+ const payload = Convert.object(data).toBase64Url();
220
+
221
+ const signer = await did.getSigner();
222
+ const signature = await signer.sign({
223
+ data: Convert.string(`${header}.${payload}`).toUint8Array(),
224
+ });
225
+
226
+ const signatureBase64Url = Convert.uint8Array(signature).toBase64Url();
227
+ return `${header}.${payload}.${signatureBase64Url}`;
228
+ }
229
+
230
+ /** Verifies a JWT signature using the DID in the `kid` header. Returns the parsed payload. */
231
+ async function verifyJwt({ jwt }: { jwt: string }): Promise<Record<string, unknown>> {
232
+ const [headerB64U, payloadB64U, signatureB64U] = jwt.split('.');
233
+
234
+ const header: JoseHeaderParams = Convert.base64Url(headerB64U).toObject();
235
+
236
+ if (!header.kid) {
237
+ throw new Error('Connect: JWT missing required "kid" header value.');
238
+ }
239
+
240
+ const { didDocument } = await DidJwk.resolve(header.kid.split('#')[0]);
241
+
242
+ if (!didDocument) {
243
+ throw new Error('Connect: JWT verification failed — could not resolve DID.');
244
+ }
245
+
246
+ const { publicKeyJwk } =
247
+ didDocument.verificationMethod?.find((method: any) => {
248
+ return method.id === header.kid;
249
+ }) ?? {};
250
+
251
+ if (!publicKeyJwk) {
252
+ throw new Error('Connect: JWT verification failed — public key not found in DID document.');
253
+ }
254
+
255
+ const EdDsa = new EdDsaAlgorithm();
256
+ const isValid = await EdDsa.verify({
257
+ key : publicKeyJwk,
258
+ signature : Convert.base64Url(signatureB64U).toUint8Array(),
259
+ data : Convert.string(`${headerB64U}.${payloadB64U}`).toUint8Array(),
260
+ });
261
+
262
+ if (!isValid) {
263
+ throw new Error('Connect: JWT verification failed — invalid signature.');
264
+ }
265
+
266
+ return Convert.base64Url(payloadB64U).toObject() as Record<string, unknown>;
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Encryption: request (symmetric key via QR/deep link)
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /** Encrypts the connect request JWT with a symmetric key (shared via QR code or deep link). */
274
+ async function encryptRequest({
275
+ jwt,
276
+ encryptionKey,
277
+ }: {
278
+ jwt: string;
279
+ encryptionKey: Uint8Array;
280
+ }): Promise<string> {
281
+ const protectedHeader = {
282
+ alg : 'dir',
283
+ cty : 'JWT',
284
+ enc : 'XC20P',
285
+ typ : 'JWT',
286
+ };
287
+ const nonce = CryptoUtils.randomBytes(24);
288
+ const additionalData = Convert.object(protectedHeader).toUint8Array();
289
+ const jwtBytes = Convert.string(jwt).toUint8Array();
290
+ const ciphertextAndTag = await XChaCha20Poly1305.encryptRaw({ data: jwtBytes, keyBytes: encryptionKey, nonce, additionalData });
291
+
292
+ const ciphertext = ciphertextAndTag.subarray(0, -16);
293
+ const authenticationTag = ciphertextAndTag.subarray(-16);
294
+
295
+ return [
296
+ Convert.object(protectedHeader).toBase64Url(),
297
+ '', // No wrapped key (direct encryption).
298
+ Convert.uint8Array(nonce).toBase64Url(),
299
+ Convert.uint8Array(ciphertext).toBase64Url(),
300
+ Convert.uint8Array(authenticationTag).toBase64Url(),
301
+ ].join('.');
302
+ }
303
+
304
+ /** Decrypts an encrypted connect request JWE using the symmetric key from the QR/deep link. */
305
+ async function decryptRequest({
306
+ jwe,
307
+ encryptionKey,
308
+ }: {
309
+ jwe: string;
310
+ encryptionKey: string;
311
+ }): Promise<string> {
312
+ const [
313
+ protectedHeaderB64U,
314
+ ,
315
+ nonceB64U,
316
+ ciphertextB64U,
317
+ authenticationTagB64U,
318
+ ] = jwe.split('.');
319
+
320
+ const encryptionKeyBytes = Convert.base64Url(encryptionKey).toUint8Array();
321
+ const additionalData = Convert.base64Url(protectedHeaderB64U).toUint8Array();
322
+ const nonce = Convert.base64Url(nonceB64U).toUint8Array();
323
+ const ciphertext = Convert.base64Url(ciphertextB64U).toUint8Array();
324
+ const authenticationTag = Convert.base64Url(authenticationTagB64U).toUint8Array();
325
+
326
+ const ciphertextAndTag = new Uint8Array([...ciphertext, ...authenticationTag]);
327
+ const decryptedJwtBytes = await XChaCha20Poly1305.decryptRaw({ data: ciphertextAndTag, keyBytes: encryptionKeyBytes, nonce, additionalData });
328
+
329
+ return Convert.uint8Array(decryptedJwtBytes).toString();
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Encryption: response (ECDH shared key + optional PIN)
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /** Derives a shared ECDH key for encrypting/decrypting the connect response. */
337
+ async function deriveSharedKey(
338
+ privateKeyDid: BearerDid,
339
+ publicKeyDid: DidDocument
340
+ ): Promise<Uint8Array> {
341
+ const privatePortableDid = await privateKeyDid.export();
342
+
343
+ const publicJwk = publicKeyDid.verificationMethod?.[0].publicKeyJwk!;
344
+ const privateJwk = privatePortableDid.privateKeys?.[0]!;
345
+ publicJwk.alg = 'EdDSA';
346
+
347
+ const publicX25519 = await Ed25519.convertPublicKeyToX25519({ publicKey: publicJwk });
348
+ const privateX25519 = await Ed25519.convertPrivateKeyToX25519({ privateKey: privateJwk });
349
+
350
+ const sharedKey = await X25519.sharedSecret({
351
+ privateKeyA : privateX25519,
352
+ publicKeyB : publicX25519,
353
+ });
354
+
355
+ return Hkdf.deriveKeyBytes({
356
+ baseKeyBytes : new Uint8Array(sharedKey),
357
+ hash : 'SHA-256',
358
+ salt : new Uint8Array(),
359
+ info : new Uint8Array(),
360
+ length : 256,
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Encrypts the connect response JWT.
366
+ *
367
+ * For remote (relay-mediated) flows, `pin` is required — it is added to the
368
+ * AAD to prevent MITM attacks via the untrusted relay.
369
+ *
370
+ * For local (same-device) flows, `pin` may be omitted — the ECDH encryption
371
+ * alone is sufficient when there is no untrusted intermediary.
372
+ */
373
+ async function encryptResponse({
374
+ jwt,
375
+ encryptionKey,
376
+ delegateDidKeyId,
377
+ pin,
378
+ }: {
379
+ jwt: string;
380
+ encryptionKey: Uint8Array;
381
+ delegateDidKeyId: string;
382
+ pin?: string;
383
+ }): Promise<string> {
384
+ const protectedHeader = {
385
+ alg : 'dir',
386
+ cty : 'JWT',
387
+ enc : 'XC20P',
388
+ typ : 'JWT',
389
+ kid : delegateDidKeyId,
390
+ };
391
+ const nonce = CryptoUtils.randomBytes(24);
392
+
393
+ // Build AAD — include PIN if provided (remote flows).
394
+ const aadObject = pin
395
+ ? { ...protectedHeader, pin }
396
+ : { ...protectedHeader };
397
+ const additionalData = Convert.object(aadObject).toUint8Array();
398
+
399
+ const jwtBytes = Convert.string(jwt).toUint8Array();
400
+ const ciphertextAndTag = await XChaCha20Poly1305.encryptRaw({ data: jwtBytes, keyBytes: encryptionKey, nonce, additionalData });
401
+
402
+ const ciphertext = ciphertextAndTag.subarray(0, -16);
403
+ const authenticationTag = ciphertextAndTag.subarray(-16);
404
+
405
+ return [
406
+ Convert.object(protectedHeader).toBase64Url(),
407
+ '', // No wrapped key (direct encryption).
408
+ Convert.uint8Array(nonce).toBase64Url(),
409
+ Convert.uint8Array(ciphertext).toBase64Url(),
410
+ Convert.uint8Array(authenticationTag).toBase64Url(),
411
+ ].join('.');
412
+ }
413
+
414
+ /**
415
+ * Decrypts the connect response JWE using ECDH + optional PIN.
416
+ *
417
+ * @param clientDid - The ephemeral DID used at connect initiation (for ECDH).
418
+ * @param jwe - The encrypted response JWE.
419
+ * @param pin - The PIN entered by the user (required for remote flows, omit for local).
420
+ */
421
+ async function decryptResponse(
422
+ clientDid: BearerDid,
423
+ jwe: string,
424
+ pin?: string
425
+ ): Promise<string> {
426
+ const [
427
+ protectedHeaderB64U,
428
+ ,
429
+ nonceB64U,
430
+ ciphertextB64U,
431
+ authenticationTagB64U,
432
+ ] = jwe.split('.');
433
+
434
+ const header = Convert.base64Url(protectedHeaderB64U).toObject() as Jwk;
435
+ if (!header.kid) {
436
+ throw new Error('Connect: JWE protected header is missing required "kid" property.');
437
+ }
438
+ const delegateResolvedDid = await DidJwk.resolve(header.kid.split('#')[0]);
439
+
440
+ const sharedKey = await EnboxConnectProtocol.deriveSharedKey(
441
+ clientDid,
442
+ delegateResolvedDid.didDocument!
443
+ );
444
+
445
+ // Build AAD — include PIN if provided (must match what was used during encryption).
446
+ const aadObject = pin
447
+ ? { ...header, pin }
448
+ : { ...header };
449
+ const AAD = Convert.object(aadObject).toUint8Array();
450
+
451
+ const nonce = Convert.base64Url(nonceB64U).toUint8Array();
452
+ const ciphertext = Convert.base64Url(ciphertextB64U).toUint8Array();
453
+ const authenticationTag = Convert.base64Url(authenticationTagB64U).toUint8Array();
454
+
455
+ const ciphertextAndTag = new Uint8Array([...ciphertext, ...authenticationTag]);
456
+ const decryptedJwtBytes = await XChaCha20Poly1305.decryptRaw({ data: ciphertextAndTag, keyBytes: sharedKey, nonce, additionalData: AAD });
457
+
458
+ return Convert.uint8Array(decryptedJwtBytes).toString();
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Request creation and retrieval
463
+ // ---------------------------------------------------------------------------
464
+
465
+ /** Creates an {@link EnboxConnectRequest}. */
466
+ async function createConnectRequest(
467
+ options: RequireOnly<
468
+ EnboxConnectRequest,
469
+ 'clientDid' | 'callbackUrl' | 'permissionRequests' | 'appName'
470
+ >
471
+ ): Promise<EnboxConnectRequest> {
472
+ const stateBytes = CryptoUtils.randomBytes(16);
473
+ const nonceBytes = CryptoUtils.randomBytes(16);
474
+
475
+ return {
476
+ ...options,
477
+ nonce : Convert.uint8Array(nonceBytes).toBase64Url(),
478
+ responseMode : 'direct_post',
479
+ state : Convert.uint8Array(stateBytes).toBase64Url(),
480
+ supportedDidMethods : options.supportedDidMethods ?? ['did:dht', 'did:jwk'],
481
+ };
482
+ }
483
+
484
+ /**
485
+ * Fetches an encrypted connect request from the authorize endpoint
486
+ * and decrypts it using the encryption key from the QR/deep link.
487
+ */
488
+ async function getConnectRequest(requestUri: string, encryptionKey: string): Promise<EnboxConnectRequest> {
489
+ const response = await fetch(requestUri, { signal: AbortSignal.timeout(30_000) });
490
+ const jwe = await response.text();
491
+ const jwt = await decryptRequest({ jwe, encryptionKey });
492
+ return (await verifyJwt({ jwt })) as unknown as EnboxConnectRequest;
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Response creation
497
+ // ---------------------------------------------------------------------------
498
+
499
+ /** Creates an {@link EnboxConnectResponse} with timestamps. */
500
+ async function createConnectResponse(
501
+ options: RequireOnly<
502
+ EnboxConnectResponse,
503
+ 'providerDid' | 'delegateDid' | 'aud' | 'delegateGrants' | 'delegatePortableDid'
504
+ >
505
+ ): Promise<EnboxConnectResponse> {
506
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
507
+
508
+ return {
509
+ ...options,
510
+ iat : currentTimeInSeconds,
511
+ exp : currentTimeInSeconds + 600, // 10 minutes
512
+ };
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Permission grants
517
+ // ---------------------------------------------------------------------------
518
+
519
+ function shouldUseDelegatePermission(scope: DwnPermissionScope): boolean {
520
+ if (isRecordPermissionScope(scope)) {
521
+ return true;
522
+ } else if (scope.interface === DwnInterfaceName.Protocols && scope.method === DwnMethodName.Configure) {
523
+ return true;
524
+ }
525
+ return false;
526
+ }
527
+
528
+ /**
529
+ * Creates permission grants that assign the requested scopes to a delegate DID.
530
+ */
531
+ async function createPermissionGrants(
532
+ selectedDid: string,
533
+ delegateBearerDid: BearerDid,
534
+ agent: EnboxAgent,
535
+ scopes: DwnPermissionScope[],
536
+ ): Promise<DwnDataEncodedRecordsWriteMessage[]> {
537
+ const permissionsApi = new AgentPermissionsApi({ agent });
538
+
539
+ logger.log(`Creating permission grants for ${scopes.length} scopes...`);
540
+ const permissionGrants = await Promise.all(
541
+ scopes.map((scope) => {
542
+ const delegated = shouldUseDelegatePermission(scope);
543
+ return permissionsApi.createGrant({
544
+ delegated,
545
+ store : true,
546
+ grantedTo : delegateBearerDid.uri,
547
+ scope,
548
+ dateExpires : '2040-06-25T16:09:16.693356Z', // TODO: make dateExpires configurable
549
+ author : selectedDid,
550
+ });
551
+ })
552
+ );
553
+
554
+ logger.log(`Sending ${permissionGrants.length} permission grants to remote DWN...`);
555
+ const messagePromises = permissionGrants.map(async (grant) => {
556
+ const { encodedData, ...rawMessage } = grant.message;
557
+ const data = Convert.base64Url(encodedData).toUint8Array();
558
+ const { reply } = await agent.sendDwnRequest({
559
+ author : selectedDid,
560
+ target : selectedDid,
561
+ messageType : DwnInterface.RecordsWrite,
562
+ dataStream : new Blob([data as BlobPart]),
563
+ rawMessage,
564
+ });
565
+
566
+ if (reply.status.code !== 202 && reply.status.code !== 409) {
567
+ logger.error(`Error sending RecordsWrite: ${reply.status.detail}`);
568
+ throw new Error(`Could not send permission grant. Error: ${reply.status.detail}`);
569
+ }
570
+
571
+ return grant.message;
572
+ });
573
+
574
+ try {
575
+ return await Promise.all(messagePromises);
576
+ } catch (error) {
577
+ logger.error(`Error during batch-send of permission grants: ${error}`);
578
+ throw error;
579
+ }
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // Protocol installation
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /**
587
+ * Installs a DWN protocol on the provider's DWN if it doesn't already exist.
588
+ * Ensures the protocol is available on both the local and remote DWN.
589
+ */
590
+ async function prepareProtocol(
591
+ selectedDid: string,
592
+ agent: EnboxAgent,
593
+ protocolDefinition: DwnProtocolDefinition
594
+ ): Promise<void> {
595
+ const queryMessage = await agent.processDwnRequest({
596
+ author : selectedDid,
597
+ messageType : DwnInterface.ProtocolsQuery,
598
+ target : selectedDid,
599
+ messageParams : { filter: { protocol: protocolDefinition.protocol } },
600
+ });
601
+
602
+ if (queryMessage.reply.status.code !== 200) {
603
+ throw new Error(`Could not fetch protocol: ${queryMessage.reply.status.detail}`);
604
+ } else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) {
605
+ logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`);
606
+
607
+ const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({
608
+ author : selectedDid,
609
+ target : selectedDid,
610
+ messageType : DwnInterface.ProtocolsConfigure,
611
+ messageParams : { definition: protocolDefinition },
612
+ });
613
+
614
+ if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
615
+ throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
616
+ }
617
+
618
+ await agent.processDwnRequest({
619
+ author : selectedDid,
620
+ target : selectedDid,
621
+ messageType : DwnInterface.ProtocolsConfigure,
622
+ rawMessage : configureMessage
623
+ });
624
+ } else {
625
+ logger.log(`Protocol already exists: ${protocolDefinition.protocol}`);
626
+
627
+ const configureMessage = queryMessage.reply.entries![0];
628
+ const { reply: sendReply } = await agent.sendDwnRequest({
629
+ author : selectedDid,
630
+ target : selectedDid,
631
+ messageType : DwnInterface.ProtocolsConfigure,
632
+ rawMessage : configureMessage,
633
+ });
634
+
635
+ if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
636
+ throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
637
+ }
638
+ }
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // Full wallet-side flow (provider submits response)
643
+ // ---------------------------------------------------------------------------
644
+
645
+ /**
646
+ * Executes the full wallet-side (provider) flow:
647
+ * 1. Creates a delegate DID
648
+ * 2. Installs requested protocols
649
+ * 3. Creates permission grants
650
+ * 4. Builds, signs, and encrypts the response
651
+ * 5. POSTs the encrypted response to the callback URL
652
+ *
653
+ * @param selectedDid - The provider's DID that is granting access.
654
+ * @param connectRequest - The decoded connect request from the app.
655
+ * @param pin - The PIN for response encryption AAD (required for remote flows).
656
+ * @param agent - The agent instance for DWN operations.
657
+ */
658
+ async function submitConnectResponse(
659
+ selectedDid: string,
660
+ connectRequest: EnboxConnectRequest,
661
+ pin: string | undefined,
662
+ agent: EnboxAgent
663
+ ): Promise<void> {
664
+ const delegateBearerDid = await DidJwk.create();
665
+ const delegatePortableDid = await delegateBearerDid.export();
666
+
667
+ const delegateGrantPromises = connectRequest.permissionRequests.map(
668
+ async (permissionRequest) => {
669
+ const { protocolDefinition, permissionScopes } = permissionRequest;
670
+
671
+ const grantsMatchProtocolUri = permissionScopes.every(
672
+ scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol
673
+ );
674
+ if (!grantsMatchProtocolUri) {
675
+ throw new Error('All permission scopes must match the protocol URI they are provided with.');
676
+ }
677
+
678
+ await prepareProtocol(selectedDid, agent, protocolDefinition);
679
+
680
+ return EnboxConnectProtocol.createPermissionGrants(
681
+ selectedDid,
682
+ delegateBearerDid,
683
+ agent,
684
+ permissionScopes
685
+ );
686
+ }
687
+ );
688
+
689
+ const delegateGrants = (await Promise.all(delegateGrantPromises)).flat();
690
+
691
+ logger.log('Building connect response...');
692
+ const responseObject = await EnboxConnectProtocol.createConnectResponse({
693
+ providerDid : selectedDid,
694
+ delegateDid : delegateBearerDid.uri,
695
+ aud : connectRequest.clientDid,
696
+ nonce : connectRequest.nonce,
697
+ delegateGrants,
698
+ delegatePortableDid,
699
+ });
700
+
701
+ logger.log('Signing connect response...');
702
+ const responseObjectJwt = await EnboxConnectProtocol.signJwt({
703
+ did : delegateBearerDid,
704
+ data : responseObject as unknown as Record<string, unknown>,
705
+ });
706
+
707
+ const clientDid = await DidJwk.resolve(connectRequest.clientDid);
708
+
709
+ const sharedKey = await EnboxConnectProtocol.deriveSharedKey(
710
+ delegateBearerDid,
711
+ clientDid?.didDocument!
712
+ );
713
+
714
+ logger.log('Encrypting connect response...');
715
+ const encryptedResponse = await EnboxConnectProtocol.encryptResponse({
716
+ jwt : responseObjectJwt!,
717
+ encryptionKey : sharedKey,
718
+ delegateDidKeyId : delegateBearerDid.document.verificationMethod![0].id,
719
+ pin,
720
+ });
721
+
722
+ const formEncodedRequest = new URLSearchParams({
723
+ id_token : encryptedResponse,
724
+ state : connectRequest.state,
725
+ }).toString();
726
+
727
+ logger.log(`Sending connect response to: ${connectRequest.callbackUrl}`);
728
+ await fetch(connectRequest.callbackUrl, {
729
+ body : formEncodedRequest,
730
+ method : 'POST',
731
+ headers : {
732
+ 'Content-Type': 'application/x-www-form-urlencoded',
733
+ },
734
+ signal: AbortSignal.timeout(30_000),
735
+ });
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Namespace export
740
+ // ---------------------------------------------------------------------------
741
+
742
+ export const EnboxConnectProtocol = {
743
+ buildConnectUrl,
744
+ signJwt,
745
+ verifyJwt,
746
+ encryptRequest,
747
+ decryptRequest,
748
+ encryptResponse,
749
+ decryptResponse,
750
+ deriveSharedKey,
751
+ createConnectRequest,
752
+ getConnectRequest,
753
+ createConnectResponse,
754
+ createPermissionGrants,
755
+ submitConnectResponse,
756
+ };
757
+
758
+ // ---------------------------------------------------------------------------
759
+ // Deprecated aliases — migration aid from the old `Oidc` namespace
760
+ // ---------------------------------------------------------------------------
761
+
762
+ /** @deprecated Use {@link EnboxConnectProtocol} instead. */
763
+ export const Oidc = {
764
+ createAuthRequest : createConnectRequest,
765
+ encryptAuthRequest : encryptRequest,
766
+ getAuthRequest : getConnectRequest,
767
+ decryptAuthRequest : decryptRequest,
768
+ createPermissionGrants,
769
+ createResponseObject : createConnectResponse,
770
+ encryptAuthResponse : encryptResponse,
771
+ decryptAuthResponse : decryptResponse,
772
+ deriveSharedKey,
773
+ signJwt,
774
+ verifyJwt,
775
+ buildOidcUrl : buildConnectUrl,
776
+ generateCodeChallenge : undefined as never, // Removed — PKCE was never functional.
777
+ submitAuthResponse : submitConnectResponse,
778
+ };