@enbox/agent 0.3.1 → 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.
- package/dist/browser.mjs +12 -30
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/connect.js +20 -24
- package/dist/esm/connect.js.map +1 -1
- package/dist/esm/dwn-api.js +149 -22
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/dwn-discovery-file.js +1 -1
- package/dist/esm/dwn-discovery-payload.js +20 -21
- package/dist/esm/dwn-discovery-payload.js.map +1 -1
- package/dist/esm/dwn-key-delivery.js.map +1 -1
- package/dist/esm/{oidc.js → enbox-connect-protocol.js} +236 -248
- package/dist/esm/enbox-connect-protocol.js.map +1 -0
- package/dist/esm/enbox-user-agent.js +18 -5
- package/dist/esm/enbox-user-agent.js.map +1 -1
- package/dist/esm/index.js +4 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/local-dwn.js +21 -51
- package/dist/esm/local-dwn.js.map +1 -1
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/store-data.js.map +1 -1
- package/dist/esm/sync-engine-level.js +1 -1
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-messages.js +1 -1
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/types/connect.d.ts +15 -19
- package/dist/types/connect.d.ts.map +1 -1
- package/dist/types/dwn-api.d.ts +46 -6
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/dwn-discovery-file.d.ts +1 -1
- package/dist/types/dwn-discovery-payload.d.ts +18 -19
- package/dist/types/dwn-discovery-payload.d.ts.map +1 -1
- package/dist/types/enbox-connect-protocol.d.ts +220 -0
- package/dist/types/enbox-connect-protocol.d.ts.map +1 -0
- package/dist/types/enbox-user-agent.d.ts +10 -1
- package/dist/types/enbox-user-agent.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/local-dwn.d.ts +16 -32
- package/dist/types/local-dwn.d.ts.map +1 -1
- package/package.json +9 -11
- package/src/connect.ts +38 -47
- package/src/dwn-api.ts +175 -29
- package/src/dwn-discovery-file.ts +1 -1
- package/src/dwn-discovery-payload.ts +23 -24
- package/src/dwn-key-delivery.ts +1 -1
- package/src/enbox-connect-protocol.ts +778 -0
- package/src/enbox-user-agent.ts +27 -4
- package/src/index.ts +4 -2
- package/src/local-dwn.ts +22 -53
- package/src/permissions-api.ts +3 -3
- package/src/store-data.ts +1 -1
- package/src/sync-engine-level.ts +1 -1
- package/src/sync-messages.ts +1 -1
- package/dist/esm/oidc.js.map +0 -1
- package/dist/types/oidc.d.ts +0 -250
- package/dist/types/oidc.d.ts.map +0 -1
- 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
|
+
};
|