@enbox/agent 0.5.15 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/browser.mjs +11 -11
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/dwn-api.js +433 -33
  4. package/dist/esm/dwn-api.js.map +1 -1
  5. package/dist/esm/dwn-encryption.js +131 -12
  6. package/dist/esm/dwn-encryption.js.map +1 -1
  7. package/dist/esm/dwn-key-delivery.js +64 -47
  8. package/dist/esm/dwn-key-delivery.js.map +1 -1
  9. package/dist/esm/enbox-connect-protocol.js +400 -3
  10. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  11. package/dist/esm/permissions-api.js +11 -1
  12. package/dist/esm/permissions-api.js.map +1 -1
  13. package/dist/esm/sync-closure-resolver.js +8 -1
  14. package/dist/esm/sync-closure-resolver.js.map +1 -1
  15. package/dist/esm/sync-engine-level.js +407 -6
  16. package/dist/esm/sync-engine-level.js.map +1 -1
  17. package/dist/esm/sync-messages.js +10 -3
  18. package/dist/esm/sync-messages.js.map +1 -1
  19. package/dist/types/dwn-api.d.ts +159 -0
  20. package/dist/types/dwn-api.d.ts.map +1 -1
  21. package/dist/types/dwn-encryption.d.ts +39 -2
  22. package/dist/types/dwn-encryption.d.ts.map +1 -1
  23. package/dist/types/dwn-key-delivery.d.ts +1 -9
  24. package/dist/types/dwn-key-delivery.d.ts.map +1 -1
  25. package/dist/types/enbox-connect-protocol.d.ts +166 -1
  26. package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
  27. package/dist/types/permissions-api.d.ts.map +1 -1
  28. package/dist/types/sync-closure-resolver.d.ts.map +1 -1
  29. package/dist/types/sync-engine-level.d.ts +45 -1
  30. package/dist/types/sync-engine-level.d.ts.map +1 -1
  31. package/dist/types/sync-messages.d.ts +2 -2
  32. package/dist/types/sync-messages.d.ts.map +1 -1
  33. package/dist/types/types/permissions.d.ts +9 -0
  34. package/dist/types/types/permissions.d.ts.map +1 -1
  35. package/dist/types/types/sync.d.ts +70 -2
  36. package/dist/types/types/sync.d.ts.map +1 -1
  37. package/package.json +5 -4
  38. package/src/dwn-api.ts +494 -38
  39. package/src/dwn-encryption.ts +160 -11
  40. package/src/dwn-key-delivery.ts +73 -61
  41. package/src/enbox-connect-protocol.ts +575 -6
  42. package/src/permissions-api.ts +13 -1
  43. package/src/sync-closure-resolver.ts +7 -1
  44. package/src/sync-engine-level.ts +368 -4
  45. package/src/sync-messages.ts +14 -5
  46. package/src/types/permissions.ts +9 -0
  47. package/src/types/sync.ts +86 -2
@@ -13,7 +13,9 @@
13
13
  * and ECDH (Ed25519 → X25519 + HKDF) for key agreement.
14
14
  */
15
15
 
16
+ import type { DerivedPrivateJwk } from '@enbox/dwn-sdk-js';
16
17
  import type { EnboxPlatformAgent } from './types/agent.js';
18
+ import type { PrivateKeyJwk } from '@enbox/crypto';
17
19
  import type { RequireOnly } from '@enbox/common';
18
20
  import type { DidDocument, PortableDid } from '@enbox/dids';
19
21
  import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js';
@@ -31,6 +33,71 @@ export type ConnectPermissionRequest = {
31
33
  /** The scope of the permissions being requested for the given protocol */
32
34
  permissionScopes: DwnPermissionScope[];
33
35
  };
