@enbox/agent 0.5.16 → 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 (43) 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-engine-level.js +407 -6
  14. package/dist/esm/sync-engine-level.js.map +1 -1
  15. package/dist/esm/sync-messages.js +10 -3
  16. package/dist/esm/sync-messages.js.map +1 -1
  17. package/dist/types/dwn-api.d.ts +159 -0
  18. package/dist/types/dwn-api.d.ts.map +1 -1
  19. package/dist/types/dwn-encryption.d.ts +39 -2
  20. package/dist/types/dwn-encryption.d.ts.map +1 -1
  21. package/dist/types/dwn-key-delivery.d.ts +1 -9
  22. package/dist/types/dwn-key-delivery.d.ts.map +1 -1
  23. package/dist/types/enbox-connect-protocol.d.ts +166 -1
  24. package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
  25. package/dist/types/permissions-api.d.ts.map +1 -1
  26. package/dist/types/sync-engine-level.d.ts +45 -1
  27. package/dist/types/sync-engine-level.d.ts.map +1 -1
  28. package/dist/types/sync-messages.d.ts +2 -2
  29. package/dist/types/sync-messages.d.ts.map +1 -1
  30. package/dist/types/types/permissions.d.ts +9 -0
  31. package/dist/types/types/permissions.d.ts.map +1 -1
  32. package/dist/types/types/sync.d.ts +70 -2
  33. package/dist/types/types/sync.d.ts.map +1 -1
  34. package/package.json +5 -4
  35. package/src/dwn-api.ts +494 -38
  36. package/src/dwn-encryption.ts +160 -11
  37. package/src/dwn-key-delivery.ts +73 -61
  38. package/src/enbox-connect-protocol.ts +575 -6
  39. package/src/permissions-api.ts +13 -1
  40. package/src/sync-engine-level.ts +368 -4
  41. package/src/sync-messages.ts +14 -5
  42. package/src/types/permissions.ts +9 -0
  43. package/src/types/sync.ts +86 -2
@@ -7,7 +7,7 @@ import type {
7
7
  RecordsReadReply,
8
8
  RecordsWriteMessage,
9
9
  } from '@enbox/dwn-sdk-js';
10
- import type { KeyIdentifier, PublicKeyJwk } from '@enbox/crypto';
10
+ import type { Jwk, KeyIdentifier, PublicKeyJwk } from '@enbox/crypto';
11
11
 
12
12
  import type { EnboxPlatformAgent } from './types/agent.js';
13
13
  import type {
@@ -16,7 +16,6 @@ import type {
16
16
  SendDwnRequest,
17
17
  } from './types/dwn.js';
18
18
 
19
- import { X25519 } from '@enbox/crypto';
20
19
  import {
21
20
  Cid,
22
21
  ContentEncryptionAlgorithm,
@@ -26,6 +25,7 @@ import {
26
25
  KeyDerivationScheme,
27
26
  Records,
28
27
  } from '@enbox/dwn-sdk-js';
28
+ import { Ed25519, X25519 } from '@enbox/crypto';
29
29
 
30
30
  import { DwnInterface } from './types/dwn.js';
31
31
  import { isDwnRequest } from './dwn-type-guards.js';
@@ -140,22 +140,33 @@ export async function getEncryptionKeyInfo(
140
140
  );
141
141
  }
142
142
 
143
- // 4. Verify it's an X25519 key
144
- const publicKeyJwk = verificationMethod.publicKeyJwk;
145
- if (publicKeyJwk.crv !== 'X25519') {
143
+ // 4. Resolve or derive the X25519 key for encryption.
144
+ // Standard case: the keyAgreement VM already has an X25519 key.
145
+ // Delegate case: did:jwk with Ed25519 only — convert to X25519.
146
+ // The Ed25519→X25519 conversion is a standard cryptographic operation
147
+ // (RFC 8032 / libsodium). The converted X25519 key must already be
148
+ // present in the agent's KMS (imported via the delegate PortableDid).
149
+ let resolvedPublicKeyJwk = verificationMethod.publicKeyJwk;
150
+
151
+ if (resolvedPublicKeyJwk.crv === 'Ed25519') {
152
+ resolvedPublicKeyJwk = await Ed25519.convertPublicKeyToX25519({
153
+ publicKey: resolvedPublicKeyJwk,
154
+ });
155
+ } else if (resolvedPublicKeyJwk.crv !== 'X25519') {
146
156
  throw new Error(
147
157
  `AgentDwnApi: keyAgreement key for '${didUri}' uses curve ` +
148
- `'${publicKeyJwk.crv}', but DWN encryption requires 'X25519'.`
158
+ `'${resolvedPublicKeyJwk.crv}', but DWN encryption requires ` +
159
+ `'X25519' (or 'Ed25519' which is auto-converted).`
149
160
  );
150
161
  }
151
162
 
152
163
  // 5. Compute the KMS key URI (does NOT export the key)
153
- const keyUri = await agent.keyManager.getKeyUri({ key: publicKeyJwk });
164
+ const keyUri = await agent.keyManager.getKeyUri({ key: resolvedPublicKeyJwk });
154
165
 
155
166
  return {
156
167
  keyId : verificationMethod.id,
157
168
  keyUri,
158
- publicKeyJwk : publicKeyJwk as PublicKeyJwk,
169
+ publicKeyJwk : resolvedPublicKeyJwk as PublicKeyJwk,
159
170
  };
160
171
  }
161
172
 
@@ -295,11 +306,55 @@ export function buildContextKeyDecrypter(
295
306
  };
296
307
  }
