From 7d560b1c91af82ea02813c22e9b08cb33b6e11ee Mon Sep 17 00:00:00 2001 From: John Henderson Date: Thu, 28 Apr 2022 08:45:47 +0200 Subject: [PATCH] Feat/iva 16/add tx review (#55) This PR adds the ability for a reviewer of a presentation submission to submit their review (including issued credentials). This allows holders/presenters to receive credentials at the end of a VC-API exchange. Includes a refactoring of the VP submission verification into a separate service. --- README.md | 101 ----- apps/vc-api/README.md | 7 +- apps/vc-api/docs/exchanges.md | 142 ++++++ apps/vc-api/docs/openapi.json | 19 +- .../vc-api/credentials/credentials.service.ts | 3 +- .../credentials/dtos/verify-options.dto.ts | 3 +- .../credentials/types/credential-verifier.ts | 24 + .../types/presentation-verifier.ts | 24 + .../credentials/types/verify-options.ts | 26 ++ .../exchanges/dtos/exchange-definition.dto.ts | 11 +- ...xchange-interact-service-definition.dto.ts | 6 +- .../exchanges/dtos/submission-review.dto.ts | 41 ++ .../presentation-submission.entity.ts | 8 +- .../entities/transaction.entity.spec.ts | 415 +++--------------- .../exchanges/entities/transaction.entity.ts | 123 ++---- .../vc-api/exchanges/exchange.service.spec.ts | 163 ++++--- .../src/vc-api/exchanges/exchange.service.ts | 48 +- .../types/presentation-review-status.ts | 3 +- .../exchanges/types/submission-verifier.ts | 31 ++ .../types/vp-request-interact-service-type.ts | 6 +- .../vp-submission-verifier.service.spec.ts | 398 +++++++++++++++++ .../vp-submission-verifier.service.ts | 131 ++++++ apps/vc-api/src/vc-api/vc-api.controller.ts | 20 + apps/vc-api/src/vc-api/vc-api.module.ts | 3 +- .../resident-card/resident-card.e2e-suite.ts | 35 +- apps/vc-api/test/wallet-client.ts | 19 + 26 files changed, 1162 insertions(+), 648 deletions(-) create mode 100644 apps/vc-api/docs/exchanges.md create mode 100644 apps/vc-api/src/vc-api/credentials/types/credential-verifier.ts create mode 100644 apps/vc-api/src/vc-api/credentials/types/presentation-verifier.ts create mode 100644 apps/vc-api/src/vc-api/credentials/types/verify-options.ts create mode 100644 apps/vc-api/src/vc-api/exchanges/dtos/submission-review.dto.ts create mode 100644 apps/vc-api/src/vc-api/exchanges/types/submission-verifier.ts create mode 100644 apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.spec.ts create mode 100644 apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.ts diff --git a/README.md b/README.md index 056a84ae..6f93f308 100644 --- a/README.md +++ b/README.md @@ -70,107 +70,6 @@ The rational for DIDKit's use is that it: ![architecture](http://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/energywebfoundation/ssi/master/vc-api.component.puml) - -## Credential Exchange Flow - -This flow is based of [VC-API Exchanges](https://w3c-ccg.github.io/vc-api/#initiate-exchange). - -### Initial Exchange Configuration -```mermaid -sequenceDiagram - participant App as Use Case App - actor Admin as Use Case Admin - participant VC as VC-API - - Admin->>VC: configure the exchange definition - Admin->>App: communicate "exchange invitation" -``` - -### Credential Presentation/Issuance - -The following is a sequence diagram of an credential exchange flow. -This flow can be either a credential verification exchange (an exchange between a holder and a verifier) or a credential issuance exchange (an exchange between an issuer and a verifier). - -```mermaid -sequenceDiagram - actor R as Holder - participant RSH as Holder SSI Hub - participant RSB as Web UI - participant ISH as Verifier/Issuer SSI Wallet - participant IService as Verification/Issuance Service - - rect rgb(243, 255, 255) - note right of R: initiate exchange - R->>RSB: provide exchange url (e.g. from QR code or link) - RSB->>ISH: initiate credential exchange - - activate ISH - ISH->>ISH: Read exchange definition - ISH-->>RSB: return VP request - deactivate ISH - end - - rect rgb(255, 243, 255) - note right of R: submit presentation - RSB->>RSH: request VCs based on credential query - activate RSH - RSH-->>RSB: return VCs that could match credential query - deactivate RSH - RSB->>R: display VP request with possible VCs - R-->>RSB: enter required input and/or select credentials - RSB->>R: request credential application (presentation) signature - R-->>RSB: approve signature - end - - rect rgb(255, 255, 235) - note right of R: process presention - alt mediated presention processing - RSB->>ISH: submit presentation - activate ISH - ISH->>ISH: Verify presentation signatures and satisfaction of credential query - ISH-->>RSB: reply with "mediation in progress" VP Request - deactivate ISH - - par review presentation - ISH->>IService: notify verification service of new presentation - IService->>ISH: query outstanding presentations to review - activate ISH - ISH-->>IService: return presentation to review - deactivate ISH - IService->>IService: process presentation - opt credential issuance - IService->>IService: prepare & issue VCs (as a VP) - end - IService->>ISH: submit presentation processing result (possibly including VCs) - and query presentation status - R->>RSB: query presentation submissions - RSB->>RSH: query outstanding presentations - RSB->>ISH: query presentation review status - alt presentation is processed - ISH-->>RSB: return review result (possibly including VP with VC) - opt - ISH->>ISH: execute configured notifications - end - else presentation not yet processed - ISH-->>RSB: return "mediation in progress" VP Request - end - - end - RSB->>RSH: store VC - RSB-->>R: display issued credential to requester - else unmediated application processing - RSB->>ISH: submit credential application to issuer hub - activate ISH - ISH->>ISH: Verify presentation signatures and satisfaction of credential query - ISH-->>RSB: return review result - opt - ISH->>ISH: execute configured notifications - end - deactivate ISH - end - end -``` - ## Installation This repository is a monorepo that uses [Rush](https://rushjs.io/) with the PNPM package manager. diff --git a/apps/vc-api/README.md b/apps/vc-api/README.md index 10b01b54..fb5f7c6a 100644 --- a/apps/vc-api/README.md +++ b/apps/vc-api/README.md @@ -9,6 +9,11 @@ This [vc-api app](./apps/vc-api) is a NestJs implementation of the [W3C Credenti See [tutorials](./docs/tutorials/). +## Credentials Exchanges + +Credential exchanges are the processes by which credentials are moved between wallet/agent and issuer/verifer. +For more information on these processes, see the [exchanges documentation](./docs/exchanges.md). + ## Installation Install using the [rush commands](../../README.md#installation) described in the root README. @@ -64,7 +69,7 @@ Not all of the endpoints available from the VC-API app are standard. | Continue Exchange | Yes | https://w3c-ccg.github.io/vc-api/#continue-exchange | Configure Exchange | No | | Query Submissions | No | -| Submit Processing Result | No | +| Submit Submission Review | No | ### DID Module diff --git a/apps/vc-api/docs/exchanges.md b/apps/vc-api/docs/exchanges.md new file mode 100644 index 00000000..a3c0f1af --- /dev/null +++ b/apps/vc-api/docs/exchanges.md @@ -0,0 +1,142 @@ + + +# VC-API Exchanges + +## Credential Exchange Flows + +This flow is based of [VC-API Exchanges](https://w3c-ccg.github.io/vc-api/#initiate-exchange). + +### Initial Exchange Configuration +```mermaid +sequenceDiagram + participant App as Use Case App + actor Admin as Use Case Admin + participant VC as VC-API + + Admin->>VC: configure the exchange definition + Admin->>App: communicate "exchange invitation" +``` + +### Credential Presentation/Issuance + +The following is a sequence diagram of an credential exchange flow. +This flow can be either a credential verification exchange (an exchange between a holder and a verifier) or a credential issuance exchange (an exchange between an issuer and a verifier). + +```mermaid +sequenceDiagram + actor R as Holder + participant RSH as Holder SSI Hub + participant RSB as Web UI + participant ISH as Verifier/Issuer SSI Wallet + participant IService as Verification/Issuance Service + + rect rgb(243, 255, 255) + note right of R: initiate exchange + R->>RSB: provide exchange url (e.g. from QR code or link) + RSB->>ISH: initiate credential exchange + + activate ISH + ISH->>ISH: Read exchange definition + ISH-->>RSB: return VP request + deactivate ISH + end + + rect rgb(255, 243, 255) + note right of R: submit presentation + RSB->>RSH: request VCs based on credential query + activate RSH + RSH-->>RSB: return VCs that could match credential query + deactivate RSH + RSB->>R: display VP request with possible VCs + R-->>RSB: enter required input and/or select credentials + RSB->>R: request credential application (presentation) signature + R-->>RSB: approve signature + end + + rect rgb(255, 255, 235) + note right of R: process presention + alt mediated presention processing + RSB->>ISH: submit presentation + activate ISH + ISH->>ISH: Verify presentation signatures and satisfaction of credential query + ISH-->>RSB: reply with "mediation in progress" VP Request + deactivate ISH + + par review presentation + ISH->>IService: notify verification service of new presentation + IService->>ISH: query outstanding presentations to review + activate ISH + ISH-->>IService: return presentation to review + deactivate ISH + IService->>IService: process presentation + opt credential issuance + IService->>IService: prepare & issue VCs (as a VP) + end + IService->>ISH: submit presentation processing result (possibly including VCs) + and query presentation status + R->>RSB: query presentation submissions + RSB->>RSH: query outstanding presentations + RSB->>ISH: query presentation review status + alt presentation is processed + ISH-->>RSB: return review result (possibly including VP with VC) + opt + ISH->>ISH: execute configured notifications + end + else presentation not yet processed + ISH-->>RSB: return "mediation in progress" VP Request + end + + end + RSB->>RSH: store VC + RSB-->>R: display issued credential to requester + else unmediated application processing + RSB->>ISH: submit credential application to issuer hub + activate ISH + ISH->>ISH: Verify presentation signatures and satisfaction of credential query + ISH-->>RSB: return review result + opt + ISH->>ISH: execute configured notifications + end + deactivate ISH + end + end +``` + +## Exchange Interaction Types + +The exchange interaction types used by this VC-API implementation are directly related to Verifiable Presentation Request [Interaction Types](https://w3c-ccg.github.io/vp-request-spec/#mediated-presentation). +The interaction types indicate to the receiver of the presentation request how they can expect to interact further with the issuer/verifier. + +### Mediated Exchange Interactions + +Mediated exchange interactions signal to the receiver of the presentation request that the exchange is mediated by an additional component and may not be automatically processed. +Mediated exchanges therefore allow for a review of presention submission by a human or automated process as well as the issuance of credentials based on this review. + +Due to the duration of the mediation process being unknown, the submitter of the verifiable presentation may have to query repeateadly in order to check if the result of the mediation is available. + +### Unmediated Exchange Interactions + +Mediated exchange interactions signal to the receiver of the presentation request that the exchange is not mediated by an additional component and can be automatically processed. + +## Exchange Definitions + +In order to keep the VC-API implementation generic (not specific to any use-cases), exchanges are configured rather than coded into the application. + +This configuration is done at runtime via the use of Exchange Definitions. + +For details on the structure and properties of an exchange definition, see the [Exchange Definition Data Transfer Object documentation](../src/vc-api/exchanges/dtos/exchange-definition.dto.ts) \ No newline at end of file diff --git a/apps/vc-api/docs/openapi.json b/apps/vc-api/docs/openapi.json index 21aa7a3d..8da7203c 100644 --- a/apps/vc-api/docs/openapi.json +++ b/apps/vc-api/docs/openapi.json @@ -128,6 +128,22 @@ "responses": { "200": { "description": "" } }, "tags": ["vc-api"] } + }, + "/vc-api/exchanges/{exchangeId}/{transactionId}/review": { + "post": { + "operationId": "VcApiController_addSubmissionReview", + "parameters": [ + { "name": "transactionId", "required": true, "in": "path", "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/SubmissionReviewDto" } } + } + }, + "responses": { "201": { "description": "" } }, + "tags": ["vc-api"] + } } }, "info": { @@ -145,7 +161,8 @@ "AuthenticateDto": { "type": "object", "properties": {} }, "ProvePresentationDto": { "type": "object", "properties": {} }, "ExchangeDefinitionDto": { "type": "object", "properties": {} }, - "VerifiablePresentationDto": { "type": "object", "properties": {} } + "VerifiablePresentationDto": { "type": "object", "properties": {} }, + "SubmissionReviewDto": { "type": "object", "properties": {} } } } } diff --git a/apps/vc-api/src/vc-api/credentials/credentials.service.ts b/apps/vc-api/src/vc-api/credentials/credentials.service.ts index cdc560fc..23451d78 100644 --- a/apps/vc-api/src/vc-api/credentials/credentials.service.ts +++ b/apps/vc-api/src/vc-api/credentials/credentials.service.ts @@ -34,6 +34,7 @@ import { VerifyOptionsDto } from './dtos/verify-options.dto'; import { VerificationResultDto } from './dtos/verification-result.dto'; import { AuthenticateDto } from './dtos/authenticate.dto'; import { ProvePresentationDto } from './dtos/prove-presentation.dto'; +import { CredentialVerifier } from './types/credential-verifier'; /** * Credential issuance options that Spruce accepts @@ -59,7 +60,7 @@ interface ISpruceVerifyOptions { * This encapsulates the use of Spruce DIDKit */ @Injectable() -export class CredentialsService { +export class CredentialsService implements CredentialVerifier { constructor(private didService: DIDService, private keyService: KeyService) {} async issueCredential(issueDto: IssueCredentialDto): Promise { diff --git a/apps/vc-api/src/vc-api/credentials/dtos/verify-options.dto.ts b/apps/vc-api/src/vc-api/credentials/dtos/verify-options.dto.ts index 9ca86c49..62a0b668 100644 --- a/apps/vc-api/src/vc-api/credentials/dtos/verify-options.dto.ts +++ b/apps/vc-api/src/vc-api/credentials/dtos/verify-options.dto.ts @@ -17,13 +17,14 @@ import { ProofPurpose } from '@sphereon/pex'; import { IsString, IsOptional } from 'class-validator'; +import { VerifyOptions } from '../types/verify-options'; /** * Parameters for verifying a verifiable credential or a verifiable presentation * https://w3c-ccg.github.io/vc-api/verifier.html#operation/verifyCredential * https://w3c-ccg.github.io/vc-api/verifier.html#operation/verifyPresentation */ -export class VerifyOptionsDto { +export class VerifyOptionsDto implements VerifyOptions { /** * The URI of the verificationMethod used for the proof. Default assertionMethod URI. */ diff --git a/apps/vc-api/src/vc-api/credentials/types/credential-verifier.ts b/apps/vc-api/src/vc-api/credentials/types/credential-verifier.ts new file mode 100644 index 00000000..914b12d0 --- /dev/null +++ b/apps/vc-api/src/vc-api/credentials/types/credential-verifier.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2021, 2022 Energy Web Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { VerifiableCredential } from '../../exchanges/types/verifiable-credential'; +import { VerificationResult } from './verification-result'; +import { VerifyOptions } from './verify-options'; + +export interface CredentialVerifier { + verifyCredential: (vc: VerifiableCredential, options: VerifyOptions) => Promise; +} diff --git a/apps/vc-api/src/vc-api/credentials/types/presentation-verifier.ts b/apps/vc-api/src/vc-api/credentials/types/presentation-verifier.ts new file mode 100644 index 00000000..85c11f6c --- /dev/null +++ b/apps/vc-api/src/vc-api/credentials/types/presentation-verifier.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2021, 2022 Energy Web Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { VerifiablePresentation } from '../../exchanges/types/verifiable-presentation'; +import { VerificationResult } from './verification-result'; +import { VerifyOptions } from './verify-options'; + +export interface PresentationVerifier { + verifyPresentation: (vp: VerifiablePresentation, options: VerifyOptions) => Promise; +} diff --git a/apps/vc-api/src/vc-api/credentials/types/verify-options.ts b/apps/vc-api/src/vc-api/credentials/types/verify-options.ts new file mode 100644 index 00000000..63c6df72 --- /dev/null +++ b/apps/vc-api/src/vc-api/credentials/types/verify-options.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2021, 2022 Energy Web Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * See "VerifyOptions" from + * - https://w3c-ccg.github.io/vc-api/verifier.html#operation/verifyCredential + * - https://w3c-ccg.github.io/vc-api/verifier.html#operation/verifyPresentation + */ +export interface VerifyOptions { + challenge?: string; + proofPurpose?: string; +} diff --git a/apps/vc-api/src/vc-api/exchanges/dtos/exchange-definition.dto.ts b/apps/vc-api/src/vc-api/exchanges/dtos/exchange-definition.dto.ts index d8c38b79..2b7e5658 100644 --- a/apps/vc-api/src/vc-api/exchanges/dtos/exchange-definition.dto.ts +++ b/apps/vc-api/src/vc-api/exchanges/dtos/exchange-definition.dto.ts @@ -22,12 +22,18 @@ import { CallbackConfigurationDto } from './callback-configuration.dto'; import { Type } from 'class-transformer'; /** - * A exchange definition + * In order to keep the VC-API implementation generic (not specific to any use-cases), exchanges are configured rather than coded into the application. + * This configuration is done at runtime via the use of Exchange Definitions. */ export class ExchangeDefinitionDto { @IsString() exchangeId: string; + /** + * The Interact Service Definitions are related to the Interaction Types of the Verifiable Presentation Request (VPR) specification. + * However, as it is a configuration object, it not identical to a VPR interact services. + * It can be see as the input data that the application uses to generate VPR interact services during the exchanges. + */ @ValidateNested() @IsArray() @Type(() => ExchangeInteractServiceDefinitionDto) @@ -38,6 +44,9 @@ export class ExchangeDefinitionDto { @Type(() => VpRequestQueryDto) query: VpRequestQueryDto[]; + /** + * Indicates whether or not + */ @IsBoolean() isOneTime: boolean; diff --git a/apps/vc-api/src/vc-api/exchanges/dtos/exchange-interact-service-definition.dto.ts b/apps/vc-api/src/vc-api/exchanges/dtos/exchange-interact-service-definition.dto.ts index 59b4152f..9ce27749 100644 --- a/apps/vc-api/src/vc-api/exchanges/dtos/exchange-interact-service-definition.dto.ts +++ b/apps/vc-api/src/vc-api/exchanges/dtos/exchange-interact-service-definition.dto.ts @@ -15,13 +15,17 @@ * along with this program. If not, see . */ -import { IsEnum, IsUrl } from 'class-validator'; +import { IsEnum } from 'class-validator'; import { VpRequestInteractServiceType } from '../types/vp-request-interact-service-type'; /** * A definition of an interact service to be used in a workflow */ export class ExchangeInteractServiceDefinitionDto { + /** + * The "type" of the interact service. + * See Verifiable Presentation Request [Interaction Types](https://w3c-ccg.github.io/vp-request-spec/#interaction-types) for background. + */ @IsEnum(VpRequestInteractServiceType) type: VpRequestInteractServiceType; } diff --git a/apps/vc-api/src/vc-api/exchanges/dtos/submission-review.dto.ts b/apps/vc-api/src/vc-api/exchanges/dtos/submission-review.dto.ts new file mode 100644 index 00000000..42c15ceb --- /dev/null +++ b/apps/vc-api/src/vc-api/exchanges/dtos/submission-review.dto.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2021, 2022 Energy Web Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Type } from 'class-transformer'; +import { IsEnum, IsOptional, ValidateNested } from 'class-validator'; +import { VerifiablePresentationDto } from '../../credentials/dtos/verifiable-presentation.dto'; + +export enum ReviewResult { + approved = 'approved', + rejected = 'rejected' +} + +export class SubmissionReviewDto { + /** + * The judgement made by the reviewer + */ + @IsEnum(ReviewResult) + result: ReviewResult; + + /** + * A reviewer may want to include credentials (wrapped in a VP) to the holder + */ + @ValidateNested() + @IsOptional() + @Type(() => VerifiablePresentationDto) + vp?: VerifiablePresentationDto; +} diff --git a/apps/vc-api/src/vc-api/exchanges/entities/presentation-submission.entity.ts b/apps/vc-api/src/vc-api/exchanges/entities/presentation-submission.entity.ts index 22c576bb..53a0e6c1 100644 --- a/apps/vc-api/src/vc-api/exchanges/entities/presentation-submission.entity.ts +++ b/apps/vc-api/src/vc-api/exchanges/entities/presentation-submission.entity.ts @@ -27,13 +27,9 @@ import { VerifiablePresentation } from '../types/verifiable-presentation'; */ @Entity() export class PresentationSubmissionEntity { - constructor(vp: VerifiablePresentation) { + constructor(vp: VerifiablePresentation, verificationResult: VerificationResult) { this.vp = vp; - this.verificationResult = { - checks: [], // TODO: add correct checks (e.g. proof check from service) - warnings: [], - errors: [] - }; + this.verificationResult = verificationResult; } @PrimaryGeneratedColumn() diff --git a/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.spec.ts b/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.spec.ts index 59b0ae02..fe149c52 100644 --- a/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.spec.ts +++ b/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.spec.ts @@ -15,64 +15,74 @@ * along with this program. If not, see . */ -import { ExchangeResponseDto } from '../dtos/exchange-response.dto'; -import { VerifiablePresentation } from '../types/verifiable-presentation'; +import { PresentationReviewStatus } from '../types/presentation-review-status'; +import { SubmissionVerifier } from '../types/submission-verifier'; import { VpRequestInteractServiceType } from '../types/vp-request-interact-service-type'; -import { VpRequestQuery } from '../types/vp-request-query'; -import { VpRequestQueryType } from '../types/vp-request-query-type'; import { TransactionEntity } from './transaction.entity'; import { VpRequestEntity } from './vp-request.entity'; describe('TransactionEntity', () => { const challenge = 'a9511bdb-5577-4d2f-95e3-e819fe5d3c33'; - beforeEach(async () => {}); + const callback_1 = 'https://my-callback.com'; + const configuredCallback = [{ url: callback_1 }]; + const exchangeId = 'my-exchange'; + const transactionId = '9ec5686e-6381-41c4-9286-3c93cdefac53'; - afterEach(async () => { - jest.resetAllMocks(); - }); + const vp = { + '@context': [], + type: [], + verifiableCredential: [], + proof: { + challenge + } + }; - describe('processPresentation', () => { - it('should return result upon successful verification of UnMediatedPresentation', async () => { - const vp = { - '@context': [], - type: [], - verifiableCredential: [], - proof: { - challenge - } - }; + const submissionVerificationResult = { + checks: ['proof'], + warnings: [], + errors: [] + }; + + const mockSubmissionVerifier: SubmissionVerifier = { + verifyVpRequestSubmission: jest.fn().mockResolvedValue(submissionVerificationResult) + }; + + describe('mediatedPresentation interact service type', () => { + describe('constructor', () => { const vpRequest: VpRequestEntity = { challenge, query: [], interact: { service: [ { - type: VpRequestInteractServiceType.unmediatedPresentation, + type: VpRequestInteractServiceType.mediatedPresentation, serviceEndpoint: 'https://endpoint.com' } ] } }; - const callback_1 = 'https://my-callback.com'; - const configuredCallback = [{ url: callback_1 }]; - const exchangeId = 'my-exchange'; - const transactionId = '9ec5686e-6381-41c4-9286-3c93cdefac53'; - const transaction = new TransactionEntity(transactionId, exchangeId, vpRequest, configuredCallback); - const { callback, response } = transaction.processPresentation(vp); - expect(callback).toHaveLength(1); - expect(callback[0].url).toEqual(callback_1); - expect(response.errors).toHaveLength(0); - expect(response.vpRequest).toBeUndefined(); - expect(response.vp).toBeUndefined(); // No issued credentials in the VP - expect(transaction.presentationSubmission.vp).toEqual(vp); + it('should create a transaction with pending review', async () => { + const transaction = new TransactionEntity(transactionId, exchangeId, vpRequest, configuredCallback); + expect(transaction.presentationReview.reviewStatus).toEqual( + PresentationReviewStatus.pendingSubmission + ); + }); + it('should process a presentation submission', async () => { + const transaction = new TransactionEntity(transactionId, exchangeId, vpRequest, configuredCallback); + const { callback, response } = await transaction.processPresentation(vp, mockSubmissionVerifier); + expect(transaction.presentationSubmission.vp).toEqual(vp); + expect(transaction.presentationSubmission.verificationResult).toEqual(submissionVerificationResult); + expect(transaction.presentationReview.reviewStatus).toEqual(PresentationReviewStatus.pendingReview); + expect(transaction.presentationReview.VP).toEqual(undefined); // Issuer hasn't submitted a VP yet + }); }); }); - describe('validate presentation', () => { - function getResponse(query: VpRequestQuery[], vp: VerifiablePresentation): ExchangeResponseDto { + describe('processPresentation', () => { + it('should return result upon successful verification of UnMediatedPresentation', async () => { const vpRequest: VpRequestEntity = { challenge, - query, + query: [], interact: { service: [ { @@ -82,336 +92,15 @@ describe('TransactionEntity', () => { ] } }; - const configuredCallback = []; - const exchangeId = 'my-exchange'; - const transactionId = '9ec5686e-6381-41c4-9286-3c93cdefac53'; const transaction = new TransactionEntity(transactionId, exchangeId, vpRequest, configuredCallback); - const { response } = transaction.processPresentation(vp); - return response; - } - - it('should throw an error when the challenge does not match', async () => { - const vp = { - '@context': [], - type: [], - verifiableCredential: [], - proof: { - challenge: 'a9511bdb-5577-4d2f-95e3-e34efsdfsdfsd' - } - }; - const query = [ - { - type: VpRequestQueryType.didAuth, - credentialQuery: undefined - } - ]; - - const response = getResponse(query, vp); - expect(response.errors.length).toBeGreaterThan(0); - expect(response.errors).toContain('Challenge does not match'); - }); - - describe('didAuth request type', () => { - it('should throw an error when presentation holder is empty', async () => { - const vp = { - '@context': [], - type: [], - verifiableCredential: [], - proof: { - challenge - } - }; - const query = [ - { - type: VpRequestQueryType.didAuth, - credentialQuery: undefined - } - ]; - - const response = getResponse(query, vp); - expect(response.errors.length).toBeGreaterThan(0); - expect(response.errors).toContain('Presentation holder is required for didAuth query'); - }); - }); - - describe('presentationDefinition request type', () => { - it('should throw an error when presentation not meet request requirements', async () => { - const vp = { - '@context': [], - type: [], - verifiableCredential: [ - { - '@context': [ - 'https://www.w3.org/2018/credentials/v1', - { - issuerFields: { - '@id': 'ew:issuerFields', - '@type': 'ew:IssuerFields' - }, - namespace: 'ew:namespace', - role: { - '@id': 'ew:role', - '@type': 'ew:Role' - }, - ew: 'https://energyweb.org/ld-context-2022#', - version: 'ew:version', - EWFRole: 'ew:EWFRole' - } - ], - id: 'urn:uuid:7f94d397-3e70-4a43-945e-1a13069e636f', - type: ['VerifiableCredential', 'EWFRole'], - credentialSubject: { - id: 'did:example:1234567894ad31s12', - issuerFields: [], - role: { - namespace: 'test.iam.ewc', - version: '1' - } - }, - issuer: 'did:example:123456789af312312i', - issuanceDate: '2022-03-18T08:57:32.477Z' - } - ], - proof: { - challenge - } - }; - const query = [ - { - type: VpRequestQueryType.presentationDefinition, - credentialQuery: [ - { - presentationDefinition: { - id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', - input_descriptors: [ - { - id: 'some_id', - name: 'Required credential', - constraints: { - fields: [ - { - path: ['$.credentialSubject.role.namespace'], - filter: { - type: 'string', - const: 'customer.roles.rebeam.apps.eliagroup.iam.ewc' - } - } - ] - } - } - ] - } - } - ] - } - ]; - - const response = getResponse(query, vp as any); - expect(response.errors.length).toBeGreaterThan(0); - expect(response.errors).toContainEqual( - expect.stringContaining('Presentation definition (1) validation failed') - ); - }); - - it('should throw an error when credential is missing issuer fields', async () => { - const vp = { - '@context': [], - type: [], - verifiableCredential: [ - { - '@context': [ - 'https://www.w3.org/2018/credentials/v1', - { - issuerFields: { - '@id': 'ew:issuerFields', - '@type': 'ew:IssuerFields' - }, - namespace: 'ew:namespace', - role: { - '@id': 'ew:role', - '@type': 'ew:Role' - }, - ew: 'https://energyweb.org/ld-context-2022#', - version: 'ew:version', - EWFRole: 'ew:EWFRole' - } - ], - id: 'urn:uuid:7f94d397-3e70-4a43-945e-1a13069e636f', - type: ['VerifiableCredential', 'EWFRole'], - credentialSubject: { - id: 'did:example:1234567894ad31s12', - issuerFields: [{ key: 'foo', value: 'bar' }], - role: { - namespace: 'customer.roles.rebeam.apps.eliagroup.iam.ewc', - version: '1' - } - }, - issuer: 'did:example:123456789af312312i', - issuanceDate: '2022-03-18T08:57:32.477Z' - } - ], - proof: { - challenge - } - }; - const query = [ - { - type: VpRequestQueryType.presentationDefinition, - credentialQuery: [ - { - presentationDefinition: { - id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', - input_descriptors: [ - { - id: 'some_id_3', - name: 'Required credential issuer fields', - constraints: { - fields: [ - { - path: ['$.credentialSubject.issuerFields[*].key'], - filter: { - type: 'string', - const: 'bar' - } - } - ] - } - } - ] - } - } - ] - } - ]; - - const response = getResponse(query, vp as any); - expect(response.errors.length).toBeGreaterThan(0); - expect(response.errors).toContainEqual( - expect.stringContaining('Presentation definition (1) validation failed') - ); - }); - - it('should success when presentation meet request requirements', async () => { - const vp = { - '@context': [], - type: [], - verifiableCredential: [ - { - '@context': [ - 'https://www.w3.org/2018/credentials/v1', - { - issuerFields: { - '@id': 'ew:issuerFields', - '@type': 'ew:IssuerFields' - }, - namespace: 'ew:namespace', - role: { - '@id': 'ew:role', - '@type': 'ew:Role' - }, - ew: 'https://energyweb.org/ld-context-2022#', - version: 'ew:version', - EWFRole: 'ew:EWFRole' - } - ], - id: 'urn:uuid:7f94d397-3e70-4a43-945e-1a13069e636f', - type: ['VerifiableCredential', 'EWFRole'], - credentialSubject: { - id: 'did:example:1234567894ad31s12', - issuerFields: [ - { key: 'foo', value: 'bar' }, - { key: 'bar', value: 'foo' } - ], - role: { - namespace: 'customer.roles.rebeam.apps.eliagroup.iam.ewc', - version: '1' - } - }, - issuer: 'did:example:123456789af312312i', - issuanceDate: '2022-03-18T08:57:32.477Z' - } - ], - proof: { - challenge - } - }; - const query = [ - { - type: VpRequestQueryType.presentationDefinition, - credentialQuery: [ - { - presentationDefinition: { - id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', - input_descriptors: [ - { - id: 'some_id', - name: 'Required credential', - constraints: { - fields: [ - { - path: ['$.credentialSubject.role.namespace'], - filter: { - type: 'string', - const: 'customer.roles.rebeam.apps.eliagroup.iam.ewc' - } - } - ] - } - } - ] - } - }, - { - presentationDefinition: { - id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', - input_descriptors: [ - { - id: 'some_id_2', - name: 'Required credential version', - constraints: { - fields: [ - { - path: ['$.credentialSubject.role.version'], - filter: { - type: 'string', - const: '1' - } - } - ] - } - } - ] - } - }, - { - presentationDefinition: { - id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', - input_descriptors: [ - { - id: 'some_id_3', - name: 'Required credential issuer fields', - constraints: { - fields: [ - { - path: ['$.credentialSubject.issuerFields[*].key'], - filter: { - type: 'string', - const: 'foo' - } - } - ] - } - } - ] - } - } - ] - } - ]; - - const response = getResponse(query, vp as any); - expect(response.errors).toHaveLength(0); - }); + const { callback, response } = await transaction.processPresentation(vp, mockSubmissionVerifier); + expect(callback).toHaveLength(1); + expect(callback[0].url).toEqual(callback_1); + expect(response.errors).toHaveLength(0); + expect(response.vpRequest).toBeUndefined(); + expect(response.vp).toBeUndefined(); // No issued credentials in the VP + expect(transaction.presentationSubmission.verificationResult).toEqual(submissionVerificationResult); + expect(transaction.presentationSubmission.vp).toEqual(vp); }); }); }); diff --git a/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.ts b/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.ts index cf2a709a..6626f4cb 100644 --- a/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.ts +++ b/apps/vc-api/src/vc-api/exchanges/entities/transaction.entity.ts @@ -17,16 +17,15 @@ import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; -import { IPresentation, IPresentationDefinition, PEX } from '@sphereon/pex'; import { ExchangeResponseDto } from '../dtos/exchange-response.dto'; import { PresentationReviewStatus } from '../types/presentation-review-status'; import { VerifiablePresentation } from '../types/verifiable-presentation'; import { VpRequestInteractServiceType } from '../types/vp-request-interact-service-type'; -import { VpRequestQueryType } from '../types/vp-request-query-type'; import { PresentationReviewEntity } from './presentation-review.entity'; import { VpRequestEntity } from './vp-request.entity'; import { CallbackConfiguration } from '../types/callback-configuration'; import { PresentationSubmissionEntity } from './presentation-submission.entity'; +import { SubmissionVerifier } from '../types/submission-verifier'; /** * A TypeOrm entity representing an exchange transaction @@ -49,7 +48,7 @@ export class TransactionEntity { if (vpRequest?.interact?.service[0]?.type === VpRequestInteractServiceType.mediatedPresentation) { this.presentationReview = { presentationReviewId: uuidv4(), - reviewStatus: PresentationReviewStatus.pending + reviewStatus: PresentationReviewStatus.pendingSubmission }; } this.callback = callback; @@ -80,7 +79,7 @@ export class TransactionEntity { presentationReview?: PresentationReviewEntity; /** - * Each transaction is a part of an exchange execution + * Each transaction is a part of an exchange * https://w3c-ccg.github.io/vc-api/#exchange-examples */ @Column('text') @@ -93,80 +92,31 @@ export class TransactionEntity { nullable: true }) @JoinColumn() - presentationSubmission: PresentationSubmissionEntity; + presentationSubmission?: PresentationSubmissionEntity; @Column('simple-json') callback: CallbackConfiguration[]; - private verifyVpRequestTypeDidAuth(presentation: VerifiablePresentation): string[] { - // https://w3c-ccg.github.io/vp-request-spec/#did-authentication-request - const errors: string[] = []; - - if (!presentation.holder) { - errors.push('Presentation holder is required for didAuth query'); - } - - return errors; + public approvePresentationSubmission(issuanceVp: VerifiablePresentation): void { + this.presentationReview.reviewStatus = PresentationReviewStatus.approved; + this.presentationReview.VP = issuanceVp; } - private verifyVpRequestTypePresentationDefinition( - presentation: VerifiablePresentation, - credentialQuery: Array<{ presentationDefinition: IPresentationDefinition }> - ): string[] { - // https://identity.foundation/presentation-exchange/#presentation-definition - const errors: string[] = []; - const pex: PEX = new PEX(); - - credentialQuery.forEach(({ presentationDefinition }, index) => { - const { errors: partialErrors } = pex.evaluatePresentation( - presentationDefinition, - presentation as IPresentation - ); - - errors.push( - ...partialErrors.map( - (error) => - `Presentation definition (${index + 1}) validation failed, reason: ${error.message || 'Unknown'}` - ) - ); - }); - - return errors; - } - - private validatePresentation(presentation: VerifiablePresentation): string[] { - const commonErrors = []; - // Common checking - if (presentation.proof.challenge !== this.vpRequest.challenge) { - commonErrors.push('Challenge does not match'); - } - - // Type specific checking - const partialErrors = this.vpRequest.query.flatMap((vpQuery) => { - switch (vpQuery.type) { - case VpRequestQueryType.didAuth: - return this.verifyVpRequestTypeDidAuth(presentation); - case VpRequestQueryType.presentationDefinition: - return this.verifyVpRequestTypePresentationDefinition(presentation, vpQuery.credentialQuery); - default: - return ['Unknown request query type']; - } - }); - - return [...partialErrors, ...commonErrors]; + public rejectPresentationSubmission(): void { + this.presentationReview.reviewStatus = PresentationReviewStatus.rejected; } /** * Process a presentation submission. - * Check the correctness of the presentation against the VP Request Credential Queries. - * Does NOT check signatures. * @param presentation + * @param verifier */ - public processPresentation(presentation: VerifiablePresentation): { - response: ExchangeResponseDto; - callback: CallbackConfiguration[]; - } { - const errors = this.validatePresentation(presentation); + public async processPresentation( + presentation: VerifiablePresentation, + verifier: SubmissionVerifier + ): Promise<{ response: ExchangeResponseDto; callback: CallbackConfiguration[] }> { + const verificationResult = await verifier.verifyVpRequestSubmission(presentation, this.vpRequest); + const errors = verificationResult.errors; if (errors.length > 0) { return { @@ -179,44 +129,39 @@ export class TransactionEntity { const service = this.vpRequest.interact.service[0]; // TODO: Not sure how to handle multiple interaction services if (service.type == VpRequestInteractServiceType.mediatedPresentation) { - if (this.presentationReview.reviewStatus == PresentationReviewStatus.pending) { - // In this case, this is the first submission to the exchange + if (this.presentationReview.reviewStatus == PresentationReviewStatus.pendingSubmission) { + // TODO: should we allow overwrite of a previous submitted submission? if (!this.presentationSubmission) { - this.presentationSubmission = new PresentationSubmissionEntity(presentation); + this.presentationSubmission = new PresentationSubmissionEntity(presentation, verificationResult); } + this.presentationReview.reviewStatus = PresentationReviewStatus.pendingReview; return { response: { errors: [], vpRequest: { challenge: uuidv4(), - query: [{ type: VpRequestQueryType.didAuth, credentialQuery: [] }], - interact: this.vpRequest.interact // Just ask the same endpoint again + query: [], + interact: this.vpRequest.interact // Holder should query the same endpoint again to check if it has been reviewed } }, callback: [] }; } - if (this.presentationReview.reviewStatus == PresentationReviewStatus.approved) { - if (this.presentationReview.VP) { - return { - response: { - errors: [], - vp: this.presentationReview.VP - }, - callback: [] - }; - } else { - return { - response: { - errors: [] - }, - callback: [] - }; - } + if ( + this.presentationReview.reviewStatus == PresentationReviewStatus.approved || + this.presentationReview.reviewStatus == PresentationReviewStatus.rejected + ) { + return { + response: { + errors: [], + vp: this.presentationReview?.VP + }, + callback: [] + }; } } if (service.type == VpRequestInteractServiceType.unmediatedPresentation) { - this.presentationSubmission = new PresentationSubmissionEntity(presentation); + this.presentationSubmission = new PresentationSubmissionEntity(presentation, verificationResult); return { response: { errors: [] diff --git a/apps/vc-api/src/vc-api/exchanges/exchange.service.spec.ts b/apps/vc-api/src/vc-api/exchanges/exchange.service.spec.ts index 8fb031ae..7d1ab78e 100644 --- a/apps/vc-api/src/vc-api/exchanges/exchange.service.spec.ts +++ b/apps/vc-api/src/vc-api/exchanges/exchange.service.spec.ts @@ -16,48 +16,77 @@ */ import { Test, TestingModule } from '@nestjs/testing'; -import { HttpModule } from '@nestjs/axios'; -import { getRepositoryToken, TypeOrmModule } from '@nestjs/typeorm'; -import { TypeOrmSQLiteModule } from '../../in-memory-db'; -import { Repository } from 'typeorm'; -import { CredentialsService } from '../credentials/credentials.service'; +import { HttpService } from '@nestjs/axios'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { ExchangeEntity } from './entities/exchange.entity'; -import { VpRequestEntity } from './entities/vp-request.entity'; import { ExchangeService } from './exchange.service'; import { ExchangeDefinitionDto } from './dtos/exchange-definition.dto'; import { VpRequestInteractServiceType } from './types/vp-request-interact-service-type'; import { TransactionEntity } from './entities/transaction.entity'; import { VpRequestQueryType } from './types/vp-request-query-type'; -import { PresentationReviewEntity } from './entities/presentation-review.entity'; import { ConfigService } from '@nestjs/config'; -import { PresentationSubmissionEntity } from './entities/presentation-submission.entity'; +import { ReviewResult, SubmissionReviewDto } from './dtos/submission-review.dto'; +import { VpSubmissionVerifierService } from './vp-submission-verifier.service'; +import { SubmissionVerifier } from './types/submission-verifier'; const baseUrl = 'https://test-exchange.com'; +const exchangeId = 'test-exchange'; +const vp = { + '@context': ['https://www.w3.org/2018/credentials/v1', 'https://www.w3.org/2018/credentials/examples/v1'], + type: ['VerifiablePresentation'], + verifiableCredential: [], + holder: 'did:key:z6MksBH4LMy8SoYFUNjDXtQ2Rq4dHnyuemowxXqzLpuB6nvc', + proof: {} +}; + +const submissionVerificationResult = { + checks: ['proof'], + warnings: [], + errors: [] +}; + +const mockSubmissionVerifier: SubmissionVerifier = { + verifyVpRequestSubmission: jest.fn().mockResolvedValue(submissionVerificationResult) +}; describe('ExchangeService', () => { let service: ExchangeService; - let credentialsService: CredentialsService; - let exchangeRepository: Repository; - let vpRequestRepository: Repository; + + // https://stackoverflow.com/a/55366343 + let transaction: TransactionEntity; + const transactionRepositoryMockFactory = jest.fn(() => ({ + findOne: jest.fn(() => transaction), + save: jest.fn((entity) => { + transaction = entity; + }) + })); + let exchange: ExchangeEntity; + const repositoryMockFactory = jest.fn(() => ({ + findOne: jest.fn(() => exchange), + save: jest.fn((entity) => { + exchange = entity; + }) + })); + + const mockHttpService = { + post: jest.fn(() => { + return { + subscribe: jest.fn() + }; + }) + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [ - TypeOrmSQLiteModule(), - TypeOrmModule.forFeature([ - VpRequestEntity, - ExchangeEntity, - TransactionEntity, - PresentationReviewEntity, - PresentationSubmissionEntity - ]), - HttpModule - ], providers: [ ExchangeService, { - provide: CredentialsService, - useValue: {} + provide: VpSubmissionVerifierService, + useValue: mockSubmissionVerifier + }, + { + provide: HttpService, + useValue: mockHttpService }, { provide: ConfigService, @@ -66,18 +95,13 @@ describe('ExchangeService', () => { return baseUrl; }) } - } + }, + { provide: getRepositoryToken(TransactionEntity), useFactory: transactionRepositoryMockFactory }, + { provide: getRepositoryToken(ExchangeEntity), useFactory: repositoryMockFactory } ] }).compile(); - credentialsService = module.get(CredentialsService); service = module.get(ExchangeService); - exchangeRepository = module.get>(getRepositoryToken(ExchangeEntity)); - vpRequestRepository = module.get>(getRepositoryToken(VpRequestEntity)); - }); - - afterEach(async () => { - jest.resetAllMocks(); }); it('should be defined', () => { @@ -140,33 +164,58 @@ describe('ExchangeService', () => { }); describe('continueExchange', () => { - // TODO: Write after https://github.com/energywebfoundation/ssi/pull/46 as this will make it easier to test - it.skip('should send transaction dto if callback is configured', async () => { - const transactionId = 'test-tx'; - // const exchangeDef: ExchangeDefinitionDto = { - // exchangeId: exchangeId, - // interactServices: [ - // { - // type: VpRequestInteractServiceType.unmediatedPresentation - // } - // ], - // query: [], - // isOneTime: false, - // callback: [] - // }; - const vp = { - '@context': [ - 'https://www.w3.org/2018/credentials/v1', - 'https://www.w3.org/2018/credentials/examples/v1' + it('should send transaction dto if callback is configured', async () => { + const exchangeDef: ExchangeDefinitionDto = { + exchangeId: exchangeId, + interactServices: [ + { + type: VpRequestInteractServiceType.unmediatedPresentation + } ], - type: ['VerifiablePresentation'], - verifiableCredential: [], - holder: 'did:key:z6MksBH4LMy8SoYFUNjDXtQ2Rq4dHnyuemowxXqzLpuB6nvc', - proof: {} + query: [], + isOneTime: false, + callback: [ + { + url: 'http://example.com' + } + ] }; - const exchangeResponse = await service.continueExchange(vp, transactionId); - expect(exchangeResponse.vpRequest.interact.service).toHaveLength(1); - expect(exchangeResponse.vpRequest.interact.service[0].serviceEndpoint).toContain(baseUrl); + await service.createExchange(exchangeDef); + const exchangeResponse = await service.startExchange(exchangeId); + const transactionId = exchangeResponse.vpRequest.interact.service[0].serviceEndpoint.split('/').pop(); + await service.continueExchange(vp, transactionId); + expect(mockHttpService.post.mock.calls).toHaveLength(1); }); }); + + describe('addReview', () => { + it.each([[ReviewResult.approved], [ReviewResult.rejected]])( + 'should set %s result', + async (reviewResult: ReviewResult) => { + const exchangeDef: ExchangeDefinitionDto = { + exchangeId: exchangeId, + interactServices: [ + { + type: VpRequestInteractServiceType.mediatedPresentation + } + ], + query: [], + isOneTime: true, + callback: [] + }; + await service.createExchange(exchangeDef); + const exchangeResponse = await service.startExchange(exchangeId); + const transactionId = exchangeResponse.vpRequest.interact.service[0].serviceEndpoint.split('/').pop(); + await service.continueExchange(vp, transactionId); + + const reviewDto: SubmissionReviewDto = { + result: reviewResult + }; + const result = await service.addReview(transactionId, reviewDto); + expect(transaction.presentationReview.reviewStatus).toEqual(reviewResult); + expect(transaction.presentationReview.VP).toBeUndefined(); + expect(result.errors).toHaveLength(0); + } + ); + }); }); diff --git a/apps/vc-api/src/vc-api/exchanges/exchange.service.ts b/apps/vc-api/src/vc-api/exchanges/exchange.service.ts index d0770be6..131d2fe0 100644 --- a/apps/vc-api/src/vc-api/exchanges/exchange.service.ts +++ b/apps/vc-api/src/vc-api/exchanges/exchange.service.ts @@ -18,9 +18,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { InjectRepository } from '@nestjs/typeorm'; -import { ProofPurpose } from '@sphereon/pex'; import { Repository } from 'typeorm'; -import { CredentialsService } from '../credentials/credentials.service'; import { VerifiablePresentationDto } from '../credentials/dtos/verifiable-presentation.dto'; import { ExchangeEntity } from './entities/exchange.entity'; import { ExchangeResponseDto } from './dtos/exchange-response.dto'; @@ -28,13 +26,14 @@ import { VpRequestDto } from './dtos/vp-request.dto'; import { ExchangeDefinitionDto } from './dtos/exchange-definition.dto'; import { TransactionEntity } from './entities/transaction.entity'; import { ConfigService } from '@nestjs/config'; -import { VerifyOptionsDto } from '../credentials/dtos/verify-options.dto'; import { TransactionDto } from './dtos/transaction.dto'; +import { ReviewResult, SubmissionReviewDto } from './dtos/submission-review.dto'; +import { VpSubmissionVerifierService } from './vp-submission-verifier.service'; @Injectable() export class ExchangeService { constructor( - private vcApiService: CredentialsService, + private vpSubmissionVerifierService: VpSubmissionVerifierService, @InjectRepository(TransactionEntity) private transactionRepository: Repository, @InjectRepository(ExchangeEntity) @@ -96,19 +95,10 @@ export class ExchangeService { }; } const transaction = transactionQuery.transaction; - const vpRequest = transaction.vpRequest; - const verifyOptions: VerifyOptionsDto = { - challenge: vpRequest.challenge, - proofPurpose: ProofPurpose.authentication, - verificationMethod: verifiablePresentation.proof.verificationMethod as string //TODO: fix types here - }; - const result = await this.vcApiService.verifyPresentation(verifiablePresentation, verifyOptions); - if (!result.checks.includes('proof') || result.errors.length > 0) { - return { - errors: [`${transactionId}: verification of presentation proof not successful`, ...result.errors] - }; - } - const { response, callback } = transaction.processPresentation(verifiablePresentation); + const { response, callback } = await transaction.processPresentation( + verifiablePresentation, + this.vpSubmissionVerifierService + ); await this.transactionRepository.save(transaction); callback?.forEach((callback) => { // TODO: check if toDto is working. Seems be keeping it as Entity type. @@ -133,7 +123,7 @@ export class ExchangeService { transactionId: string ): Promise<{ errors: string[]; transaction?: TransactionEntity }> { const transaction = await this.transactionRepository.findOne(transactionId, { - relations: ['vpRequest', 'presentationReview'] + relations: ['vpRequest', 'presentationReview', 'presentationSubmission'] }); if (!transaction) { return { errors: [`${transactionId}: no transaction found for this transaction id`] }; @@ -149,4 +139,26 @@ export class ExchangeService { } return { errors: [], transaction: transaction }; } + + public async addReview(transactionId: string, reviewDto: SubmissionReviewDto) { + const transactionQuery = await this.getExchangeTransaction(transactionId); + if (transactionQuery.errors.length > 0 || !transactionQuery.transaction) { + return { + errors: transactionQuery.errors + }; + } + const transaction = transactionQuery.transaction; + switch (reviewDto.result) { + case ReviewResult.approved: + transaction.approvePresentationSubmission(reviewDto.vp); + break; + case ReviewResult.rejected: + transaction.rejectPresentationSubmission(); + break; + } + await this.transactionRepository.save(transaction); + return { + errors: [] + }; + } } diff --git a/apps/vc-api/src/vc-api/exchanges/types/presentation-review-status.ts b/apps/vc-api/src/vc-api/exchanges/types/presentation-review-status.ts index 284fa723..811e5e29 100644 --- a/apps/vc-api/src/vc-api/exchanges/types/presentation-review-status.ts +++ b/apps/vc-api/src/vc-api/exchanges/types/presentation-review-status.ts @@ -24,7 +24,8 @@ * Maybe similar to Aries Issue-Credential protocol {@link https://github.com/hyperledger/aries-rfcs/blob/main/features/0453-issue-credential-v2/README.md} */ export enum PresentationReviewStatus { - pending = 'pending', + pendingSubmission = 'pending_submission', + pendingReview = 'pending_review', approved = 'approved', rejected = 'rejected' } diff --git a/apps/vc-api/src/vc-api/exchanges/types/submission-verifier.ts b/apps/vc-api/src/vc-api/exchanges/types/submission-verifier.ts new file mode 100644 index 00000000..e21b52b5 --- /dev/null +++ b/apps/vc-api/src/vc-api/exchanges/types/submission-verifier.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2021, 2022 Energy Web Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { VerificationResult } from '../../credentials/types/verification-result'; +import { VpRequestEntity } from '../entities/vp-request.entity'; +import { VerifiablePresentation } from './verifiable-presentation'; + +/** + * Intended to represent a verifier of a VP Request Submission. + * TODO: Maybe shouldn't only be for VPR verification but allow for more generic types. + */ +export interface SubmissionVerifier { + verifyVpRequestSubmission: ( + vp: VerifiablePresentation, + vpRequest: VpRequestEntity + ) => Promise; +} diff --git a/apps/vc-api/src/vc-api/exchanges/types/vp-request-interact-service-type.ts b/apps/vc-api/src/vc-api/exchanges/types/vp-request-interact-service-type.ts index 8d55f392..87e2de23 100644 --- a/apps/vc-api/src/vc-api/exchanges/types/vp-request-interact-service-type.ts +++ b/apps/vc-api/src/vc-api/exchanges/types/vp-request-interact-service-type.ts @@ -16,7 +16,7 @@ */ /** - * These should be the interact service types that are both + * The interact service types that are both * - supported by the wallet app * - listed in the VP Request spec https://w3c-ccg.github.io/vp-request-spec/#interaction-types */ @@ -27,7 +27,9 @@ export enum VpRequestInteractServiceType { unmediatedPresentation = 'UnmediatedHttpPresentationService2021', /** - * https://w3c-ccg.github.io/vp-request-spec/#mediated-presentation + * See https://w3c-ccg.github.io/vp-request-spec/#mediated-presentation for background. + * Note that the specification (as of v0.1, 25-04-2022), refers to "MediatedBrowserPresentationService2021". + * This [GitHub issue](https://github.com/w3c-ccg/vp-request-spec/issues/17) is open to discuss the usage of Mediated Presentations Services */ mediatedPresentation = 'MediatedHttpPresentationService2021' } diff --git a/apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.spec.ts b/apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.spec.ts new file mode 100644 index 00000000..2b055b07 --- /dev/null +++ b/apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.spec.ts @@ -0,0 +1,398 @@ +/** + * Copyright 2021, 2022 Energy Web Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { CredentialsService } from '../credentials/credentials.service'; +import { VerificationResult } from '../credentials/types/verification-result'; +import { VpRequestEntity } from './entities/vp-request.entity'; +import { VerifiablePresentation } from './types/verifiable-presentation'; +import { VpRequestQuery } from './types/vp-request-query'; +import { VpRequestQueryType } from './types/vp-request-query-type'; +import { VpSubmissionVerifierService } from './vp-submission-verifier.service'; + +const presentationVerificationResult = { + checks: ['proof'], + warnings: [], + errors: [] +}; + +const mockCredentialService = { + verifyPresentation: jest.fn().mockResolvedValue(presentationVerificationResult) +}; + +describe('VpSubmissionVerifierService', () => { + const challenge = 'a9511bdb-5577-4d2f-95e3-e819fe5d3c33'; + let service: VpSubmissionVerifierService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VpSubmissionVerifierService, + { + provide: CredentialsService, + useValue: mockCredentialService + } + ] + }).compile(); + + service = module.get(VpSubmissionVerifierService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('verifyVpRequestSubmission', () => { + async function getVerificationResult( + query: VpRequestQuery[], + vp: VerifiablePresentation + ): Promise { + const vpRequest: VpRequestEntity = { + challenge, + query, + interact: { + service: [] + } + }; + return await service.verifyVpRequestSubmission(vp, vpRequest); + } + + it('should throw an error when the challenge does not match', async () => { + const vp = { + '@context': [], + type: [], + verifiableCredential: [], + proof: { + challenge: 'a9511bdb-5577-4d2f-95e3-e34efsdfsdfsd' + } + }; + const query = [ + { + type: VpRequestQueryType.didAuth, + credentialQuery: undefined + } + ]; + + const response = await getVerificationResult(query, vp); + expect(response.errors.length).toBeGreaterThan(0); + expect(response.errors).toContain('Challenge does not match'); + }); + + describe('didAuth request type', () => { + it('should throw an error when presentation holder is empty', async () => { + const vp = { + '@context': [], + type: [], + verifiableCredential: [], + proof: { + challenge + } + }; + const query = [ + { + type: VpRequestQueryType.didAuth, + credentialQuery: undefined + } + ]; + + const response = await getVerificationResult(query, vp); + expect(response.errors.length).toBeGreaterThan(0); + expect(response.errors).toContain('Presentation holder is required for didAuth query'); + }); + }); + + describe('presentationDefinition request type', () => { + it('should throw an error when presentation not meet request requirements', async () => { + const vp = { + '@context': [], + type: [], + verifiableCredential: [ + { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + { + issuerFields: { + '@id': 'ew:issuerFields', + '@type': 'ew:IssuerFields' + }, + namespace: 'ew:namespace', + role: { + '@id': 'ew:role', + '@type': 'ew:Role' + }, + ew: 'https://energyweb.org/ld-context-2022#', + version: 'ew:version', + EWFRole: 'ew:EWFRole' + } + ], + id: 'urn:uuid:7f94d397-3e70-4a43-945e-1a13069e636f', + type: ['VerifiableCredential', 'EWFRole'], + credentialSubject: { + id: 'did:example:1234567894ad31s12', + issuerFields: [], + role: { + namespace: 'test.iam.ewc', + version: '1' + } + }, + issuer: 'did:example:123456789af312312i', + issuanceDate: '2022-03-18T08:57:32.477Z' + } + ], + proof: { + challenge + } + }; + const query = [ + { + type: VpRequestQueryType.presentationDefinition, + credentialQuery: [ + { + presentationDefinition: { + id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', + input_descriptors: [ + { + id: 'some_id', + name: 'Required credential', + constraints: { + fields: [ + { + path: ['$.credentialSubject.role.namespace'], + filter: { + type: 'string', + const: 'customer.roles.rebeam.apps.eliagroup.iam.ewc' + } + } + ] + } + } + ] + } + } + ] + } + ]; + + const response = await getVerificationResult(query, vp as any); + expect(response.errors.length).toBeGreaterThan(0); + expect(response.errors).toContainEqual( + expect.stringContaining('Presentation definition (1) validation failed') + ); + }); + + it('should throw an error when credential is missing issuer fields', async () => { + const vp = { + '@context': [], + type: [], + verifiableCredential: [ + { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + { + issuerFields: { + '@id': 'ew:issuerFields', + '@type': 'ew:IssuerFields' + }, + namespace: 'ew:namespace', + role: { + '@id': 'ew:role', + '@type': 'ew:Role' + }, + ew: 'https://energyweb.org/ld-context-2022#', + version: 'ew:version', + EWFRole: 'ew:EWFRole' + } + ], + id: 'urn:uuid:7f94d397-3e70-4a43-945e-1a13069e636f', + type: ['VerifiableCredential', 'EWFRole'], + credentialSubject: { + id: 'did:example:1234567894ad31s12', + issuerFields: [{ key: 'foo', value: 'bar' }], + role: { + namespace: 'customer.roles.rebeam.apps.eliagroup.iam.ewc', + version: '1' + } + }, + issuer: 'did:example:123456789af312312i', + issuanceDate: '2022-03-18T08:57:32.477Z' + } + ], + proof: { + challenge + } + }; + const query = [ + { + type: VpRequestQueryType.presentationDefinition, + credentialQuery: [ + { + presentationDefinition: { + id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', + input_descriptors: [ + { + id: 'some_id_3', + name: 'Required credential issuer fields', + constraints: { + fields: [ + { + path: ['$.credentialSubject.issuerFields[*].key'], + filter: { + type: 'string', + const: 'bar' + } + } + ] + } + } + ] + } + } + ] + } + ]; + + const response = await getVerificationResult(query, vp as any); + expect(response.errors.length).toBeGreaterThan(0); + expect(response.errors).toContainEqual( + expect.stringContaining('Presentation definition (1) validation failed') + ); + }); + + it('should success when presentation meet request requirements', async () => { + const vp = { + '@context': [], + type: [], + verifiableCredential: [ + { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + { + issuerFields: { + '@id': 'ew:issuerFields', + '@type': 'ew:IssuerFields' + }, + namespace: 'ew:namespace', + role: { + '@id': 'ew:role', + '@type': 'ew:Role' + }, + ew: 'https://energyweb.org/ld-context-2022#', + version: 'ew:version', + EWFRole: 'ew:EWFRole' + } + ], + id: 'urn:uuid:7f94d397-3e70-4a43-945e-1a13069e636f', + type: ['VerifiableCredential', 'EWFRole'], + credentialSubject: { + id: 'did:example:1234567894ad31s12', + issuerFields: [ + { key: 'foo', value: 'bar' }, + { key: 'bar', value: 'foo' } + ], + role: { + namespace: 'customer.roles.rebeam.apps.eliagroup.iam.ewc', + version: '1' + } + }, + issuer: 'did:example:123456789af312312i', + issuanceDate: '2022-03-18T08:57:32.477Z' + } + ], + proof: { + challenge + } + }; + const query = [ + { + type: VpRequestQueryType.presentationDefinition, + credentialQuery: [ + { + presentationDefinition: { + id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', + input_descriptors: [ + { + id: 'some_id', + name: 'Required credential', + constraints: { + fields: [ + { + path: ['$.credentialSubject.role.namespace'], + filter: { + type: 'string', + const: 'customer.roles.rebeam.apps.eliagroup.iam.ewc' + } + } + ] + } + } + ] + } + }, + { + presentationDefinition: { + id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', + input_descriptors: [ + { + id: 'some_id_2', + name: 'Required credential version', + constraints: { + fields: [ + { + path: ['$.credentialSubject.role.version'], + filter: { + type: 'string', + const: '1' + } + } + ] + } + } + ] + } + }, + { + presentationDefinition: { + id: '286bc1e0-f1bd-488a-a873-8d71be3c690e', + input_descriptors: [ + { + id: 'some_id_3', + name: 'Required credential issuer fields', + constraints: { + fields: [ + { + path: ['$.credentialSubject.issuerFields[*].key'], + filter: { + type: 'string', + const: 'foo' + } + } + ] + } + } + ] + } + } + ] + } + ]; + + const response = await getVerificationResult(query, vp as any); + expect(response.errors).toHaveLength(0); + }); + }); + }); +}); diff --git a/apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.ts b/apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.ts new file mode 100644 index 00000000..60a87def --- /dev/null +++ b/apps/vc-api/src/vc-api/exchanges/vp-submission-verifier.service.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2021, 2022 Energy Web Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Injectable } from '@nestjs/common'; +import { ProofPurpose, IPresentationDefinition, PEX, IPresentation } from '@sphereon/pex'; +import { CredentialsService } from '../credentials/credentials.service'; +import { VerificationResult } from '../credentials/types/verification-result'; +import { VpRequestEntity } from './entities/vp-request.entity'; +import { SubmissionVerifier } from './types/submission-verifier'; +import { VerifiablePresentation } from './types/verifiable-presentation'; +import { VpRequestQueryType } from './types/vp-request-query-type'; + +/** + * Inspired by https://github.com/gataca-io/vui-core/blob/6c599bdf7086f9a702e6657089fa343ae62a417a/service/validatorServiceDIFPE.go + * Verifies: + * - Signatures/Proofs + * - Conformance with VpRequest (e.g. credential queries) + * - Authority of Issuer: TODO use this package https://www.npmjs.com/package/@energyweb/vc-verification + */ +@Injectable() +export class VpSubmissionVerifierService implements SubmissionVerifier { + constructor(private credentialsService: CredentialsService) {} + + public async verifyVpRequestSubmission( + vp: VerifiablePresentation, + vpRequest: VpRequestEntity + ): Promise { + const proofVerifiactionResult = await this.verifyPresentationProof(vp, vpRequest.challenge); + const vpRequestValidationErrors = this.validatePresentationAgainstVpRequest(vp, vpRequest); + return { + errors: [...proofVerifiactionResult.errors, ...vpRequestValidationErrors], + checks: [...proofVerifiactionResult.checks], + warnings: [] + }; + } + + private async verifyPresentationProof( + vp: VerifiablePresentation, + challenge: string + ): Promise { + const verifyOptions = { + challenge, + proofPurpose: ProofPurpose.authentication, + verificationMethod: vp.proof.verificationMethod as string //TODO: fix types here + }; + const result = await this.credentialsService.verifyPresentation(vp, verifyOptions); + if (!result.checks.includes('proof') || result.errors.length > 0) { + return { + errors: [`verification of presentation proof not successful`, ...result.errors], + checks: [], + warnings: [] + }; + } + return result; + } + + private validatePresentationAgainstVpRequest( + presentation: VerifiablePresentation, + vpRequest: VpRequestEntity + ): string[] { + const commonErrors = []; + // Common checking + if (presentation.proof.challenge !== vpRequest.challenge) { + commonErrors.push('Challenge does not match'); + } + + // Type specific checking + const partialErrors = vpRequest.query.flatMap((vpQuery) => { + switch (vpQuery.type) { + case VpRequestQueryType.didAuth: + return this.verifyVpRequestTypeDidAuth(presentation); + case VpRequestQueryType.presentationDefinition: + return this.verifyVpRequestTypePresentationDefinition(presentation, vpQuery.credentialQuery); + default: + return ['Unknown request query type']; + } + }); + + return [...partialErrors, ...commonErrors]; + } + + private verifyVpRequestTypeDidAuth(presentation: VerifiablePresentation): string[] { + // https://w3c-ccg.github.io/vp-request-spec/#did-authentication-request + const errors: string[] = []; + + if (!presentation.holder) { + errors.push('Presentation holder is required for didAuth query'); + } + + return errors; + } + + private verifyVpRequestTypePresentationDefinition( + presentation: VerifiablePresentation, + credentialQuery: Array<{ presentationDefinition: IPresentationDefinition }> + ): string[] { + // https://identity.foundation/presentation-exchange/#presentation-definition + const errors: string[] = []; + const pex: PEX = new PEX(); + + credentialQuery.forEach(({ presentationDefinition }, index) => { + const { errors: partialErrors } = pex.evaluatePresentation( + presentationDefinition, + presentation as IPresentation + ); + + errors.push( + ...partialErrors.map( + (error) => + `Presentation definition (${index + 1}) validation failed, reason: ${error.message || 'Unknown'}` + ) + ); + }); + + return errors; + } +} diff --git a/apps/vc-api/src/vc-api/vc-api.controller.ts b/apps/vc-api/src/vc-api/vc-api.controller.ts index c279d8db..145e3d45 100644 --- a/apps/vc-api/src/vc-api/vc-api.controller.ts +++ b/apps/vc-api/src/vc-api/vc-api.controller.ts @@ -28,6 +28,7 @@ import { ExchangeDefinitionDto } from './exchanges/dtos/exchange-definition.dto' import { ProvePresentationDto } from './credentials/dtos/prove-presentation.dto'; import { GetTransactionDto } from './exchanges/dtos/get-transaction.dto'; import { TransactionDto } from './exchanges/dtos/transaction.dto'; +import { SubmissionReviewDto } from './exchanges/dtos/submission-review.dto'; /** * VcApi API conforms to W3C vc-api @@ -145,4 +146,23 @@ export class VcApiController { }; return response; } + + /** + * Update a transaction review + * A NON-STANDARD endpoint currently. + * Similar to https://github.com/energywebfoundation/ssi-hub/blob/8b860e7cdae4e1b1aa75afeab8b9df7ab26befbb/src/modules/claim/claim.controller.ts#L80 + * + * TODO: Perhaps reviews are not separate from transactions? Perhaps one updates the transaction directly + * TODO: Needs to have special authorization + * @param exchangeId id of the exchange + * @param transactionId id of the exchange transaction + * @returns + */ + @Post('/exchanges/:exchangeId/:transactionId/review') + async addSubmissionReview( + @Param('transactionId') transactionId: string, + @Body() submissionReview: SubmissionReviewDto + ) { + return await this.exchangeService.addReview(transactionId, submissionReview); + } } diff --git a/apps/vc-api/src/vc-api/vc-api.module.ts b/apps/vc-api/src/vc-api/vc-api.module.ts index 0a1ec805..0e92bd21 100644 --- a/apps/vc-api/src/vc-api/vc-api.module.ts +++ b/apps/vc-api/src/vc-api/vc-api.module.ts @@ -29,6 +29,7 @@ import { VpRequestEntity } from './exchanges/entities/vp-request.entity'; import { TransactionEntity } from './exchanges/entities/transaction.entity'; import { PresentationReviewEntity } from './exchanges/entities/presentation-review.entity'; import { PresentationSubmissionEntity } from './exchanges/entities/presentation-submission.entity'; +import { VpSubmissionVerifierService } from './exchanges/vp-submission-verifier.service'; @Module({ imports: [ @@ -45,7 +46,7 @@ import { PresentationSubmissionEntity } from './exchanges/entities/presentation- HttpModule ], controllers: [VcApiController], - providers: [CredentialsService, ExchangeService], + providers: [CredentialsService, ExchangeService, VpSubmissionVerifierService], exports: [CredentialsService, ExchangeService] }) export class VcApiModule {} diff --git a/apps/vc-api/test/vc-api/exchanges/resident-card/resident-card.e2e-suite.ts b/apps/vc-api/test/vc-api/exchanges/resident-card/resident-card.e2e-suite.ts index 00eb8b23..e6e179a6 100644 --- a/apps/vc-api/test/vc-api/exchanges/resident-card/resident-card.e2e-suite.ts +++ b/apps/vc-api/test/vc-api/exchanges/resident-card/resident-card.e2e-suite.ts @@ -22,10 +22,14 @@ import { ResidentCardIssuance } from './resident-card-issuance.exchange'; import { ProofPurpose } from '@sphereon/pex'; import { ResidentCardPresentation } from './resident-card-presentation.exchange'; import { app, getContinuationEndpoint, vcApiBaseUrl, walletClient } from '../../../app.e2e-spec'; +import { + ReviewResult, + SubmissionReviewDto +} from '../../../../src/vc-api/exchanges/dtos/submission-review.dto'; export const residentCardExchangeSuite = () => { it('should support Resident Card issuance and presentation', async () => { - // Configure credential issuance exchange + // As issuer, configure credential issuance exchange // POST /exchanges const exchange = new ResidentCardIssuance(); await request(app.getHttpServer()) @@ -57,16 +61,39 @@ export const residentCardExchangeSuite = () => { expect(didAuthVp).toBeDefined(); // As holder, continue exchange by submitting did auth presention - await walletClient.continueExchange(issuanceExchangeContinuationEndpoint, didAuthVp, true); + const firstContinuationResponse = await walletClient.continueExchange( + issuanceExchangeContinuationEndpoint, + didAuthVp, + true + ); + const submissionCheckEndpoint = firstContinuationResponse.vpRequest.interact.service[0].serviceEndpoint; // As the issuer, get the transaction + // TODO TODO TODO!!! How does the issuer know the transactionId? const urlComponents = issuanceExchangeContinuationEndpoint.split('/'); const transactionId = urlComponents.pop(); const transaction = await walletClient.getExchangeTransaction(exchange.getExchangeId(), transactionId); - // TODO: have the issuer get the review and approve. For now, just issue directly + // As the issuer, check the result of the transaction verification + expect(transaction.presentationSubmission.verificationResult.checks).toContain('proof'); + expect(transaction.presentationSubmission.verificationResult.errors).toHaveLength(0); + + // As the issuer, create a presentation to provide the credential to the holder const issueResult = await exchange.issueCredential(didAuthVp, walletClient); - const issuedVc = issueResult.vp.verifiableCredential[0]; + const issuedVP = issueResult.vp; // VP used to wrapped issued credentials + const submissionReview: SubmissionReviewDto = { + result: ReviewResult.approved, + vp: issuedVP + }; + await walletClient.addSubmissionReview(exchange.getExchangeId(), transactionId, submissionReview); + + // As the holder, check for a reviewed submission + const secondContinuationResponse = await walletClient.continueExchange( + issuanceExchangeContinuationEndpoint, + didAuthVp, + false + ); + const issuedVc = secondContinuationResponse.vp.verifiableCredential[0]; expect(issuedVc).toBeDefined(); // Configure presentation exchange diff --git a/apps/vc-api/test/wallet-client.ts b/apps/vc-api/test/wallet-client.ts index ace714b0..8f4b5845 100644 --- a/apps/vc-api/test/wallet-client.ts +++ b/apps/vc-api/test/wallet-client.ts @@ -26,6 +26,7 @@ import { VpRequestDto } from '../src/vc-api/exchanges/dtos/vp-request.dto'; import { ExchangeResponseDto } from '../src/vc-api/exchanges/dtos/exchange-response.dto'; import { VpRequestQueryType } from '../src/vc-api/exchanges/types/vp-request-query-type'; import { TransactionDto } from '../src/vc-api/exchanges/dtos/transaction.dto'; +import { SubmissionReviewDto } from '../src/vc-api/exchanges/dtos/submission-review.dto'; /** * A wallet client for e2e tests @@ -106,7 +107,10 @@ export class WalletClient { expect(continueExchangeResponse.body.errors).toHaveLength(0); if (expectsVpRequest) { expect(continueExchangeResponse.body.vpRequest).toBeDefined(); + } else { + expect(continueExchangeResponse.body.vpRequest).toBeUndefined(); } + return continueExchangeResponse.body as ExchangeResponseDto; } /** @@ -120,4 +124,19 @@ export class WalletClient { expect(continueExchangeResponse.body.transaction).toBeDefined(); return continueExchangeResponse.body.transaction as TransactionDto; } + + /** + * POST /exchanges/{exchangeId}/{transactionId}/review + */ + async addSubmissionReview( + exchangeId: string, + transactionId: string, + submissionReviewDto: SubmissionReviewDto + ) { + const continueExchangeResponse = await request(this.#app.getHttpServer()) + .post(`/vc-api/exchanges/${exchangeId}/${transactionId}/review`) + .send(submissionReviewDto) + .expect(201); + return continueExchangeResponse?.body; + } }