36
+
37
+ /**
38
+ * A scope-aware decryption key delivered to delegates during the connect flow.
39
+ *
40
+ * Two scope kinds:
41
+ *
42
+ * - **`protocol`** — protocol-wide key at depth `[ProtocolPath, protocolUri]`.
43
+ * Can derive leaf keys for any type path within the protocol.
44
+ * Issued when the grant covers the entire protocol (no `protocolPath`).
45
+ *
46
+ * - **`protocolPath`** — exact-path key at depth
47
+ * `[ProtocolPath, protocolUri, ...pathSegments]`.
48
+ * Can only decrypt records at that exact path — not siblings or descendants.
49
+ * Issued when the grant is narrowed to a specific `protocolPath`.
50
+ *
51
+ * Common conditions (both kinds):
52
+ * 1. The protocol has `encryptionRequired: true` types (single-party only)
53
+ * 2. The delegate has at least one read-like scope (Read/Query/Subscribe)
54
+ * 3. The protocol does NOT use multi-party / role-based access patterns
55
+ *
56
+ * Out of scope (fail closed):
57
+ * - `contextId`-scoped encrypted delegate reads
58
+ * - multi-party / ProtocolContext encrypted delegate reads
59
+ */
60
+ export type DelegateDecryptionKey =
61
+ | {
62
+ /** The protocol URI this key is scoped to. */
63
+ protocol: string;
64
+ /** Protocol-wide decryption scope. */
65
+ scope: { kind: 'protocol' };
66
+ /** The derived private key material for ProtocolPath decryption. */
67
+ derivedPrivateKey: DerivedPrivateJwk;
68
+ }
69
+ | {
70
+ /** The protocol URI this key is scoped to. */
71
+ protocol: string;
72
+ /** Exact-path decryption scope — siblings and descendants are NOT accessible. */
73
+ scope: { kind: 'protocolPath'; protocolPath: string; match: 'exact' };
74
+ /** The derived private key material for ProtocolPath decryption. */
75
+ derivedPrivateKey: DerivedPrivateJwk;
76
+ };
77
+
78
+ /**
79
+ * A context-scoped decryption key for a multi-party encrypted protocol.
80
+ *
81
+ * Delivered to delegates during the connect flow so they can decrypt
82
+ * ProtocolContext-encrypted records without the owner's root X25519 key.
83
+ *
84
+ * Each key is scoped to one rootContextId — it unlocks all records within
85
+ * that context domain but cannot access other contexts in the protocol.
86
+ *
87
+ * Delivered only when:
88
+ * 1. The protocol has multi-party access patterns (detected by `isMultiPartyContext`)
89
+ * 2. The delegate has a protocol-wide read-like scope (no protocolPath/contextId)
90
+ * 3. The protocol has `encryptionRequired: true` types
91
+ */
92
+ export type DelegateContextKey = {
93
+ /** The protocol URI this key belongs to. */
94
+ protocol: string;
95
+ /** The root context ID this key unlocks. */
96
+ contextId: string;
97
+ /** The derived private key at `[ProtocolContext, rootContextId]`. */
98
+ derivedPrivateKey: DerivedPrivateJwk;
99
+ };
100
+
34
101
  import type {
35
102
  JoseHeaderParams,
36
103
  Jwk } from '@enbox/crypto';
@@ -45,12 +112,15 @@ import {
45
112
  X25519,
46
113
  XChaCha20Poly1305,
47
114
  } from '@enbox/crypto';
48
- import { DwnInterfaceName, DwnMethodName } from '@enbox/dwn-sdk-js';
115
+ import { DwnInterfaceName, DwnMethodName, HdKey, KeyDerivationScheme, PermissionsProtocol } from '@enbox/dwn-sdk-js';
49
116
 
50
117
  import { AgentPermissionsApi } from './permissions-api.js';
51
118
  import { concatenateUrl } from './utils.js';
52
119
  import { DwnInterface } from './types/dwn.js';
120
+ import { getEncryptionKeyInfo } from './dwn-encryption.js';
121
+ import { isMultiPartyContext } from './protocol-utils.js';
53
122
  import { isRecordPermissionScope } from './dwn-api.js';
123
+ import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
54
124
 
55
125
  // ---------------------------------------------------------------------------
56
126
  // Types
@@ -143,6 +213,41 @@ export type EnboxConnectResponse = {
143
213
 
144
214
  /** The delegate DID's full portable form, including private keys. */
145
215
  delegatePortableDid: PortableDid;
216
+
217
+ /**
218
+ * Scope-aware decryption keys for encrypted protocols.
219
+ *
220
+ * Derived only for read-like permission scopes (Read/Query/Subscribe) on
221
+ * protocols with `encryptionRequired: true` types. Write-only delegates
222
+ * receive no decryption keys.
223
+ */
224
+ delegateDecryptionKeys?: DelegateDecryptionKey[];
225
+
226
+ /**
227
+ * Context-scoped decryption keys for multi-party encrypted protocols.
228
+ *
229
+ * Derived at connect time for each existing rootContextId in multi-party
230
+ * protocols where the delegate has a protocol-wide read-like scope.
231
+ * Each key is scoped to `[ProtocolContext, rootContextId]` and can decrypt
232
+ * all records within that context domain.
233
+ *
234
+ * Contexts created after connect are delivered automatically by
235
+ * `postWriteKeyDelivery()` when the owner creates a new multi-party root
236
+ * record on the same agent instance (same-process delivery).
237
+ * Cross-device delivery is a documented follow-up.
238
+ */
239
+ delegateContextKeys?: DelegateContextKey[];
240
+
241
+ /**
242
+ * Protocol URIs that have multi-party encrypted access patterns.
243
+ *
244
+ * Delivered even when no contexts exist yet (cold-start), so the
245
+ * delegate's agent can register for future context key delivery.
246
+ */
247
+ delegateMultiPartyProtocols?: string[];
248
+
249
+ /** Per-grant revocation mappings for session-bound self-revocation on disconnect. */
250
+ sessionRevocations?: { grantId: string; revocationGrantId: string }[];
146
251
  };
