UNPKG

@sphereon/pex

Version:

A Typescript implementation of the v1 and v2 DIF Presentation Exchange specification

939 lines (938 loc) 111 kB
import { JSONPath as jp } from '@astronautlabs/jsonpath'; import { Rules } from '@sphereon/pex-models'; import { CredentialMapper, } from '@sphereon/ssi-types'; import { Status } from '../ConstraintUtils'; import { PEX } from '../PEX'; import { PresentationSubmissionLocation } from '../signing'; import { InternalPresentationDefinitionV1, InternalPresentationDefinitionV2, } from '../types'; import { JsonPathUtils, ObjectUtils } from '../utils'; import { getVpFormatForVcFormat } from '../utils/formatMap'; import { SubmissionRequirementMatchType, } from './core'; import { EvaluationClient } from './evaluationClient'; export class EvaluationClientWrapper { _client; constructor() { this._client = new EvaluationClient(); } getEvaluationClient() { return this._client; } selectFrom(presentationDefinition, wrappedVerifiableCredentials, opts) { let selectResults; this._client.evaluate(presentationDefinition, wrappedVerifiableCredentials, opts); const warnings = [...this.formatNotInfo(Status.WARN)]; const errors = [...this.formatNotInfo(Status.ERROR)]; if (presentationDefinition.submission_requirements) { const info = this._client.results.filter((result) => result.evaluator === 'MarkForSubmissionEvaluation' && result.payload.group && result.status !== Status.ERROR); const marked = Array.from(new Set(info)); let matchSubmissionRequirements; try { matchSubmissionRequirements = this.matchSubmissionRequirements(presentationDefinition, presentationDefinition.submission_requirements, marked); } catch (e) { const matchingError = { status: Status.ERROR, message: JSON.stringify(e), tag: 'matchSubmissionRequirements', }; return { errors: errors ? [...errors, matchingError] : [matchingError], warnings: warnings, areRequiredCredentialsPresent: Status.ERROR, }; } const matches = this.extractMatches(matchSubmissionRequirements); const credentials = matches.map((e) => jp.nodes(this._client.wrappedVcs.map((wrapped) => wrapped.original), e)[0].value); const areRequiredCredentialsPresent = this.determineAreRequiredCredentialsPresent(presentationDefinition, matchSubmissionRequirements); selectResults = { errors: areRequiredCredentialsPresent === Status.INFO ? [] : errors, matches: [...matchSubmissionRequirements], areRequiredCredentialsPresent, verifiableCredential: credentials, warnings, }; } else { const marked = this._client.results.filter((result) => result.evaluator === 'MarkForSubmissionEvaluation' && result.status !== Status.ERROR); const checkWithoutSRResults = this.checkWithoutSubmissionRequirements(marked, presentationDefinition); if (!checkWithoutSRResults.length) { const matchSubmissionRequirements = this.matchWithoutSubmissionRequirements(marked, presentationDefinition); const matches = this.extractMatches(matchSubmissionRequirements); const credentials = matches.map((e) => jp.nodes(this._client.wrappedVcs.map((wrapped) => wrapped.original), e)[0].value); selectResults = { errors: [], matches: [...matchSubmissionRequirements], areRequiredCredentialsPresent: Status.INFO, verifiableCredential: credentials, warnings, }; } else { return { errors: errors, matches: [], areRequiredCredentialsPresent: Status.ERROR, verifiableCredential: wrappedVerifiableCredentials.map((value) => value.original), warnings: warnings, }; } } this.fillSelectableCredentialsToVerifiableCredentialsMapping(selectResults, wrappedVerifiableCredentials); selectResults.areRequiredCredentialsPresent = this.determineAreRequiredCredentialsPresent(presentationDefinition, selectResults?.matches); this.remapMatches(wrappedVerifiableCredentials.map((wrapped) => wrapped.original), selectResults.matches, selectResults?.verifiableCredential); selectResults.matches?.forEach((m) => { this.updateSubmissionRequirementMatchPathToAlias(m, 'verifiableCredential'); }); if (selectResults.areRequiredCredentialsPresent === Status.INFO) { selectResults.errors = []; } else { selectResults.errors = errors; selectResults.warnings = warnings; selectResults.verifiableCredential = wrappedVerifiableCredentials.map((value) => value.original); } return selectResults; } remapMatches(verifiableCredentials, submissionRequirementMatches, vcsToSend) { submissionRequirementMatches?.forEach((srm) => { if (srm.from_nested) { this.remapMatches(verifiableCredentials, srm.from_nested, vcsToSend); } else { srm.vc_path.forEach((match, index, matches) => { const vc = jp.query(verifiableCredentials, match)[0]; const newIndex = vcsToSend?.findIndex((svc) => JSON.stringify(svc) === JSON.stringify(vc)); if (newIndex === -1) { throw new Error(`The index of the VerifiableCredential in your current call can't be found in your previously submitted credentials. Are you trying to send a new Credential?\nverifiableCredential: ${vc}`); } matches[index] = `$[${newIndex}]`; }); srm.name; } }); } extractMatches(matchSubmissionRequirements) { const matches = []; matchSubmissionRequirements.forEach((e) => { matches.push(...e.vc_path); if (e.from_nested) { matches.push(...this.extractMatches(e.from_nested)); } }); return Array.from(new Set(matches)); } /** * Since this is without SubmissionRequirements object, each InputDescriptor has to have at least one corresponding VerifiableCredential * @param marked: info logs for `MarkForSubmissionEvaluation` handler * @param pd * @private */ checkWithoutSubmissionRequirements(marked, pd) { const checkResult = []; if (!pd.input_descriptors) { return []; } if (!marked.length) { return [ { input_descriptor_path: '', evaluator: 'checkWithoutSubmissionRequirement', verifiable_credential_path: '', status: Status.ERROR, payload: `Not all the InputDescriptors are addressed`, }, ]; } const inputDescriptors = pd.input_descriptors; const markedInputDescriptorPaths = ObjectUtils.getDistinctFieldInObject(marked, 'input_descriptor_path'); if (markedInputDescriptorPaths.length !== inputDescriptors.length) { const inputDescriptorsFromLogs = markedInputDescriptorPaths.map((value) => JsonPathUtils.extractInputField(pd, [value])[0].value).map((value) => value.id); for (let i = 0; i < pd.input_descriptors.length; i++) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (inputDescriptorsFromLogs.indexOf(pd.input_descriptors[i].id) == -1) { checkResult.push({ input_descriptor_path: `$.input_descriptors[${i}]`, evaluator: 'checkWithoutSubmissionRequirement', verifiable_credential_path: '', status: Status.ERROR, payload: `Not all the InputDescriptors are addressed`, }); } } } return checkResult; } matchSubmissionRequirements(pd, submissionRequirements, marked) { const submissionRequirementMatches = []; for (const [srIndex, sr] of Object.entries(submissionRequirements)) { // Create a default SubmissionRequirementMatch object const srm = { rule: sr.rule, vc_path: [], name: sr.name, type: SubmissionRequirementMatchType.SubmissionRequirement, id: Number(srIndex), }; if (sr.from) { srm.from = sr.from; } // Assign min, max, and count regardless of 'from' or 'from_nested' sr.min ? (srm.min = sr.min) : undefined; sr.max ? (srm.max = sr.max) : undefined; sr.count ? (srm.count = sr.count) : undefined; if (sr.from) { const matchingVcPaths = this.getMatchingVcPathsForSubmissionRequirement(pd, sr, marked); srm.vc_path.push(...matchingVcPaths); submissionRequirementMatches.push(srm); } else if (sr.from_nested) { // Recursive call to matchSubmissionRequirements for nested requirements try { srm.from_nested = this.matchSubmissionRequirements(pd, sr.from_nested, marked); submissionRequirementMatches.push(srm); } catch (err) { throw new Error(`Error in handling value of from_nested: ${sr.from_nested}: err: ${err}`); } } else { // Throw an error if neither 'from' nor 'from_nested' is found throw new Error("Invalid SubmissionRequirement object: Must contain either 'from' or 'from_nested'"); } } return submissionRequirementMatches; } matchWithoutSubmissionRequirements(marked, pd) { const submissionRequirementMatches = []; const partitionedIdToVcMap = this.createIdToVcMap(marked); for (const [idPath, sameIdVcs] of partitionedIdToVcMap.entries()) { if (!sameIdVcs || !sameIdVcs.length) { continue; } for (const vcPath of sameIdVcs) { const inputDescriptorResults = JsonPathUtils.extractInputField(pd, [idPath]); if (inputDescriptorResults.length) { const inputDescriptor = inputDescriptorResults[0].value; submissionRequirementMatches.push({ name: inputDescriptor.name || inputDescriptor.id, rule: Rules.All, vc_path: [vcPath], type: SubmissionRequirementMatchType.InputDescriptor, id: inputDescriptor.id, }); } } } return this.removeDuplicateSubmissionRequirementMatches(submissionRequirementMatches); } getMatchingVcPathsForSubmissionRequirement(pd, sr, marked) { const vcPaths = new Set(); if (!sr.from) return Array.from(vcPaths); for (const m of marked) { const inputDescriptor = jp.query(pd, m.input_descriptor_path)[0]; if (inputDescriptor.group && inputDescriptor.group.indexOf(sr.from) === -1) { continue; } if (m.payload.group.includes(sr.from)) { vcPaths.add(m.verifiable_credential_path); } } return Array.from(vcPaths); } evaluate(pd, wvcs, opts) { this._client.evaluate(pd, wvcs, opts); const result = { areRequiredCredentialsPresent: Status.INFO, // TODO: we should handle the string case verifiableCredential: wvcs.map((wrapped) => wrapped.original), }; result.warnings = this.formatNotInfo(Status.WARN); result.errors = this.formatNotInfo(Status.ERROR); this._client.assertPresentationSubmission(); if (this._client.presentationSubmission?.descriptor_map.length) { this._client.presentationSubmission.descriptor_map = this._client.presentationSubmission.descriptor_map.filter((v) => v !== undefined); result.value = JSON.parse(JSON.stringify(this._client.presentationSubmission)); } if (this._client.generatePresentationSubmission) { this.updatePresentationSubmissionPathToVpPath(result.value); } result.verifiableCredential = this._client.wrappedVcs.map((wrapped) => wrapped.original); result.areRequiredCredentialsPresent = result.value?.descriptor_map?.length ? Status.INFO : Status.ERROR; return result; } evaluatePresentations(pd, wvps, opts) { // If submission is provided as input, we match the presentations against the submission. In this case the submission MUST be valid if (opts?.presentationSubmission) { return this.evaluatePresentationsAgainstSubmission(pd, wvps, opts.presentationSubmission, opts); } const wrappedPresentations = Array.isArray(wvps) ? wvps : [wvps]; const allWvcs = wrappedPresentations.reduce((all, wvp) => [...all, ...wvp.vcs], []); const result = { areRequiredCredentialsPresent: Status.INFO, presentations: Array.isArray(wvps) ? wvps.map((wvp) => wvp.original) : [wvps.original], errors: [], warnings: [], }; // Reset and configure the evaluation client on each iteration this._client = new EvaluationClient(); this._client.evaluate(pd, allWvcs, opts); result.warnings = this.formatNotInfo(Status.WARN); result.errors = this.formatNotInfo(Status.ERROR); this._client.assertPresentationSubmission(); if (this._client.presentationSubmission?.descriptor_map.length) { this._client.presentationSubmission.descriptor_map = this._client.presentationSubmission.descriptor_map.filter((v) => v !== undefined); result.value = JSON.parse(JSON.stringify(this._client.presentationSubmission)); } const useExternalSubmission = opts?.presentationSubmissionLocation !== undefined ? opts.presentationSubmissionLocation === PresentationSubmissionLocation.EXTERNAL : Array.isArray(wvps); if (this._client.generatePresentationSubmission && result.value && useExternalSubmission) { // we map the descriptors of the generated submission to take into account the nexted values result.value.descriptor_map = result.value.descriptor_map.map((descriptor) => { const [wvcResult] = JsonPathUtils.extractInputField(allWvcs, [descriptor.path]); if (!wvcResult) { throw new Error(`Could not find descriptor path ${descriptor.path} in wrapped verifiable credentials`); } const matchingWvc = wvcResult.value; const matchingVpIndex = wrappedPresentations.findIndex((wvp) => wvp.vcs.includes(matchingWvc)); const matchingVp = wrappedPresentations[matchingVpIndex]; const matcingWvcIndexInVp = matchingVp.vcs.findIndex((wvc) => wvc === matchingWvc); return this.updateDescriptorToExternal(descriptor, { // We don't want to add vp index if the input to evaluate was a single presentation vpIndex: Array.isArray(wvps) ? matchingVpIndex : undefined, vcIndex: matcingWvcIndexInVp, }); }); } else if (this._client.generatePresentationSubmission && result.value) { this.updatePresentationSubmissionPathToVpPath(result.value); } result.areRequiredCredentialsPresent = result.value?.descriptor_map?.length ? Status.INFO : Status.ERROR; return result; } extractWrappedVcFromWrappedVp(descriptor, descriptorIndex, wvp) { // Decoded won't work for sd-jwt or jwt?!?! const [vcResult] = JsonPathUtils.extractInputField(wvp.decoded, [descriptor.path]); if (!vcResult) { return { error: { status: Status.ERROR, tag: 'SubmissionPathNotFound', message: `Unable to extract path ${descriptor.path} for submission.descriptor_path[${descriptorIndex}] from verifiable presentation`, }, wvc: undefined, }; } // FIXME figure out possible types, can't see that in debug mode... const isCredential = CredentialMapper.isCredential(vcResult.value); if (!vcResult.value || (typeof vcResult.value === 'string' && !isCredential) || (typeof vcResult.value !== 'string' && !isCredential && !('verifiableCredential' in vcResult.value || 'vp' in vcResult.value))) { return { error: { status: Status.ERROR, tag: 'NoVerifiableCredentials', message: `No verifiable credentials found at path "${descriptor.path}" for submission.descriptor_path[${descriptorIndex}]`, }, wvc: undefined, }; } // When result is an array, extract the first Verifiable Credential from the array FIXME figure out proper types, can't see that in debug mode... let originalVc; if (isCredential) { originalVc = vcResult.value; } else if (typeof vcResult.value !== 'string') { if ('verifiableCredential' in vcResult.value) { originalVc = Array.isArray(vcResult.value.verifiableCredential) ? vcResult.value.verifiableCredential[0] : vcResult.value.verifiableCredential; } else { throw Error('Could not deduce original VC from evaluation result'); } } else { throw Error('Could not deduce original VC from evaluation result'); } // Find the corresponding Wrapped Verifiable Credential (wvc) based on the original VC const wvc = wvp.vcs.find((wrappedVc) => CredentialMapper.areOriginalVerifiableCredentialsEqual(wrappedVc.original, originalVc)); if (!wvc) { return { error: { status: Status.ERROR, tag: 'SubmissionPathNotFound', message: `Unable to find wrapped VC for the extracted credential at path "${descriptor.path}" in descriptor_path[${descriptorIndex}]`, }, wvc: undefined, }; } return { wvc, error: undefined, }; } evaluatePresentationsAgainstSubmission(pd, wvps, submission, opts) { const result = { areRequiredCredentialsPresent: Status.INFO, presentations: Array.isArray(wvps) ? wvps.map((wvp) => wvp.original) : [wvps.original], errors: [], warnings: [], value: submission, }; if (submission.definition_id !== pd.id) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'SubmissionDefinitionIdNotFound', message: `Presentation submission defines definition_id '${submission.definition_id}', but the provided definition has id '${pd.id}'`, }); } // If only a single VP is passed that is not w3c and no presentationSubmissionLocation, we set the default location to presentation. Otherwise we assume it's external const presentationSubmissionLocation = opts?.presentationSubmissionLocation ?? (Array.isArray(wvps) || !CredentialMapper.isW3cPresentation(Array.isArray(wvps) ? wvps[0].presentation : wvps.presentation) ? PresentationSubmissionLocation.EXTERNAL : PresentationSubmissionLocation.PRESENTATION); // Iterate over each descriptor in the submission for (const [descriptorIndex, descriptor] of submission.descriptor_map.entries()) { let matchingVp; if (presentationSubmissionLocation === PresentationSubmissionLocation.EXTERNAL) { // Extract VPs matching the descriptor path const vpResults = JsonPathUtils.extractInputField(wvps, [descriptor.path]); if (!vpResults.length) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'SubmissionPathNotFound', message: `Unable to extract path ${descriptor.path} for submission.descriptor_map[${descriptorIndex}] from presentation(s)`, }); continue; } else if (vpResults.length > 1) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'SubmissionPathMultipleEntries', message: `Extraction of path ${descriptor.path} for submission.descriptor_map[${descriptorIndex}] resulted in multiple values being returned.`, }); continue; } matchingVp = vpResults[0].value; if (Array.isArray(matchingVp)) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'SubmissionPathMultipleEntries', message: `Extraction of path ${descriptor.path} for submission.descriptor_map[${descriptorIndex}] returned multiple entires. This is probably because the submission uses '$' to reference the presentation, while an array was used (thus all presentations are selected). Make sure the submission uses the correct path.`, }); continue; } if (!matchingVp) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'SubmissionPathNotFound', message: `Extraction of path ${descriptor.path} for submission.descriptor_map[${descriptorIndex}] succeeded, but the value was undefined.`, }); continue; } if (matchingVp.format !== descriptor.format) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'SubmissionFormatNoMatch', message: `The VP at path ${descriptor.path} does not match the required format ${descriptor.format}`, }); continue; } } else { // When submission location is PRESENTATION, assume a single VP matchingVp = Array.isArray(wvps) ? wvps[0] : wvps; } let vc; let vcPath = `presentation ${descriptor.path}`; if (presentationSubmissionLocation === PresentationSubmissionLocation.EXTERNAL) { if (descriptor.path_nested) { const extractionResult = this.extractWrappedVcFromWrappedVp(descriptor.path_nested, descriptorIndex.toString(), matchingVp); if (extractionResult.error) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push(extractionResult.error); continue; } vc = extractionResult.wvc; vcPath += ` with nested credential ${descriptor.path_nested.path}`; } else if (descriptor.format === 'vc+sd-jwt') { if (!matchingVp.vcs || !matchingVp.vcs.length) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'NoCredentialsFound', message: `No credentials found in VP at path ${descriptor.path}`, }); continue; } vc = matchingVp.vcs[0]; } else if (descriptor.format === 'mso_mdoc') { // We already know the format is mso_mdoc so this cast is safe const vcs = matchingVp.vcs; vcPath += ` with nested mdoc with doctype ${descriptor.id}`; const matchingVc = vcs.find((vc) => descriptor.id === vc.credential.docType.asStr); if (!matchingVc) { const allDoctypes = vcs.map((vc) => `'${vc.credential.docType.asStr}'`).join(', '); result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'NoCredentialsFound', message: `No mdoc credential with doctype '${descriptor.id}' found in mdoc vp. Available documents are ${allDoctypes}`, }); continue; } vc = matchingVc; } else { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'UnsupportedFormat', message: `VP format ${matchingVp.format} is not supported`, }); continue; } } else { const extractionResult = this.extractWrappedVcFromWrappedVp(descriptor, descriptorIndex.toString(), matchingVp); if (extractionResult.error) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push(extractionResult.error); continue; } vc = extractionResult.wvc; vcPath = `credential ${descriptor.path}`; } // TODO: we should probably add support for holder dids in the kb-jwt of an SD-JWT. We can extract this from the // `wrappedPresentation.original.compactKbJwt`, but as HAIP doesn't use dids, we'll leave it for now. // Determine holder DIDs const holderDIDs = CredentialMapper.isW3cPresentation(matchingVp.presentation) && matchingVp.presentation.holder ? [matchingVp.presentation.holder] : opts?.holderDIDs || []; if (pd.input_descriptors.findIndex((_id) => _id.id === descriptor.id) === -1) { result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push({ status: Status.ERROR, tag: 'SubmissionInputDescriptorIdNotFound', message: `Submission references descriptor id '${descriptor.id}' but presentation definition with id '${pd.id}' does not have an input descriptor with this id. Available input descriptors are ${pd.input_descriptors.map((i) => `'${i.id}'`).join(', ')}`, }); } else { // Get the presentation definition specific to the current descriptor const pdForDescriptor = this.internalPresentationDefinitionForDescriptor(pd, descriptor.id); // Reset and configure the evaluation client on each iteration this._client = new EvaluationClient(); this._client.evaluate(pdForDescriptor, [vc], { ...opts, holderDIDs, presentationSubmission: undefined, generatePresentationSubmission: undefined, }); // Check if the evaluation resulted in exactly one descriptor map entry if (this._client.presentationSubmission.descriptor_map.length !== 1) { const submissionDescriptor = `submission.descriptor_map[${descriptorIndex}]`; result.areRequiredCredentialsPresent = Status.ERROR; result.errors?.push(...this.formatNotInfo(Status.ERROR, submissionDescriptor, vcPath)); result.warnings?.push(...this.formatNotInfo(Status.WARN, submissionDescriptor, vcPath)); } } } // Output submission is same as input presentation submission, it's just that if it doesn't match, we return Error. const submissionAgainstDefinitionResult = this.validateIfSubmissionSatisfiesDefinition(pd, submission); if (!submissionAgainstDefinitionResult.doesSubmissionSatisfyDefinition) { result.errors?.push({ status: Status.ERROR, tag: 'SubmissionDoesNotSatisfyDefinition', // TODO: it would be nice to add the nested errors here for beter understanding WHY the submission // does not satisfy the definition, as we have that info, but we can only include one message here message: submissionAgainstDefinitionResult.error, }); result.areRequiredCredentialsPresent = Status.ERROR; } return result; } checkIfSubmissionSatisfiesSubmissionRequirement(pd, submission, submissionRequirement, submissionRequirementName) { if ((submissionRequirement.from && submissionRequirement.from_nested) || (!submissionRequirement.from && !submissionRequirement.from_nested)) { return { isSubmissionRequirementSatisfied: false, totalMatches: 0, errors: [ `Either 'from' OR 'from_nested' MUST be present on submission requirement ${submissionRequirementName}, but not neither and not both`, ], }; } const result = { isSubmissionRequirementSatisfied: false, totalMatches: 0, maxRequiredMatches: submissionRequirement.rule === Rules.Pick ? submissionRequirement.max : undefined, minRequiredMatches: submissionRequirement.rule === Rules.Pick ? submissionRequirement.min : undefined, errors: [], }; // Populate from_nested requirements if (submissionRequirement.from_nested) { const nestedResults = submissionRequirement.from_nested.map((nestedSubmissionRequirement, index) => this.checkIfSubmissionSatisfiesSubmissionRequirement(pd, submission, nestedSubmissionRequirement, `${submissionRequirementName}.from_nested[${index}]`)); result.totalRequiredMatches = submissionRequirement.rule === Rules.All ? submissionRequirement.from_nested.length : submissionRequirement.count; result.totalMatches = nestedResults.filter((n) => n.isSubmissionRequirementSatisfied).length; result.nested = nestedResults; } // Populate from requirements if (submissionRequirement.from) { const inputDescriptorsForGroup = pd.input_descriptors.filter((descriptor) => descriptor.group?.includes(submissionRequirement.from)); const descriptorIdsInSubmission = submission.descriptor_map.map((descriptor) => descriptor.id); const inputDescriptorsInSubmission = inputDescriptorsForGroup.filter((inputDescriptor) => descriptorIdsInSubmission.includes(inputDescriptor.id)); result.totalMatches = inputDescriptorsInSubmission.length; result.totalRequiredMatches = submissionRequirement.rule === Rules.All ? inputDescriptorsForGroup.length : submissionRequirement.count; } // Validate if the min/max/count requirements are satisfied if (result.totalRequiredMatches !== undefined && result.totalMatches !== result.totalRequiredMatches) { result.errors.push(`Expected ${result.totalRequiredMatches} requirements to be satisfied for submission requirement ${submissionRequirementName}, but found ${result.totalMatches}`); } if (result.minRequiredMatches !== undefined && result.totalMatches < result.minRequiredMatches) { result.errors.push(`Expected at least ${result.minRequiredMatches} requirements to be satisfied from submission requirement ${submissionRequirementName}, but found ${result.totalMatches}`); } if (result.maxRequiredMatches !== undefined && result.totalMatches > result.maxRequiredMatches) { result.errors.push(`Expected at most ${result.maxRequiredMatches} requirements to be satisfied from submission requirement ${submissionRequirementName}, but found ${result.totalMatches}`); } result.isSubmissionRequirementSatisfied = result.errors.length === 0; return result; } /** * Checks whether a submission satisfies the requirements of a presentation definition */ validateIfSubmissionSatisfiesDefinition(pd, submission) { const submissionDescriptorIds = submission.descriptor_map.map((descriptor) => descriptor.id); const result = { doesSubmissionSatisfyDefinition: false, totalMatches: 0, totalRequiredMatches: 0, }; // All MUST match if (pd.submission_requirements) { const submissionRequirementResults = pd.submission_requirements.map((submissionRequirement, index) => this.checkIfSubmissionSatisfiesSubmissionRequirement(pd, submission, submissionRequirement, `$.submission_requirements[${index}]`)); result.totalRequiredMatches = pd.submission_requirements.length; result.totalMatches = submissionRequirementResults.filter((r) => r.isSubmissionRequirementSatisfied).length; result.submissionRequirementResults = submissionRequirementResults; if (result.totalMatches !== result.totalRequiredMatches) { result.error = `Expected all submission requirements (${result.totalRequiredMatches}) to be satisfied in submission, but found ${result.totalMatches}.`; } } else { result.totalRequiredMatches = pd.input_descriptors.length; result.totalMatches = submissionDescriptorIds.length; const notInSubmission = pd.input_descriptors.filter((inputDescriptor) => !submissionDescriptorIds.includes(inputDescriptor.id)); if (notInSubmission.length > 0) { result.error = `Expected all input descriptors (${pd.input_descriptors.map((i) => `'${i.id}'`).join(', ')}) to be satisfied in submission, but found ${submissionDescriptorIds.map((i) => `'${i}'`).join(',')}. Missing ${notInSubmission.map((d) => `'${d.id}'`).join(', ')}`; } } result.doesSubmissionSatisfyDefinition = result.error === undefined; return result; } internalPresentationDefinitionForDescriptor(pd, descriptorId) { const inputDescriptorIndex = pd.input_descriptors.findIndex((i) => i.id === descriptorId); // If we receive a submission with input descriptors that do not exist if (inputDescriptorIndex === -1) { throw new Error(`Input descriptor with id '${descriptorId}' not found in presentation definition with id '${pd.id}'. Available input descriptors are ${pd.input_descriptors.map((i) => `'${i.id}'`).join(', ')}`); } if (pd instanceof InternalPresentationDefinitionV2) { return new InternalPresentationDefinitionV2(pd.id, [pd.input_descriptors[inputDescriptorIndex]], pd.format, pd.frame, pd.name, pd.purpose, // we ignore submission requirements as we're verifying a single input descriptor here undefined); } else if (pd instanceof InternalPresentationDefinitionV1) { return new InternalPresentationDefinitionV1(pd.id, [pd.input_descriptors[inputDescriptorIndex]], pd.format, pd.name, pd.purpose, // we ignore submission requirements as we're verifying a single input descriptor here undefined); } throw new Error('Unrecognized presentation definition instance'); } formatNotInfo(status, descriptorPath, vcPath) { return this._client.results .filter((result) => result.status === status) .map((x) => { const _vcPath = vcPath ?? `$.verifiableCredential${x.verifiable_credential_path.substring(1)}`; const _descriptorPath = descriptorPath ?? x.input_descriptor_path; return { tag: x.evaluator, status: x.status, message: `${x.message}: ${_descriptorPath}: ${_vcPath}`, }; }); } submissionFrom(pd, vcs, opts) { if (!this._client.results || this._client.results.length === 0) { if (vcs.length === 0) { throw Error('The WrappedVerifiableCredentials input array is empty'); } throw Error('You need to call evaluate() before pex.presentationFrom()'); } if (!this._client.generatePresentationSubmission) { return this._client.presentationSubmission; } if (pd.submission_requirements) { const marked = this._client.results.filter((result) => result.evaluator === 'MarkForSubmissionEvaluation' && result.payload.group && result.status !== Status.ERROR); const [updatedMarked, upIdx] = this.matchUserSelectedVcs(marked, vcs); const groupCount = new Map(); //TODO instanceof fails in some cases, need to check how to fix it if ('input_descriptors' in pd) { pd.input_descriptors.forEach((e) => { if (e.group) { e.group.forEach((key) => { if (groupCount.has(key)) { groupCount.set(key, groupCount.get(key) + 1); } else { groupCount.set(key, 1); } }); } }); } const result = this.evaluateRequirements(pd.submission_requirements, updatedMarked, groupCount, 0); const finalIdx = upIdx.filter((ui) => result[1].find((r) => r.verifiable_credential_path === ui[1])); this.updatePresentationSubmission(finalIdx); this.updatePresentationSubmissionPathToVpPath(); if (opts?.presentationSubmissionLocation === PresentationSubmissionLocation.EXTERNAL) { this.updatePresentationSubmissionToExternal(); } return this._client.presentationSubmission; } const marked = this._client.results.filter((result) => result.evaluator === 'MarkForSubmissionEvaluation' && result.status !== Status.ERROR); const updatedIndexes = this.matchUserSelectedVcs(marked, vcs); this.updatePresentationSubmission(updatedIndexes[1]); this.updatePresentationSubmissionPathToVpPath(); if (opts?.presentationSubmissionLocation === PresentationSubmissionLocation.EXTERNAL) { this.updatePresentationSubmissionToExternal(); } return this._client.presentationSubmission; } updatePresentationSubmission(updatedIndexes) { if (!this._client.generatePresentationSubmission) { return; // never update a supplied submission } this._client.presentationSubmission.descriptor_map = this._client.presentationSubmission.descriptor_map .filter((descriptor) => updatedIndexes.find((ui) => ui[0] === descriptor.path)) .map((descriptor) => { const result = updatedIndexes.find((ui) => ui[0] === descriptor.path); if (result) { descriptor.path = result[1]; } return descriptor; }); } updatePresentationSubmissionToExternal(presentationSubmission) { const descriptors = presentationSubmission?.descriptor_map ?? this._client.presentationSubmission.descriptor_map; // Get all VCs to check if they should be in separate VPs const vcs = this._client.wrappedVcs.map((wvc) => wvc.original); const useMultipleVPs = !PEX.allowMultipleVCsPerPresentation(vcs) && this._client.wrappedVcs.length > 1; const updatedDescriptors = descriptors.map((d, index) => { // If using multiple VPs, update path to include VP index if (useMultipleVPs) { return this.updateDescriptorToExternal(d, { vpIndex: index, vcIndex: 0 }); } return this.updateDescriptorToExternal(d); }); if (presentationSubmission) { return { ...presentationSubmission, descriptor_map: updatedDescriptors, }; } this._client.presentationSubmission.descriptor_map = updatedDescriptors; return this._client.presentationSubmission; } updateDescriptorToExternal(descriptor, { vpIndex, vcIndex, } = {}) { if (descriptor.path_nested) { return descriptor; } const { nestedCredentialPath, vpFormat } = getVpFormatForVcFormat(descriptor.format); const newDescriptor = { ...descriptor, format: vpFormat, path: vpIndex !== undefined ? `$[${vpIndex}]` : '$', }; if (nestedCredentialPath) { newDescriptor.path_nested = { ...descriptor, path: vcIndex !== undefined ? `${nestedCredentialPath}[${vcIndex}]` : descriptor.path.replace('$.verifiableCredential', nestedCredentialPath).replace('$[', `${nestedCredentialPath}[`), }; } return newDescriptor; } matchUserSelectedVcs(marked, vcs) { const userSelected = vcs.map((vc, index) => [index, JSON.stringify(vc.original)]); const allCredentials = this._client.wrappedVcs.map((vc, index) => [index, JSON.stringify(vc.original)]); const updatedIndexes = []; userSelected.forEach((us, i) => { allCredentials.forEach((ac, j) => { if (ac[1] === us[1]) { updatedIndexes.push([`$[${j}]`, `$[${i}]`]); } }); }); marked = marked .filter((m) => updatedIndexes.find((ui) => ui[0] === m.verifiable_credential_path)) .map((m) => { const index = updatedIndexes.find((ui) => ui[0] === m.verifiable_credential_path); if (index) { m.verifiable_credential_path = index[1]; } return m; }); return [marked, updatedIndexes]; } evaluateRequirements(submissionRequirement, marked, groupCount, level) { let total = 0; const result = []; for (const sr of submissionRequirement) { if (sr.from) { if (sr.rule === Rules.All) { const [count, matched] = this.countMatchingInputDescriptors(sr, marked); if (count !== (groupCount.get(sr.from) || 0)) { throw Error(`Not all input descriptors are members of group ${sr.from}`); } total++; result.push(...matched); } else if (sr.rule === Rules.Pick) { const [count, matched] = this.countMatchingInputDescriptors(sr, marked); try { this.handleCount(sr, count, level); total++; } catch (error) { if (level === 0) throw error; } result.push(...matched); } } else if (sr.from_nested) { const [count, matched] = this.evaluateRequirements(sr.from_nested, marked, groupCount, ++level); total += count; result.push(...matched); this.handleCount(sr, count, level); } } return [total, result]; } countMatchingInputDescriptors(submissionRequirement, marked) { let count = 0; const matched = []; for (const m of marked) { if (m.payload.group.includes(submissionRequirement.from)) { matched.push(m); count++; } } return [count, matched]; } handleCount(submissionRequirement, count, level) { if (submissionRequirement.count) { if (count !== submissionRequirement.count) { throw Error(`Count: expected: ${submissionRequirement.count} actual: ${count} at level: ${level}`); } } if (submissionRequirement.min) { if (count < submissionRequirement.min) { throw Error(`Min: expected: ${submissionRequirement.min} actual: ${count} at level: ${level}`); } } if (submissionRequirement.max) { if (count > submissionRequirement.max) { throw Error(`Max: expected: ${submissionRequirement.max} actual: ${count} at level: ${level}`); } } } removeDuplicateSubmissionRequirementMatches(matches) { return matches.filter((match, index) => { const _match = JSON.stringify(match); return (index === matches.findIndex((obj) => { return JSON.stringify(obj) === _match; })); }); } fillSelectableCredentialsToVerifiableCredentialsMapping(selectResults, wrappedVcs) { if (selectResults) { selectResults.verifiableCredential?.forEach((selectableCredential) => { const foundIndex = wrappedVcs.findIndex((wrappedVc) => CredentialMapper.areOriginalVerifiableCredentialsEqual(wrappedVc.original, selectableCredential)); if (foundIndex === -1) { throw new Error('index is not right'); } selectResults.vcIndexes ? !selectResults.vcIndexes.includes(foundIndex) && selectResults.vcIndexes.push(foundIndex) : (selectResults.vcIndexes = [foundIndex]); }); } } determineAreRequiredCredentialsPresent(presentationDefinition, matchSubmissionRequirements, parentMsr) { if (!matchSubmissionRequirements || !matchSubmissionRequirements.length) { return Status.ERROR; } // collect child statuses const childStatuses = matchSubmissionRequirements.map((m) => this.determineSubmissionRequirementStatus(presentationDefinition, m)); // decide status based on child statuses and parent's rule if (!parentMsr) { if (childStatuses.includes(Status.ERROR)) { return Status.ERROR; } else if (childStatuses.includes(Status.WARN)) { return Status.WARN; } else { return Status.INFO; } } else { if (parentMsr.rule === Rules.All && childStatuses.includes(Status.ERROR)) { return Status.ERROR; } const nonErrStatCount = childStatuses.filter((status) => status !== Status.ERROR).length; if (parentMsr.count) { return parentMsr.count > nonErrStatCount ? Status.ERROR : parentMsr.count < nonErrStatCount ? Status.WARN : Status.INFO; } else { if (parentMsr.min && parentMsr.min > nonErrStatCount) { return Status.ERROR; } else if (parentMsr.max && parentMsr.max < nonErrStatCount) { return Status.WARN; } } } return Status.INFO; } determineSubmissionRequirementStatus(pd, m) { if (m.from && m.from_nested) { throw new Error('Invalid submission_requirement object: MUST contain either a from or from_nested property.'); } if (!m.from && !m.from_nested && m.vc_path.length !== 1) { return Status.ERROR; } if (m.from) { const groupCount = this.countGroupIDs(pd.input_descriptors, m.from); switch (m.rule) { case Rules.All: // Ensure that all descriptors associated with `m.from` are satisfied. return m.vc_path.length === groupCount ? Status.INFO : Status.WARN; case Rules.Pick: return this.getPickRuleStatus(m); default: return Status.ERROR; } } else if (m.from_nested) { return this.determineAreRequiredCredentialsPresent(pd, m.from_nested, m);