297
308
 
309
+ /** Cache entry shape for scope-aware delegate decryption keys. */
310
+ export type DelegateDecryptionKeyEntry = {
311
+ protocol: string;
312
+ scope: { kind: 'protocol' } | { kind: 'protocolPath'; protocolPath: string; match: 'exact' };
313
+ derivedPrivateKey: DerivedPrivateJwk;
314
+ };
315
+
316
+ /**
317
+ * Builds a KeyDecrypter for an exact-path delegate key that enforces the
318
+ * record's full derivation path matches the key's path exactly — siblings
319
+ * and descendants are NOT accessible.
320
+ */
321
+ export function buildExactProtocolPathDecrypter(
322
+ key: DerivedPrivateJwk,
323
+ ): KeyDecrypter {
324
+ return {
325
+ rootKeyId : key.rootKeyId,
326
+ derivationScheme : key.derivationScheme,
327
+ decrypt : async (
328
+ fullDerivationPath: string[],
329
+ jwePayload: { ephemeralPublicKey: Jwk; encryptedKey: Uint8Array },
330
+ ): Promise<Uint8Array> => {
331
+ const keyPath = key.derivationPath ?? [];
332
+ if (keyPath.length !== fullDerivationPath.length ||
333
+ !keyPath.every((seg: string, i: number) => seg === fullDerivationPath[i])) {
334
+ throw new Error(
335
+ 'Delegate decryption key is out of scope for this protocol path. ' +
336
+ `Key path: [${keyPath.join(', ')}], ` +
337
+ `record path: [${fullDerivationPath.join(', ')}].`
338
+ );
339
+ }
340
+ const leafPrivateKeyBytes = await Records.derivePrivateKey(key, fullDerivationPath);
341
+ const leafPrivateKeyJwk = await X25519.bytesToPrivateKey({ privateKeyBytes: leafPrivateKeyBytes });
342
+ return Encryption.ecdhEsUnwrapKey(leafPrivateKeyJwk, jwePayload.ephemeralPublicKey, jwePayload.encryptedKey);
343
+ },
344
+ };
345
+ }
346
+
298
347
  /**
299
348
  * Resolves the appropriate KeyDecrypter for a record's encryption scheme.
300
349
  * Handles both single-party (ProtocolPath) and multi-party (ProtocolContext).
301
350
  *
351
+ * For ProtocolPath records:
352
+ * - Owner: derives key directly from KMS
353
+ * - Delegate with protocol-wide key: uses ancestor-prefix derivation
354
+ * - Delegate with exact-path key: enforces exact path match
355
+ *
302
356
  * For ProtocolContext records:
357
+ * - Delegate: uses delivered context key from the connect flow
303
358
  * - Context creator: derives key directly from KMS
304
359
  * - Participant: fetches contextKey via key-delivery protocol, caches it
305
360
  *
@@ -309,6 +364,8 @@ export function buildContextKeyDecrypter(
309
364
  * @param targetDid - The target DID (DWN owner), if known
310
365
  * @param contextDerivedKeyCache - Cache for context-derived private keys
311
366
  * @param fetchContextKeyRecordFn - Function to fetch context key records from key-delivery protocol
367
+ * @param delegateDecryptionKeyCache - Cache for scope-aware delegate decryption keys
368
+ * @param granteeDid - The delegate DID (if this is a delegated request)
312
369
  */