147
252
 
148
253
  /** The connect server endpoint types. */
@@ -530,6 +635,7 @@ async function createPermissionGrants(
530
635
  delegateBearerDid: BearerDid,
531
636
  agent: EnboxPlatformAgent,
532
637
  scopes: DwnPermissionScope[],
638
+ delegateKeyDeliveryData?: { rootKeyId: string; publicKeyJwk: Record<string, any> },
533
639
  ): Promise<DwnDataEncodedRecordsWriteMessage[]> {
534
640
  const permissionsApi = new AgentPermissionsApi({ agent });
535
641
 
@@ -537,6 +643,16 @@ async function createPermissionGrants(
537
643
  const permissionGrants = await Promise.all(
538
644
  scopes.map((scope) => {
539
645
  const delegated = shouldUseDelegatePermission(scope);
646
+
647
+ // Attach delegate key-delivery tags to read-like grants so the
648
+ // owner can encrypt future contextKey records to the delegate.
649
+ const readMethods = new Set([
650
+ DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
651
+ ]);
652
+ const isReadLike = isRecordPermissionScope(scope)
653
+ && readMethods.has(scope.method as DwnMethodName);
654
+ const delegateKeyDelivery = (isReadLike && delegateKeyDeliveryData) ? delegateKeyDeliveryData : undefined;
655
+
540
656
  return permissionsApi.createGrant({
541
657
  delegated,
542
658
  store : true,
@@ -544,6 +660,7 @@ async function createPermissionGrants(
544
660
  scope,
545
661
  dateExpires : '2040-06-25T16:09:16.693356Z', // TODO: make dateExpires configurable
546
662
  author : selectedDid,
663
+ delegateKeyDelivery,
547
664
  });
548
665
  })
549
666
  );
@@ -604,12 +721,22 @@ async function createPermissionGrants(
604
721
  /**
605
722
  * Installs a DWN protocol on the provider's DWN if it doesn't already exist.
606
723
  * Ensures the protocol is available on both the local and remote DWN.
724
+ *
725
+ * When the protocol definition contains types with `encryptionRequired: true`,
726
+ * the protocol is installed with `encryption: true` so that the agent injects
727
+ * `$encryption` keys (derived from the owner's X25519 root key) into the
728
+ * protocol definition. This ensures the protocol is immediately usable for
729
+ * encrypted record operations by both the owner and any delegates.
607
730
  */
608
731
  async function prepareProtocol(
609
732
  selectedDid: string,
610
733
  agent: EnboxPlatformAgent,
611
734
  protocolDefinition: DwnProtocolDefinition
612
735
  ): Promise<void> {
736
+ // Detect whether any type in the protocol requires encryption.
737
+ const needsEncryption = Object.values(protocolDefinition.types ?? {})
738
+ .some((type: any) => type?.encryptionRequired === true);
739
+
613
740
  const queryMessage = await agent.processDwnRequest({
614
741
  author : selectedDid,
615
742
  messageType : DwnInterface.ProtocolsQuery,
@@ -627,6 +754,7 @@ async function prepareProtocol(
627
754
  target : selectedDid,
628
755
  messageType : DwnInterface.ProtocolsConfigure,
629
756
  messageParams : { definition: protocolDefinition },
757
+ encryption : needsEncryption || undefined,
630
758
  });
631
759
 
632
760
  if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
@@ -656,6 +784,277 @@ async function prepareProtocol(
656
784
  }
657
785
  }
658
786
 
787
+ /**
788
+ * Derives the minimal set of decryption keys implied by read-like permission
789
+ * scopes for a single-party encrypted protocol.
790
+ *
791
+ * Rules:
792
+ * - Only Records.Read / Records.Query / Records.Subscribe scopes contribute.
793
+ * - Write / Delete / Count scopes produce no decryption keys.
794
+ * - If any unrestricted (no `protocolPath`) read scope exists, one
795
+ * protocol-wide key is emitted and narrower keys are dropped.
796
+ * - Otherwise one exact-path key is emitted per unique `protocolPath`.
797
+ * - Scopes with `contextId` cause a fail-closed error.
798
+ * - Multi-party protocols cause a fail-closed error.
799
+ *
800
+ * @param agent - The platform agent (must hold the owner's KMS keys)
801
+ * @param ownerDid - The DID of the protocol owner
802
+ * @param protocolUri - The protocol URI
803
+ * @param scopes - The permission scopes for this protocol
804
+ * @param protocolDefinition - The protocol definition (for multi-party detection)
805
+ * @returns An array of `DelegateDecryptionKey` (may be empty)
806
+ */
807
+ async function deriveScopedDecryptionKeys(
808
+ agent: EnboxPlatformAgent,
809
+ ownerDid: string,
810
+ protocolUri: string,
811
+ scopes: DwnPermissionScope[],
812
+ protocolDefinition: DwnProtocolDefinition,
813
+ ): Promise<DelegateDecryptionKey[]> {
814
+ const readMethods = new Set([
815
+ DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
816
+ ]);
817
+
818
+ // Collect read-like scopes only.
819
+ const readScopes = scopes.filter(
820
+ (s): s is DwnPermissionScope & { method: string } =>
821
+ isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
822
+ );
823
+
824
+ if (readScopes.length === 0) {
825
+ return []; // write/delete only → no decryption keys
826
+ }
827
+
828
+ // Fail closed: reject contextId-scoped encrypted reads.
829
+ for (const scope of readScopes) {
830
+ if ('contextId' in scope && (scope as any).contextId) {
831
+ throw new Error(
832
+ `Encrypted delegate access scoped by contextId is not supported ` +
833
+ `yet; use protocol-wide permissions for protocol '${protocolUri}'.`,
834
+ );
835
+ }
836
+ }
837
+
838
+ // Defense-in-depth: reject if any root is multi-party.
839
+ // The caller should have already routed multi-party protocols to
840
+ // deriveContextKeysForDelegate instead.
841
+ const { multiParty } = classifyProtocolRoots(protocolDefinition);
842
+ if (multiParty.length > 0) {
843
+ throw new Error(
844
+ `deriveScopedDecryptionKeys called for protocol with multi-party ` +
845
+ `roots [${multiParty.join(', ')}]. Use deriveContextKeysForDelegate ` +
846
+ `for multi-party protocols.`,
847
+ );
848
+ }
849
+
850
+ // Check if any scope is protocol-wide (no protocolPath).
851
+ const hasProtocolWideRead = readScopes.some(
852
+ (s) => !('protocolPath' in s) || !(s as any).protocolPath,
853
+ );
854
+
855
+ const { keyId, keyUri } = await getEncryptionKeyInfo(agent, ownerDid);
856
+
857
+ // If any unrestricted read scope exists, emit one protocol-wide key
858
+ // and skip narrower keys (the protocol-wide key subsumes them).
859
+ if (hasProtocolWideRead) {
860
+ const derivationPath = [KeyDerivationScheme.ProtocolPath, protocolUri];
861
+ const derivedBytes = await agent.keyManager.derivePrivateKeyBytes({
862
+ keyUri, derivationPath,
863
+ });
864
+ const derivedJwk = await X25519.bytesToPrivateKey({ privateKeyBytes: derivedBytes });
865
+
866
+ return [{
867
+ protocol : protocolUri,
868
+ scope : { kind: 'protocol' },
869
+ derivedPrivateKey : {
870
+ rootKeyId : keyId,
871
+ derivationScheme : KeyDerivationScheme.ProtocolPath,
872
+ derivationPath,
873
+ derivedPrivateKey : derivedJwk as PrivateKeyJwk,
874
+ },
875
+ }];
876
+ }
877
+
878
+ // All read scopes are protocolPath-scoped.
879
+ // Emit one exact-path key per unique protocolPath.
880
+ const uniquePaths = new Set<string>();
881
+ for (const scope of readScopes) {
882
+ const pp = (scope as any).protocolPath as string | undefined;
883
+ if (pp) { uniquePaths.add(pp); }
884
+ }
885
+
886
+ const keys: DelegateDecryptionKey[] = [];
887
+ for (const protocolPath of uniquePaths) {
888
+ const pathSegments = protocolPath.split('/');
889
+ const derivationPath = [KeyDerivationScheme.ProtocolPath, protocolUri, ...pathSegments];
890
+ const derivedBytes = await agent.keyManager.derivePrivateKeyBytes({
891
+ keyUri, derivationPath,
892
+ });
893
+ const derivedJwk = await X25519.bytesToPrivateKey({ privateKeyBytes: derivedBytes });
894
+
895
+ keys.push({
896
+ protocol : protocolUri,
897
+ scope : { kind: 'protocolPath', protocolPath, match: 'exact' },
898
+ derivedPrivateKey : {
899
+ rootKeyId : keyId,
900
+ derivationScheme : KeyDerivationScheme.ProtocolPath,
901
+ derivationPath,
902
+ derivedPrivateKey : derivedJwk as PrivateKeyJwk,
903
+ },
904
+ });
905
+ }
906
+
907
+ return keys;
908
+ }
909
+
910
+ /**
911
+ * Detects whether a protocol definition has any root-level type whose subtree
912
+ * triggers multi-party semantics. Delegates to the canonical
913
+ * `isMultiPartyContext()` from `protocol-utils.ts` which checks:
914
+ *
915
+ * - `$role: true` descendants in the subtree
916
+ * - Relational `who`/`of` `$actions` rules that grant `read` access
917
+ *
918
+ * These patterns cause the DWN agent to use ProtocolContext encryption at
919
+ * write time, which is not supported in delegate sessions yet.
920
+ */
921
+ /**
922
+ * Classifies root-level types in a protocol definition into multi-party
923
+ * and single-party buckets. Used to detect mixed protocols that cannot
924
+ * be safely modeled with a single key type.
925
+ */
926
+ function classifyProtocolRoots(
927
+ definition: DwnProtocolDefinition,
928
+ ): { multiParty: string[]; singleParty: string[] } {
929
+ const structure = definition.structure;
930
+ if (!structure) { return { multiParty: [], singleParty: [] }; }
931
+
932
+ const multiParty: string[] = [];
933
+ const singleParty: string[] = [];
934
+
935
+ for (const rootTypeName of Object.keys(structure)) {
936
+ if (rootTypeName.startsWith('$')) { continue; }
937
+ if (isMultiPartyContext(definition, rootTypeName)) {
938
+ multiParty.push(rootTypeName);
939
+ } else {
940
+ singleParty.push(rootTypeName);
941
+ }
942
+ }
943
+
944
+ return { multiParty, singleParty };
945
+ }
946
+
947
+ /**
948
+ * Derives per-context decryption keys for a delegate's access to a multi-party
949
+ * encrypted protocol. Queries the owner's DWN for all root-level records
950
+ * (thread roots, etc.) and derives a `[ProtocolContext, rootContextId]` key
951
+ * for each.
952
+ *
953
+ * Validates scopes first — only protocol-wide read-like scopes are accepted.
954
+ * `protocolPath`-scoped and `contextId`-scoped reads throw (not yet supported).
955
+ * Write-only scopes return empty (no decryption keys needed).
956
+ *
957
+ * @param agent - The platform agent (must hold the owner's KMS keys)
958
+ * @param ownerDid - The DID of the protocol owner
959
+ * @param protocolDefinition - The protocol definition
960
+ * @param scopes - The permission scopes for this protocol
961
+ * @returns An array of `DelegateContextKey` (may be empty)
962
+ */
963
+ async function deriveContextKeysForDelegate(
964
+ agent: EnboxPlatformAgent,
965
+ ownerDid: string,
966
+ protocolDefinition: DwnProtocolDefinition,
967
+ scopes: DwnPermissionScope[],
968
+ ): Promise<DelegateContextKey[]> {
969
+ const readMethods = new Set([
970
+ DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
971
+ ]);
972
+
973
+ const readScopes = scopes.filter(
974
+ (s): s is DwnPermissionScope & { method: string } =>
975
+ isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
976
+ );
977
+
978
+ if (readScopes.length === 0) {
979
+ return []; // write-only → no context keys
980
+ }
981
+
982
+ // Fail closed: reject contextId-scoped reads.
983
+ for (const scope of readScopes) {
984
+ if ('contextId' in scope && (scope as any).contextId) {
985
+ throw new Error(
986
+ `Encrypted delegate access scoped by contextId is not supported ` +
987
+ `yet; use protocol-wide permissions for protocol ` +
988
+ `'${protocolDefinition.protocol}'.`,
989
+ );
990
+ }
991
+ }
992
+
993
+ // Fail closed: reject protocolPath-scoped reads on multi-party protocols.
994
+ for (const scope of readScopes) {
995
+ if ('protocolPath' in scope && (scope as any).protocolPath) {
996
+ throw new Error(
997
+ `Encrypted delegate access scoped by protocolPath on multi-party ` +
998
+ `protocols is not supported yet; use protocol-wide permissions for ` +
999
+ `protocol '${protocolDefinition.protocol}'.`,
1000
+ );
1001
+ }
1002
+ }
1003
+
1004
+ // All read scopes are protocol-wide. Derive context keys for each
1005
+ // existing root-level context in the protocol.
1006
+ const { keyId, keyUri } = await getEncryptionKeyInfo(agent, ownerDid);
1007
+ const protocolUri = protocolDefinition.protocol;
1008
+
1009
+ // Find root-level types (non-$ keys in structure).
1010
+ const rootTypes = Object.keys(protocolDefinition.structure ?? {})
1011
+ .filter((k) => !k.startsWith('$'));
1012
+
1013
+ const contextKeys: DelegateContextKey[] = [];
1014
+ const seenContextIds = new Set<string>();
1015
+
1016
+ for (const rootType of rootTypes) {
1017
+ // Query all root records for this type.
1018
+ const { reply } = await agent.processDwnRequest({
1019
+ author : ownerDid,
1020
+ target : ownerDid,
1021
+ messageType : DwnInterface.RecordsQuery,
1022
+ messageParams : {
1023
+ filter: { protocol: protocolUri, protocolPath: rootType },
1024
+ },
1025
+ });
1026
+
1027
+ for (const entry of reply.entries ?? []) {
1028
+ const rootContextId = (entry as any).contextId?.split('/')[0]
1029
+ || (entry as any).recordId;
1030
+
1031
+ if (!rootContextId || seenContextIds.has(rootContextId)) { continue; }
1032
+ seenContextIds.add(rootContextId);
1033
+
1034
+ const derivationPath = [KeyDerivationScheme.ProtocolContext, rootContextId];
1035
+ const derivedBytes = await agent.keyManager.derivePrivateKeyBytes({
1036
+ keyUri, derivationPath,
1037
+ });
1038
+ const derivedJwk = await X25519.bytesToPrivateKey({
1039
+ privateKeyBytes: derivedBytes,
1040
+ });
1041
+
1042
+ contextKeys.push({
1043
+ protocol : protocolUri,
1044
+ contextId : rootContextId,
1045
+ derivedPrivateKey : {
1046
+ rootKeyId : keyId,
1047
+ derivationScheme : KeyDerivationScheme.ProtocolContext,
1048
+ derivationPath,
1049
+ derivedPrivateKey : derivedJwk as PrivateKeyJwk,
1050
+ },
1051
+ });
1052
+ }
1053
+ }
1054
+
1055
+ return contextKeys;
1056
+ }
1057
+
659
1058
  // ---------------------------------------------------------------------------
660
1059
  // Full wallet-side flow (provider submits response)
661
1060
  // ---------------------------------------------------------------------------
@@ -682,6 +1081,62 @@ async function submitConnectResponse(
682
1081
  const delegateBearerDid = await DidJwk.create();
683
1082
  const delegatePortableDid = await delegateBearerDid.export();
684
1083
 
1084
+ // Add X25519 key derived from the delegate's Ed25519 key.
1085
+ // did:jwk only supports one verification method, but DWN encryption
1086
+ // requires X25519 for key agreement. Including the derived X25519
1087
+ // private key in the PortableDid ensures the delegate agent's KMS
1088
+ // has both keys after import. The Ed25519→X25519 conversion is a
1089
+ // standard cryptographic operation (RFC 8032 / libsodium).
1090
+ const delegateEdPrivateKey = delegatePortableDid.privateKeys![0];
1091
+ const delegateX25519PrivateKey = await Ed25519.convertPrivateKeyToX25519({
1092
+ privateKey: delegateEdPrivateKey,
1093
+ });
1094
+ delegatePortableDid.privateKeys!.push(delegateX25519PrivateKey);
1095
+
1096
+ // Derive the delegate's key-delivery ProtocolPath leaf public key.
1097
+ // This is the pre-derived key that the owner will use later when writing
1098
+ // contextKey records addressed to this delegate. The owner cannot derive
1099
+ // this from the delegate's root public key alone (HKDF needs the private
1100
+ // key), so we compute it now while we have temporary access to the
1101
+ // delegate's private key material.
1102
+ const delegateX25519PrivateKeyBytes = await X25519.privateKeyToBytes({
1103
+ privateKey: delegateX25519PrivateKey,
1104
+ });
1105
+ const keyDeliveryDerivationPath = [
1106
+ KeyDerivationScheme.ProtocolPath,
1107
+ KeyDeliveryProtocolDefinition.protocol,
1108
+ 'contextKey',
1109
+ ];
1110
+ const delegateLeafPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(
1111
+ delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath,
1112
+ );
1113
+ const delegateLeafPrivateKeyJwk = await X25519.bytesToPrivateKey({
1114
+ privateKeyBytes: delegateLeafPrivateKeyBytes,
1115
+ });
1116
+ const delegateKeyDeliveryLeafPublicKey = await X25519.getPublicKey({
1117
+ key: delegateLeafPrivateKeyJwk,
1118
+ });
1119
+
1120
+ // The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
1121
+ // For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
1122
+ // this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
1123
+ // `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
1124
+ // the same id — which they do because both derive from verificationMethod.id
1125
+ // of the keyAgreement relationship.
1126
+ const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod![0].id;
1127
+ const delegateKeyDeliveryData = {
1128
+ rootKeyId : delegateKeyAgreementVmId,
1129
+ publicKeyJwk : delegateKeyDeliveryLeafPublicKey,
1130
+ };
1131
+
1132
+ // Derive scope-aware decryption keys for encrypted protocols.
1133
+ // Single-party: ProtocolPath keys (protocol-wide or exact-path).
1134
+ // Multi-party: ProtocolContext keys (per rootContextId).
1135
+ // Write-only delegates receive no decryption capability.
1136
+ const delegateDecryptionKeys: DelegateDecryptionKey[] = [];
1137
+ const delegateContextKeys: DelegateContextKey[] = [];
1138
+ const delegateMultiPartyProtocols: string[] = [];
1139
+
685
1140
  const delegateGrantPromises = connectRequest.permissionRequests.map(
686
1141
  async (permissionRequest) => {
687
1142
  const { protocolDefinition, permissionScopes } = permissionRequest;
@@ -695,25 +1150,136 @@ async function submitConnectResponse(
695
1150
 
696
1151
  await prepareProtocol(selectedDid, agent, protocolDefinition);
697
1152
 
1153
+ const hasEncryptedTypes = Object.values(protocolDefinition.types ?? {})
1154
+ .some((type: any) => type?.encryptionRequired === true);
1155
+
1156
+ if (hasEncryptedTypes) {
1157
+ const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
1158
+
1159
+ if (multiParty.length > 0 && singleParty.length > 0) {
1160
+ // Mixed protocol: some roots are multi-party, others single-party.
1161
+ // We cannot safely model this with either key type alone.
1162
+ throw new Error(
1163
+ `Encrypted delegate access for protocols with mixed single-party ` +
1164
+ `and multi-party roots is not supported yet. ` +
1165
+ `Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
1166
+ `[${multiParty.join(', ')}] and single-party roots ` +
1167
+ `[${singleParty.join(', ')}].`,
1168
+ );
1169
+ }
1170
+
1171
+ if (multiParty.length > 0) {
1172
+ // Pure multi-party: derive per-context keys for existing contexts.
1173
+ // Unsupported scope shapes (protocolPath, contextId) throw.
1174
+ const ctxKeys = await deriveContextKeysForDelegate(
1175
+ agent, selectedDid, protocolDefinition, permissionScopes,
1176
+ );
1177
+ delegateContextKeys.push(...ctxKeys);
1178
+
1179
+ // Only register the protocol for post-connect delivery if the
1180
+ // delegate has at least one read-like scope. Write-only delegates
1181
+ // must NOT receive context keys — they have no decryption need.
1182
+ const readMethods = new Set([
1183
+ DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
1184
+ ]);
1185
+ const hasReadLikeScope = permissionScopes.some(
1186
+ (s): boolean => isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1187
+ );
1188
+ if (hasReadLikeScope) {
1189
+ delegateMultiPartyProtocols.push(protocolDefinition.protocol);
1190
+ }
1191
+ } else {
1192
+ // Pure single-party: derive ProtocolPath keys.
1193
+ // Unsupported scope shapes (contextId) throw.
1194
+ const keys = await deriveScopedDecryptionKeys(
1195
+ agent, selectedDid, protocolDefinition.protocol,
1196
+ permissionScopes, protocolDefinition,
1197
+ );
1198
+ delegateDecryptionKeys.push(...keys);
1199
+ }
1200
+ }
1201
+
698
1202
  return EnboxConnectProtocol.createPermissionGrants(
699
1203
  selectedDid,
700
1204
  delegateBearerDid,
701
1205
  agent,
702
- permissionScopes
1206
+ permissionScopes,
1207
+ delegateKeyDeliveryData,
703
1208
  );
704
1209
  }
705
1210
  );
706
1211
 
707
1212
  const delegateGrants = (await Promise.all(delegateGrantPromises)).flat();
708
1213
 
1214
+ // Create per-grant contextId-scoped revocation grants.
1215
+ // Each revocation grant authorizes the delegate to write a revocation
1216
+ // ONLY for the specific session grant it corresponds to.
1217
+ const permissionsApi = new AgentPermissionsApi({ agent });
1218
+ const sessionRevocations: { grantId: string; revocationGrantId: string }[] = [];
1219
+ let revGrantEndpoints: string[] = [];
1220
+ try {
1221
+ revGrantEndpoints = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
1222
+ } catch {
1223
+ // Endpoint resolution failure — revocation grants will be local-only until sync.
1224
+ }
1225
+
1226
+ // Snapshot the current length — revocation grants are appended to delegateGrants
1227
+ // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1228
+ const sessionGrantCount = delegateGrants.length;
1229
+ for (let i = 0; i < sessionGrantCount; i++) {
1230
+ const grantMessage = delegateGrants[i];
1231
+ const revGrant = await permissionsApi.createGrant({
1232
+ delegated : true,
1233
+ store : true,
1234
+ grantedTo : delegateBearerDid.uri,
1235
+ scope : {
1236
+ interface : DwnInterfaceName.Records,
1237
+ method : DwnMethodName.Write,
1238
+ protocol : PermissionsProtocol.uri,
1239
+ contextId : grantMessage.recordId,
1240
+ },
1241
+ dateExpires : '2040-06-25T16:09:16.693356Z',
1242
+ author : selectedDid,
1243
+ });
1244
+ sessionRevocations.push({
1245
+ grantId : grantMessage.recordId,
1246
+ revocationGrantId : revGrant.message.recordId,
1247
+ });
1248
+
1249
+ // Fan out the revocation grant to all owner DWN endpoints (same
1250
+ // as session grants) so that immediate disconnect can send a
1251
+ // delegated revocation to a remote DWN that recognises the grant.
1252
+ const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
1253
+ const revData = Convert.base64Url(revEncoded).toUint8Array();
1254
+ for (const dwnUrl of revGrantEndpoints) {
1255
+ try {
1256
+ await agent.rpc.sendDwnRequest({
1257
+ dwnUrl,
1258
+ targetDid : selectedDid,
1259
+ message : revRawMessage,
1260
+ data : new Blob([revData as BlobPart]),
1261
+ });
1262
+ } catch {
1263
+ // Best-effort — sync will deliver eventually.
1264
+ }
1265
+ }
1266
+
1267
+ // Include the revocation grant in the delegate grants for distribution
1268
+ delegateGrants.push(revGrant.message);
1269
+ }
1270
+
709
1271
  logger.log('Building connect response...');
710
1272
  const responseObject = await EnboxConnectProtocol.createConnectResponse({
711
- providerDid : selectedDid,
712
- delegateDid : delegateBearerDid.uri,
713
- aud : connectRequest.clientDid,
714
- nonce : connectRequest.nonce,
1273
+ providerDid : selectedDid,
1274
+ delegateDid : delegateBearerDid.uri,
1275
+ aud : connectRequest.clientDid,
1276
+ nonce : connectRequest.nonce,
715
1277
  delegateGrants,
716
1278
  delegatePortableDid,
1279
+ delegateDecryptionKeys : delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
1280
+ delegateContextKeys : delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
1281
+ delegateMultiPartyProtocols : delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
1282
+ sessionRevocations : sessionRevocations.length > 0 ? sessionRevocations : undefined,
717
1283
  });
718
1284
 
719
1285
  logger.log('Signing connect response...');
@@ -771,4 +1337,7 @@ export const EnboxConnectProtocol = {
771
1337
  createConnectResponse,
772
1338
  createPermissionGrants,
773
1339
  submitConnectResponse,
1340
+ deriveScopedDecryptionKeys,
1341
+ deriveContextKeysForDelegate,
1342
+ classifyProtocolRoots,
774
1343
  };