@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.
- package/dist/browser.mjs +11 -11
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/dwn-api.js +433 -33
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/dwn-encryption.js +131 -12
- package/dist/esm/dwn-encryption.js.map +1 -1
- package/dist/esm/dwn-key-delivery.js +64 -47
- package/dist/esm/dwn-key-delivery.js.map +1 -1
- package/dist/esm/enbox-connect-protocol.js +400 -3
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/permissions-api.js +11 -1
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +8 -1
- package/dist/esm/sync-closure-resolver.js.map +1 -1
- package/dist/esm/sync-engine-level.js +407 -6
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-messages.js +10 -3
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/types/dwn-api.d.ts +159 -0
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/dwn-encryption.d.ts +39 -2
- package/dist/types/dwn-encryption.d.ts.map +1 -1
- package/dist/types/dwn-key-delivery.d.ts +1 -9
- package/dist/types/dwn-key-delivery.d.ts.map +1 -1
- package/dist/types/enbox-connect-protocol.d.ts +166 -1
- package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
- package/dist/types/permissions-api.d.ts.map +1 -1
- package/dist/types/sync-closure-resolver.d.ts.map +1 -1
- package/dist/types/sync-engine-level.d.ts +45 -1
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +2 -2
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/types/permissions.d.ts +9 -0
- package/dist/types/types/permissions.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +70 -2
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/dwn-api.ts +494 -38
- package/src/dwn-encryption.ts +160 -11
- package/src/dwn-key-delivery.ts +73 -61
- package/src/enbox-connect-protocol.ts +575 -6
- package/src/permissions-api.ts +13 -1
- package/src/sync-closure-resolver.ts +7 -1
- package/src/sync-engine-level.ts +368 -4
- package/src/sync-messages.ts +14 -5
- package/src/types/permissions.ts +9 -0
- 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
|
|
712
|
-
delegateDid
|
|
713
|
-
aud
|
|
714
|
-
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
|
};
|