@enbox/agent 0.7.0 → 0.7.2
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 +11 -11
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/agent-session.js +16 -0
- package/dist/esm/agent-session.js.map +1 -0
- package/dist/esm/dwn-api.js +5 -4
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/dwn-encryption.js +11 -4
- package/dist/esm/dwn-encryption.js.map +1 -1
- package/dist/esm/enbox-connect-protocol.js +376 -207
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/local-key-manager.js.map +1 -1
- package/dist/esm/protocol-utils.js +19 -6
- package/dist/esm/protocol-utils.js.map +1 -1
- package/dist/esm/store-data.js.map +1 -1
- package/dist/esm/store-key.js +1 -1
- package/dist/esm/store-key.js.map +1 -1
- package/dist/esm/sync-engine-level.js +18 -5
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/types/dwn.js.map +1 -1
- package/dist/esm/utils.js +0 -12
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/agent-session.d.ts +59 -0
- package/dist/types/agent-session.d.ts.map +1 -0
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/dwn-encryption.d.ts.map +1 -1
- package/dist/types/enbox-connect-protocol.d.ts +34 -3
- package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/local-key-manager.d.ts.map +1 -1
- package/dist/types/protocol-utils.d.ts.map +1 -1
- package/dist/types/sync-engine-level.d.ts +6 -1
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/types/dwn.d.ts +10 -1
- package/dist/types/types/dwn.d.ts.map +1 -1
- package/dist/types/utils.d.ts +0 -2
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/agent-session.ts +78 -0
- package/src/dwn-api.ts +5 -4
- package/src/dwn-encryption.ts +14 -6
- package/src/enbox-connect-protocol.ts +466 -256
- package/src/index.ts +2 -1
- package/src/local-key-manager.ts +7 -3
- package/src/protocol-utils.ts +19 -8
- package/src/store-data.ts +1 -1
- package/src/store-key.ts +1 -1
- package/src/sync-engine-level.ts +19 -6
- package/src/types/dwn.ts +21 -12
- 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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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) => !
|
|
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
|
-
|
|
589
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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,
|