From f4ef07b3851793675dbae94261d0135d6b219838 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 13:26:58 -0300 Subject: [PATCH 01/10] didcomm delegation offer --- integration-tests/delegation-offer.test.ts | 185 ++++++++++ integration-tests/helpers/wallet-helpers.ts | 38 +++ .../src/delegation/delegation-issuance.ts | 42 +++ .../core/src/delegation/delegation-offer.ts | 317 ++++++++++++++++++ packages/core/src/did-provider.ts | 16 +- 5 files changed, 592 insertions(+), 6 deletions(-) create mode 100644 integration-tests/delegation-offer.test.ts create mode 100644 packages/core/src/delegation/delegation-offer.ts diff --git a/integration-tests/delegation-offer.test.ts b/integration-tests/delegation-offer.test.ts new file mode 100644 index 00000000..e4893b06 --- /dev/null +++ b/integration-tests/delegation-offer.test.ts @@ -0,0 +1,185 @@ +import { + createFullWalletClient, + closeWallet, +} from './helpers/wallet-helpers'; +import { + acceptDelegationOffer, + createDelegationOffer, + createOOBInvitation, + handleMessage, +} from '../packages/core/src/delegation/delegation-offer'; +import {issueCredential} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-issuance'; +import { + TRAVEL_AGENCY_CONTEXT, + travelAgencyPolicy, +} from './delegation/delegation-fixtures'; + +async function issueRootCredential(walletClient) { + const roleId = 'e79c0d16-8739-4e54-94d7-53d9f1c97c71'; + const credentialData = { + '@context': TRAVEL_AGENCY_CONTEXT, + type: [ + 'VerifiableCredential', + 'TravelAgencyCredential', + 'DelegationCredential', + ], + issuer: { + id: 'did:test:root-issuer', + name: 'Travel Agency', + }, + credentialSubject: { + id: 'did:test:travel-agency', + allowedRoutes: ['US-NYC-LAX', 'US-SFO-SEA', 'US-ORD-MIA'], + purchaseLimit: 10000, + reserveFlights: true, + reserveHotels: true, + }, + issuanceDate: new Date().toISOString(), + }; + const [issuerKey] = await walletClient.didProvider.getDIDKeyPairs(); + const credential = await issueCredential( + credentialData, + issuerKey, + travelAgencyPolicy, + roleId, + ); + await walletClient.wallet.addDocument(credential); + return credential; +} + +describe('Credential Distribution', () => { + let issuerWallet: Awaited>; + let holderWallet: Awaited>; + let stopIssuerAutoFetch: () => void; + let stopHolderAutoFetch: () => void; + + beforeAll(async () => { + console.log('[setup] creating issuer wallet...'); + issuerWallet = await createFullWalletClient(); + console.log('[setup] issuer wallet ready, DID:', issuerWallet.did); + + console.log('[setup] creating holder wallet...'); + holderWallet = await createFullWalletClient(); + console.log('[setup] holder wallet ready, DID:', holderWallet.did); + + console.log('[setup] starting auto-fetch for both wallets...'); + stopIssuerAutoFetch = issuerWallet.messageProvider.startAutoFetch(); + stopHolderAutoFetch = holderWallet.messageProvider.startAutoFetch(); + console.log('[setup] auto-fetch started'); + }, 60_000); + + afterAll(async () => { + console.log('[teardown] stopping auto-fetch...'); + stopIssuerAutoFetch?.(); + stopHolderAutoFetch?.(); + + console.log('[teardown] closing issuer wallet...'); + await closeWallet(issuerWallet.wallet); + + console.log('[teardown] closing holder wallet...'); + await closeWallet(holderWallet.wallet); + + console.log('[teardown] done'); + }); + + it('issuer should share OOB invitation with holder', async () => { + const rootCredential = await issueRootCredential(issuerWallet); + + // Step 1: Issuer creates a delegation offer and shares the OOB invitation URL with the holder + const delegationOffer = await createDelegationOffer(issuerWallet, { + delegationPolicy: travelAgencyPolicy, + credentialId: rootCredential.id, + delegationRole: 'e79c0d16-8739-4e54-94d7-53d9f1c97c71', + }); + + const qrCode = createOOBInvitation(issuerWallet.did, delegationOffer); + console.log('[issuer] OOB invitation URL created:', qrCode); + + // Listens for delegation offer events and accepts them when received + const offerAcceptPromise = new Promise(resolve => { + holderWallet.wallet.eventManager.addListener( + 'delegationOfferReceived', + async delegationOffer => { + console.log( + '[holder] delegationOfferReceived event:', + delegationOffer, + ); + await acceptDelegationOffer({ + delegationOffer, + wallet: holderWallet.wallet, + messageProvider: holderWallet.messageProvider, + }); + resolve(delegationOffer); + }, + ); + }); + + // Step 2: Holder scans the OOB invitation URL — this decodes it, persists the offer, and emits the event + console.log('[holder] scanning OOB invitation URL...'); + await handleMessage(qrCode, { + wallet: holderWallet.wallet, + messageProvider: holderWallet.messageProvider, + }); + + // Wait for the offer to be accepted in the event listener, then verify it was processed correctly + const acceptedOffer = await offerAcceptPromise; + console.log('[holder] delegation offer accepted:', acceptedOffer.id); + + // Verify the delegation offer was added to the holder's wallet + const storedOffer = await holderWallet.wallet.getDocumentById( + acceptedOffer.id, + ); + console.log('[holder] stored delegation offer:', storedOffer); + expect(storedOffer).toBeDefined(); + expect(storedOffer.type).toContain('DelegationOffer'); + + // Step 3: The issuer should receive a credential request message from the holder as a result of accepting the offer + + const credentialRequestFromHolder = await issuerWallet.messageProvider.waitForMessage(); + + console.log('[issuer] received message after offer acceptance:', credentialRequestFromHolder); + + expect(credentialRequestFromHolder).toBeDefined(); + expect(credentialRequestFromHolder.from).toBe(holderWallet.did); + expect(credentialRequestFromHolder.type).toBe('https://didcomm.org/issue-credential/3.0/request-credential'); + + // create handlers for credential request messages for delegation offer + await handleMessage(credentialRequestFromHolder, { + wallet: issuerWallet.wallet, + messageProvider: issuerWallet.messageProvider, + }); + + // check if delegation offer is accepted in the issuer wallet, and holder did is added to the document + + const updatedOffer = await issuerWallet.wallet.getDocumentById(acceptedOffer.id); + console.log('[issuer] updated delegation offer after handling credential request:', updatedOffer); + + expect(updatedOffer).toBeDefined(); + expect(updatedOffer.type).toContain('DelegationOffer'); + expect(updatedOffer.status).toBe('accepted'); + expect(updatedOffer.holderDID).toBe(holderWallet.did); + + // Step 4: Holder receive the delegatable credential from the issuer + const delegatableCredential = await holderWallet.messageProvider.waitForMessage(); + + + console.log('[holder] received message after credential request:', delegatableCredential); + + expect(delegatableCredential).toBeDefined(); + expect(delegatableCredential.type).toBe('https://didcomm.org/issue-credential/3.0/issue-credential'); + expect(delegatableCredential.from).toBe(issuerWallet.did); + expect(delegatableCredential.body.delegationOfferId).toBe(acceptedOffer.id); + expect(Array.isArray(delegatableCredential.body.credentials)).toBe(true); + expect(delegatableCredential.body.credentials.length).toBeGreaterThan(0); + + const [issuedCredential] = delegatableCredential.body.credentials; + expect(issuedCredential.type).toContain('DelegationCredential'); + expect(issuedCredential.rootCredentialId).toBe(rootCredential.id); + expect(issuedCredential.roleId).toBe('e79c0d16-8739-4e54-94d7-53d9f1c97c71'); + + const delegationChain = delegatableCredential.body.delegationChain; + expect(Array.isArray(delegationChain)).toBe(true); + expect(delegationChain.length).toBeGreaterThan(0); + expect(delegationChain[0].id).toBe(rootCredential.id); + }, 60_000); +}); diff --git a/integration-tests/helpers/wallet-helpers.ts b/integration-tests/helpers/wallet-helpers.ts index 3ae2de1f..f155b8d0 100644 --- a/integration-tests/helpers/wallet-helpers.ts +++ b/integration-tests/helpers/wallet-helpers.ts @@ -33,6 +33,44 @@ let didProvider: IDIDProvider; let credentialProvider: ICredentialProvider; let messageProvider: IMessageProvider; + +type FullWalletClient = { + wallet: IWallet; + did: string; + dataStore: DataStore; + didProvider: IDIDProvider; + credentialProvider: ICredentialProvider; + messageProvider: IMessageProvider; +} + +export async function createFullWalletClient(): Promise { + const dataStore = await createDataStore({ + databasePath: ':memory:', + dbType: 'sqlite', + defaultNetwork: 'testnet', + }); + const wallet = await createWallet({ + dataStore, + }); + const didProvider = createDIDProvider({wallet}); + const credentialProvider = createCredentialProvider({wallet}); + const messageProvider = createMessageProvider({wallet, didProvider}) as any; + await wallet.waitForEvent(WalletEvents.networkConnected); + + const did = await didProvider.getDefaultDID(); + + console.log('Wallet created with default DID:', did); + + return { + did, + wallet, + dataStore, + didProvider, + credentialProvider, + messageProvider, + }; +} + export async function createNewWallet({ dontWaitForNetwork, dataStore, diff --git a/packages/core/src/delegation/delegation-issuance.ts b/packages/core/src/delegation/delegation-issuance.ts index 1c9b332d..34893894 100644 --- a/packages/core/src/delegation/delegation-issuance.ts +++ b/packages/core/src/delegation/delegation-issuance.ts @@ -4,6 +4,7 @@ import {isDelegatableCredential} from './delegation-utils'; import {buildDelegationPolicyAttributes} from './delegation-policy'; import {delegationService} from '@docknetwork/wallet-sdk-wasm/src/services/delegation'; import {v4 as uuidv4} from 'uuid'; +import { getAllDIDs, getDefaultDID, getDIDKeyPair } from '../did-provider'; /** * Issue a delegatable credential @@ -43,3 +44,44 @@ export async function issueCredential( return credential; } + +export async function delegateCredential({ + credential, + wallet, + delegationPolicy, + roleId, +}) { + assert(isDelegatableCredential(credential), 'Credential is not delegatable'); + + const [issuerDID] = await getAllDIDs({wallet}); + const keyPair = await getDIDKeyPair(wallet, issuerDID); + + // get credential data from the original credential, excluding delegation metadata + const credentialData = { + ...(await buildDelegationPolicyAttributes(delegationPolicy)), + '@context': credential['@context'], + id: `urn:uuid:${uuidv4()}`, + roleId: roleId, + rootCredentialId: credential.rootCredentialId || credential.id, + type: credential.type, + issuer: { + id: issuerDID.didDocument.id, + name: issuerDID.name, + }, + issuanceDate: new Date().toISOString(), + credentialSubject: credential.credentialSubject, + }; + + const delegationCredential = await issueCredential( + credentialData, + keyPair, + delegationPolicy, + roleId, + credential.rootCredentialId || credential.id, + ); + + console.log('[delegateCredential] delegating credential with data:', credentialData); + + return delegationCredential; + // define issuer +} \ No newline at end of file diff --git a/packages/core/src/delegation/delegation-offer.ts b/packages/core/src/delegation/delegation-offer.ts new file mode 100644 index 00000000..d33a3c02 --- /dev/null +++ b/packages/core/src/delegation/delegation-offer.ts @@ -0,0 +1,317 @@ +import {v4 as uuid} from 'uuid'; +import {getAllDIDs, getDefaultDID} from '../did-provider'; +import { delegateCredential, issueCredential } from './delegation-issuance'; +import { getDelegationChain } from './delegation-chain'; + +const GOAL_CODE = 'dock.offer-delegation'; +const OOB_INVITATION = 'https://didcomm.org/out-of-band/2.0/invitation'; +const REQUEST_CREDENTIAL = + 'https://didcomm.org/issue-credential/3.0/request-credential'; +const ISSUE_CREDENTIAL = + 'https://didcomm.org/issue-credential/3.0/issue-credential'; +const ACK = 'https://didcomm.org/issue-credential/3.0/ack'; + +function base64urlEncode(input: string): string { + return Buffer.from(input, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function base64urlDecode(input: string): string { + const padded = input.replace(/-/g, '+').replace(/_/g, '/'); + const padLen = (4 - (padded.length % 4)) % 4; + return Buffer.from(padded + '='.repeat(padLen), 'base64').toString('utf8'); +} + +type CapabilityPreview = { + name: string; + value: string; +}; + +// Simplified preview of a delegation offer, used for display in the wallet UI +type DelegationOfferPreview = { + id: string; + issuer: { + did: string; + name: string; + }; + createdAt: string; + role: string; + expirationDate: string; + capabilities: CapabilityPreview[]; + attributes: string[]; +}; + +type DelegationOffer = { + id: string; + messageId?: string; + issuerDID?: string; + status: 'sent' | 'accepted' | 'rejected'; + [key: string]: any; +}; + +export async function createDelegationOffer(walletClient, { + delegationPolicy, + delegationRole, + credentialId, +}: { + delegationPolicy: any; + delegationRole: string; + credentialId?: string; +}) { + // TODO: Check if credential is delegatable + + const offerId = uuid(); + const delegationOffer = { + id: offerId, + credentialId: credentialId, + issuerDID: walletClient.did, + issuer: { + did: walletClient.did, + }, + to: undefined, + delegationPolicy, + delegationRole, + capabilities: [], + attributes: [], + delegationConstraints: {}, + sentAt: new Date().toISOString(), + updatedAt: null, + status: 'sent', + }; + + // Persist on the issuer side so DELEGATION_REQUEST_HANDLER can look it up + // when the holder replies with a credential request. + await walletClient.wallet.addDocument({ + type: 'DelegationOffer', + ...delegationOffer, + }); + + return delegationOffer; +} + +// OOB invitation (issuer → holder via QR/link) +export function createOOBInvitation(issuerDID, delegationOffer) { + const delegationOfferMessage = { + type: OOB_INVITATION, + id: delegationOffer.id, + from: issuerDID, + body: { + goal_code: GOAL_CODE, + goal: 'Acme is offering you a delegation', + offer_id: delegationOffer.id, + }, + attachments: [ + { + id: delegationOffer.id, + media_type: 'application/json', + // TODO: create delegation offer preview + data: {json: delegationOffer}, + }, + ], + }; + + const offerUrl = + 'didcomm://?_oob=' + + base64urlEncode(JSON.stringify(delegationOfferMessage)); + + return offerUrl; +} + +// Decode an OOB invitation URL into a DIDComm message object. +// Returns the input unchanged if it's already an object. +export function decodeMessage(message) { + if (typeof message !== 'string') { + return message; + } + + const oobPrefix = 'didcomm://?_oob='; + if (!message.startsWith(oobPrefix)) { + console.log('[decodeMessage] unrecognized URL scheme, skipping'); + return null; + } + + const encoded = message.slice(oobPrefix.length); + try { + return JSON.parse(base64urlDecode(encoded)); + } catch (err) { + console.log('[decodeMessage] failed to decode OOB payload:', err); + return null; + } +} + +export async function acceptDelegationOffer({ + delegationOffer, + wallet, + messageProvider, +}: { + delegationOffer: DelegationOffer; + wallet: any; + messageProvider: any; +}) { + const issuerDID = delegationOffer.issuerDID; + const holderDID = await getDefaultDID({wallet}); + const holderName = await getAllDIDs({wallet}).then( + dids => dids.find(d => d.didDocument.id === holderDID)?.name, + ); + + console.log('[holder] accepting delegation offer:', { + issuerDID, + holderDID, + holderName, + }); + + const requestCredentialMessage = { + type: REQUEST_CREDENTIAL, + pthid: delegationOffer.messageId, // parent thread = the OOB invitation + from: holderDID, + to: issuerDID, + body: { + goal_code: GOAL_CODE, + sender_profile: {name: holderName}, + offer_id: delegationOffer.id, + }, + }; + + console.log( + '[holder] sending delegation request to issuer:', + requestCredentialMessage, + ); + + await messageProvider.sendMessage(requestCredentialMessage); +} + +// Delegation message handlers +export const INVITATION_HANDLER = { + check: function (message) { + return ( + message.type === OOB_INVITATION && message.body?.goal_code === GOAL_CODE + ); + }, + handle: async function ( + message, + { + // context + messageProvider, + wallet, + }, + ) { + console.log('[INVITATION_HANDLER] handling message:', message); + + const offerAttachment = message.attachments?.[0]?.data?.json ?? {}; + const delegationOffer: DelegationOffer = { + ...offerAttachment, + id: message.body.offer_id, + messageId: message.id, + issuerDID: message.from, + status: 'sent', + }; + + await wallet.addDocument({ + type: 'DelegationOffer', + ...delegationOffer, + }); + + console.log( + '[INVITATION_HANDLER] emitting delegationOfferReceived for offer:', + delegationOffer.id, + ); + wallet.eventManager.emit('delegationOfferReceived', delegationOffer); + }, +}; + +export const DELEGATION_REQUEST_HANDLER = { + check: function (message) { + return ( + message.type === REQUEST_CREDENTIAL && + message.body?.goal_code === GOAL_CODE + ); + }, + handle: async function (message, {wallet, messageProvider}) { + console.log('[DELEGATION_REQUEST_HANDLER] handling message:', message); + + const offerId = message.body.offer_id; + const delegationOffer = await wallet.getDocumentById(offerId); + if (!delegationOffer) { + console.log( + '[DELEGATION_REQUEST_HANDLER] no matching delegation offer found for request:', + offerId, + ); + return; + } + + const holderDID = message.from; + + // update delegation offer status to accepted and add holder DID + delegationOffer.status = 'accepted'; + delegationOffer.holderDID = holderDID; + delegationOffer.updatedAt = new Date().toISOString(); + + await wallet.updateDocument(delegationOffer); + + const parentCredential = await wallet.getDocumentById( + delegationOffer.credentialId, + ); + + const delegatedCredential = await delegateCredential({ + credential: parentCredential, + wallet, + delegationPolicy: delegationOffer.delegationPolicy, + roleId: delegationOffer.delegationRole, + }); + + const delegationChain = await getDelegationChain(parentCredential, wallet); + + const issuerDID = Array.isArray(message.to) ? message.to[0] : message.to; + + await messageProvider.sendMessage({ + type: ISSUE_CREDENTIAL, + from: issuerDID, + to: holderDID, + message: { + goal_code: GOAL_CODE, + delegationOfferId: delegationOffer.id, + credentials: [delegatedCredential], + delegationChain, + }, + }); + }, +}; + +export const messageHandlers = [ + INVITATION_HANDLER, + DELEGATION_REQUEST_HANDLER, +]; + +export async function handleMessage( + message, + context: { + wallet; + messageProvider; + }, +) { + console.log('[handleMessage] called with:', message); + + const decoded = decodeMessage(message); + if (!decoded) { + console.log('[handleMessage] message could not be decoded, skipping'); + return; + } + + const handler = messageHandlers.find(h => h.check(decoded)); + if (!handler) { + console.log( + '[handleMessage] no handler matched message type:', + decoded.type, + ); + return; + } + + console.log( + '[handleMessage] dispatching to handler for type:', + decoded.type, + ); + return handler.handle(decoded, context); +} diff --git a/packages/core/src/did-provider.ts b/packages/core/src/did-provider.ts index 413adabf..1e214449 100644 --- a/packages/core/src/did-provider.ts +++ b/packages/core/src/did-provider.ts @@ -174,7 +174,7 @@ export async function createDIDKey({wallet, name, derivePath=undefined, type=und * Internal function to retrieve all DIDs stored in the wallet * @private */ -export async function getAll({wallet}) { +export async function getAllDIDs({wallet}) { assert(!!wallet, 'wallet is required'); const dids = await wallet.getDocumentsByType('DIDResolutionResponse'); return dids; @@ -186,20 +186,24 @@ export async function getAll({wallet}) { */ export async function getDefaultDID({wallet}) { assert(!!wallet, 'wallet is required'); - const allDids = await getAll({ wallet }); + const allDids = await getAllDIDs({ wallet }); return allDids[0]?.didDocument.id; } +export async function getDIDKeyPair(wallet, didDoc) { + return wallet.getDocumentById(didDoc.correlation[0]) +} + /** * Internal function to retrieve all keypairs associated with DIDs * @private */ export async function getDIDKeyPairs({wallet}) { assert(!!wallet, 'wallet is required'); - const didDocs = await getAll({wallet}); + const didDocs = await getAllDIDs({wallet}); const keyPairs = []; for (const didDoc of didDocs) { - const keyPair = await wallet.getDocumentById(didDoc.correlation[0]); + const keyPair = await getDIDKeyPair(wallet, didDoc); keyPairs.push(keyPair); } return keyPairs; @@ -208,7 +212,7 @@ export async function getDIDKeyPairs({wallet}) { export async function ensureDID({wallet}) { assert(!!wallet, 'wallet is required'); - const dids = await getAll({wallet}); + const dids = await getAllDIDs({wallet}); if (dids.length === 0) { return createDIDKey({wallet, name: 'Default DID'}); } @@ -335,7 +339,7 @@ export function createDIDProvider({wallet}): IDIDProvider { * console.log(`Found ${allDIDs.length} DIDs in wallet`); */ async getAll() { - return getAll({wallet}); + return getAllDIDs({wallet}); }, /** * Retrieves all keypairs associated with DIDs in the wallet From 5178de5a448dd407718dfa520978c7d1e0a03021 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 13:50:59 -0300 Subject: [PATCH 02/10] address PR review feedback for delegation offer flow --- .../src/delegation/delegation-issuance.ts | 23 ++-- .../core/src/delegation/delegation-offer.ts | 121 ++++++++---------- 2 files changed, 66 insertions(+), 78 deletions(-) diff --git a/packages/core/src/delegation/delegation-issuance.ts b/packages/core/src/delegation/delegation-issuance.ts index 34893894..935c7be5 100644 --- a/packages/core/src/delegation/delegation-issuance.ts +++ b/packages/core/src/delegation/delegation-issuance.ts @@ -4,7 +4,7 @@ import {isDelegatableCredential} from './delegation-utils'; import {buildDelegationPolicyAttributes} from './delegation-policy'; import {delegationService} from '@docknetwork/wallet-sdk-wasm/src/services/delegation'; import {v4 as uuidv4} from 'uuid'; -import { getAllDIDs, getDefaultDID, getDIDKeyPair } from '../did-provider'; +import {getAllDIDs, getDIDKeyPair} from '../did-provider'; /** * Issue a delegatable credential @@ -50,13 +50,23 @@ export async function delegateCredential({ wallet, delegationPolicy, roleId, + delegatorDID, +}: { + credential: any; + wallet: any; + delegationPolicy: DelegationPolicy; + roleId: string; + delegatorDID: string; }) { assert(isDelegatableCredential(credential), 'Credential is not delegatable'); + assert(!!delegatorDID, 'delegatorDID is required'); + + const allDIDs = await getAllDIDs({wallet}); + const issuerDID = allDIDs.find(d => d.didDocument.id === delegatorDID); + assert(!!issuerDID, `delegatorDID ${delegatorDID} not found in wallet`); - const [issuerDID] = await getAllDIDs({wallet}); const keyPair = await getDIDKeyPair(wallet, issuerDID); - // get credential data from the original credential, excluding delegation metadata const credentialData = { ...(await buildDelegationPolicyAttributes(delegationPolicy)), '@context': credential['@context'], @@ -72,16 +82,11 @@ export async function delegateCredential({ credentialSubject: credential.credentialSubject, }; - const delegationCredential = await issueCredential( + return issueCredential( credentialData, keyPair, delegationPolicy, roleId, credential.rootCredentialId || credential.id, ); - - console.log('[delegateCredential] delegating credential with data:', credentialData); - - return delegationCredential; - // define issuer } \ No newline at end of file diff --git a/packages/core/src/delegation/delegation-offer.ts b/packages/core/src/delegation/delegation-offer.ts index d33a3c02..a5486d82 100644 --- a/packages/core/src/delegation/delegation-offer.ts +++ b/packages/core/src/delegation/delegation-offer.ts @@ -1,7 +1,8 @@ import {v4 as uuid} from 'uuid'; +import {logger} from '@docknetwork/wallet-sdk-data-store/src/logger'; import {getAllDIDs, getDefaultDID} from '../did-provider'; -import { delegateCredential, issueCredential } from './delegation-issuance'; -import { getDelegationChain } from './delegation-chain'; +import {delegateCredential} from './delegation-issuance'; +import {getDelegationChain} from './delegation-chain'; const GOAL_CODE = 'dock.offer-delegation'; const OOB_INVITATION = 'https://didcomm.org/out-of-band/2.0/invitation'; @@ -9,7 +10,6 @@ const REQUEST_CREDENTIAL = 'https://didcomm.org/issue-credential/3.0/request-credential'; const ISSUE_CREDENTIAL = 'https://didcomm.org/issue-credential/3.0/issue-credential'; -const ACK = 'https://didcomm.org/issue-credential/3.0/ack'; function base64urlEncode(input: string): string { return Buffer.from(input, 'utf8') @@ -25,25 +25,6 @@ function base64urlDecode(input: string): string { return Buffer.from(padded + '='.repeat(padLen), 'base64').toString('utf8'); } -type CapabilityPreview = { - name: string; - value: string; -}; - -// Simplified preview of a delegation offer, used for display in the wallet UI -type DelegationOfferPreview = { - id: string; - issuer: { - did: string; - name: string; - }; - createdAt: string; - role: string; - expirationDate: string; - capabilities: CapabilityPreview[]; - attributes: string[]; -}; - type DelegationOffer = { id: string; messageId?: string; @@ -93,21 +74,28 @@ export async function createDelegationOffer(walletClient, { } // OOB invitation (issuer → holder via QR/link) -export function createOOBInvitation(issuerDID, delegationOffer) { +export function createOOBInvitation( + issuerDID, + delegationOffer, + {goal, issuerName}: {goal?: string; issuerName?: string} = {}, +) { const delegationOfferMessage = { type: OOB_INVITATION, id: delegationOffer.id, from: issuerDID, body: { goal_code: GOAL_CODE, - goal: 'Acme is offering you a delegation', + goal: + goal ?? + (issuerName + ? `${issuerName} is offering you a delegation` + : 'You have received a delegation offer'), offer_id: delegationOffer.id, }, attachments: [ { id: delegationOffer.id, media_type: 'application/json', - // TODO: create delegation offer preview data: {json: delegationOffer}, }, ], @@ -129,7 +117,7 @@ export function decodeMessage(message) { const oobPrefix = 'didcomm://?_oob='; if (!message.startsWith(oobPrefix)) { - console.log('[decodeMessage] unrecognized URL scheme, skipping'); + logger.debug('decodeMessage: unrecognized URL scheme, skipping'); return null; } @@ -137,7 +125,7 @@ export function decodeMessage(message) { try { return JSON.parse(base64urlDecode(encoded)); } catch (err) { - console.log('[decodeMessage] failed to decode OOB payload:', err); + logger.error(`decodeMessage: failed to decode OOB payload: ${err}`); return null; } } @@ -157,12 +145,6 @@ export async function acceptDelegationOffer({ dids => dids.find(d => d.didDocument.id === holderDID)?.name, ); - console.log('[holder] accepting delegation offer:', { - issuerDID, - holderDID, - holderName, - }); - const requestCredentialMessage = { type: REQUEST_CREDENTIAL, pthid: delegationOffer.messageId, // parent thread = the OOB invitation @@ -175,12 +157,17 @@ export async function acceptDelegationOffer({ }, }; - console.log( - '[holder] sending delegation request to issuer:', - requestCredentialMessage, - ); - await messageProvider.sendMessage(requestCredentialMessage); + + // Mirror issuer-side bookkeeping: mark the holder's stored offer as accepted. + const storedOffer = await wallet.getDocumentById(delegationOffer.id); + if (storedOffer) { + await wallet.updateDocument({ + ...storedOffer, + status: 'accepted', + updatedAt: new Date().toISOString(), + }); + } } // Delegation message handlers @@ -190,16 +177,7 @@ export const INVITATION_HANDLER = { message.type === OOB_INVITATION && message.body?.goal_code === GOAL_CODE ); }, - handle: async function ( - message, - { - // context - messageProvider, - wallet, - }, - ) { - console.log('[INVITATION_HANDLER] handling message:', message); - + handle: async function (message, {wallet}) { const offerAttachment = message.attachments?.[0]?.data?.json ?? {}; const delegationOffer: DelegationOffer = { ...offerAttachment, @@ -214,9 +192,8 @@ export const INVITATION_HANDLER = { ...delegationOffer, }); - console.log( - '[INVITATION_HANDLER] emitting delegationOfferReceived for offer:', - delegationOffer.id, + logger.debug( + `INVITATION_HANDLER: emitting delegationOfferReceived for offer ${delegationOffer.id}`, ); wallet.eventManager.emit('delegationOfferReceived', delegationOffer); }, @@ -230,21 +207,33 @@ export const DELEGATION_REQUEST_HANDLER = { ); }, handle: async function (message, {wallet, messageProvider}) { - console.log('[DELEGATION_REQUEST_HANDLER] handling message:', message); - const offerId = message.body.offer_id; const delegationOffer = await wallet.getDocumentById(offerId); if (!delegationOffer) { - console.log( - '[DELEGATION_REQUEST_HANDLER] no matching delegation offer found for request:', - offerId, + logger.debug( + `DELEGATION_REQUEST_HANDLER: no matching delegation offer found for request ${offerId}`, + ); + return; + } + + // Authorization checks: only the targeted holder (if any) may accept, + // and the offer must still be in the 'sent' state to prevent replay. + if (delegationOffer.status !== 'sent') { + logger.debug( + `DELEGATION_REQUEST_HANDLER: rejecting request for offer ${offerId} — already ${delegationOffer.status}`, + ); + return; + } + + if (delegationOffer.to && delegationOffer.to !== message.from) { + logger.debug( + `DELEGATION_REQUEST_HANDLER: rejecting request for offer ${offerId} — sender does not match offer.to`, ); return; } const holderDID = message.from; - // update delegation offer status to accepted and add holder DID delegationOffer.status = 'accepted'; delegationOffer.holderDID = holderDID; delegationOffer.updatedAt = new Date().toISOString(); @@ -255,17 +244,18 @@ export const DELEGATION_REQUEST_HANDLER = { delegationOffer.credentialId, ); + const issuerDID = Array.isArray(message.to) ? message.to[0] : message.to; + const delegatedCredential = await delegateCredential({ credential: parentCredential, wallet, delegationPolicy: delegationOffer.delegationPolicy, roleId: delegationOffer.delegationRole, + delegatorDID: delegationOffer.issuerDID || issuerDID, }); const delegationChain = await getDelegationChain(parentCredential, wallet); - const issuerDID = Array.isArray(message.to) ? message.to[0] : message.to; - await messageProvider.sendMessage({ type: ISSUE_CREDENTIAL, from: issuerDID, @@ -292,26 +282,19 @@ export async function handleMessage( messageProvider; }, ) { - console.log('[handleMessage] called with:', message); - const decoded = decodeMessage(message); if (!decoded) { - console.log('[handleMessage] message could not be decoded, skipping'); + logger.debug('handleMessage: message could not be decoded, skipping'); return; } const handler = messageHandlers.find(h => h.check(decoded)); if (!handler) { - console.log( - '[handleMessage] no handler matched message type:', - decoded.type, + logger.debug( + `handleMessage: no handler matched message type ${decoded.type}`, ); return; } - console.log( - '[handleMessage] dispatching to handler for type:', - decoded.type, - ); return handler.handle(decoded, context); } From a59f5edcdacb1f277415f12749ac21f32da91cc4 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 13:55:56 -0300 Subject: [PATCH 03/10] add ISSUE_CREDENTIAL_HANDLER for holder-side credential receipt and ACK --- integration-tests/delegation-offer.test.ts | 42 ++++++++++++ .../core/src/delegation/delegation-offer.ts | 66 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/integration-tests/delegation-offer.test.ts b/integration-tests/delegation-offer.test.ts index e4893b06..5faef6a6 100644 --- a/integration-tests/delegation-offer.test.ts +++ b/integration-tests/delegation-offer.test.ts @@ -181,5 +181,47 @@ describe('Credential Distribution', () => { expect(Array.isArray(delegationChain)).toBe(true); expect(delegationChain.length).toBeGreaterThan(0); expect(delegationChain[0].id).toBe(rootCredential.id); + + // Step 5: Holder dispatches the issue-credential message — the handler + // stores the delegated credential, sends an ACK back, and emits an event. + const credentialReceivedPromise = new Promise(resolve => { + holderWallet.wallet.eventManager.addListener( + 'delegatedCredentialReceived', + payload => { + console.log('[holder] delegatedCredentialReceived event:', payload); + resolve(payload); + }, + ); + }); + + await handleMessage(delegatableCredential, { + wallet: holderWallet.wallet, + messageProvider: holderWallet.messageProvider, + }); + + const receivedPayload = await credentialReceivedPromise; + expect(receivedPayload.delegationOfferId).toBe(acceptedOffer.id); + expect(receivedPayload.credentials[0].id).toBe(issuedCredential.id); + + // Verify the delegated credential was persisted on the holder side. + const storedCredential = await holderWallet.wallet.getDocumentById( + issuedCredential.id, + ); + expect(storedCredential).toBeDefined(); + expect(storedCredential.rootCredentialId).toBe(rootCredential.id); + + // Verify the holder's stored offer was advanced to 'accepted'. + const holderStoredOffer = await holderWallet.wallet.getDocumentById( + acceptedOffer.id, + ); + expect(holderStoredOffer.status).toBe('accepted'); + + // Step 6: The issuer should receive an ACK from the holder. + const ackMessage = await issuerWallet.messageProvider.waitForMessage(); + console.log('[issuer] received ACK:', ackMessage); + expect(ackMessage.type).toBe('https://didcomm.org/issue-credential/3.0/ack'); + expect(ackMessage.from).toBe(holderWallet.did); + expect(ackMessage.body.delegationOfferId).toBe(acceptedOffer.id); + expect(ackMessage.body.status).toBe('OK'); }, 60_000); }); diff --git a/packages/core/src/delegation/delegation-offer.ts b/packages/core/src/delegation/delegation-offer.ts index a5486d82..58055a25 100644 --- a/packages/core/src/delegation/delegation-offer.ts +++ b/packages/core/src/delegation/delegation-offer.ts @@ -10,6 +10,7 @@ const REQUEST_CREDENTIAL = 'https://didcomm.org/issue-credential/3.0/request-credential'; const ISSUE_CREDENTIAL = 'https://didcomm.org/issue-credential/3.0/issue-credential'; +const ACK = 'https://didcomm.org/issue-credential/3.0/ack'; function base64urlEncode(input: string): string { return Buffer.from(input, 'utf8') @@ -270,9 +271,74 @@ export const DELEGATION_REQUEST_HANDLER = { }, }; +export const ISSUE_CREDENTIAL_HANDLER = { + check: function (message) { + return ( + message.type === ISSUE_CREDENTIAL && + message.body?.goal_code === GOAL_CODE + ); + }, + handle: async function (message, {wallet, messageProvider}) { + const offerId = message.body.delegationOfferId; + const credentials = message.body.credentials ?? []; + const delegationChain = message.body.delegationChain ?? []; + + if (credentials.length === 0) { + logger.debug( + `ISSUE_CREDENTIAL_HANDLER: no credentials in message for offer ${offerId}`, + ); + return; + } + + for (const credential of credentials) { + await wallet.addDocument(credential); + } + + for (const ancestor of delegationChain) { + const existing = await wallet.getDocumentById(ancestor.id); + if (!existing) { + await wallet.addDocument(ancestor); + } + } + + if (offerId) { + const storedOffer = await wallet.getDocumentById(offerId); + if (storedOffer) { + await wallet.updateDocument({ + ...storedOffer, + status: 'accepted', + updatedAt: new Date().toISOString(), + }); + } + } + + const holderDID = Array.isArray(message.to) ? message.to[0] : message.to; + const issuerDID = message.from; + + await messageProvider.sendMessage({ + type: ACK, + from: holderDID, + to: issuerDID, + pthid: message.id, + body: { + goal_code: GOAL_CODE, + delegationOfferId: offerId, + status: 'OK', + }, + }); + + wallet.eventManager.emit('delegatedCredentialReceived', { + delegationOfferId: offerId, + credentials, + delegationChain, + }); + }, +}; + export const messageHandlers = [ INVITATION_HANDLER, DELEGATION_REQUEST_HANDLER, + ISSUE_CREDENTIAL_HANDLER, ]; export async function handleMessage( From 5ee249e889f4669d47f08f6dba6f60c0a2da9acc Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 14:11:59 -0300 Subject: [PATCH 04/10] update gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 429febb8..2924a19f 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ poc *.db reports/ .turbo -packages/react-native/public/*.module.wasm \ No newline at end of file +packages/react-native/public/*.module.wasm + +integration-tests/debugging/* \ No newline at end of file From 0beba87562fe2bde2400abfa3510f2b693eb8d68 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 14:21:51 -0300 Subject: [PATCH 05/10] harden delegation offer flow: expiration, slim OOB payload, security checks --- integration-tests/delegation-offer.test.ts | 4 +- .../src/delegation/delegation-issuance.ts | 2 +- .../core/src/delegation/delegation-offer.ts | 142 ++++++++++++++---- 3 files changed, 117 insertions(+), 31 deletions(-) diff --git a/integration-tests/delegation-offer.test.ts b/integration-tests/delegation-offer.test.ts index 5faef6a6..044421d8 100644 --- a/integration-tests/delegation-offer.test.ts +++ b/integration-tests/delegation-offer.test.ts @@ -86,7 +86,9 @@ describe('Credential Distribution', () => { const rootCredential = await issueRootCredential(issuerWallet); // Step 1: Issuer creates a delegation offer and shares the OOB invitation URL with the holder - const delegationOffer = await createDelegationOffer(issuerWallet, { + const delegationOffer = await createDelegationOffer({ + wallet: issuerWallet.wallet, + issuerDID: issuerWallet.did, delegationPolicy: travelAgencyPolicy, credentialId: rootCredential.id, delegationRole: 'e79c0d16-8739-4e54-94d7-53d9f1c97c71', diff --git a/packages/core/src/delegation/delegation-issuance.ts b/packages/core/src/delegation/delegation-issuance.ts index 935c7be5..0873e8ad 100644 --- a/packages/core/src/delegation/delegation-issuance.ts +++ b/packages/core/src/delegation/delegation-issuance.ts @@ -89,4 +89,4 @@ export async function delegateCredential({ roleId, credential.rootCredentialId || credential.id, ); -} \ No newline at end of file +} diff --git a/packages/core/src/delegation/delegation-offer.ts b/packages/core/src/delegation/delegation-offer.ts index 58055a25..f8e15757 100644 --- a/packages/core/src/delegation/delegation-offer.ts +++ b/packages/core/src/delegation/delegation-offer.ts @@ -1,8 +1,10 @@ +import assert from 'assert'; import {v4 as uuid} from 'uuid'; import {logger} from '@docknetwork/wallet-sdk-data-store/src/logger'; import {getAllDIDs, getDefaultDID} from '../did-provider'; import {delegateCredential} from './delegation-issuance'; import {getDelegationChain} from './delegation-chain'; +import {isDelegatableCredential} from './delegation-utils'; const GOAL_CODE = 'dock.offer-delegation'; const OOB_INVITATION = 'https://didcomm.org/out-of-band/2.0/invitation'; @@ -26,32 +28,69 @@ function base64urlDecode(input: string): string { return Buffer.from(padded + '='.repeat(padLen), 'base64').toString('utf8'); } +function pickDID(value: string | string[] | undefined): string | undefined { + if (!value) return undefined; + return Array.isArray(value) ? value[0] : value; +} + type DelegationOffer = { id: string; messageId?: string; issuerDID?: string; - status: 'sent' | 'accepted' | 'rejected'; + status: 'sent' | 'requested' | 'accepted' | 'rejected'; + expiresAt?: string; [key: string]: any; }; -export async function createDelegationOffer(walletClient, { +export type DelegationOfferPreview = { + id: string; + issuerDID: string; + issuerName?: string; + role: string; + createdAt: string; + expiresAt?: string; +}; + +const DEFAULT_OFFER_EXPIRATION_MS = 24 * 60 * 60 * 1000; + +export async function createDelegationOffer({ + wallet, + issuerDID, delegationPolicy, delegationRole, credentialId, + expiresInMs = DEFAULT_OFFER_EXPIRATION_MS, }: { + wallet: any; + issuerDID: string; delegationPolicy: any; delegationRole: string; credentialId?: string; + expiresInMs?: number; }) { - // TODO: Check if credential is delegatable + if (credentialId) { + const parentCredential = await wallet.getDocumentById(credentialId); + if (parentCredential) { + assert( + isDelegatableCredential(parentCredential), + `Credential ${credentialId} is not delegatable`, + ); + } + } + + const dids = await getAllDIDs({wallet}); + const issuer = dids.find(d => d.didDocument.id === issuerDID); + const issuerName = issuer?.name; const offerId = uuid(); + const sentAt = new Date(); const delegationOffer = { id: offerId, credentialId: credentialId, - issuerDID: walletClient.did, + issuerDID, + issuerName, issuer: { - did: walletClient.did, + did: issuerDID, }, to: undefined, delegationPolicy, @@ -59,14 +98,15 @@ export async function createDelegationOffer(walletClient, { capabilities: [], attributes: [], delegationConstraints: {}, - sentAt: new Date().toISOString(), + sentAt: sentAt.toISOString(), + expiresAt: new Date(sentAt.getTime() + expiresInMs).toISOString(), updatedAt: null, status: 'sent', }; // Persist on the issuer side so DELEGATION_REQUEST_HANDLER can look it up // when the holder replies with a credential request. - await walletClient.wallet.addDocument({ + await wallet.addDocument({ type: 'DelegationOffer', ...delegationOffer, }); @@ -80,6 +120,17 @@ export function createOOBInvitation( delegationOffer, {goal, issuerName}: {goal?: string; issuerName?: string} = {}, ) { + const finalIssuerName = issuerName ?? delegationOffer.issuerName; + + const preview: DelegationOfferPreview = { + id: delegationOffer.id, + issuerDID: issuerDID, + issuerName: finalIssuerName, + role: delegationOffer.delegationRole, + createdAt: delegationOffer.sentAt, + expiresAt: delegationOffer.expiresAt, + }; + const delegationOfferMessage = { type: OOB_INVITATION, id: delegationOffer.id, @@ -88,8 +139,8 @@ export function createOOBInvitation( goal_code: GOAL_CODE, goal: goal ?? - (issuerName - ? `${issuerName} is offering you a delegation` + (finalIssuerName + ? `${finalIssuerName} is offering you a delegation` : 'You have received a delegation offer'), offer_id: delegationOffer.id, }, @@ -97,7 +148,7 @@ export function createOOBInvitation( { id: delegationOffer.id, media_type: 'application/json', - data: {json: delegationOffer}, + data: {json: preview}, }, ], }; @@ -142,9 +193,8 @@ export async function acceptDelegationOffer({ }) { const issuerDID = delegationOffer.issuerDID; const holderDID = await getDefaultDID({wallet}); - const holderName = await getAllDIDs({wallet}).then( - dids => dids.find(d => d.didDocument.id === holderDID)?.name, - ); + const dids = await getAllDIDs({wallet}); + const holderName = dids.find(d => d.didDocument.id === holderDID)?.name; const requestCredentialMessage = { type: REQUEST_CREDENTIAL, @@ -165,7 +215,7 @@ export async function acceptDelegationOffer({ if (storedOffer) { await wallet.updateDocument({ ...storedOffer, - status: 'accepted', + status: 'requested', updatedAt: new Date().toISOString(), }); } @@ -220,15 +270,30 @@ export const DELEGATION_REQUEST_HANDLER = { // Authorization checks: only the targeted holder (if any) may accept, // and the offer must still be in the 'sent' state to prevent replay. if (delegationOffer.status !== 'sent') { - logger.debug( + logger.warn( `DELEGATION_REQUEST_HANDLER: rejecting request for offer ${offerId} — already ${delegationOffer.status}`, ); return; } - if (delegationOffer.to && delegationOffer.to !== message.from) { + if (delegationOffer.to) { + const targets = Array.isArray(delegationOffer.to) + ? delegationOffer.to + : [delegationOffer.to]; + if (!targets.includes(message.from)) { + logger.warn( + `DELEGATION_REQUEST_HANDLER: rejecting request for offer ${offerId} — sender does not match offer.to`, + ); + return; + } + } + + if ( + delegationOffer.expiresAt && + new Date(delegationOffer.expiresAt) < new Date() + ) { logger.debug( - `DELEGATION_REQUEST_HANDLER: rejecting request for offer ${offerId} — sender does not match offer.to`, + `DELEGATION_REQUEST_HANDLER: rejecting expired offer ${offerId}`, ); return; } @@ -245,7 +310,7 @@ export const DELEGATION_REQUEST_HANDLER = { delegationOffer.credentialId, ); - const issuerDID = Array.isArray(message.to) ? message.to[0] : message.to; + const issuerDID = pickDID(message.to); const delegatedCredential = await delegateCredential({ credential: parentCredential, @@ -280,6 +345,30 @@ export const ISSUE_CREDENTIAL_HANDLER = { }, handle: async function (message, {wallet, messageProvider}) { const offerId = message.body.delegationOfferId; + + if (!offerId) { + logger.debug( + 'ISSUE_CREDENTIAL_HANDLER: missing delegationOfferId in message body', + ); + return; + } + + const storedOffer = await wallet.getDocumentById(offerId); + if (!storedOffer) { + logger.debug( + `ISSUE_CREDENTIAL_HANDLER: no stored offer found for ${offerId}`, + ); + return; + } + + // SECURITY: only accept credentials from the DID that originally made the offer + if (message.from !== storedOffer.issuerDID) { + logger.debug( + `ISSUE_CREDENTIAL_HANDLER: rejecting credential for offer ${offerId} — sender ${message.from} does not match stored issuerDID ${storedOffer.issuerDID}`, + ); + return; + } + const credentials = message.body.credentials ?? []; const delegationChain = message.body.delegationChain ?? []; @@ -301,18 +390,13 @@ export const ISSUE_CREDENTIAL_HANDLER = { } } - if (offerId) { - const storedOffer = await wallet.getDocumentById(offerId); - if (storedOffer) { - await wallet.updateDocument({ - ...storedOffer, - status: 'accepted', - updatedAt: new Date().toISOString(), - }); - } - } + await wallet.updateDocument({ + ...storedOffer, + status: 'accepted', + updatedAt: new Date().toISOString(), + }); - const holderDID = Array.isArray(message.to) ? message.to[0] : message.to; + const holderDID = pickDID(message.to); const issuerDID = message.from; await messageProvider.sendMessage({ From 7dbfa1a1362015f7d4ed712a5e75af4cee52d9f5 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 14:55:37 -0300 Subject: [PATCH 06/10] test: rejection paths for delegation offer handlers --- .../delegation-offer-handlers.test.ts | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 integration-tests/delegation-offer-handlers.test.ts diff --git a/integration-tests/delegation-offer-handlers.test.ts b/integration-tests/delegation-offer-handlers.test.ts new file mode 100644 index 00000000..119ec7c7 --- /dev/null +++ b/integration-tests/delegation-offer-handlers.test.ts @@ -0,0 +1,235 @@ +import { + DELEGATION_REQUEST_HANDLER, + ISSUE_CREDENTIAL_HANDLER, +} from '../packages/core/src/delegation/delegation-offer'; + +function createStubWallet(offer: any) { + const documents: Record = {}; + if (offer) { + documents[offer.id] = offer; + } + return { + documents, + sentMessages: [] as any[], + emittedEvents: [] as any[], + getDocumentById: jest.fn(async (id: string) => documents[id]), + addDocument: jest.fn(async (doc: any) => { + documents[doc.id] = doc; + return doc; + }), + updateDocument: jest.fn(async (doc: any) => { + documents[doc.id] = doc; + return doc; + }), + eventManager: { + emit: jest.fn(), + }, + }; +} + +function createStubMessageProvider() { + return { + sendMessage: jest.fn(async () => undefined), + }; +} + +describe('DELEGATION_REQUEST_HANDLER rejection paths', () => { + const offerId = 'offer-1'; + const issuerDID = 'did:test:issuer'; + const holderDID = 'did:test:holder'; + const attackerDID = 'did:test:attacker'; + + function buildRequest(overrides: any = {}) { + return { + type: 'https://didcomm.org/issue-credential/3.0/request-credential', + from: holderDID, + to: issuerDID, + body: { + goal_code: 'dock.offer-delegation', + offer_id: offerId, + }, + ...overrides, + }; + } + + function buildStoredOffer(overrides: any = {}) { + return { + id: offerId, + type: 'DelegationOffer', + issuerDID, + status: 'sent', + credentialId: 'cred-1', + delegationPolicy: {}, + delegationRole: 'role-1', + sentAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ...overrides, + }; + } + + it('returns silently when no offer is found for the request', async () => { + const wallet = createStubWallet(null); + const messageProvider = createStubMessageProvider(); + + await DELEGATION_REQUEST_HANDLER.handle(buildRequest(), { + wallet, + messageProvider, + }); + + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + expect(wallet.updateDocument).not.toHaveBeenCalled(); + }); + + it('rejects requests for offers not in `sent` state', async () => { + const wallet = createStubWallet(buildStoredOffer({status: 'accepted'})); + const messageProvider = createStubMessageProvider(); + + await DELEGATION_REQUEST_HANDLER.handle(buildRequest(), { + wallet, + messageProvider, + }); + + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + expect(wallet.updateDocument).not.toHaveBeenCalled(); + }); + + it('rejects requests from senders not in offer.to (array form)', async () => { + const wallet = createStubWallet( + buildStoredOffer({to: [holderDID]}), + ); + const messageProvider = createStubMessageProvider(); + + await DELEGATION_REQUEST_HANDLER.handle( + buildRequest({from: attackerDID}), + {wallet, messageProvider}, + ); + + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + expect(wallet.updateDocument).not.toHaveBeenCalled(); + }); + + it('rejects requests from senders not matching offer.to (string form)', async () => { + const wallet = createStubWallet(buildStoredOffer({to: holderDID})); + const messageProvider = createStubMessageProvider(); + + await DELEGATION_REQUEST_HANDLER.handle( + buildRequest({from: attackerDID}), + {wallet, messageProvider}, + ); + + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + expect(wallet.updateDocument).not.toHaveBeenCalled(); + }); + + it('rejects expired offers', async () => { + const wallet = createStubWallet( + buildStoredOffer({ + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }), + ); + const messageProvider = createStubMessageProvider(); + + await DELEGATION_REQUEST_HANDLER.handle(buildRequest(), { + wallet, + messageProvider, + }); + + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + expect(wallet.updateDocument).not.toHaveBeenCalled(); + }); +}); + +describe('ISSUE_CREDENTIAL_HANDLER rejection paths', () => { + const offerId = 'offer-2'; + const issuerDID = 'did:test:issuer'; + const holderDID = 'did:test:holder'; + const attackerDID = 'did:test:attacker'; + + function buildIssueMessage(overrides: any = {}) { + return { + id: 'msg-1', + type: 'https://didcomm.org/issue-credential/3.0/issue-credential', + from: issuerDID, + to: [holderDID], + body: { + goal_code: 'dock.offer-delegation', + delegationOfferId: offerId, + credentials: [{id: 'delegated-cred-1', type: ['DelegationCredential']}], + delegationChain: [], + }, + ...overrides, + }; + } + + function buildStoredOffer(overrides: any = {}) { + return { + id: offerId, + type: 'DelegationOffer', + issuerDID, + status: 'requested', + ...overrides, + }; + } + + it('returns when delegationOfferId is missing from the message', async () => { + const wallet = createStubWallet(buildStoredOffer()); + const messageProvider = createStubMessageProvider(); + + await ISSUE_CREDENTIAL_HANDLER.handle( + buildIssueMessage({ + body: {goal_code: 'dock.offer-delegation', credentials: []}, + }), + {wallet, messageProvider}, + ); + + expect(wallet.addDocument).not.toHaveBeenCalled(); + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + }); + + it('returns when no stored offer exists for the delegationOfferId', async () => { + const wallet = createStubWallet(null); + const messageProvider = createStubMessageProvider(); + + await ISSUE_CREDENTIAL_HANDLER.handle(buildIssueMessage(), { + wallet, + messageProvider, + }); + + expect(wallet.addDocument).not.toHaveBeenCalled(); + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + }); + + it('rejects credentials from a DID other than the stored issuerDID', async () => { + const wallet = createStubWallet(buildStoredOffer()); + const messageProvider = createStubMessageProvider(); + + await ISSUE_CREDENTIAL_HANDLER.handle( + buildIssueMessage({from: attackerDID}), + {wallet, messageProvider}, + ); + + expect(wallet.addDocument).not.toHaveBeenCalled(); + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + expect(wallet.eventManager.emit).not.toHaveBeenCalled(); + }); + + it('returns when the credentials array is empty', async () => { + const wallet = createStubWallet(buildStoredOffer()); + const messageProvider = createStubMessageProvider(); + + await ISSUE_CREDENTIAL_HANDLER.handle( + buildIssueMessage({ + body: { + goal_code: 'dock.offer-delegation', + delegationOfferId: offerId, + credentials: [], + delegationChain: [], + }, + }), + {wallet, messageProvider}, + ); + + expect(wallet.addDocument).not.toHaveBeenCalled(); + expect(messageProvider.sendMessage).not.toHaveBeenCalled(); + }); +}); From a8d5e8cf435d3aa80db311ffb16e20536385ea5e Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 15:43:23 -0300 Subject: [PATCH 07/10] fix: treat EDV 404 responses as empty find results The EDV server returns HTTP 404 on /query for vaults with no indices yet (e.g. fresh vaults from wrong biometric credentials). ky now surfaces this as 'Request failed with status code 404 Not Found' rather than the legacy 'Not Found' message the universal-wallet catch handles, so biometric authentication failures were leaking raw HTTP errors instead of the domain-level 'Invalid identifier' error. --- packages/wasm/src/services/edv/service.test.js | 12 ++++++++++++ packages/wasm/src/services/edv/service.ts | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/wasm/src/services/edv/service.test.js b/packages/wasm/src/services/edv/service.test.js index 389b2e4b..51ecb258 100644 --- a/packages/wasm/src/services/edv/service.test.js +++ b/packages/wasm/src/services/edv/service.test.js @@ -160,6 +160,18 @@ describe('EDVService', () => { await expect(service.find({})).rejects.toThrow('Network error'); }); + it('should return empty documents array when EDV responds with 404', async () => { + const error = new Error('Request failed with status code 404 Not Found'); + error.status = 404; + service.storageInterface = { + find: jest.fn().mockRejectedValue(error), + }; + + const result = await service.find({}); + + expect(result).toEqual({documents: []}); + }); + it('should re-throw errors with unrelated messages', async () => { const error = new Error('Permission denied'); service.storageInterface = { diff --git a/packages/wasm/src/services/edv/service.ts b/packages/wasm/src/services/edv/service.ts index 9af80009..06e78c30 100644 --- a/packages/wasm/src/services/edv/service.ts +++ b/packages/wasm/src/services/edv/service.ts @@ -281,7 +281,10 @@ export class EDVService { try { return await this.storageInterface.find(params); } catch (error) { - if (error.message.includes('Vault indices do not exist')) { + if ( + error.message.includes('Vault indices do not exist') || + error.status === 404 + ) { return { documents: [], }; From 1aafcb9d9ac11ce11f15455f426df6d97d01dd92 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 29 May 2026 15:58:49 -0300 Subject: [PATCH 08/10] narrow EDV 404 handling to /query endpoint --- packages/wasm/src/services/edv/service.test.js | 16 +++++++++++++++- packages/wasm/src/services/edv/service.ts | 7 ++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/wasm/src/services/edv/service.test.js b/packages/wasm/src/services/edv/service.test.js index 51ecb258..1ded2c2d 100644 --- a/packages/wasm/src/services/edv/service.test.js +++ b/packages/wasm/src/services/edv/service.test.js @@ -160,9 +160,10 @@ describe('EDVService', () => { await expect(service.find({})).rejects.toThrow('Network error'); }); - it('should return empty documents array when EDV responds with 404', async () => { + it('should return empty documents array when /query responds with 404', async () => { const error = new Error('Request failed with status code 404 Not Found'); error.status = 404; + error.requestUrl = 'https://edv.example.com/edvs/abc/query'; service.storageInterface = { find: jest.fn().mockRejectedValue(error), }; @@ -172,6 +173,19 @@ describe('EDVService', () => { expect(result).toEqual({documents: []}); }); + it('should re-throw 404 errors from non-query endpoints', async () => { + const error = new Error('Request failed with status code 404 Not Found'); + error.status = 404; + error.requestUrl = 'https://edv.example.com/edvs/abc/documents/123'; + service.storageInterface = { + find: jest.fn().mockRejectedValue(error), + }; + + await expect(service.find({})).rejects.toThrow( + 'Request failed with status code 404 Not Found', + ); + }); + it('should re-throw errors with unrelated messages', async () => { const error = new Error('Permission denied'); service.storageInterface = { diff --git a/packages/wasm/src/services/edv/service.ts b/packages/wasm/src/services/edv/service.ts index 06e78c30..61aa5e3f 100644 --- a/packages/wasm/src/services/edv/service.ts +++ b/packages/wasm/src/services/edv/service.ts @@ -281,9 +281,14 @@ export class EDVService { try { return await this.storageInterface.find(params); } catch (error) { + const isQuery404 = + error.status === 404 && + typeof error.requestUrl === 'string' && + error.requestUrl.endsWith('/query'); + if ( error.message.includes('Vault indices do not exist') || - error.status === 404 + isQuery404 ) { return { documents: [], From f8ca8e2edf9e89edf385a77120b79e20cfcef08e Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Tue, 2 Jun 2026 10:21:03 -0300 Subject: [PATCH 09/10] require caller to supply OOB invitation goal text --- integration-tests/delegation-offer.test.ts | 4 +++- packages/core/src/delegation/delegation-offer.ts | 10 ++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/integration-tests/delegation-offer.test.ts b/integration-tests/delegation-offer.test.ts index 044421d8..134eaa93 100644 --- a/integration-tests/delegation-offer.test.ts +++ b/integration-tests/delegation-offer.test.ts @@ -94,7 +94,9 @@ describe('Credential Distribution', () => { delegationRole: 'e79c0d16-8739-4e54-94d7-53d9f1c97c71', }); - const qrCode = createOOBInvitation(issuerWallet.did, delegationOffer); + const qrCode = createOOBInvitation(issuerWallet.did, delegationOffer, { + goal: 'Test issuer is offering you a delegation', + }); console.log('[issuer] OOB invitation URL created:', qrCode); // Listens for delegation offer events and accepts them when received diff --git a/packages/core/src/delegation/delegation-offer.ts b/packages/core/src/delegation/delegation-offer.ts index f8e15757..63409498 100644 --- a/packages/core/src/delegation/delegation-offer.ts +++ b/packages/core/src/delegation/delegation-offer.ts @@ -118,8 +118,10 @@ export async function createDelegationOffer({ export function createOOBInvitation( issuerDID, delegationOffer, - {goal, issuerName}: {goal?: string; issuerName?: string} = {}, + {goal, issuerName}: {goal: string; issuerName?: string}, ) { + assert(!!goal, 'goal is required'); + const finalIssuerName = issuerName ?? delegationOffer.issuerName; const preview: DelegationOfferPreview = { @@ -137,11 +139,7 @@ export function createOOBInvitation( from: issuerDID, body: { goal_code: GOAL_CODE, - goal: - goal ?? - (finalIssuerName - ? `${finalIssuerName} is offering you a delegation` - : 'You have received a delegation offer'), + goal, offer_id: delegationOffer.id, }, attachments: [ From f90c10954c05354560b08b149263897287f5a3cd Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Tue, 2 Jun 2026 14:05:33 -0300 Subject: [PATCH 10/10] validate delegationPolicy in createDelegationOffer and request handler --- .../core/src/delegation/delegation-offer.ts | 48 ++- .../delegation-policy-validation.test.ts | 231 +++++++++++++++ .../delegation-policy-validation.ts | 278 ++++++++++++++++++ 3 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/delegation/delegation-policy-validation.test.ts create mode 100644 packages/core/src/delegation/delegation-policy-validation.ts diff --git a/packages/core/src/delegation/delegation-offer.ts b/packages/core/src/delegation/delegation-offer.ts index 63409498..4c6035e2 100644 --- a/packages/core/src/delegation/delegation-offer.ts +++ b/packages/core/src/delegation/delegation-offer.ts @@ -4,7 +4,12 @@ import {logger} from '@docknetwork/wallet-sdk-data-store/src/logger'; import {getAllDIDs, getDefaultDID} from '../did-provider'; import {delegateCredential} from './delegation-issuance'; import {getDelegationChain} from './delegation-chain'; +import {getDelegationDetails} from './delegation-policy'; import {isDelegatableCredential} from './delegation-utils'; +import { + assertPolicyConformsToParent, + validateDelegationPolicy, +} from './delegation-policy-validation'; const GOAL_CODE = 'dock.offer-delegation'; const OOB_INVITATION = 'https://didcomm.org/out-of-band/2.0/invitation'; @@ -68,6 +73,12 @@ export async function createDelegationOffer({ credentialId?: string; expiresInMs?: number; }) { + validateDelegationPolicy(delegationPolicy); + assert( + delegationPolicy.ruleset.roles.some(r => r.roleId === delegationRole), + `delegationRole "${delegationRole}" not found in delegationPolicy.ruleset.roles`, + ); + if (credentialId) { const parentCredential = await wallet.getDocumentById(credentialId); if (parentCredential) { @@ -75,6 +86,13 @@ export async function createDelegationOffer({ isDelegatableCredential(parentCredential), `Credential ${credentialId} is not delegatable`, ); + const parentDetails = await getDelegationDetails(parentCredential, wallet); + if (parentDetails.delegationPolicy) { + assertPolicyConformsToParent(delegationPolicy, parentDetails.delegationPolicy, { + delegationRole, + remainingDepth: parentDetails.remainingDelegationDepth, + }); + } } } @@ -296,6 +314,32 @@ export const DELEGATION_REQUEST_HANDLER = { return; } + const parentCredential = await wallet.getDocumentById( + delegationOffer.credentialId, + ); + + try { + validateDelegationPolicy(delegationOffer.delegationPolicy); + if (parentCredential && isDelegatableCredential(parentCredential)) { + const parentDetails = await getDelegationDetails(parentCredential, wallet); + if (parentDetails.delegationPolicy) { + assertPolicyConformsToParent( + delegationOffer.delegationPolicy, + parentDetails.delegationPolicy, + { + delegationRole: delegationOffer.delegationRole, + remainingDepth: parentDetails.remainingDelegationDepth, + }, + ); + } + } + } catch (err: any) { + logger.warn( + `DELEGATION_REQUEST_HANDLER: rejecting offer ${offerId} — policy validation failed: ${err.message}`, + ); + return; + } + const holderDID = message.from; delegationOffer.status = 'accepted'; @@ -304,10 +348,6 @@ export const DELEGATION_REQUEST_HANDLER = { await wallet.updateDocument(delegationOffer); - const parentCredential = await wallet.getDocumentById( - delegationOffer.credentialId, - ); - const issuerDID = pickDID(message.to); const delegatedCredential = await delegateCredential({ diff --git a/packages/core/src/delegation/delegation-policy-validation.test.ts b/packages/core/src/delegation/delegation-policy-validation.test.ts new file mode 100644 index 00000000..95e53fdf --- /dev/null +++ b/packages/core/src/delegation/delegation-policy-validation.test.ts @@ -0,0 +1,231 @@ +import {DelegationPolicy} from './delegation-types'; +import { + delegationPolicyPharmacy, + delegationPolicyTravelAgent, +} from './delegation-fixtures'; +import { + assertPolicyConformsToParent, + validateDelegationPolicy, +} from './delegation-policy-validation'; + +function clonePolicy(policy: any): DelegationPolicy { + return JSON.parse(JSON.stringify(policy)); +} + +describe('validateDelegationPolicy', () => { + it('accepts the travel-agent fixture', () => { + expect(() => validateDelegationPolicy(delegationPolicyTravelAgent)).not.toThrow(); + }); + + it('accepts the pharmacy fixture', () => { + expect(() => validateDelegationPolicy(delegationPolicyPharmacy)).not.toThrow(); + }); + + it('rejects a non-object', () => { + expect(() => validateDelegationPolicy(null)).toThrow(/must be an object/); + expect(() => validateDelegationPolicy('foo')).toThrow(/must be an object/); + }); + + it('rejects a wrong type field', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + (policy as any).type = 'NotAPolicy'; + expect(() => validateDelegationPolicy(policy)).toThrow(/type must be/); + }); + + it('rejects an unsupported delegationTarget', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.delegationTarget = 'multi-credential' as any; + expect(() => validateDelegationPolicy(policy)).toThrow(/delegationTarget/); + }); + + it('rejects maxDelegationDepth out of range', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.overallConstraints.maxDelegationDepth = 99; + expect(() => validateDelegationPolicy(policy)).toThrow(/maxDelegationDepth/); + }); + + it('rejects an invalid lifetime unit', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.overallConstraints.delegatedCredentialLifetime.unit = 'weeks'; + expect(() => validateDelegationPolicy(policy)).toThrow(/unit/); + }); + + it('rejects a non-positive lifetime value', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.overallConstraints.delegatedCredentialLifetime.value = 0; + expect(() => validateDelegationPolicy(policy)).toThrow(/value/); + }); + + it('rejects duplicate capability names', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.capabilities.push({...policy.ruleset.capabilities[0]}); + expect(() => validateDelegationPolicy(policy)).toThrow(/duplicate capability/); + }); + + it('rejects an unsupported capability schema type', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + (policy.ruleset.capabilities[0].schema as any).type = 'object'; + expect(() => validateDelegationPolicy(policy)).toThrow(/schema\.type/); + }); + + it('rejects duplicate roleIds', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.roles[1].roleId = policy.ruleset.roles[0].roleId; + expect(() => validateDelegationPolicy(policy)).toThrow(/duplicate roleId/); + }); + + it('rejects an orphan parentRoleId', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.roles[1].parentRoleId = 'does-not-exist'; + expect(() => validateDelegationPolicy(policy)).toThrow(/unknown parentRoleId/); + }); + + it('rejects more than one root role', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.roles[1].parentRoleId = null; + expect(() => validateDelegationPolicy(policy)).toThrow(/exactly one root role/); + }); + + it('rejects a grant referencing an unknown capability', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.roles[0].capabilityGrants[0].capability = 'Phantom'; + expect(() => validateDelegationPolicy(policy)).toThrow(/unknown capability/); + }); + + it('rejects a grant whose schema.type does not match the capability', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + (policy.ruleset.roles[0].capabilityGrants[0].schema as any).type = 'integer'; + expect(() => validateDelegationPolicy(policy)).toThrow(/schema\.type/); + }); + + it('rejects a child role granting a capability the ancestor does not', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.roles[0].capabilityGrants = policy.ruleset.roles[0].capabilityGrants.filter( + g => g.capability !== 'Reserve Hotels', + ); + expect(() => validateDelegationPolicy(policy)).toThrow( + /grants "Reserve Hotels" but ancestor/, + ); + }); + + it('rejects a child integer maximum exceeding the ancestor maximum', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + const hotel = policy.ruleset.roles.find(r => r.label === 'Hotel Sub-agent')!; + const purchase = hotel.capabilityGrants.find(g => g.capability === 'Purchase')!; + (purchase.schema as any).maximum = 200; + expect(() => validateDelegationPolicy(policy)).toThrow(/maximum exceeds parent/); + }); + + it('rejects a child array enum that is not a subset of the ancestor enum', () => { + const policy = clonePolicy(delegationPolicyPharmacy); + const pharmacy = policy.ruleset.roles.find(r => r.label === 'Pharmacy')!; + const claims = pharmacy.capabilityGrants.find(g => g.capability === 'Allowed Claims')!; + (claims.schema as any).items.enum = ['Refund']; + expect(() => validateDelegationPolicy(policy)).toThrow(/items\.enum is not a subset/); + }); + + it('rejects child explicit attributes when not a subset of an ancestor explicit list', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.roles[0].attributes = ['subject.firstName']; + const child = policy.ruleset.roles.find(r => r.label === 'Corporate Account Manager')!; + child.attributes = ['subject.lastName']; + expect(() => validateDelegationPolicy(policy)).toThrow(/attributes are not a subset/); + }); + + it('allows a child wildcard under a narrowed ancestor', () => { + const policy = clonePolicy(delegationPolicyTravelAgent); + policy.ruleset.roles[0].attributes = ['subject.firstName']; + expect(() => validateDelegationPolicy(policy)).not.toThrow(); + }); +}); + +describe('assertPolicyConformsToParent', () => { + const PARENT_ROLE = 'e79c0d16-8739-4e54-94d7-53d9f1c97c71'; + const baseOpts = {delegationRole: PARENT_ROLE, remainingDepth: 3}; + + it('accepts a policy identical to the parent', () => { + expect(() => + assertPolicyConformsToParent( + clonePolicy(delegationPolicyTravelAgent), + delegationPolicyTravelAgent, + baseOpts, + ), + ).not.toThrow(); + }); + + it('rejects when remaining delegation depth is zero', () => { + expect(() => + assertPolicyConformsToParent( + clonePolicy(delegationPolicyTravelAgent), + delegationPolicyTravelAgent, + {delegationRole: PARENT_ROLE, remainingDepth: 0}, + ), + ).toThrow(/no remaining delegation depth/); + }); + + it('rejects when child maxDelegationDepth exceeds parent', () => { + const child = clonePolicy(delegationPolicyTravelAgent); + child.ruleset.overallConstraints.maxDelegationDepth = 9; + const parent = clonePolicy(delegationPolicyTravelAgent); + parent.ruleset.overallConstraints.maxDelegationDepth = 2; + expect(() => assertPolicyConformsToParent(child, parent, baseOpts)).toThrow( + /maxDelegationDepth/, + ); + }); + + it('rejects when child lifetime exceeds parent lifetime (cross-unit)', () => { + const child = clonePolicy(delegationPolicyTravelAgent); + child.ruleset.overallConstraints.delegatedCredentialLifetime = {value: 400, unit: 'days'}; + const parent = clonePolicy(delegationPolicyTravelAgent); + parent.ruleset.overallConstraints.delegatedCredentialLifetime = {value: 1, unit: 'years'}; + expect(() => assertPolicyConformsToParent(child, parent, baseOpts)).toThrow( + /delegatedCredentialLifetime/, + ); + }); + + it('rejects when the delegationRole is missing from the policy', () => { + expect(() => + assertPolicyConformsToParent( + clonePolicy(delegationPolicyTravelAgent), + delegationPolicyTravelAgent, + {delegationRole: 'missing-role', remainingDepth: 3}, + ), + ).toThrow(/not found in delegationPolicy/); + }); + + it('rejects when the child grants a capability the parent does not', () => { + const child = clonePolicy(delegationPolicyTravelAgent); + const childRoot = child.ruleset.roles.find(r => r.roleId === PARENT_ROLE)!; + childRoot.capabilityGrants.push({ + capability: 'Phantom', + schema: {type: 'boolean', const: true}, + } as any); + expect(() => + assertPolicyConformsToParent(child, delegationPolicyTravelAgent, baseOpts), + ).toThrow(/parent does not/); + }); + + it('rejects when child integer maximum exceeds parent', () => { + const child = clonePolicy(delegationPolicyTravelAgent); + const childRoot = child.ruleset.roles.find(r => r.roleId === PARENT_ROLE)!; + const purchase = childRoot.capabilityGrants.find(g => g.capability === 'Purchase')!; + (purchase.schema as any).maximum = 999; + expect(() => + assertPolicyConformsToParent(child, delegationPolicyTravelAgent, baseOpts), + ).toThrow(/maximum exceeds parent/); + }); + + it('rejects when child enum is not a subset of parent enum', () => { + const PHARMACY_ROOT = '6ed167b3-90be-4f9a-a8d2-542d2f212d79'; + const child = clonePolicy(delegationPolicyPharmacy); + const childRoot = child.ruleset.roles.find(r => r.roleId === PHARMACY_ROOT)!; + const claims = childRoot.capabilityGrants.find(g => g.capability === 'Allowed Claims')!; + (claims.schema as any).items.enum = ['Refund']; + expect(() => + assertPolicyConformsToParent(child, delegationPolicyPharmacy as DelegationPolicy, { + delegationRole: PHARMACY_ROOT, + remainingDepth: 3, + }), + ).toThrow(/items\.enum is not a subset/); + }); +}); diff --git a/packages/core/src/delegation/delegation-policy-validation.ts b/packages/core/src/delegation/delegation-policy-validation.ts new file mode 100644 index 00000000..e6a4a019 --- /dev/null +++ b/packages/core/src/delegation/delegation-policy-validation.ts @@ -0,0 +1,278 @@ +import assert from 'assert'; +import { + CapabilityGrant, + DelegationPolicy, + Role, +} from './delegation-types'; + +export const MAX_DELEGATION_DEPTH = 9; +export const ALLOWED_LIFETIME_UNITS = ['days', 'months', 'years'] as const; +export const ALLOWED_GRANT_TYPES = ['boolean', 'array', 'integer'] as const; +export const ALLOWED_DELEGATION_TARGETS = ['single-credential'] as const; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function lifetimeToDays(lifetime: {value: number; unit: string}): number { + const numeric = Number(lifetime?.value); + if (!Number.isFinite(numeric)) return 0; + if (lifetime.unit === 'months') return numeric * 30; + if (lifetime.unit === 'years') return numeric * 365; + return numeric; +} + +function getGrantsByCapability(role: Role): Record { + const out: Record = {}; + for (const grant of role.capabilityGrants || []) { + out[grant.capability] = grant; + } + return out; +} + +function getAncestorChain(role: Role, rolesById: Record): Role[] { + const chain: Role[] = []; + let cursor = role.parentRoleId ? rolesById[role.parentRoleId] : null; + const visited = new Set(); + while (cursor && !visited.has(cursor.roleId)) { + visited.add(cursor.roleId); + chain.push(cursor); + cursor = cursor.parentRoleId ? rolesById[cursor.parentRoleId] : null; + } + return chain; +} + +function isSubset(child: T[], parent: T[]): boolean { + const set = new Set(parent); + return child.every(v => set.has(v)); +} + +function getEnum(schema: any): string[] | null { + if (schema?.type === 'array' && Array.isArray(schema?.items?.enum)) { + return schema.items.enum; + } + return null; +} + +function isWildcardAttrs(attrs: string[]): boolean { + return attrs.length === 1 && attrs[0] === '*'; +} + +function assertGrantNarrows( + childSchema: any, + parentSchema: any, + describe: (suffix: string) => string, +) { + if (childSchema.type === 'integer' && parentSchema.maximum !== undefined) { + assert( + childSchema.maximum !== undefined && childSchema.maximum <= parentSchema.maximum, + describe(`maximum exceeds parent ${parentSchema.maximum}`), + ); + } + + if (childSchema.type === 'array') { + const parentEnum = getEnum(parentSchema); + if (parentEnum) { + const childEnum = getEnum(childSchema); + assert( + childEnum && isSubset(childEnum, parentEnum), + describe('items.enum is not a subset of parent'), + ); + } + } +} + +/** + * Validate the structural integrity of a delegation policy. + * + * Throws on the first failure. Only checks invariants that downstream code + * relies on or that have security implications — fields like display labels + * and metadata strings are left to the type contract. + */ +export function validateDelegationPolicy(policy: any): asserts policy is DelegationPolicy { + assert(isPlainObject(policy), 'delegationPolicy must be an object'); + assert( + policy.type === 'DelegationPolicy', + `delegationPolicy.type must be 'DelegationPolicy'`, + ); + assert(typeof policy.name === 'string', 'delegationPolicy.name is required'); + assert(isPlainObject(policy.ruleset), 'delegationPolicy.ruleset must be an object'); + + const ruleset = policy.ruleset; + assert( + Array.isArray(ruleset.roles) && ruleset.roles.length > 0, + 'delegationPolicy.ruleset.roles must be a non-empty array', + ); + assert( + Array.isArray(ruleset.capabilities), + 'delegationPolicy.ruleset.capabilities must be an array', + ); + assert( + (ALLOWED_DELEGATION_TARGETS as readonly string[]).includes(ruleset.delegationTarget as string), + `delegationPolicy.ruleset.delegationTarget must be one of: ${ALLOWED_DELEGATION_TARGETS.join(', ')}`, + ); + + assert(isPlainObject(ruleset.overallConstraints), 'overallConstraints must be an object'); + const constraints: any = ruleset.overallConstraints; + assert( + Number.isInteger(constraints.maxDelegationDepth) && + constraints.maxDelegationDepth >= 0 && + constraints.maxDelegationDepth <= MAX_DELEGATION_DEPTH, + `maxDelegationDepth must be an integer in [0, ${MAX_DELEGATION_DEPTH}]`, + ); + assert( + isPlainObject(constraints.delegatedCredentialLifetime), + 'delegatedCredentialLifetime must be an object', + ); + const lifetime: any = constraints.delegatedCredentialLifetime; + assert( + Number.isInteger(lifetime.value) && lifetime.value > 0, + 'delegatedCredentialLifetime.value must be a positive integer', + ); + assert( + (ALLOWED_LIFETIME_UNITS as readonly string[]).includes(lifetime.unit), + `delegatedCredentialLifetime.unit must be one of: ${ALLOWED_LIFETIME_UNITS.join(', ')}`, + ); + + const capabilitiesByName: Record = {}; + for (const cap of ruleset.capabilities) { + assert( + !capabilitiesByName[cap.name], + `duplicate capability name: ${cap.name}`, + ); + assert( + (ALLOWED_GRANT_TYPES as readonly string[]).includes(cap.schema?.type), + `capability "${cap.name}" schema.type must be one of: ${ALLOWED_GRANT_TYPES.join(', ')}`, + ); + capabilitiesByName[cap.name] = cap; + } + + const rolesById: Record = {}; + let rootCount = 0; + for (const role of ruleset.roles) { + assert(!rolesById[role.roleId], `duplicate roleId: ${role.roleId}`); + rolesById[role.roleId] = role; + if (role.parentRoleId === null) rootCount++; + } + assert(rootCount === 1, `ruleset must have exactly one root role, found ${rootCount}`); + + for (const role of ruleset.roles) { + if (role.parentRoleId !== null) { + assert( + rolesById[role.parentRoleId], + `role ${role.roleId} references unknown parentRoleId ${role.parentRoleId}`, + ); + } + } + + for (const role of ruleset.roles) { + for (const grant of role.capabilityGrants) { + const cap = capabilitiesByName[grant.capability]; + assert( + cap, + `role ${role.roleId} grants unknown capability "${grant.capability}"`, + ); + assert( + (grant.schema as any)?.type === cap.schema.type, + `role ${role.roleId} grant "${grant.capability}" schema.type must be "${cap.schema.type}"`, + ); + } + } + + for (const role of ruleset.roles) { + const ancestors = getAncestorChain(role, rolesById); + if (ancestors.length === 0) continue; + + const childGrants = getGrantsByCapability(role); + + for (const ancestor of ancestors) { + const ancestorGrants = getGrantsByCapability(ancestor); + + for (const capName of Object.keys(childGrants)) { + assert( + ancestorGrants[capName], + `role ${role.roleId} grants "${capName}" but ancestor ${ancestor.roleId} does not`, + ); + assertGrantNarrows( + childGrants[capName].schema, + ancestorGrants[capName].schema, + suffix => `role ${role.roleId} grant "${capName}" ${suffix}`, + ); + } + + if (!isWildcardAttrs(ancestor.attributes) && !isWildcardAttrs(role.attributes)) { + assert( + isSubset(role.attributes, ancestor.attributes), + `role ${role.roleId} attributes are not a subset of ancestor ${ancestor.roleId} attributes`, + ); + } + } + } +} + +/** + * Assert that a delegation policy is a valid narrowing of a parent policy. + * + * Run after `validateDelegationPolicy(policy)` succeeds. The parent policy is + * trusted to already be valid (it came from a credential we issued or accepted). + */ +export function assertPolicyConformsToParent( + policy: DelegationPolicy, + parentPolicy: DelegationPolicy, + { + delegationRole, + remainingDepth, + }: {delegationRole: string; remainingDepth: number}, +) { + assert(remainingDepth > 0, 'parent credential has no remaining delegation depth'); + + const childConstraints = policy.ruleset.overallConstraints; + const parentConstraints = parentPolicy.ruleset.overallConstraints; + + assert( + childConstraints.maxDelegationDepth <= parentConstraints.maxDelegationDepth, + `maxDelegationDepth ${childConstraints.maxDelegationDepth} exceeds parent ${parentConstraints.maxDelegationDepth}`, + ); + + const parentDays = lifetimeToDays(parentConstraints.delegatedCredentialLifetime); + const childDays = lifetimeToDays(childConstraints.delegatedCredentialLifetime); + if (parentDays > 0) { + assert( + childDays <= parentDays, + `delegatedCredentialLifetime exceeds parent (${parentConstraints.delegatedCredentialLifetime.value} ${parentConstraints.delegatedCredentialLifetime.unit})`, + ); + } + + const childRole = policy.ruleset.roles.find(r => r.roleId === delegationRole); + assert( + childRole, + `delegationRole "${delegationRole}" not found in delegationPolicy.ruleset.roles`, + ); + + const parentRole = parentPolicy.ruleset.roles.find(r => r.roleId === delegationRole); + assert( + parentRole, + `delegationRole "${delegationRole}" not found in parent credential's policy`, + ); + + if (!isWildcardAttrs(parentRole.attributes) && !isWildcardAttrs(childRole.attributes)) { + assert( + isSubset(childRole.attributes, parentRole.attributes), + `delegationRole "${delegationRole}" attributes are not a subset of parent's attributes`, + ); + } + + const parentGrants = getGrantsByCapability(parentRole); + for (const grant of childRole.capabilityGrants) { + const parentGrant = parentGrants[grant.capability]; + assert( + parentGrant, + `delegationRole "${delegationRole}" grants "${grant.capability}" which the parent does not`, + ); + assertGrantNarrows( + grant.schema, + parentGrant.schema, + suffix => `delegationRole "${delegationRole}" grant "${grant.capability}" ${suffix}`, + ); + } +}