@enbox/agent 0.6.8 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +455 -246
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/index.js +1 -0
- 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 +1 -0
- 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 +556 -298
- package/src/index.ts +1 -0
- 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
|
@@ -18,7 +18,7 @@ import type { EnboxPlatformAgent } from './types/agent.js';
|
|
|
18
18
|
import type { PrivateKeyJwk } from '@enbox/crypto';
|
|
19
19
|
import type { RequireOnly } from '@enbox/common';
|
|
20
20
|
import type { DidDocument, PortableDid } from '@enbox/dids';
|
|
21
|
-
import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js';
|
|
21
|
+
import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition, DwnRecordsPermissionScope } from './types/dwn.js';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The protocols of permissions requested, along with the definition and permission scopes for each protocol.
|
|
@@ -103,7 +103,7 @@ import type {
|
|
|
103
103
|
Jwk } from '@enbox/crypto';
|
|
104
104
|
|
|
105
105
|
import { type BearerDid, DidJwk } from '@enbox/dids';
|
|
106
|
-
import { Convert, logger } from '@enbox/common';
|
|
106
|
+
import { concatenateUrl, Convert, logger, nowMs, timed } from '@enbox/common';
|
|
107
107
|
import {
|
|
108
108
|
CryptoUtils,
|
|
109
109
|
Ed25519,
|
|
@@ -120,7 +120,7 @@ import { getEncryptionKeyInfo } from './dwn-encryption.js';
|
|
|
120
120
|
import { isMultiPartyContext } from './protocol-utils.js';
|
|
121
121
|
import { isRecordPermissionScope } from './dwn-api.js';
|
|
122
122
|
import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
|
|
123
|
-
import {
|
|
123
|
+
import { mapConcurrent, mapConcurrentSettled } from './utils.js';
|
|
124
124
|
|
|
125
125
|
// ---------------------------------------------------------------------------
|
|
126
126
|
// Tunables
|
|
@@ -136,6 +136,24 @@ import { concatenateUrl, mapConcurrent, mapConcurrentSettled } from './utils.js'
|
|
|
136
136
|
*/
|
|
137
137
|
const CONNECT_FANOUT_CONCURRENCY = 8;
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Per-request abort budget applied to every DWN-endpoint `sendDwnRequest`
|
|
141
|
+
* issued during the connect flow. The HttpDwnRpcClient's default per-attempt
|
|
142
|
+
* timeout is 30 s with 3 retries (~120 s worst-case per request) — that
|
|
143
|
+
* scales unacceptably when bounded fan-out has to wait for every settled
|
|
144
|
+
* task. With this budget, an unhealthy / cold endpoint short-circuits the
|
|
145
|
+
* retry loop within a few seconds (AbortError is non-retryable), keeping
|
|
146
|
+
* the user-visible "Authorizing…" wait bounded even when one of N DWN
|
|
147
|
+
* endpoints is misbehaving.
|
|
148
|
+
*
|
|
149
|
+
* Sync delivers any missed copies eventually, so aborting fast is safe:
|
|
150
|
+
* the connect-flow fan-outs are best-effort and tolerate per-task failure.
|
|
151
|
+
*/
|
|
152
|
+
const CONNECT_REQUEST_TIMEOUT_MS = 10_000;
|
|
153
|
+
|
|
154
|
+
/** Log namespace used for wallet-side connect critical-path timings. */
|
|
155
|
+
const CONNECT_PERF_LOG_PREFIX = '[connect.perf]';
|
|
156
|
+
|
|
139
157
|
// ---------------------------------------------------------------------------
|
|
140
158
|
// Types
|
|
141
159
|
// ---------------------------------------------------------------------------
|
|
@@ -318,13 +336,20 @@ function buildConnectUrl({
|
|
|
318
336
|
// JWT signing and verification
|
|
319
337
|
// ---------------------------------------------------------------------------
|
|
320
338
|
|
|
321
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* Signs an object as a JWT using an Ed25519 DID key.
|
|
341
|
+
*
|
|
342
|
+
* `data` is constrained to `object` so callers don't have to widen
|
|
343
|
+
* typed payload shapes (e.g. `EnboxConnectResponse`) to
|
|
344
|
+
* `Record<string, unknown>` at the call site. `Convert.object(data)`
|
|
345
|
+
* stringifies whatever JSON-serializable shape is passed.
|
|
346
|
+
*/
|
|
322
347
|
async function signJwt({
|
|
323
348
|
did,
|
|
324
349
|
data,
|
|
325
350
|
}: {
|
|
326
351
|
did: BearerDid;
|
|
327
|
-
data:
|
|
352
|
+
data: object;
|
|
328
353
|
}): Promise<string> {
|
|
329
354
|
const header = Convert.object({
|
|
330
355
|
alg : 'EdDSA',
|
|
@@ -343,7 +368,17 @@ async function signJwt({
|
|
|
343
368
|
return `${header}.${payload}.${signatureBase64Url}`;
|
|
344
369
|
}
|
|
345
370
|
|
|
346
|
-
/**
|
|
371
|
+
/**
|
|
372
|
+
* Verifies a JWT signature using the DID in the `kid` header. Returns the
|
|
373
|
+
* parsed payload as an untyped object.
|
|
374
|
+
*
|
|
375
|
+
* The return type is intentionally `Record<string, unknown>` rather than a
|
|
376
|
+
* caller-supplied generic — a JWT payload is bytes from a remote party, and
|
|
377
|
+
* we can't soundly assert its shape without runtime validation. Callers must
|
|
378
|
+
* apply one of the {@link assertConnectRequest} / {@link assertConnectResponse}
|
|
379
|
+
* assertion helpers (or their own type guard) to narrow the payload before
|
|
380
|
+
* accessing fields.
|
|
381
|
+
*/
|
|
347
382
|
async function verifyJwt({ jwt }: { jwt: string }): Promise<Record<string, unknown>> {
|
|
348
383
|
const [headerB64U, payloadB64U, signatureB64U] = jwt.split('.');
|
|
349
384
|
|
|
@@ -379,7 +414,129 @@ async function verifyJwt({ jwt }: { jwt: string }): Promise<Record<string, unkno
|
|
|
379
414
|
throw new Error('Connect: JWT verification failed — invalid signature.');
|
|
380
415
|
}
|
|
381
416
|
|
|
382
|
-
|
|
417
|
+
const decoded: unknown = Convert.base64Url(payloadB64U).toObject();
|
|
418
|
+
if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) {
|
|
419
|
+
throw new Error('Connect: JWT verification failed — payload must be a JSON object.');
|
|
420
|
+
}
|
|
421
|
+
return decoded as Record<string, unknown>;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Field-level validation helpers ─────────────────────────────────────
|
|
425
|
+
//
|
|
426
|
+
// The connect-request / connect-response assertions below describe their
|
|
427
|
+
// expected shape declaratively in terms of these primitive checks. Each
|
|
428
|
+
// helper throws a consistent `Connect: <context> — \`<field>\` <reason>`
|
|
429
|
+
// message on mismatch, so per-field error formatting is centralized here
|
|
430
|
+
// rather than duplicated at every call site.
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Throws a shape-validation error during connect JWT assertion.
|
|
434
|
+
*
|
|
435
|
+
* Deliberately throws plain `Error` rather than `TypeError` even though
|
|
436
|
+
* the failures are runtime type-shape mismatches. The reason is layered
|
|
437
|
+
* error handling: boundary-validation failures need to propagate through
|
|
438
|
+
* the same `try/catch` paths as every other connect-flow error — vault
|
|
439
|
+
* lock failure, JWT signature failure, DWN request failure, etc. —
|
|
440
|
+
* without `catch` blocks having to special-case a `TypeError` subclass.
|
|
441
|
+
*
|
|
442
|
+
* SonarCloud's `typescript:S7786` flags this as "too unspecific for a
|
|
443
|
+
* type check" and prefers `TypeError`. We suppress it at the file level
|
|
444
|
+
* (see `sonar-project.properties`) because every `require*` helper goes
|
|
445
|
+
* through this single throw site.
|
|
446
|
+
*/
|
|
447
|
+
function fail(context: string, field: string, reason: string): never {
|
|
448
|
+
throw new Error(`Connect: ${context} — \`${field}\` ${reason}.`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function requireString(payload: Record<string, unknown>, field: string, context: string): void {
|
|
452
|
+
if (typeof payload[field] !== 'string') { fail(context, field, 'must be a string'); }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function requireNumber(payload: Record<string, unknown>, field: string, context: string): void {
|
|
456
|
+
if (typeof payload[field] !== 'number') { fail(context, field, 'must be a number'); }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function requireArray(payload: Record<string, unknown>, field: string, context: string): void {
|
|
460
|
+
if (!Array.isArray(payload[field])) { fail(context, field, 'must be an array'); }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function requireObject(payload: Record<string, unknown>, field: string, context: string): void {
|
|
464
|
+
const value = payload[field];
|
|
465
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
466
|
+
fail(context, field, 'must be an object');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function requireLiteral<L extends string | number | boolean>(
|
|
471
|
+
payload: Record<string, unknown>, field: string, expected: L, context: string,
|
|
472
|
+
): void {
|
|
473
|
+
if (payload[field] !== expected) { fail(context, field, `must be ${JSON.stringify(expected)}`); }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function requireStringArray(payload: Record<string, unknown>, field: string, context: string): void {
|
|
477
|
+
const value = payload[field];
|
|
478
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
|
|
479
|
+
fail(context, field, 'must be a string[]');
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function requireOptionalString(payload: Record<string, unknown>, field: string, context: string): void {
|
|
484
|
+
if (payload[field] !== undefined && typeof payload[field] !== 'string') {
|
|
485
|
+
fail(context, field, 'must be a string when present');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function requireOptionalArray(payload: Record<string, unknown>, field: string, context: string): void {
|
|
490
|
+
if (payload[field] !== undefined && !Array.isArray(payload[field])) {
|
|
491
|
+
fail(context, field, 'must be an array when present');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ─── Boundary assertions ────────────────────────────────────────────────
|
|
496
|
+
//
|
|
497
|
+
// Each `assertConnect*` describes the shape of its target type as a list
|
|
498
|
+
// of per-field requirements. The structural existence/primitive checks
|
|
499
|
+
// are the only validation done at the JWT-payload boundary; deeper
|
|
500
|
+
// validation of nested arrays/objects (permission scopes, grants,
|
|
501
|
+
// portable DID structure) happens downstream in DWN-aware validators
|
|
502
|
+
// where the richer logic already lives.
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Runtime assertion that a verified JWT payload has the shape of an
|
|
506
|
+
* {@link EnboxConnectRequest}. Use immediately after `verifyJwt()` to narrow
|
|
507
|
+
* a `Record<string, unknown>` payload before accessing fields.
|
|
508
|
+
*/
|
|
509
|
+
function assertConnectRequest(payload: Record<string, unknown>): asserts payload is EnboxConnectRequest {
|
|
510
|
+
const ctx = 'invalid connect request';
|
|
511
|
+
requireString(payload, 'clientDid', ctx);
|
|
512
|
+
requireString(payload, 'appName', ctx);
|
|
513
|
+
requireArray(payload, 'permissionRequests', ctx);
|
|
514
|
+
requireString(payload, 'nonce', ctx);
|
|
515
|
+
requireString(payload, 'state', ctx);
|
|
516
|
+
requireString(payload, 'callbackUrl', ctx);
|
|
517
|
+
requireLiteral(payload, 'responseMode', 'direct_post', ctx);
|
|
518
|
+
requireStringArray(payload, 'supportedDidMethods', ctx);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Runtime assertion that a verified JWT payload has the shape of an
|
|
523
|
+
* {@link EnboxConnectResponse}. Use immediately after `verifyJwt()` to narrow
|
|
524
|
+
* a `Record<string, unknown>` payload before accessing fields.
|
|
525
|
+
*/
|
|
526
|
+
function assertConnectResponse(payload: Record<string, unknown>): asserts payload is EnboxConnectResponse {
|
|
527
|
+
const ctx = 'invalid connect response';
|
|
528
|
+
requireString(payload, 'providerDid', ctx);
|
|
529
|
+
requireString(payload, 'delegateDid', ctx);
|
|
530
|
+
requireString(payload, 'aud', ctx);
|
|
531
|
+
requireNumber(payload, 'iat', ctx);
|
|
532
|
+
requireNumber(payload, 'exp', ctx);
|
|
533
|
+
requireOptionalString(payload, 'nonce', ctx);
|
|
534
|
+
requireArray(payload, 'delegateGrants', ctx);
|
|
535
|
+
requireObject(payload, 'delegatePortableDid', ctx);
|
|
536
|
+
requireOptionalArray(payload, 'delegateDecryptionKeys', ctx);
|
|
537
|
+
requireOptionalArray(payload, 'delegateContextKeys', ctx);
|
|
538
|
+
requireOptionalArray(payload, 'delegateMultiPartyProtocols', ctx);
|
|
539
|
+
requireOptionalArray(payload, 'sessionRevocations', ctx);
|
|
383
540
|
}
|
|
384
541
|
|
|
385
542
|
// ---------------------------------------------------------------------------
|
|
@@ -622,7 +779,9 @@ async function getConnectRequest(requestUri: string, encryptionKey: string): Pro
|
|
|
622
779
|
const response = await fetch(requestUri, { signal: AbortSignal.timeout(30_000) });
|
|
623
780
|
const jwe = await response.text();
|
|
624
781
|
const jwt = await decryptRequest({ jwe, encryptionKey });
|
|
625
|
-
|
|
782
|
+
const payload = await verifyJwt({ jwt });
|
|
783
|
+
assertConnectRequest(payload);
|
|
784
|
+
return payload;
|
|
626
785
|
}
|
|
627
786
|
|
|
628
787
|
// ---------------------------------------------------------------------------
|
|
@@ -722,6 +881,7 @@ async function createPermissionGrants(
|
|
|
722
881
|
targetDid : selectedDid,
|
|
723
882
|
message : rawMessage,
|
|
724
883
|
data : new Blob([data as BlobPart]),
|
|
884
|
+
signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
|
|
725
885
|
});
|
|
726
886
|
return { grantIndex, dwnUrl, reply };
|
|
727
887
|
},
|
|
@@ -760,24 +920,31 @@ async function createPermissionGrants(
|
|
|
760
920
|
// ---------------------------------------------------------------------------
|
|
761
921
|
|
|
762
922
|
/**
|
|
763
|
-
*
|
|
764
|
-
*
|
|
923
|
+
* Ensures the protocol is installed on the provider's local DWN so that the
|
|
924
|
+
* agent can sign and (when applicable) encrypt grants for it during
|
|
925
|
+
* `submitConnectResponse`.
|
|
926
|
+
*
|
|
927
|
+
* Remote installation (push to every owner DWN endpoint) is the
|
|
928
|
+
* responsibility of the calling client (the wallet's own `prepareProtocol`
|
|
929
|
+
* runs *before* `submitConnectResponse` and fans out to every endpoint in
|
|
930
|
+
* parallel). When the protocol already exists locally — the common case —
|
|
931
|
+
* this function performs a single local `ProtocolsQuery` and returns: there
|
|
932
|
+
* is no remote send, so a slow/unhealthy DWN endpoint cannot block the
|
|
933
|
+
* "Authorizing…" hot path.
|
|
765
934
|
*
|
|
766
|
-
* When the protocol
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
935
|
+
* When the protocol is *not* installed locally — a safety fallback for
|
|
936
|
+
* callers that did not pre-install — the protocol is configured locally
|
|
937
|
+
* (with `encryption: true` when any type declares `encryptionRequired: true`,
|
|
938
|
+
* so the agent injects `$encryption` keys derived from the owner's X25519
|
|
939
|
+
* root key) and then fanned out to every owner DWN endpoint with bounded
|
|
940
|
+
* concurrency and a short per-request budget. Endpoint failures are
|
|
941
|
+
* non-fatal — sync delivers any missing copies eventually.
|
|
771
942
|
*/
|
|
772
943
|
async function prepareProtocol(
|
|
773
944
|
selectedDid: string,
|
|
774
945
|
agent: EnboxPlatformAgent,
|
|
775
946
|
protocolDefinition: DwnProtocolDefinition
|
|
776
947
|
): Promise<void> {
|
|
777
|
-
// Detect whether any type in the protocol requires encryption.
|
|
778
|
-
const needsEncryption = Object.values(protocolDefinition.types ?? {})
|
|
779
|
-
.some((type: any) => type?.encryptionRequired === true);
|
|
780
|
-
|
|
781
948
|
const queryMessage = await agent.processDwnRequest({
|
|
782
949
|
author : selectedDid,
|
|
783
950
|
messageType : DwnInterface.ProtocolsQuery,
|
|
@@ -787,42 +954,66 @@ async function prepareProtocol(
|
|
|
787
954
|
|
|
788
955
|
if (queryMessage.reply.status.code !== 200) {
|
|
789
956
|
throw new Error(`Could not fetch protocol: ${queryMessage.reply.status.detail}`);
|
|
790
|
-
}
|
|
791
|
-
logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`);
|
|
792
|
-
|
|
793
|
-
const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({
|
|
794
|
-
author : selectedDid,
|
|
795
|
-
target : selectedDid,
|
|
796
|
-
messageType : DwnInterface.ProtocolsConfigure,
|
|
797
|
-
messageParams : { definition: protocolDefinition },
|
|
798
|
-
encryption : needsEncryption || undefined,
|
|
799
|
-
});
|
|
957
|
+
}
|
|
800
958
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
959
|
+
const isInstalledLocally = queryMessage.reply.entries !== undefined
|
|
960
|
+
&& queryMessage.reply.entries.length > 0;
|
|
961
|
+
|
|
962
|
+
if (isInstalledLocally) {
|
|
963
|
+
// Already installed locally. The wallet's pre-call `prepareProtocol`
|
|
964
|
+
// is responsible for fanning the protocol out to every owner DWN
|
|
965
|
+
// endpoint; sync delivers any missing copies eventually. Skipping the
|
|
966
|
+
// remote send here turns this hot path into a single local DB read
|
|
967
|
+
// (~10 ms) instead of a sequential per-endpoint network round-trip
|
|
968
|
+
// with retries — the latter could take minutes if any endpoint was
|
|
969
|
+
// slow or unreachable.
|
|
970
|
+
logger.log(`Protocol already installed locally: ${protocolDefinition.protocol}`);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
804
973
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
logger.log(`Protocol already exists: ${protocolDefinition.protocol}`);
|
|
813
|
-
|
|
814
|
-
const configureMessage = queryMessage.reply.entries![0];
|
|
815
|
-
const { reply: sendReply } = await agent.sendDwnRequest({
|
|
816
|
-
author : selectedDid,
|
|
817
|
-
target : selectedDid,
|
|
818
|
-
messageType : DwnInterface.ProtocolsConfigure,
|
|
819
|
-
rawMessage : configureMessage,
|
|
820
|
-
});
|
|
974
|
+
// Safety fallback — protocol is missing locally, so the caller did not
|
|
975
|
+
// pre-install. Configure it locally (with encryption derivation if any
|
|
976
|
+
// type requires it) so the agent can sign/encrypt grants, then push to
|
|
977
|
+
// every owner DWN endpoint in parallel with a short per-request budget.
|
|
978
|
+
logger.log(`Protocol not installed, configuring locally: ${protocolDefinition.protocol}`);
|
|
979
|
+
const needsEncryption = Object.values(protocolDefinition.types ?? {})
|
|
980
|
+
.some((type: any) => type?.encryptionRequired === true);
|
|
821
981
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
982
|
+
const { reply: configureReply, message: configureMessage } = await agent.processDwnRequest({
|
|
983
|
+
author : selectedDid,
|
|
984
|
+
target : selectedDid,
|
|
985
|
+
messageType : DwnInterface.ProtocolsConfigure,
|
|
986
|
+
messageParams : { definition: protocolDefinition },
|
|
987
|
+
encryption : needsEncryption || undefined,
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
if (configureReply.status.code !== 202 && configureReply.status.code !== 409) {
|
|
991
|
+
throw new Error(`Could not configure protocol locally: ${configureReply.status.detail}`);
|
|
825
992
|
}
|
|
993
|
+
|
|
994
|
+
let dwnEndpointUrls: string[] = [];
|
|
995
|
+
try {
|
|
996
|
+
dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
|
|
997
|
+
} catch {
|
|
998
|
+
// Endpoint resolution failure — protocol stays local-only until sync.
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (dwnEndpointUrls.length === 0) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Best-effort remote fan-out with bounded concurrency and a per-request
|
|
1006
|
+
// abort signal. Failures are tolerated (sync delivers eventually).
|
|
1007
|
+
await mapConcurrentSettled(
|
|
1008
|
+
dwnEndpointUrls,
|
|
1009
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
1010
|
+
(dwnUrl) => agent.rpc.sendDwnRequest({
|
|
1011
|
+
dwnUrl,
|
|
1012
|
+
targetDid : selectedDid,
|
|
1013
|
+
message : configureMessage!,
|
|
1014
|
+
signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
|
|
1015
|
+
}),
|
|
1016
|
+
);
|
|
826
1017
|
}
|
|
827
1018
|
|
|
828
1019
|
/**
|
|
@@ -856,10 +1047,12 @@ async function deriveScopedDecryptionKeys(
|
|
|
856
1047
|
DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
|
|
857
1048
|
]);
|
|
858
1049
|
|
|
859
|
-
// Collect read-like scopes only.
|
|
1050
|
+
// Collect read-like scopes only. `isRecordPermissionScope` narrows to
|
|
1051
|
+
// `DwnRecordsPermissionScope`, which declares `protocolPath?: string`
|
|
1052
|
+
// and `contextId?: string` — no `as any` needed for those reads below.
|
|
860
1053
|
const readScopes = scopes.filter(
|
|
861
|
-
(s): s is
|
|
862
|
-
isRecordPermissionScope(s) && readMethods.has(s.method
|
|
1054
|
+
(s): s is DwnRecordsPermissionScope =>
|
|
1055
|
+
isRecordPermissionScope(s) && readMethods.has(s.method),
|
|
863
1056
|
);
|
|
864
1057
|
|
|
865
1058
|
if (readScopes.length === 0) {
|
|
@@ -868,7 +1061,7 @@ async function deriveScopedDecryptionKeys(
|
|
|
868
1061
|
|
|
869
1062
|
// Fail closed: reject contextId-scoped encrypted reads.
|
|
870
1063
|
for (const scope of readScopes) {
|
|
871
|
-
if (
|
|
1064
|
+
if (scope.contextId) {
|
|
872
1065
|
throw new Error(
|
|
873
1066
|
`Encrypted delegate access scoped by contextId is not supported ` +
|
|
874
1067
|
`yet; use protocol-wide permissions for protocol '${protocolUri}'.`,
|
|
@@ -889,9 +1082,7 @@ async function deriveScopedDecryptionKeys(
|
|
|
889
1082
|
}
|
|
890
1083
|
|
|
891
1084
|
// Check if any scope is protocol-wide (no protocolPath).
|
|
892
|
-
const hasProtocolWideRead = readScopes.some(
|
|
893
|
-
(s) => !('protocolPath' in s) || !(s as any).protocolPath,
|
|
894
|
-
);
|
|
1085
|
+
const hasProtocolWideRead = readScopes.some((s) => !s.protocolPath);
|
|
895
1086
|
|
|
896
1087
|
const { keyId, keyUri } = await getEncryptionKeyInfo(agent, ownerDid);
|
|
897
1088
|
|
|
@@ -920,8 +1111,7 @@ async function deriveScopedDecryptionKeys(
|
|
|
920
1111
|
// Emit one exact-path key per unique protocolPath.
|
|
921
1112
|
const uniquePaths = new Set<string>();
|
|
922
1113
|
for (const scope of readScopes) {
|
|
923
|
-
|
|
924
|
-
if (pp) { uniquePaths.add(pp); }
|
|
1114
|
+
if (scope.protocolPath) { uniquePaths.add(scope.protocolPath); }
|
|
925
1115
|
}
|
|
926
1116
|
|
|
927
1117
|
const keys: DelegateDecryptionKey[] = [];
|
|
@@ -1011,9 +1201,12 @@ async function deriveContextKeysForDelegate(
|
|
|
1011
1201
|
DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
|
|
1012
1202
|
]);
|
|
1013
1203
|
|
|
1204
|
+
// `isRecordPermissionScope` narrows to `DwnRecordsPermissionScope`,
|
|
1205
|
+
// which declares `protocolPath?: string` and `contextId?: string` —
|
|
1206
|
+
// no `as any` needed for the field reads below.
|
|
1014
1207
|
const readScopes = scopes.filter(
|
|
1015
|
-
(s): s is
|
|
1016
|
-
isRecordPermissionScope(s) && readMethods.has(s.method
|
|
1208
|
+
(s): s is DwnRecordsPermissionScope =>
|
|
1209
|
+
isRecordPermissionScope(s) && readMethods.has(s.method),
|
|
1017
1210
|
);
|
|
1018
1211
|
|
|
1019
1212
|
if (readScopes.length === 0) {
|
|
@@ -1022,7 +1215,7 @@ async function deriveContextKeysForDelegate(
|
|
|
1022
1215
|
|
|
1023
1216
|
// Fail closed: reject contextId-scoped reads.
|
|
1024
1217
|
for (const scope of readScopes) {
|
|
1025
|
-
if (
|
|
1218
|
+
if (scope.contextId) {
|
|
1026
1219
|
throw new Error(
|
|
1027
1220
|
`Encrypted delegate access scoped by contextId is not supported ` +
|
|
1028
1221
|
`yet; use protocol-wide permissions for protocol ` +
|
|
@@ -1033,7 +1226,7 @@ async function deriveContextKeysForDelegate(
|
|
|
1033
1226
|
|
|
1034
1227
|
// Fail closed: reject protocolPath-scoped reads on multi-party protocols.
|
|
1035
1228
|
for (const scope of readScopes) {
|
|
1036
|
-
if (
|
|
1229
|
+
if (scope.protocolPath) {
|
|
1037
1230
|
throw new Error(
|
|
1038
1231
|
`Encrypted delegate access scoped by protocolPath on multi-party ` +
|
|
1039
1232
|
`protocols is not supported yet; use protocol-wide permissions for ` +
|
|
@@ -1066,8 +1259,7 @@ async function deriveContextKeysForDelegate(
|
|
|
1066
1259
|
});
|
|
1067
1260
|
|
|
1068
1261
|
for (const entry of reply.entries ?? []) {
|
|
1069
|
-
const rootContextId =
|
|
1070
|
-
|| (entry as any).recordId;
|
|
1262
|
+
const rootContextId = entry.contextId?.split('/')[0] ?? entry.recordId;
|
|
1071
1263
|
|
|
1072
1264
|
if (!rootContextId || seenContextIds.has(rootContextId)) { continue; }
|
|
1073
1265
|
seenContextIds.add(rootContextId);
|
|
@@ -1119,260 +1311,324 @@ async function submitConnectResponse(
|
|
|
1119
1311
|
pin: string | undefined,
|
|
1120
1312
|
agent: EnboxPlatformAgent
|
|
1121
1313
|
): Promise<void> {
|
|
1122
|
-
const
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
// Derive the delegate's key-delivery ProtocolPath leaf public key.
|
|
1138
|
-
// This is the pre-derived key that the owner will use later when writing
|
|
1139
|
-
// contextKey records addressed to this delegate. The owner cannot derive
|
|
1140
|
-
// this from the delegate's root public key alone (HKDF needs the private
|
|
1141
|
-
// key), so we compute it now while we have temporary access to the
|
|
1142
|
-
// delegate's private key material.
|
|
1143
|
-
const delegateX25519PrivateKeyBytes = await X25519.privateKeyToBytes({
|
|
1144
|
-
privateKey: delegateX25519PrivateKey,
|
|
1145
|
-
});
|
|
1146
|
-
const keyDeliveryDerivationPath = [
|
|
1147
|
-
KeyDerivationScheme.ProtocolPath,
|
|
1148
|
-
KeyDeliveryProtocolDefinition.protocol,
|
|
1149
|
-
'contextKey',
|
|
1150
|
-
];
|
|
1151
|
-
const delegateLeafPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(
|
|
1152
|
-
delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath,
|
|
1314
|
+
const submitStart = nowMs();
|
|
1315
|
+
const numProtocols = connectRequest.permissionRequests.length;
|
|
1316
|
+
const numScopes = connectRequest.permissionRequests.reduce(
|
|
1317
|
+
(sum, req) => sum + req.permissionScopes.length,
|
|
1318
|
+
0,
|
|
1319
|
+
);
|
|
1320
|
+
// Tracked across the try/finally so the aggregate `submitConnectResponse.total`
|
|
1321
|
+
// log emits on both success and failure paths — operators bisecting wall-time
|
|
1322
|
+
// from wallet debug logs need the total even when a phase throws.
|
|
1323
|
+
let sessionGrantCount = 0;
|
|
1324
|
+
let outcome: 'ok' | 'fail' = 'ok';
|
|
1325
|
+
logger.log(
|
|
1326
|
+
`${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.start `
|
|
1327
|
+
+ `(protocols=${numProtocols}, scopes=${numScopes})`,
|
|
1153
1328
|
);
|
|
1154
|
-
const delegateLeafPrivateKeyJwk = await X25519.bytesToPrivateKey({
|
|
1155
|
-
privateKeyBytes: delegateLeafPrivateKeyBytes,
|
|
1156
|
-
});
|
|
1157
|
-
const delegateKeyDeliveryLeafPublicKey = await X25519.getPublicKey({
|
|
1158
|
-
key: delegateLeafPrivateKeyJwk,
|
|
1159
|
-
});
|
|
1160
1329
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1330
|
+
try {
|
|
1331
|
+
const delegateBearerDid = await timed(`${CONNECT_PERF_LOG_PREFIX} delegateDid.create`, () => DidJwk.create());
|
|
1332
|
+
const delegatePortableDid = await delegateBearerDid.export();
|
|
1333
|
+
|
|
1334
|
+
// Add X25519 key derived from the delegate's Ed25519 key.
|
|
1335
|
+
// did:jwk only supports one verification method, but DWN encryption
|
|
1336
|
+
// requires X25519 for key agreement. Including the derived X25519
|
|
1337
|
+
// private key in the PortableDid ensures the delegate agent's KMS
|
|
1338
|
+
// has both keys after import. The Ed25519→X25519 conversion is a
|
|
1339
|
+
// standard cryptographic operation (RFC 8032 / libsodium).
|
|
1340
|
+
const delegateEdPrivateKey = delegatePortableDid.privateKeys![0];
|
|
1341
|
+
const delegateX25519PrivateKey = await Ed25519.convertPrivateKeyToX25519({
|
|
1342
|
+
privateKey: delegateEdPrivateKey,
|
|
1343
|
+
});
|
|
1344
|
+
delegatePortableDid.privateKeys!.push(delegateX25519PrivateKey);
|
|
1345
|
+
|
|
1346
|
+
// Derive the delegate's key-delivery ProtocolPath leaf public key.
|
|
1347
|
+
// This is the pre-derived key that the owner will use later when writing
|
|
1348
|
+
// contextKey records addressed to this delegate. The owner cannot derive
|
|
1349
|
+
// this from the delegate's root public key alone (HKDF needs the private
|
|
1350
|
+
// key), so we compute it now while we have temporary access to the
|
|
1351
|
+
// delegate's private key material.
|
|
1352
|
+
const delegateX25519PrivateKeyBytes = await X25519.privateKeyToBytes({
|
|
1353
|
+
privateKey: delegateX25519PrivateKey,
|
|
1354
|
+
});
|
|
1355
|
+
const keyDeliveryDerivationPath = [
|
|
1356
|
+
KeyDerivationScheme.ProtocolPath,
|
|
1357
|
+
KeyDeliveryProtocolDefinition.protocol,
|
|
1358
|
+
'contextKey',
|
|
1359
|
+
];
|
|
1360
|
+
const delegateLeafPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(
|
|
1361
|
+
delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath,
|
|
1362
|
+
);
|
|
1363
|
+
const delegateLeafPrivateKeyJwk = await X25519.bytesToPrivateKey({
|
|
1364
|
+
privateKeyBytes: delegateLeafPrivateKeyBytes,
|
|
1365
|
+
});
|
|
1366
|
+
const delegateKeyDeliveryLeafPublicKey = await X25519.getPublicKey({
|
|
1367
|
+
key: delegateLeafPrivateKeyJwk,
|
|
1368
|
+
});
|
|
1172
1369
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1370
|
+
// The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
|
|
1371
|
+
// For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
|
|
1372
|
+
// this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
|
|
1373
|
+
// `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
|
|
1374
|
+
// the same id — which they do because both derive from verificationMethod.id
|
|
1375
|
+
// of the keyAgreement relationship.
|
|
1376
|
+
const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod![0].id;
|
|
1377
|
+
const delegateKeyDeliveryData = {
|
|
1378
|
+
rootKeyId : delegateKeyAgreementVmId,
|
|
1379
|
+
publicKeyJwk : delegateKeyDeliveryLeafPublicKey,
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
// Derive scope-aware decryption keys for encrypted protocols.
|
|
1383
|
+
// Single-party: ProtocolPath keys (protocol-wide or exact-path).
|
|
1384
|
+
// Multi-party: ProtocolContext keys (per rootContextId).
|
|
1385
|
+
// Write-only delegates receive no decryption capability.
|
|
1386
|
+
const delegateDecryptionKeys: DelegateDecryptionKey[] = [];
|
|
1387
|
+
const delegateContextKeys: DelegateContextKey[] = [];
|
|
1388
|
+
const delegateMultiPartyProtocols: string[] = [];
|
|
1389
|
+
|
|
1390
|
+
const delegateGrants = await timed(
|
|
1391
|
+
`${CONNECT_PERF_LOG_PREFIX} permissionGrants.fanout (protocols=${numProtocols})`,
|
|
1392
|
+
async () => {
|
|
1393
|
+
const delegateGrantPromises = connectRequest.permissionRequests.map(
|
|
1394
|
+
async (permissionRequest) => {
|
|
1395
|
+
const { protocolDefinition, permissionScopes } = permissionRequest;
|
|
1396
|
+
|
|
1397
|
+
const grantsMatchProtocolUri = permissionScopes.every(
|
|
1398
|
+
scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol
|
|
1399
|
+
);
|
|
1400
|
+
if (!grantsMatchProtocolUri) {
|
|
1401
|
+
throw new Error('All permission scopes must match the protocol URI they are provided with.');
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
await prepareProtocol(selectedDid, agent, protocolDefinition);
|
|
1405
|
+
|
|
1406
|
+
const hasEncryptedTypes = Object.values(protocolDefinition.types ?? {})
|
|
1407
|
+
.some((type: any) => type?.encryptionRequired === true);
|
|
1408
|
+
|
|
1409
|
+
if (hasEncryptedTypes) {
|
|
1410
|
+
const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
|
|
1411
|
+
|
|
1412
|
+
if (multiParty.length > 0 && singleParty.length > 0) {
|
|
1413
|
+
// Mixed protocol: some roots are multi-party, others single-party.
|
|
1414
|
+
// We cannot safely model this with either key type alone.
|
|
1415
|
+
throw new Error(
|
|
1416
|
+
`Encrypted delegate access for protocols with mixed single-party ` +
|
|
1417
|
+
`and multi-party roots is not supported yet. ` +
|
|
1418
|
+
`Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
|
|
1419
|
+
`[${multiParty.join(', ')}] and single-party roots ` +
|
|
1420
|
+
`[${singleParty.join(', ')}].`,
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (multiParty.length > 0) {
|
|
1425
|
+
// Pure multi-party: derive per-context keys for existing contexts.
|
|
1426
|
+
// Unsupported scope shapes (protocolPath, contextId) throw.
|
|
1427
|
+
const ctxKeys = await deriveContextKeysForDelegate(
|
|
1428
|
+
agent, selectedDid, protocolDefinition, permissionScopes,
|
|
1429
|
+
);
|
|
1430
|
+
delegateContextKeys.push(...ctxKeys);
|
|
1431
|
+
|
|
1432
|
+
// Only register the protocol for post-connect delivery if the
|
|
1433
|
+
// delegate has at least one read-like scope. Write-only delegates
|
|
1434
|
+
// must NOT receive context keys — they have no decryption need.
|
|
1435
|
+
const readMethods = new Set([
|
|
1436
|
+
DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
|
|
1437
|
+
]);
|
|
1438
|
+
const hasReadLikeScope = permissionScopes.some(
|
|
1439
|
+
(s): boolean => isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
|
|
1440
|
+
);
|
|
1441
|
+
if (hasReadLikeScope) {
|
|
1442
|
+
delegateMultiPartyProtocols.push(protocolDefinition.protocol);
|
|
1443
|
+
}
|
|
1444
|
+
} else {
|
|
1445
|
+
// Pure single-party: derive ProtocolPath keys.
|
|
1446
|
+
// Unsupported scope shapes (contextId) throw.
|
|
1447
|
+
const keys = await deriveScopedDecryptionKeys(
|
|
1448
|
+
agent, selectedDid, protocolDefinition.protocol,
|
|
1449
|
+
permissionScopes, protocolDefinition,
|
|
1450
|
+
);
|
|
1451
|
+
delegateDecryptionKeys.push(...keys);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
return EnboxConnectProtocol.createPermissionGrants(
|
|
1456
|
+
selectedDid,
|
|
1457
|
+
delegateBearerDid,
|
|
1458
|
+
agent,
|
|
1459
|
+
permissionScopes,
|
|
1460
|
+
delegateKeyDeliveryData,
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
);
|
|
1180
1464
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1465
|
+
return (await Promise.all(delegateGrantPromises)).flat();
|
|
1466
|
+
},
|
|
1467
|
+
);
|
|
1184
1468
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1469
|
+
// Create per-grant contextId-scoped revocation grants.
|
|
1470
|
+
// Each revocation grant authorizes the delegate to write a revocation
|
|
1471
|
+
// ONLY for the specific session grant it corresponds to.
|
|
1472
|
+
const permissionsApi = new AgentPermissionsApi({ agent });
|
|
1473
|
+
const sessionRevocations: { grantId: string; revocationGrantId: string }[] = [];
|
|
1474
|
+
let revGrantEndpoints: string[] = [];
|
|
1475
|
+
try {
|
|
1476
|
+
revGrantEndpoints = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
|
|
1477
|
+
} catch {
|
|
1478
|
+
// Endpoint resolution failure — revocation grants will be local-only until sync.
|
|
1479
|
+
}
|
|
1191
1480
|
|
|
1192
|
-
|
|
1481
|
+
// Snapshot the current length — revocation grants are appended to delegateGrants
|
|
1482
|
+
// below, but we must NOT iterate over them (they are meta-grants, not session grants).
|
|
1483
|
+
sessionGrantCount = delegateGrants.length;
|
|
1484
|
+
|
|
1485
|
+
// Phase 1: create all revocation grants locally with bounded concurrency.
|
|
1486
|
+
// createGrant is local-only (storage + signing) so it's cheap, but we still
|
|
1487
|
+
// cap parallelism to avoid head-of-line blocking when sessionGrantCount is
|
|
1488
|
+
// large (e.g. dapp requesting many scopes at once).
|
|
1489
|
+
const revGrantResults = await timed(
|
|
1490
|
+
`${CONNECT_PERF_LOG_PREFIX} revocationGrants.create (n=${sessionGrantCount})`,
|
|
1491
|
+
() => mapConcurrent(
|
|
1492
|
+
delegateGrants.slice(0, sessionGrantCount),
|
|
1493
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
1494
|
+
(grantMessage) =>
|
|
1495
|
+
permissionsApi.createGrant({
|
|
1496
|
+
delegated : true,
|
|
1497
|
+
store : true,
|
|
1498
|
+
grantedTo : delegateBearerDid.uri,
|
|
1499
|
+
scope : {
|
|
1500
|
+
interface : DwnInterfaceName.Records,
|
|
1501
|
+
method : DwnMethodName.Write,
|
|
1502
|
+
protocol : PermissionsProtocol.uri,
|
|
1503
|
+
contextId : grantMessage.recordId,
|
|
1504
|
+
},
|
|
1505
|
+
dateExpires : '2040-06-25T16:09:16.693356Z',
|
|
1506
|
+
author : selectedDid,
|
|
1507
|
+
}).then((revGrant) => ({ grantMessage, revGrant })),
|
|
1508
|
+
),
|
|
1509
|
+
);
|
|
1193
1510
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1511
|
+
// Phase 2: fan out every revocation grant to every owner DWN endpoint with
|
|
1512
|
+
// a single global concurrency cap so that (grants × endpoints) cannot blow
|
|
1513
|
+
// up. This is best-effort (sync delivers eventually) so individual failures
|
|
1514
|
+
// are tolerated by `mapConcurrentSettled`.
|
|
1515
|
+
const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
|
|
1516
|
+
sessionRevocations.push({
|
|
1517
|
+
grantId : grantMessage.recordId,
|
|
1518
|
+
revocationGrantId : revGrant.message.recordId,
|
|
1519
|
+
});
|
|
1196
1520
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1521
|
+
const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
|
|
1522
|
+
const revData = Uint8Array.from(Convert.base64Url(revEncoded).toUint8Array());
|
|
1199
1523
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
// We cannot safely model this with either key type alone.
|
|
1203
|
-
throw new Error(
|
|
1204
|
-
`Encrypted delegate access for protocols with mixed single-party ` +
|
|
1205
|
-
`and multi-party roots is not supported yet. ` +
|
|
1206
|
-
`Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
|
|
1207
|
-
`[${multiParty.join(', ')}] and single-party roots ` +
|
|
1208
|
-
`[${singleParty.join(', ')}].`,
|
|
1209
|
-
);
|
|
1210
|
-
}
|
|
1524
|
+
// Include the revocation grant in the delegate grants for distribution.
|
|
1525
|
+
delegateGrants.push(revGrant.message);
|
|
1211
1526
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
// Unsupported scope shapes (protocolPath, contextId) throw.
|
|
1215
|
-
const ctxKeys = await deriveContextKeysForDelegate(
|
|
1216
|
-
agent, selectedDid, protocolDefinition, permissionScopes,
|
|
1217
|
-
);
|
|
1218
|
-
delegateContextKeys.push(...ctxKeys);
|
|
1219
|
-
|
|
1220
|
-
// Only register the protocol for post-connect delivery if the
|
|
1221
|
-
// delegate has at least one read-like scope. Write-only delegates
|
|
1222
|
-
// must NOT receive context keys — they have no decryption need.
|
|
1223
|
-
const readMethods = new Set([
|
|
1224
|
-
DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
|
|
1225
|
-
]);
|
|
1226
|
-
const hasReadLikeScope = permissionScopes.some(
|
|
1227
|
-
(s): boolean => isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
|
|
1228
|
-
);
|
|
1229
|
-
if (hasReadLikeScope) {
|
|
1230
|
-
delegateMultiPartyProtocols.push(protocolDefinition.protocol);
|
|
1231
|
-
}
|
|
1232
|
-
} else {
|
|
1233
|
-
// Pure single-party: derive ProtocolPath keys.
|
|
1234
|
-
// Unsupported scope shapes (contextId) throw.
|
|
1235
|
-
const keys = await deriveScopedDecryptionKeys(
|
|
1236
|
-
agent, selectedDid, protocolDefinition.protocol,
|
|
1237
|
-
permissionScopes, protocolDefinition,
|
|
1238
|
-
);
|
|
1239
|
-
delegateDecryptionKeys.push(...keys);
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1527
|
+
return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
|
|
1528
|
+
});
|
|
1242
1529
|
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1530
|
+
if (revSendTasks.length > 0) {
|
|
1531
|
+
await timed(
|
|
1532
|
+
`${CONNECT_PERF_LOG_PREFIX} revocationGrants.fanout (sends=${revSendTasks.length}, endpoints=${revGrantEndpoints.length})`,
|
|
1533
|
+
() => mapConcurrentSettled(
|
|
1534
|
+
revSendTasks,
|
|
1535
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
1536
|
+
({ revRawMessage, revData, dwnUrl }) =>
|
|
1537
|
+
agent.rpc.sendDwnRequest({
|
|
1538
|
+
dwnUrl,
|
|
1539
|
+
targetDid : selectedDid,
|
|
1540
|
+
message : revRawMessage,
|
|
1541
|
+
data : new Blob([revData]),
|
|
1542
|
+
signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
|
|
1543
|
+
}),
|
|
1544
|
+
),
|
|
1249
1545
|
);
|
|
1250
1546
|
}
|
|
1251
|
-
);
|
|
1252
1547
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
// below, but we must NOT iterate over them (they are meta-grants, not session grants).
|
|
1269
|
-
const sessionGrantCount = delegateGrants.length;
|
|
1548
|
+
const responseObject = await timed(
|
|
1549
|
+
`${CONNECT_PERF_LOG_PREFIX} response.build`,
|
|
1550
|
+
() => EnboxConnectProtocol.createConnectResponse({
|
|
1551
|
+
providerDid : selectedDid,
|
|
1552
|
+
delegateDid : delegateBearerDid.uri,
|
|
1553
|
+
aud : connectRequest.clientDid,
|
|
1554
|
+
nonce : connectRequest.nonce,
|
|
1555
|
+
delegateGrants,
|
|
1556
|
+
delegatePortableDid,
|
|
1557
|
+
delegateDecryptionKeys : delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
|
|
1558
|
+
delegateContextKeys : delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
|
|
1559
|
+
delegateMultiPartyProtocols : delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
|
|
1560
|
+
sessionRevocations : sessionRevocations.length > 0 ? sessionRevocations : undefined,
|
|
1561
|
+
}),
|
|
1562
|
+
);
|
|
1270
1563
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
(grantMessage) =>
|
|
1279
|
-
permissionsApi.createGrant({
|
|
1280
|
-
delegated : true,
|
|
1281
|
-
store : true,
|
|
1282
|
-
grantedTo : delegateBearerDid.uri,
|
|
1283
|
-
scope : {
|
|
1284
|
-
interface : DwnInterfaceName.Records,
|
|
1285
|
-
method : DwnMethodName.Write,
|
|
1286
|
-
protocol : PermissionsProtocol.uri,
|
|
1287
|
-
contextId : grantMessage.recordId,
|
|
1288
|
-
},
|
|
1289
|
-
dateExpires : '2040-06-25T16:09:16.693356Z',
|
|
1290
|
-
author : selectedDid,
|
|
1291
|
-
}).then((revGrant) => ({ grantMessage, revGrant })),
|
|
1292
|
-
);
|
|
1564
|
+
const responseObjectJwt = await timed(
|
|
1565
|
+
`${CONNECT_PERF_LOG_PREFIX} response.sign`,
|
|
1566
|
+
() => EnboxConnectProtocol.signJwt({
|
|
1567
|
+
did : delegateBearerDid,
|
|
1568
|
+
data : responseObject,
|
|
1569
|
+
}),
|
|
1570
|
+
);
|
|
1293
1571
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
|
|
1299
|
-
sessionRevocations.push({
|
|
1300
|
-
grantId : grantMessage.recordId,
|
|
1301
|
-
revocationGrantId : revGrant.message.recordId,
|
|
1302
|
-
});
|
|
1572
|
+
const clientDid = await timed(
|
|
1573
|
+
`${CONNECT_PERF_LOG_PREFIX} clientDid.resolve`,
|
|
1574
|
+
() => DidJwk.resolve(connectRequest.clientDid),
|
|
1575
|
+
);
|
|
1303
1576
|
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1577
|
+
const sharedKey = await timed(
|
|
1578
|
+
`${CONNECT_PERF_LOG_PREFIX} response.deriveSharedKey`,
|
|
1579
|
+
() => EnboxConnectProtocol.deriveSharedKey(
|
|
1580
|
+
delegateBearerDid,
|
|
1581
|
+
clientDid?.didDocument!,
|
|
1582
|
+
),
|
|
1583
|
+
);
|
|
1306
1584
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1585
|
+
const encryptedResponse = await timed(
|
|
1586
|
+
`${CONNECT_PERF_LOG_PREFIX} response.encrypt`,
|
|
1587
|
+
() => EnboxConnectProtocol.encryptResponse({
|
|
1588
|
+
jwt : responseObjectJwt,
|
|
1589
|
+
encryptionKey : sharedKey,
|
|
1590
|
+
delegatePublicKeyJwk : delegateBearerDid.document.verificationMethod![0].publicKeyJwk!,
|
|
1591
|
+
pin,
|
|
1592
|
+
}),
|
|
1593
|
+
);
|
|
1309
1594
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1595
|
+
const formEncodedRequest = new URLSearchParams({
|
|
1596
|
+
id_token : encryptedResponse,
|
|
1597
|
+
state : connectRequest.state,
|
|
1598
|
+
}).toString();
|
|
1599
|
+
|
|
1600
|
+
await timed(
|
|
1601
|
+
`${CONNECT_PERF_LOG_PREFIX} response.callbackPost`,
|
|
1602
|
+
async () => {
|
|
1603
|
+
const response = await fetch(connectRequest.callbackUrl, {
|
|
1604
|
+
body : formEncodedRequest,
|
|
1605
|
+
method : 'POST',
|
|
1606
|
+
headers : {
|
|
1607
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1608
|
+
},
|
|
1609
|
+
signal: AbortSignal.timeout(30_000),
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
if (!response.ok) {
|
|
1613
|
+
// NOTE: delegate grants have already been written/fanned out by this
|
|
1614
|
+
// point, so callers may observe partial owner-side state when the
|
|
1615
|
+
// callback server rejects the final response delivery.
|
|
1616
|
+
throw new Error(`Connect: callback POST failed with HTTP ${response.status}.`);
|
|
1617
|
+
}
|
|
1312
1618
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1619
|
+
return response;
|
|
1620
|
+
},
|
|
1621
|
+
);
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
outcome = 'fail';
|
|
1624
|
+
throw err;
|
|
1625
|
+
} finally {
|
|
1626
|
+
const totalElapsed = nowMs() - submitStart;
|
|
1627
|
+
logger.log(
|
|
1628
|
+
`${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.total ${outcome} in ${totalElapsed.toFixed(1)}ms `
|
|
1629
|
+
+ `(protocols=${numProtocols}, scopes=${numScopes}, sessionGrants=${sessionGrantCount})`,
|
|
1324
1630
|
);
|
|
1325
1631
|
}
|
|
1326
|
-
|
|
1327
|
-
logger.log('Building connect response...');
|
|
1328
|
-
const responseObject = await EnboxConnectProtocol.createConnectResponse({
|
|
1329
|
-
providerDid : selectedDid,
|
|
1330
|
-
delegateDid : delegateBearerDid.uri,
|
|
1331
|
-
aud : connectRequest.clientDid,
|
|
1332
|
-
nonce : connectRequest.nonce,
|
|
1333
|
-
delegateGrants,
|
|
1334
|
-
delegatePortableDid,
|
|
1335
|
-
delegateDecryptionKeys : delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
|
|
1336
|
-
delegateContextKeys : delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
|
|
1337
|
-
delegateMultiPartyProtocols : delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
|
|
1338
|
-
sessionRevocations : sessionRevocations.length > 0 ? sessionRevocations : undefined,
|
|
1339
|
-
});
|
|
1340
|
-
|
|
1341
|
-
logger.log('Signing connect response...');
|
|
1342
|
-
const responseObjectJwt = await EnboxConnectProtocol.signJwt({
|
|
1343
|
-
did : delegateBearerDid,
|
|
1344
|
-
data : responseObject as unknown as Record<string, unknown>,
|
|
1345
|
-
});
|
|
1346
|
-
|
|
1347
|
-
const clientDid = await DidJwk.resolve(connectRequest.clientDid);
|
|
1348
|
-
|
|
1349
|
-
const sharedKey = await EnboxConnectProtocol.deriveSharedKey(
|
|
1350
|
-
delegateBearerDid,
|
|
1351
|
-
clientDid?.didDocument!
|
|
1352
|
-
);
|
|
1353
|
-
|
|
1354
|
-
logger.log('Encrypting connect response...');
|
|
1355
|
-
const encryptedResponse = await EnboxConnectProtocol.encryptResponse({
|
|
1356
|
-
jwt : responseObjectJwt,
|
|
1357
|
-
encryptionKey : sharedKey,
|
|
1358
|
-
delegatePublicKeyJwk : delegateBearerDid.document.verificationMethod![0].publicKeyJwk!,
|
|
1359
|
-
pin,
|
|
1360
|
-
});
|
|
1361
|
-
|
|
1362
|
-
const formEncodedRequest = new URLSearchParams({
|
|
1363
|
-
id_token : encryptedResponse,
|
|
1364
|
-
state : connectRequest.state,
|
|
1365
|
-
}).toString();
|
|
1366
|
-
|
|
1367
|
-
logger.log(`Sending connect response to: ${connectRequest.callbackUrl}`);
|
|
1368
|
-
await fetch(connectRequest.callbackUrl, {
|
|
1369
|
-
body : formEncodedRequest,
|
|
1370
|
-
method : 'POST',
|
|
1371
|
-
headers : {
|
|
1372
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1373
|
-
},
|
|
1374
|
-
signal: AbortSignal.timeout(30_000),
|
|
1375
|
-
});
|
|
1376
1632
|
}
|
|
1377
1633
|
|
|
1378
1634
|
// ---------------------------------------------------------------------------
|
|
@@ -1383,6 +1639,8 @@ export const EnboxConnectProtocol = {
|
|
|
1383
1639
|
buildConnectUrl,
|
|
1384
1640
|
signJwt,
|
|
1385
1641
|
verifyJwt,
|
|
1642
|
+
assertConnectRequest,
|
|
1643
|
+
assertConnectResponse,
|
|
1386
1644
|
encryptRequest,
|
|
1387
1645
|
decryptRequest,
|
|
1388
1646
|
encryptResponse,
|