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 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(); + }); +}); diff --git a/integration-tests/delegation-offer.test.ts b/integration-tests/delegation-offer.test.ts new file mode 100644 index 00000000..134eaa93 --- /dev/null +++ b/integration-tests/delegation-offer.test.ts @@ -0,0 +1,231 @@ +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({ + wallet: issuerWallet.wallet, + issuerDID: issuerWallet.did, + delegationPolicy: travelAgencyPolicy, + credentialId: rootCredential.id, + delegationRole: 'e79c0d16-8739-4e54-94d7-53d9f1c97c71', + }); + + 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 + 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); + + // 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/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..0873e8ad 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, getDIDKeyPair} from '../did-provider'; /** * Issue a delegatable credential @@ -43,3 +44,49 @@ export async function issueCredential( return credential; } + +export async function delegateCredential({ + credential, + 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 keyPair = await getDIDKeyPair(wallet, issuerDID); + + 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, + }; + + return issueCredential( + credentialData, + keyPair, + delegationPolicy, + roleId, + credential.rootCredentialId || credential.id, + ); +} diff --git a/packages/core/src/delegation/delegation-offer.ts b/packages/core/src/delegation/delegation-offer.ts new file mode 100644 index 00000000..4c6035e2 --- /dev/null +++ b/packages/core/src/delegation/delegation-offer.ts @@ -0,0 +1,488 @@ +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 {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'; +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'); +} + +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' | 'requested' | 'accepted' | 'rejected'; + expiresAt?: string; + [key: string]: any; +}; + +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; +}) { + 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) { + assert( + isDelegatableCredential(parentCredential), + `Credential ${credentialId} is not delegatable`, + ); + const parentDetails = await getDelegationDetails(parentCredential, wallet); + if (parentDetails.delegationPolicy) { + assertPolicyConformsToParent(delegationPolicy, parentDetails.delegationPolicy, { + delegationRole, + remainingDepth: parentDetails.remainingDelegationDepth, + }); + } + } + } + + 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, + issuerName, + issuer: { + did: issuerDID, + }, + to: undefined, + delegationPolicy, + delegationRole, + capabilities: [], + attributes: [], + delegationConstraints: {}, + 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 wallet.addDocument({ + type: 'DelegationOffer', + ...delegationOffer, + }); + + return delegationOffer; +} + +// OOB invitation (issuer → holder via QR/link) +export function createOOBInvitation( + issuerDID, + delegationOffer, + {goal, issuerName}: {goal: string; issuerName?: string}, +) { + assert(!!goal, 'goal is required'); + + 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, + from: issuerDID, + body: { + goal_code: GOAL_CODE, + goal, + offer_id: delegationOffer.id, + }, + attachments: [ + { + id: delegationOffer.id, + media_type: 'application/json', + data: {json: preview}, + }, + ], + }; + + 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)) { + logger.debug('decodeMessage: unrecognized URL scheme, skipping'); + return null; + } + + const encoded = message.slice(oobPrefix.length); + try { + return JSON.parse(base64urlDecode(encoded)); + } catch (err) { + logger.error(`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 dids = await getAllDIDs({wallet}); + const holderName = dids.find(d => d.didDocument.id === holderDID)?.name; + + 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, + }, + }; + + 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: 'requested', + updatedAt: new Date().toISOString(), + }); + } +} + +// 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, {wallet}) { + 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, + }); + + logger.debug( + `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}) { + const offerId = message.body.offer_id; + const delegationOffer = await wallet.getDocumentById(offerId); + if (!delegationOffer) { + 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.warn( + `DELEGATION_REQUEST_HANDLER: rejecting request for offer ${offerId} — already ${delegationOffer.status}`, + ); + return; + } + + 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 expired offer ${offerId}`, + ); + 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'; + delegationOffer.holderDID = holderDID; + delegationOffer.updatedAt = new Date().toISOString(); + + await wallet.updateDocument(delegationOffer); + + const issuerDID = pickDID(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); + + await messageProvider.sendMessage({ + type: ISSUE_CREDENTIAL, + from: issuerDID, + to: holderDID, + message: { + goal_code: GOAL_CODE, + delegationOfferId: delegationOffer.id, + credentials: [delegatedCredential], + delegationChain, + }, + }); + }, +}; + +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; + + 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 ?? []; + + 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); + } + } + + await wallet.updateDocument({ + ...storedOffer, + status: 'accepted', + updatedAt: new Date().toISOString(), + }); + + const holderDID = pickDID(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( + message, + context: { + wallet; + messageProvider; + }, +) { + const decoded = decodeMessage(message); + if (!decoded) { + logger.debug('handleMessage: message could not be decoded, skipping'); + return; + } + + const handler = messageHandlers.find(h => h.check(decoded)); + if (!handler) { + logger.debug( + `handleMessage: no handler matched message type ${decoded.type}`, + ); + return; + } + + return handler.handle(decoded, context); +} 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}`, + ); + } +} 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 diff --git a/packages/wasm/src/services/edv/service.test.js b/packages/wasm/src/services/edv/service.test.js index 389b2e4b..1ded2c2d 100644 --- a/packages/wasm/src/services/edv/service.test.js +++ b/packages/wasm/src/services/edv/service.test.js @@ -160,6 +160,32 @@ describe('EDVService', () => { await expect(service.find({})).rejects.toThrow('Network error'); }); + 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), + }; + + const result = await service.find({}); + + 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 9af80009..61aa5e3f 100644 --- a/packages/wasm/src/services/edv/service.ts +++ b/packages/wasm/src/services/edv/service.ts @@ -281,7 +281,15 @@ export class EDVService { try { return await this.storageInterface.find(params); } catch (error) { - if (error.message.includes('Vault indices do not exist')) { + const isQuery404 = + error.status === 404 && + typeof error.requestUrl === 'string' && + error.requestUrl.endsWith('/query'); + + if ( + error.message.includes('Vault indices do not exist') || + isQuery404 + ) { return { documents: [], };