diff --git a/.changeset/famous-shrimps-hammer.md b/.changeset/famous-shrimps-hammer.md new file mode 100644 index 000000000..b676268af --- /dev/null +++ b/.changeset/famous-shrimps-hammer.md @@ -0,0 +1,5 @@ +--- +"@web5/credentials": minor +--- + +Adding resolution and validation of credential schemas diff --git a/packages/credentials/package.json b/packages/credentials/package.json index e12aa5e3f..10c106636 100644 --- a/packages/credentials/package.json +++ b/packages/credentials/package.json @@ -79,6 +79,7 @@ "@web5/common": "1.0.1", "@web5/crypto": "1.0.1", "@web5/dids": "1.1.1", + "jsonschema": "1.4.1", "pako": "^2.1.0" }, "devDependencies": { diff --git a/packages/credentials/src/validators.ts b/packages/credentials/src/validators.ts index 7d682e3f3..935a8292f 100644 --- a/packages/credentials/src/validators.ts +++ b/packages/credentials/src/validators.ts @@ -3,15 +3,21 @@ import type { ICredentialSubject } from '@sphereon/ssi-types'; +import { Validator as JsonSchemaValidator } from 'jsonschema'; + import { + CredentialSchema, DEFAULT_VC_CONTEXT, DEFAULT_VC_TYPE, + VcDataModel, VerifiableCredential } from './verifiable-credential.js'; import { isValidRFC3339Timestamp, isValidXmlSchema112Timestamp } from './utils.js'; import { DEFAULT_VP_TYPE } from './verifiable-presentation.js'; +const jsonSchemaValidator = new JsonSchemaValidator(); + export class SsiValidator { static validateCredentialPayload(vc: VerifiableCredential): void { this.validateContext(vc.vcDataModel['@context']); @@ -54,6 +60,35 @@ export class SsiValidator { } } + static async validateCredentialSchema(vcDataModel: VcDataModel): Promise { + const credentialSchema = vcDataModel.credentialSchema as CredentialSchema | CredentialSchema[]; + + if (!credentialSchema || (Array.isArray(credentialSchema) && credentialSchema.length === 0)) { + throw new Error('Credential schema is missing or empty'); + } + + const schemaId = Array.isArray(credentialSchema) ? credentialSchema[0].id : credentialSchema.id; + + let jsonSchema; + try { + const response = await fetch(schemaId); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + jsonSchema = await response.json(); + } catch (error: any) { + throw new Error(`Failed to fetch schema from ${schemaId}: ${error.message}`); + } + + const validationResult = jsonSchemaValidator.validate(vcDataModel, jsonSchema); + + if (!validationResult.valid) { + throw new Error(`Schema Validation Errors: ${JSON.stringify(validationResult.errors)}`); + } + } + static asArray(arg: any | any[]): any[] { return Array.isArray(arg) ? arg : [arg]; } diff --git a/packages/credentials/src/verifiable-credential.ts b/packages/credentials/src/verifiable-credential.ts index bcdd48ef1..cfa191eba 100644 --- a/packages/credentials/src/verifiable-credential.ts +++ b/packages/credentials/src/verifiable-credential.ts @@ -317,6 +317,8 @@ export class VerifiableCredential { validatePayload(vcTyped); + if (vcTyped.credentialSchema) await SsiValidator.validateCredentialSchema(vcTyped); + return { /** The issuer of the VC. */ issuer : payload.iss!, diff --git a/packages/credentials/tests/ssi-validator.spec.ts b/packages/credentials/tests/ssi-validator.spec.ts index 91ac067f5..17ed40f61 100644 --- a/packages/credentials/tests/ssi-validator.spec.ts +++ b/packages/credentials/tests/ssi-validator.spec.ts @@ -1,10 +1,14 @@ import { expect } from 'chai'; +import sinon from 'sinon'; + import { SsiValidator } from '../src/validators.js'; -import { DEFAULT_VC_CONTEXT, DEFAULT_VC_TYPE } from '../src/verifiable-credential.js'; +import { DEFAULT_VC_CONTEXT, DEFAULT_VC_TYPE, VcDataModel } from '../src/verifiable-credential.js'; +import { DEFAULT_VP_TYPE } from '../src/verifiable-presentation.js'; describe('SsiValidator', () => { + describe('validateContext', () => { it('should throw an error if the default context is missing', () => { expect(() => SsiValidator.validateContext(['http://example.com'])).throw(`@context is missing default context "${DEFAULT_VC_CONTEXT}"`); @@ -25,6 +29,28 @@ describe('SsiValidator', () => { }); }); + describe('validateVpType', () => { + it('should throw an error if the default VP type is missing', () => { + expect(() => SsiValidator.validateVpType(['CustomType'])).to.throw(`type is missing default "${DEFAULT_VP_TYPE}"`); + }); + + it('should not throw an error if the default VP type is present', () => { + expect(() => SsiValidator.validateVpType([DEFAULT_VP_TYPE, 'CustomType'])).not.to.throw(); + }); + + it('should throw an error if the input array is empty', () => { + expect(() => SsiValidator.validateVpType([])).to.throw(`type is missing default "${DEFAULT_VP_TYPE}"`); + }); + + it('should throw an error if the input is not an array and does not contain the default VP type', () => { + expect(() => SsiValidator.validateVpType('CustomType')).to.throw(`type is missing default "${DEFAULT_VP_TYPE}"`); + }); + + it('should not throw an error if the input is not an array but contains the default VP type', () => { + expect(() => SsiValidator.validateVpType(DEFAULT_VP_TYPE)).not.to.throw(); + }); + }); + describe('validateCredentialSubject', () => { it('should throw an error if the credential subject is empty', () => { expect(() => SsiValidator.validateCredentialSubject({})).throw('credentialSubject must not be empty'); @@ -45,4 +71,105 @@ describe('SsiValidator', () => { expect(() => SsiValidator.validateTimestamp(validTimestamp)).not.throw(); }); }); + + describe('validateCredentialSchema', () => { + // Mock VcDataModel and CredentialSchema + const validVcDataModel = { + credentialSchema: { + id : 'https://schema.org/PFI', + type : 'JsonSchema' + } + } as VcDataModel; + + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + it('should throw an error if credential schema is missing', async () => { + const invalidVcDataModel = { ...validVcDataModel, credentialSchema: undefined }; + + try { + await SsiValidator.validateCredentialSchema(invalidVcDataModel); + expect.fail(); + } catch (error: any) { + expect(error.message).to.equal('Credential schema is missing or empty'); + } + }); + + it('should throw an error if credential schema is an empty array', async () => { + const invalidVcDataModel = { ...validVcDataModel, credentialSchema: [] }; + + try { + await SsiValidator.validateCredentialSchema(invalidVcDataModel); + expect.fail(); + } catch (error: any) { + expect(error.message).to.equal('Credential schema is missing or empty'); + } + }); + + it('should throw an error if fetch fails', async () => { + fetchStub.rejects(new Error('Network error')); + + try { + await SsiValidator.validateCredentialSchema(validVcDataModel); + expect.fail(); + } catch (error: any) { + expect(error.message).to.equal('Failed to fetch schema from https://schema.org/PFI: Network error'); + } + }); + + it('should throw an error if fetch returns non-200 status', async () => { + fetchStub.resolves(new Response(null, { status: 404 })); + + try { + await SsiValidator.validateCredentialSchema(validVcDataModel); + expect.fail(); + } catch (error: any) { + expect(error.message).to.contain('Failed to fetch schema from https://schema.org/PFI'); + } + }); + + it('should throw an error if schema validation fails', async () => { + const mockSchema = { + '$schema' : 'http://json-schema.org/draft-07/schema#', + 'type' : 'object', + 'properties' : { + 'credentialSubject': { + 'type' : 'object', + 'properties' : { + 'id': { + 'type': 'string' + }, + 'country_of_residence': { + 'type' : 'string', + 'pattern' : '^[A-Z]{2}$' + }, + }, + 'required': [ + 'id', + 'country_of_residence' + ] + } + }, + 'required': [ + 'issuer', + ] + }; + + fetchStub.resolves(new Response(JSON.stringify(mockSchema), { status: 200 })); + + try { + await SsiValidator.validateCredentialSchema(validVcDataModel); + expect.fail(); + } catch (error: any) { + expect(error.message).to.contain('Schema Validation Errors:'); + } + }); + }); }); diff --git a/packages/credentials/tests/verifiable-credential.spec.ts b/packages/credentials/tests/verifiable-credential.spec.ts index 9199bc83f..9218afab2 100644 --- a/packages/credentials/tests/verifiable-credential.spec.ts +++ b/packages/credentials/tests/verifiable-credential.spec.ts @@ -2,7 +2,7 @@ import type { BearerDid, PortableDid } from '@web5/dids'; import sinon from 'sinon'; import { expect } from 'chai'; -import { DidDht, DidKey, DidIon, DidJwk } from '@web5/dids'; +import { DidDht, DidKey, DidJwk } from '@web5/dids'; import { Jwt } from '../src/jwt.js'; import { VerifiableCredential } from '../src/verifiable-credential.js'; @@ -20,7 +20,7 @@ describe('Verifiable Credential Tests', () => { } beforeEach(async () => { - issuerDid = await DidKey.create(); + issuerDid = await DidJwk.create(); }); describe('Verifiable Credential (VC)', () => { @@ -92,74 +92,133 @@ describe('Verifiable Credential Tests', () => { }); it('create and sign kyc vc with did:jwk', async () => { - const subjectDid = await DidJwk.create(); - const issuerDid = await DidJwk.create(); - - const vc = await VerifiableCredential.create({ - type : 'KnowYourCustomerCred', - subject : subjectDid.uri, - issuer : issuerDid.uri, - issuanceDate : '2023-05-19T08:02:04Z', - expirationDate : `2055-05-19T08:02:04Z`, - data : { - id : subjectDid.uri, - country_of_residence : 'US', - tier : 'Tier 1' - }, - credentialSchema: { - id : ' https://schema.org/PFI', - type : 'JsonSchema' + // KYC schema, also hosted here https://developer.tbd.website/schemas/kccSchema.json + const kycSchema = { + '$schema' : 'http://json-schema.org/draft-07/schema#', + 'type' : 'object', + 'properties' : { + 'credentialSubject': { + 'type' : 'object', + 'properties' : { + 'id': { + 'type': 'string' + }, + 'countryOfResidence': { + 'type' : 'string', + 'pattern' : '^[A-Z]{2}$' + }, + 'tier': { + 'type' : 'string', + 'optional' : true + } + }, + 'required': [ + 'id', + 'countryOfResidence' + ] + }, + 'issuer': { + 'type': 'string' + }, + 'issuanceDate': { + 'type' : 'string', + 'format' : 'date-time' + }, + 'expirationDate': { + 'type' : 'string', + 'format' : 'date-time' + }, + 'credentialSchema': { + 'type' : 'object', + 'properties' : { + 'id': { + 'type' : 'string', + 'format' : 'uri' + }, + 'type': { + 'type' : 'string', + 'const' : 'JsonSchema' + } + }, + 'required': [ + 'id', + 'type' + ] + }, + 'evidence': { + 'type' : 'array', + 'items' : { + 'type' : 'object', + 'properties' : { + 'kind': { + 'type': 'string' + }, + 'checks': { + 'type' : 'array', + 'items' : { + 'type': 'string' + } + } + }, + 'optional': true + }, + 'optional': true + } }, - evidence: [ - { kind: 'document_verification', checks: ['passport', 'utility_bill'] }, - { kind: 'sanctions_check', checks: ['daily'] } + 'required': [ + 'credentialSubject', + 'issuer', + 'issuanceDate', + 'expirationDate', + 'credentialSchema' ] - }); + }; - const vcJwt = await vc.sign({ did: issuerDid }); + // Setup stub for fetch + const fetchStub = sinon.stub(globalThis, 'fetch'); - await VerifiableCredential.verify({ vcJwt }); + // Mock the schema fetch + fetchStub.withArgs('https://schema.org/PFI').resolves(new Response(JSON.stringify(kycSchema), { status: 200 })); - for( const currentVc of [vc, VerifiableCredential.parseJwt({ vcJwt })]){ - expect(currentVc.issuer).to.equal(issuerDid.uri); - expect(currentVc.subject).to.equal(subjectDid.uri); - expect(currentVc.type).to.equal('KnowYourCustomerCred'); - expect(currentVc.vcDataModel.issuanceDate).to.equal('2023-05-19T08:02:04Z'); - expect(currentVc.vcDataModel.expirationDate).to.equal('2055-05-19T08:02:04Z'); - expect(currentVc.vcDataModel.credentialSubject).to.deep.equal({ id: subjectDid.uri, country_of_residence: 'US', tier: 'Tier 1'}); - expect(currentVc.vcDataModel.credentialSchema).to.deep.equal({ id: ' https://schema.org/PFI', type: 'JsonSchema'}); - expect(currentVc.vcDataModel.evidence).to.deep.equal([ - { kind: 'document_verification', checks: ['passport', 'utility_bill'] }, - { kind: 'sanctions_check', checks: ['daily'] } - ]); - } - }); + const subjectDid = await DidJwk.create(); + const issuerDid = await DidJwk.create(); - // TBD's `did:ion` resolver has been sunset so skipping tests - // TODO: Move `did:ion` functionality to separate repo - xit('create and sign vc with did:ion', async () => { - const did = await DidIon.create(); + const issuanceDate = '2023-05-19T08:02:04Z'; + const expirationDate = '2055-05-19T08:02:04Z'; + const evidence = [ + { kind: 'document_verification', checks: ['passport', 'utility_bill'] }, + { kind: 'sanctions_check', checks: ['daily'] } + ]; + const credentialSubject = { id: subjectDid.uri, countryOfResidence: 'US', tier: 'Tier 1' }; + const credentialSchema = { id: 'https://schema.org/PFI', type: 'JsonSchema' }; const vc = await VerifiableCredential.create({ - type : 'TBDeveloperCredential', - subject : did.uri, - issuer : did.uri, - data : { - username: 'nitro' - } + type : 'KnowYourCustomerCred', + subject : subjectDid.uri, + issuer : issuerDid.uri, + issuanceDate, + expirationDate, + data : credentialSubject, + credentialSchema, + evidence }); - const vcJwt = await vc.sign({ did }); + const vcJwt = await vc.sign({ did: issuerDid }); await VerifiableCredential.verify({ vcJwt }); - for (const currentVc of [vc, VerifiableCredential.parseJwt({ vcJwt })]){ - expect(currentVc.issuer).to.equal(did.uri); - expect(currentVc.subject).to.equal(did.uri); - expect(currentVc.type).to.equal('TBDeveloperCredential'); - expect(currentVc.vcDataModel.issuanceDate).to.not.be.undefined; - expect(currentVc.vcDataModel.credentialSubject).to.deep.equal({ id: did.uri, username: 'nitro'}); - } + const currentVc = VerifiableCredential.parseJwt({ vcJwt }); + + expect(currentVc.issuer).to.equal(issuerDid.uri); + expect(currentVc.subject).to.equal(subjectDid.uri); + expect(currentVc.type).to.equal('KnowYourCustomerCred'); + expect(currentVc.vcDataModel.issuanceDate).to.equal(issuanceDate); + expect(currentVc.vcDataModel.expirationDate).to.equal(expirationDate); + expect(currentVc.vcDataModel.evidence).to.deep.equal(evidence); + expect(currentVc.vcDataModel.credentialSubject).to.deep.equal(credentialSubject); + expect(currentVc.vcDataModel.credentialSchema).to.deep.equal(credentialSchema); + + sinon.restore(); }); it('create and sign vc with did:dht', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b770d4040..432fe0f71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ importers: version: 0.4.11 isomorphic-ws: specifier: ^5.0.0 - version: 5.0.0(ws@8.18.0) + version: 5.0.0(ws@8.17.1) level: specifier: 8.0.0 version: 8.0.0 @@ -342,6 +342,9 @@ importers: '@web5/dids': specifier: 1.1.1 version: 1.1.1 + jsonschema: + specifier: 1.4.1 + version: 1.4.1 pako: specifier: ^2.1.0 version: 2.1.0 @@ -7882,12 +7885,12 @@ packages: engines: {node: '>=10'} dev: true - /isomorphic-ws@5.0.0(ws@8.18.0): + /isomorphic-ws@5.0.0(ws@8.17.1): resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: ws: '>=8.17.1' dependencies: - ws: 8.18.0 + ws: 8.17.1 dev: false /istanbul-lib-coverage@3.2.2: @@ -8063,6 +8066,10 @@ packages: engines: {'0': node >= 0.2.0} dev: true + /jsonschema@1.4.1: + resolution: {integrity: sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==} + dev: false + /jsonstream-next@3.0.0: resolution: {integrity: sha512-aAi6oPhdt7BKyQn1SrIIGZBt0ukKuOUE1qV6kJ3GgioSOYzsRc8z9Hfr1BVmacA/jLe9nARfmgMGgn68BqIAgg==} engines: {node: '>=10'} @@ -11293,7 +11300,6 @@ packages: optional: true utf-8-validate: optional: true - dev: true /ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} @@ -11306,6 +11312,7 @@ packages: optional: true utf-8-validate: optional: true + dev: true /xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} diff --git a/web5-spec b/web5-spec index a582f4757..30ebc84ff 160000 --- a/web5-spec +++ b/web5-spec @@ -1 +1 @@ -Subproject commit a582f4757c00f8797985a756729e6e6c7407bc13 +Subproject commit 30ebc84ffefc485dd35f12562279319f52ed0153