313
370
  export async function resolveKeyDecrypter(
314
371
  agent: EnboxPlatformAgent,
@@ -322,6 +379,9 @@ export async function resolveKeyDecrypter(
322
379
  sourceProtocol: string;
323
380
  sourceContextId: string;
324
381
  }) => Promise<DerivedPrivateJwk | undefined>,
382
+ delegateDecryptionKeyCache?: { get(key: string): DelegateDecryptionKeyEntry[] | undefined },
383
+ granteeDid?: string,
384
+ delegateContextKeyCache?: { get(key: string): DerivedPrivateJwk | undefined; set(key: string, value: DerivedPrivateJwk): void },
325
385
  ): Promise<KeyDecrypter> {
326
386
  const { encryption } = recordsWrite;
327
387
 
@@ -331,7 +391,37 @@ export async function resolveKeyDecrypter(
331
391
  );
332
392
 
333
393
  if (!hasContextKey || !recordsWrite.contextId) {
334
- // Single-party protocol-path encryption
394
+ // Single-party protocol-path encryption.
395
+ // Check for scope-aware delegate decryption keys first — this enables
396
+ // delegates to decrypt without the owner's root X25519 private key.
397
+ if (delegateDecryptionKeyCache && granteeDid) {
398
+ const protocol = recordsWrite.descriptor.protocol;
399
+ const protocolPath = recordsWrite.descriptor.protocolPath;
400
+ if (protocol) {
401
+ const cacheKey = `ddk~${granteeDid}`;
402
+ const allKeys = delegateDecryptionKeyCache.get(cacheKey);
403
+ if (allKeys) {
404
+ const keysForProtocol = allKeys.filter((k) => k.protocol === protocol);
405
+
406
+ // Priority 1: exact-path key matching this record's protocolPath.
407
+ if (protocolPath) {
408
+ const exactKey = keysForProtocol.find(
409
+ (k) => k.scope.kind === 'protocolPath' && k.scope.protocolPath === protocolPath
410
+ );
411
+ if (exactKey) {
412
+ return buildExactProtocolPathDecrypter(exactKey.derivedPrivateKey);
413
+ }
414
+ }
415
+
416
+ // Priority 2: protocol-wide key (ancestor-prefix derivation).
417
+ const wideKey = keysForProtocol.find((k) => k.scope.kind === 'protocol');
418
+ if (wideKey) {
419
+ return buildContextKeyDecrypter(wideKey.derivedPrivateKey);
420
+ }
421
+ }
422
+ }
423
+ }
424
+
335
425
  return getKeyDecrypter(agent, authorDid);
336
426
  }
337
427
 
@@ -342,6 +432,60 @@ export async function resolveKeyDecrypter(
342
432
 
343
433
  const rootContextId = recordsWrite.contextId.split('/')[0];
344
434
 
435
+ // Case 0: Delegate with a delivered context key for this rootContextId.
436
+ // First check the in-memory cache (same-process delivery).
437
+ // On cache miss, try to fetch a contextKey record from the owner's DWN
438
+ // (cross-device delivery via the key-delivery protocol).
439
+ //
440
+ // IMPORTANT: If this is a delegated request (granteeDid is set), we must
441
+ // NOT fall through to Cases 1/2 which use authorDid (the owner). Delegates
442
+ // must decrypt via their own delivered context key — never via the owner's
443
+ // KMS. This is fail-closed by design.
444
+ if (delegateContextKeyCache && granteeDid) {
445
+ const protocol = recordsWrite.descriptor.protocol;
446
+ if (protocol) {
447
+ const ctxCacheKey = `dctx~${granteeDid}~${protocol}~${rootContextId}`;
448
+ const delegateCtxKey = delegateContextKeyCache.get(ctxCacheKey);
449
+ if (delegateCtxKey) {
450
+ return buildContextKeyDecrypter(delegateCtxKey);
451
+ }
452
+
453
+ // Cache miss — try fetching a delivered contextKey record.
454
+ // The owner may have written one addressed to this delegate
455
+ // after the initial connect (cross-device delivery).
456
+ //
457
+ // For cross-device (ownerDid !== requesterDid), fetchContextKeyRecord
458
+ // queries the owner's tenant on the delegate's local DWN via
459
+ // processRequest. The delegate identity's connectedDid metadata
460
+ // registers ownerDid as locally-managed, so the query routes locally
461
+ // (in-process node or local-server RPC) — not to the owner's remote
462
+ // endpoint. Sync must have brought the contextKey record locally.
463
+ try {
464
+ const fetchedKey = await fetchContextKeyRecordFn({
465
+ ownerDid : authorDid,
466
+ requesterDid : granteeDid,
467
+ sourceProtocol : protocol,
468
+ sourceContextId : rootContextId,
469
+ });
470
+ if (fetchedKey) {
471
+ delegateContextKeyCache.set(ctxCacheKey, fetchedKey);
472
+ return buildContextKeyDecrypter(fetchedKey);
473
+ }
474
+ } catch {
475
+ // Delegate fetch failed — fail closed below.
476
+ }
477
+ }
478
+
479
+ // Delegate path exhausted: no cached key, no delivered record.
480
+ // Fail closed — do NOT fall through to the owner KMS path.
481
+ throw new Error(
482
+ `AgentDwnApi: Delegate '${granteeDid}' does not have a context key ` +
483
+ `for context '${rootContextId}'. No delivered contextKey record was ` +
484
+ `found via the key-delivery protocol. The delegate may need to ` +
485
+ `reconnect or wait for sync.`
486
+ );
487
+ }
488
+
345
489
  // Case 1: I am the context creator — rootKeyId matches my encryption key
346
490
  const { keyId, keyUri } = await getEncryptionKeyInfo(agent, authorDid);
347
491
  if (contextKeyEntry.header.kid === keyId) {
@@ -405,6 +549,7 @@ export async function resolveKeyDecrypter(
405
549
  * @param agent - The platform agent
406
550
  * @param contextDerivedKeyCache - Cache for context-derived private keys
407
551
  * @param fetchContextKeyRecordFn - Function to fetch context key records
552
+ * @param delegateDecryptionKeyCache - Cache for scope-aware delegate decryption keys
408
553
  */
409
554
  export async function maybeDecryptReply<T extends DwnInterface>(
410
555
  request: ProcessDwnRequest<T> | SendDwnRequest<T>,
@@ -417,6 +562,8 @@ export async function maybeDecryptReply<T extends DwnInterface>(
417
562
  sourceProtocol: string;
418
563
  sourceContextId: string;
419
564
  }) => Promise<DerivedPrivateJwk | undefined>,
565
+ delegateDecryptionKeyCache?: { get(key: string): DelegateDecryptionKeyEntry[] | undefined },
566
+ delegateContextKeyCache?: { get(key: string): DerivedPrivateJwk | undefined; set(key: string, value: DerivedPrivateJwk): void },
420
567
  ): Promise<void> {
421
568
  if (!('encryption' in request) || !request.encryption) {
422
569
  return;
@@ -430,7 +577,8 @@ export async function maybeDecryptReply<T extends DwnInterface>(
430
577
  && readReply.entry?.data) {
431
578
  const keyDecrypter = await resolveKeyDecrypter(
432
579
  agent, request.author, readReply.entry.recordsWrite, request.target,
433
- contextDerivedKeyCache, fetchContextKeyRecordFn,
580
+ contextDerivedKeyCache, fetchContextKeyRecordFn, delegateDecryptionKeyCache,
581
+ (request as any).granteeDid, delegateContextKeyCache,
434
582
  );
435
583
 
436
584
  try {
@@ -457,7 +605,8 @@ export async function maybeDecryptReply<T extends DwnInterface>(
457
605
  if (entry.encryption && entry.encodedData) {
458
606
  const keyDecrypter = await resolveKeyDecrypter(
459
607
  agent, request.author, entry as RecordsWriteMessage, request.target,
460
- contextDerivedKeyCache, fetchContextKeyRecordFn,
608
+ contextDerivedKeyCache, fetchContextKeyRecordFn, delegateDecryptionKeyCache,
609
+ (request as any).granteeDid, delegateContextKeyCache,
461
610
  );
462
611
 
463
612
  try {
@@ -2,7 +2,6 @@ import type { PublicKeyJwk } from '@enbox/crypto';
2
2
  import type {
3
3
  DerivedPrivateJwk,
4
4
  EncryptionInput,
5
- RecordsQueryReply,
6
5
  RecordsReadReply,
7
6
  } from '@enbox/dwn-sdk-js';
8
7
 
@@ -19,12 +18,12 @@ import {
19
18
  KeyDerivationScheme,
20
19
  Message,
21
20
  Protocols,
22
- Records,
23
21
  } from '@enbox/dwn-sdk-js';
24
22
 
23
+ import { DwnInterface } from './types/dwn.js';
25
24
  import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
26
- import { buildEncryptionInput, encryptAndComputeCid, getEncryptionKeyDeriver, getKeyDecrypter, ivLength } from './dwn-encryption.js';
27
- import { DwnInterface, dwnMessageConstructors } from './types/dwn.js';
25
+
26
+ import { buildEncryptionInput, encryptAndComputeCid, getEncryptionKeyDeriver, ivLength } from './dwn-encryption.js';
28
27
 
29
28
  /**
30
29
  * Parameters for writeContextKeyRecord.
@@ -255,6 +254,59 @@ export async function eagerSendContextKeyRecord(
255
254
  });
256
255
  }
257
256
 
257
+ /**
258
+ * Fetches a contextKey record from the owner's tenant via `processRequest`.
259
+ *
260
+ * This works in both agent modes:
261
+ * - In-process DWN node: `processRequest` routes locally via the DWN instance
262
+ * - Remote/local-server mode: `processRequest` routes via RPC to the local DWN server
263
+ *
264
+ * The delegate identity's `connectedDid` metadata registers `ownerDid` as a
265
+ * locally-managed DID, so `processRequest` routes to the local DWN (not the
266
+ * owner's remote endpoint).
267
+ *
268
+ * Requires sync to have brought the contextKey record to the owner's tenant
269
+ * on the delegate's DWN before this is called.
270
+ */
271
+ async function fetchCrossDeviceContextKey(
272
+ processRequest: ProcessRequestFn,
273
+ requesterDid: string,
274
+ ownerDid: string,
275
+ contextKeyFilter: Record<string, any>,
276
+ parsePayload: (bytes: Uint8Array) => DerivedPrivateJwk,
277
+ ): Promise<DerivedPrivateJwk | undefined> {
278
+ try {
279
+ const { reply } = await processRequest({
280
+ author : requesterDid,
281
+ target : ownerDid,
282
+ messageType : DwnInterface.RecordsQuery,
283
+ messageParams : { filter: contextKeyFilter },
284
+ });
285
+
286
+ if (reply.status.code !== 200 || !reply.entries?.length) {
287
+ return undefined;
288
+ }
289
+
290
+ const recordId = reply.entries[0].recordId;
291
+ const { reply: readReply } = await processRequest({
292
+ author : requesterDid,
293
+ target : ownerDid,
294
+ messageType : DwnInterface.RecordsRead,
295
+ messageParams : { filter: { recordId } },
296
+ encryption : true,
297
+ });
298
+
299
+ const readResult = readReply as RecordsReadReply;
300
+ if (!readResult.entry?.data) {
301
+ return undefined;
302
+ }
303
+
304
+ return parsePayload(await DataStream.toBytes(readResult.entry.data));
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ }
309
+
258
310
  /**
259
311
  * Fetches and decrypts a `contextKey` record from a DWN, returning the
260
312
  * `DerivedPrivateJwk` payload.
@@ -265,18 +317,12 @@ export async function eagerSendContextKeyRecord(
265
317
  * @param agent - The platform agent
266
318
  * @param params - The fetch parameters
267
319
  * @param processRequest - The agent's processRequest method (bound)
268
- * @param getSigner - Function to get a signer for a DID
269
- * @param sendDwnRpcRequest - Function to send a DWN RPC request
270
- * @param getDwnEndpointUrlsForTarget - Function to resolve DWN endpoint URLs (with local discovery)
271
320
  * @returns The decrypted `DerivedPrivateJwk`, or `undefined` if no matching record found
272
321
  */
273
322
  export async function fetchContextKeyRecord(
274
- agent: EnboxPlatformAgent,
323
+ _agent: EnboxPlatformAgent,
275
324
  params: FetchContextKeyParams,
276
325
  processRequest: ProcessRequestFn,
277
- getSigner: (author: string) => Promise<any>,
278
- sendDwnRpcRequest: (params: { targetDid: string; dwnEndpointUrls: string[]; message: any; data?: Blob }) => Promise<any>,
279
- getDwnEndpointUrlsForTarget: (targetDid: string) => Promise<string[]>,
280
326
  ): Promise<DerivedPrivateJwk | undefined> {
281
327
  const { ownerDid, requesterDid, sourceProtocol, sourceContextId } = params;
282
328
  const protocolUri = KeyDeliveryProtocolDefinition.protocol;
@@ -295,7 +341,7 @@ export async function fetchContextKeyRecord(
295
341
  JSON.parse(new TextDecoder().decode(bytes)) as DerivedPrivateJwk;
296
342
 
297
343
  if (isLocal) {
298
- // Local query: owner queries their own DWN
344
+ // Local query via processRequest: owner queries their own DWN.
299
345
  const { reply } = await processRequest({
300
346
  author : requesterDid,
301
347
  target : ownerDid,
@@ -307,13 +353,12 @@ export async function fetchContextKeyRecord(
307
353
  return undefined;
308
354
  }
309
355
 
310
- // Read the full record to get the data (auto-decrypted by processRequest)
311
- const recordId = reply.entries[0].recordId;
356
+ const localRecordId = reply.entries[0].recordId;
312
357
  const { reply: readReply } = await processRequest({
313
358
  author : requesterDid,
314
359
  target : ownerDid,
315
360
  messageType : DwnInterface.RecordsRead,
316
- messageParams : { filter: { recordId } },
361
+ messageParams : { filter: { recordId: localRecordId } },
317
362
  encryption : true,
318
363
  });
319
364
 
@@ -323,51 +368,18 @@ export async function fetchContextKeyRecord(
323
368
  }
324
369
 
325
370
  return parsePayload(await DataStream.toBytes(readResult.entry.data));
326
- } else {
327
- // Remote query: participant queries the context owner's DWN
328
- const signer = await getSigner(requesterDid);
329
- const dwnEndpointUrls = await getDwnEndpointUrlsForTarget(ownerDid);
330
-
331
- const recordsQuery = await dwnMessageConstructors[DwnInterface.RecordsQuery].create({
332
- signer,
333
- filter: contextKeyFilter,
334
- });
335
-
336
- const queryReply = await sendDwnRpcRequest({
337
- targetDid : ownerDid,
338
- dwnEndpointUrls,
339
- message : recordsQuery.message,
340
- }) as RecordsQueryReply;
341
-
342
- if (queryReply.status.code !== 200 || !queryReply.entries?.length) {
343
- return undefined;
344
- }
345
-
346
- // Read the full record remotely
347
- const recordId = queryReply.entries[0].recordId;
348
- const recordsRead = await dwnMessageConstructors[DwnInterface.RecordsRead].create({
349
- signer,
350
- filter: { recordId },
351
- });
352
-
353
- const readReply = await sendDwnRpcRequest({
354
- targetDid : ownerDid,
355
- dwnEndpointUrls,
356
- message : recordsRead.message,
357
- }) as RecordsReadReply;
358
-
359
- if (!readReply.entry?.data || !readReply.entry?.recordsWrite) {
360
- return undefined;
361
- }
362
-
363
- // Decrypt the contextKey payload using the requester's key-delivery protocol path key
364
- const keyDecrypter = await getKeyDecrypter(agent, requesterDid);
365
- const decryptedStream = await Records.decrypt(
366
- readReply.entry.recordsWrite,
367
- keyDecrypter,
368
- readReply.entry.data as ReadableStream<Uint8Array>,
369
- );
370
-
371
- return parsePayload(await DataStream.toBytes(decryptedStream));
372
371
  }
372
+
373
+ // Cross-device path: the requester is NOT the owner.
374
+ // Query the owner's tenant on the delegate's DWN via processRequest.
375
+ // The delegate identity's connectedDid metadata registers ownerDid as
376
+ // locally-managed, so processRequest routes locally (in-process or
377
+ // local-server RPC) rather than to the owner's remote DWN endpoint.
378
+ //
379
+ // Sync must have brought the contextKey record to the owner's tenant
380
+ // on the delegate's DWN before this is called.
381
+ // If fetch fails, the caller's fail-closed guard will fire.
382
+ return fetchCrossDeviceContextKey(
383
+ processRequest, requesterDid, ownerDid, contextKeyFilter, parsePayload,
384
+ );
373
385
  }