diff --git a/src/domain/verifier/useCases/index.ts b/src/domain/verifier/useCases/index.ts index 6115548f8..9e6b84258 100644 --- a/src/domain/verifier/useCases/index.ts +++ b/src/domain/verifier/useCases/index.ts @@ -7,6 +7,7 @@ import getVerificationMap from './getVerificationMap'; import lookForTx from './lookForTx'; import parseIssuerKeys from './parseIssuerKeys'; import parseRevocationKey from './parseRevocationKey'; +import validateVerifiableCredential from './validateVerifiableCredential'; export { convertToVerificationSubsteps, @@ -17,5 +18,6 @@ export { getVerificationMap, lookForTx, parseIssuerKeys, - parseRevocationKey + parseRevocationKey, + validateVerifiableCredential }; diff --git a/src/domain/verifier/useCases/validateVerifiableCredential.ts b/src/domain/verifier/useCases/validateVerifiableCredential.ts new file mode 100644 index 000000000..f53cb574f --- /dev/null +++ b/src/domain/verifier/useCases/validateVerifiableCredential.ts @@ -0,0 +1,144 @@ +import { CONTEXT_URLS } from '@blockcerts/schemas'; +import { isValidUrl } from '../../../helpers/url'; +import type { BlockcertsV3, VCCredentialStatus, VCCredentialSchema } from '../../../models/BlockcertsV3'; +import type { JsonLDContext } from '../../../models/Blockcerts'; +import { type Issuer } from '../../../models/Issuer'; + +function validateRFC3339Date (date: string): boolean { + const regex = /^-?([1-9][0-9]{3,}|0[0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?|(24:00:00(\.0+)?))(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))$/; + return regex.test(date); +} + +function isV1VerifiableCredential (context: JsonLDContext): boolean { + return context.includes(CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V1_CONTEXT); +} + +function isV2VerifiableCredential (context: JsonLDContext): boolean { + return context.includes(CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V2_CONTEXT); +} + +function validateUrl (url: string, property: string = ''): void { + if (!isValidUrl(url)) { + throw new Error(`Invalid URL: ${url}. ${property ? `Property: ${property}` : ''}`); + } +} + +function validateType (certificateType: string[]): void { + const compulsoryTypes = ['VerifiableCredential', 'VerifiablePresentation']; + if (!Array.isArray(certificateType)) { + throw new Error('`type` property must be an array'); + } + const containsCompulsoryTypes = compulsoryTypes.filter(type => certificateType.includes(type)); + if (certificateType.length === 0 || containsCompulsoryTypes.length === 0) { + throw new Error('`type` property must include `VerifiableCredential` or `VerifiablePresentation`'); + } +} + +function validateContext (context: JsonLDContext, type: string[]): void { + const vcContextUrls: string[] = [CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V1_CONTEXT, CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V2_CONTEXT]; + + if (!Array.isArray(context)) { + throw new Error('`@context` property must be an array'); + } + if (!vcContextUrls.includes(context[0] as string)) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`First @context must be one of ${vcContextUrls.join(', ')}, given ${context[0] as string}`); + } + if (isV1VerifiableCredential(context) && isV2VerifiableCredential(context)) { + throw new Error('Cannot have both v1 and v2 Verifiable Credential contexts'); + } + if (type.length > 1 && context.length === 1) { + throw new Error(`More specific type: ${type[1]} was detected but no additional context provided`); + } +} + +function validateIssuer (certificateIssuer: string | Issuer): void { + let hasError = false; + if (certificateIssuer == null) { + hasError = true; + } else if (typeof certificateIssuer === 'string' && !isValidUrl(certificateIssuer)) { + hasError = true; + } else if (typeof certificateIssuer === 'object' && !isValidUrl(certificateIssuer.id)) { + hasError = true; + } else if (Array.isArray(certificateIssuer)) { + hasError = true; + } + + if (hasError) { + throw new Error('`issuer` must be a URL string or an object with an `id` URL string'); + } +} + +function validateDateRFC3339StringFormat (date: string, propertyName: string): void { + let errorMessage = `${propertyName} must be a valid RFC3339 string.`; + if (typeof date !== 'string') { + errorMessage += `${propertyName}: ${date as any} is not a string`; + throw new Error(errorMessage); + } + if (!validateRFC3339Date(date)) { + errorMessage += ` Received: \`${date}\``; + throw new Error(errorMessage); + } +} + +function validateCredentialStatus (certificateCredentialStatus: VCCredentialStatus | VCCredentialStatus[]): void { + const statuses = Array.isArray(certificateCredentialStatus) ? certificateCredentialStatus : [certificateCredentialStatus]; + statuses.forEach(status => { + if (!status.id) { + throw new Error('credentialStatus.id must be defined'); + } + validateUrl(status.id, 'credentialStatus.id'); + if (typeof status.type !== 'string') { + throw new Error('credentialStatus.type must be a string'); + } + }); +} + +function validateCredentialSchema (certificateCredentialSchema: VCCredentialSchema | VCCredentialSchema[]): void { + const schemas = Array.isArray(certificateCredentialSchema) ? certificateCredentialSchema : [certificateCredentialSchema]; + schemas.forEach(schema => { + if (!schema.id) { + throw new Error('credentialSchema.id must be defined'); + } + validateUrl(schema.id, 'credentialSchema.id'); + if (schema.type !== 'JsonSchema') { + throw new Error('credentialSchema.type must be `JsonSchema`'); + } + }); +} + +export default function validateVerifiableCredential (credential: BlockcertsV3): void { + if (!credential.credentialSubject) { + throw new Error('`credentialSubject` must be defined'); + } + + validateType(credential.type); + validateContext(credential['@context'], credential.type); + + validateIssuer(credential.issuer); + + if (isV1VerifiableCredential(credential['@context'])) { + if (credential.issuanceDate) { + validateDateRFC3339StringFormat(credential.issuanceDate, 'issuanceDate'); + } + if (credential.expirationDate) { + validateDateRFC3339StringFormat(credential.expirationDate, 'expirationDate'); + } + } + if (isV2VerifiableCredential(credential['@context'])) { + if (credential.validFrom) { + validateDateRFC3339StringFormat(credential.validFrom, 'validFrom'); + } + if (credential.validUntil) { + validateDateRFC3339StringFormat(credential.validUntil, 'validUntil'); + } + } + + if (credential.credentialStatus) { + validateCredentialStatus(credential.credentialStatus); + } + + if (credential.credentialSchema) { + validateCredentialSchema(credential.credentialSchema); + } +} diff --git a/src/helpers/url.ts b/src/helpers/url.ts index fc84fe771..6a15146f0 100644 --- a/src/helpers/url.ts +++ b/src/helpers/url.ts @@ -2,3 +2,12 @@ export function safelyAppendUrlParameter (url: string, parameterKey: string, par const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}${parameterKey}=${parameterValue}`; } + +export function isValidUrl (url: string): boolean { + try { + const parsedUrl = new URL(url); + return !parsedUrl.pathname.includes(' ') && !parsedUrl.hostname.includes(' ') && url.includes(':'); + } catch { + return false; + } +} diff --git a/src/parsers/parseV3.ts b/src/parsers/parseV3.ts index f8be22d84..524f588b8 100644 --- a/src/parsers/parseV3.ts +++ b/src/parsers/parseV3.ts @@ -38,6 +38,11 @@ export default async function parseV3 (certificateJson: BlockcertsV3, locale: st description, credentialSubject } = certificateJson; + try { + domain.verifier.validateVerifiableCredential(certificateJson); + } catch (error) { + throw new Error(`Document presented is not a valid Verifiable Credential: ${error.message}`); + } let { validFrom } = certificateJson; const certificateMetadata = metadata ?? metadataJson; const issuer: Issuer = await domain.verifier.getIssuerProfile(issuerProfileUrl); diff --git a/test/application/certificate/certificate-v3.test.ts b/test/application/certificate/certificate-v3.test.ts index ec86178e8..8f14b6ef6 100644 --- a/test/application/certificate/certificate-v3.test.ts +++ b/test/application/certificate/certificate-v3.test.ts @@ -95,7 +95,7 @@ describe('Certificate entity test suite', function () { const certificate = new Certificate(failingFixture); await expect(certificate.init()) .rejects - .toThrow('Unable to get issuer profile - no issuer address given'); + .toThrow('Document presented is not a valid Verifiable Credential: `issuer` must be a URL string or an object with an `id` URL string'); }); }); @@ -106,7 +106,7 @@ describe('Certificate entity test suite', function () { const certificate = new Certificate(failingFixture); await expect(certificate.init()) .rejects - .toThrow('Unable to get issuer profile - no issuer address given'); + .toThrow('Document presented is not a valid Verifiable Credential: `issuer` must be a URL string or an object with an `id` URL string'); }); }); @@ -117,7 +117,7 @@ describe('Certificate entity test suite', function () { const certificate = new Certificate(failingFixture); await expect(certificate.init()) .rejects - .toThrow('Unable to get issuer profile - no issuer address given'); + .toThrow('Document presented is not a valid Verifiable Credential: `issuer` must be a URL string or an object with an `id` URL string'); }); }); @@ -128,7 +128,7 @@ describe('Certificate entity test suite', function () { const certificate = new Certificate(failingFixture); await expect(certificate.init()) .rejects - .toThrow('Unable to get issuer profile - no issuer address given'); + .toThrow('Document presented is not a valid Verifiable Credential: `issuer` must be a URL string or an object with an `id` URL string'); }); }); diff --git a/test/application/domain/verifier/validateVerifiableCredential.test.ts b/test/application/domain/verifier/validateVerifiableCredential.test.ts new file mode 100644 index 000000000..732f18470 --- /dev/null +++ b/test/application/domain/verifier/validateVerifiableCredential.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect } from 'vitest'; +import validateVerifiableCredential from '../../../../src/domain/verifier/useCases/validateVerifiableCredential'; +import validFixture from '../../../fixtures/v3/mocknet-vc-v2-name-description-multilingual.json'; +import { CONTEXT_URLS } from '@blockcerts/schemas'; +describe('domain verifier validateVerifiableCredential test suite', function () { + describe('given the credential is well formed', function () { + it('should not throw an error', function () { + expect(function () { + validateVerifiableCredential(validFixture); + }).not.toThrow(); + }); + }); + + describe('given the credential is malformed', function () { + describe('validateType method', function () { + describe('when the type is not an array', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, type: 'InvalidType' }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('`type` property must be an array'); + }); + }); + + describe('when the type does not include VerifiableCredential nor VerifiablePresentation', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, type: ['InvalidType'] }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('`type` property must include `VerifiableCredential` or `VerifiablePresentation`'); + }); + }); + }); + + describe('validateContext method', function () { + describe('when the @context is not an array', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, '@context': validFixture['@context'][0] }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('`@context` property must be an array'); + }); + }); + + describe('when the first context is not that of Verifiable Credential', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, '@context': [CONTEXT_URLS.BLOCKCERTS_V3_2_CONTEXT, CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V2_CONTEXT] }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow(`First @context must be one of ${CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V1_CONTEXT}, ${CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V2_CONTEXT}, given ${CONTEXT_URLS.BLOCKCERTS_V3_2_CONTEXT}`); + }); + }); + + describe('when the context refers to VC v1 and VC v2', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, '@context': [CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V1_CONTEXT, CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V2_CONTEXT] }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('Cannot have both v1 and v2 Verifiable Credential contexts'); + }); + }); + + describe('when more than one type is specified but only one context is defined', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, '@context': [CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V2_CONTEXT] }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow(`More specific type: ${validFixture.type[1]} was detected but no additional context provided`); + }); + }); + }); + + describe('validateIssuer method', function () { + describe('when the issuer is a string but not a valid URL', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, issuer: 'InvalidIssuerValue' }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('`issuer` must be a URL string or an object with an `id` URL string'); + }); + }); + + describe('when the issuer is an object but the id is not a valid URL', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, issuer: { id: 'InvalidIssuerValue' } }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('`issuer` must be a URL string or an object with an `id` URL string'); + }); + }); + + describe('when the issuer is an array', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, issuer: ['InvalidIssuerValue'] }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('`issuer` must be a URL string or an object with an `id` URL string'); + }); + }); + + describe('validateDateFormat method', function () { + describe('when the credential is VC v1 spec', function () { + describe('and the issuanceDate exists and is not a valid RFC3339 string', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + '@context': [ + CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V1_CONTEXT, + CONTEXT_URLS.BLOCKCERTS_V3_2_CONTEXT + ], + issuanceDate: '2024-11-13' + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('issuanceDate must be a valid RFC3339 string. Received: `2024-11-13`'); + }); + }); + + describe('and the expirationDate exists and is not a valid RFC3339 string', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + '@context': [ + CONTEXT_URLS.VERIFIABLE_CREDENTIAL_V1_CONTEXT, + CONTEXT_URLS.BLOCKCERTS_V3_2_CONTEXT + ], + expirationDate: '2024-11-13' + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('expirationDate must be a valid RFC3339 string. Received: `2024-11-13`'); + }); + }); + }); + + describe('when the credential is VC v2 spec', function () { + describe('and the validFrom date exists and is not a valid RFC3339 string', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, validFrom: '2024-11-13' }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('validFrom must be a valid RFC3339 string. Received: `2024-11-13`'); + }); + }); + + describe('and the validUntil date exists and is not a valid RFC3339 string', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, validUntil: '2024-11-13' }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('validUntil must be a valid RFC3339 string. Received: `2024-11-13`'); + }); + }); + }); + }); + + describe('validateCredentialStatus method', function () { + describe('when the credentialStatus property is defined', function () { + describe('when the property is an object but the id is not defined', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, credentialStatus: { type: 'BitStringStatusList' } }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialStatus.id must be defined'); + }); + }); + + describe('when the property is an object but the id is not a valid URL', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, credentialStatus: { type: 'BitStringStatusList', id: 'InvalidUrl' } }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('Invalid URL: InvalidUrl. Property: credentialStatus.id'); + }); + }); + + describe('when the property is an object but the type is not defined', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, credentialStatus: { id: 'https://example.com' } }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialStatus.type must be a string'); + }); + }); + + describe('when the property is an array', function () { + describe('and one of the objects does not have an id', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialStatus: [ + { + type: 'BitStringStatusList', + id: 'https://example.com' + }, + { + type: 'BitStringStatusList' + } + ] + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialStatus.id must be defined'); + }); + }); + + describe('and one of the objects does not have a type', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialStatus: [ + { + type: 'BitStringStatusList', + id: 'https://example.com' + }, + { + id: 'https://other.example.com' + } + ] + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialStatus.type must be a string'); + }); + }); + + describe('and one of the objects has an invalid URL for id', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialStatus: [ + { + type: 'BitStringStatusList', + id: 'https://example.com' + }, + { + id: 'InvalidUrl', + type: 'BitStringStatusList' + } + ] + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('Invalid URL: InvalidUrl. Property: credentialStatus.id'); + }); + }); + }); + }); + }); + + describe('validateCredentialSchema method', function () { + describe('when the credentialSchema property is defined', function () { + describe('when the property is an object but the id is not defined', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, credentialSchema: { type: 'JsonSchema' } }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialSchema.id must be defined'); + }); + }); + + describe('when the property is an object but the id is not a valid URL', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, credentialSchema: { type: 'JsonSchema', id: 'InvalidUrl' } }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('Invalid URL: InvalidUrl. Property: credentialSchema.id'); + }); + }); + + describe('when the property is an object but the type is not defined', function () { + it('should throw an error', function () { + const fixture = { ...validFixture, credentialSchema: { id: 'https://example.com' } }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialSchema.type must be `JsonSchema`'); + }); + }); + + describe('when the property is an object but the type is not the correct value', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialSchema: { + id: 'https://example.com', + type: 'InvalidType' + } + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialSchema.type must be `JsonSchema`'); + }); + }); + + describe('when the property is an array', function () { + describe('and one of the objects does not have an id', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialSchema: [ + { + type: 'JsonSchema', + id: 'https://example.com' + }, + { + type: 'JsonSchema' + } + ] + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialSchema.id must be defined'); + }); + }); + + describe('and one of the objects does not have a type', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialSchema: [ + { + type: 'JsonSchema', + id: 'https://example.com' + }, + { + id: 'https://other.example.com' + } + ] + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialSchema.type must be `JsonSchema`'); + }); + }); + + describe('and one of the objects does not have the correct value', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialSchema: [ + { + type: 'JsonSchema', + id: 'https://example.com' + }, + { + id: 'https://other.example.com', + type: 'InvalidType' + } + ] + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('credentialSchema.type must be `JsonSchema`'); + }); + }); + + describe('and one of the objects has an invalid URL for id', function () { + it('should throw an error', function () { + const fixture = { + ...validFixture, + credentialSchema: [ + { + type: 'JsonSchema', + id: 'https://example.com' + }, + { + id: 'InvalidUrl', + type: 'JsonSchema' + } + ] + }; + expect(function () { + validateVerifiableCredential(fixture); + }).toThrow('Invalid URL: InvalidUrl. Property: credentialSchema.id'); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/application/inspectors/ensureNotExpired.test.ts b/test/application/inspectors/ensureNotExpired.test.ts index 4d652d874..f3b0e7e45 100644 --- a/test/application/inspectors/ensureNotExpired.test.ts +++ b/test/application/inspectors/ensureNotExpired.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ensureNotExpired } from '../../../src/inspectors'; describe('Inspectors test suite', function () { @@ -30,5 +30,21 @@ describe('Inspectors test suite', function () { }).not.toThrow(); }); }); + + describe('given it expires in UTC-6 timezone', function () { + describe('and the current date is in UTC timezone and at that time it has expired', function () { + it('should not thrown an error', function () { + const mockDate = '2017-01-01T01:00:00Z'; + + vi.setSystemTime(mockDate); + + const fixtureDate = '2017-01-01T02:00:00-06:00'; + expect(function () { + ensureNotExpired(fixtureDate); + }).not.toThrow(); + vi.useRealTimers(); + }); + }); + }); }); }); diff --git a/test/application/parsers/parser-v3.0.test.ts b/test/application/parsers/parser-v3.0.test.ts index 8f45d07a5..363ccd303 100644 --- a/test/application/parsers/parser-v3.0.test.ts +++ b/test/application/parsers/parser-v3.0.test.ts @@ -19,6 +19,16 @@ describe('Parser v3 test suite', function () { const parsedCertificate = await parseJSON(fixtureCopy); expect(parsedCertificate.isFormatValid).toBe(false); }); + + describe('when the certificate is not a valid Verifiable Credential', function () { + it('should throw an error', async function () { + const fixtureCopy = JSON.parse(JSON.stringify(fixture)); + fixtureCopy.issuanceDate = '2022-05-22'; + const parsedCertificate = await parseJSON(fixtureCopy); + expect(parsedCertificate.isFormatValid).toBe(false); + expect(parsedCertificate.error).toBe('Document presented is not a valid Verifiable Credential: issuanceDate must be a valid RFC3339 string. Received: `2022-05-22`'); + }); + }); }); describe('given it is called with valid v3 certificate data', function () {