diff --git a/packages/credentials/package.json b/packages/credentials/package.json index 668ec55cd..55197b86e 100644 --- a/packages/credentials/package.json +++ b/packages/credentials/package.json @@ -1,6 +1,6 @@ { "name": "@web5/credentials", - "version": "0.4.1", + "version": "0.4.2", "description": "Verifiable Credentials", "type": "module", "main": "./dist/cjs/index.js", diff --git a/packages/credentials/src/validators.ts b/packages/credentials/src/validators.ts index d28c41129..2fd2b85bc 100644 --- a/packages/credentials/src/validators.ts +++ b/packages/credentials/src/validators.ts @@ -10,6 +10,7 @@ import { } from './verifiable-credential.js'; import { isValidXmlSchema112Timestamp } from './utils.js'; +import { DEFAULT_VP_TYPE } from './verifiable-presentation.js'; export class SsiValidator { static validateCredentialPayload(vc: VerifiableCredential): void { @@ -34,6 +35,13 @@ export class SsiValidator { } } + static validateVpType(value: string | string[]): void { + const input = this.asArray(value); + if (input.length < 1 || input.indexOf(DEFAULT_VP_TYPE) === -1) { + throw new Error(`type is missing default "${DEFAULT_VP_TYPE}"`); + } + } + static validateCredentialSubject(value: ICredentialSubject | ICredentialSubject[]): void { if (Object.keys(value).length === 0) { throw new Error(`credentialSubject must not be empty`); diff --git a/packages/credentials/src/verifiable-credential.ts b/packages/credentials/src/verifiable-credential.ts index d96690792..ea338c1bf 100644 --- a/packages/credentials/src/verifiable-credential.ts +++ b/packages/credentials/src/verifiable-credential.ts @@ -137,6 +137,10 @@ export class VerifiableCredential { throw new Error('Issuer and subject must be defined'); } + if(typeof issuer !== 'string' || typeof subject !== 'string') { + throw new Error('Issuer and subject must be of type string'); + } + const credentialSubject: CredentialSubject = { id: subject, ...jsonData diff --git a/packages/credentials/src/verifiable-presentation.ts b/packages/credentials/src/verifiable-presentation.ts new file mode 100644 index 000000000..a74e48df5 --- /dev/null +++ b/packages/credentials/src/verifiable-presentation.ts @@ -0,0 +1,232 @@ +import type { BearerDid } from '@web5/dids'; +import type { IPresentation} from '@sphereon/ssi-types'; + +import { utils as cryptoUtils } from '@web5/crypto'; + +import { Jwt } from './jwt.js'; +import { SsiValidator } from './validators.js'; + +export const DEFAULT_CONTEXT = 'https://www.w3.org/2018/credentials/v1'; +export const DEFAULT_VP_TYPE = 'VerifiablePresentation'; + +/** + * A Verifiable Presentation + * + * @see {@link https://www.w3.org/TR/vc-data-model/#credentials | VC Data Model} + */ +export type VpDataModel = IPresentation; + +/** + * Options for creating a verifiable presentation. + * @param holder The holder URI of the presentation, as a string. + * @param vcJwts The JWTs of the credentials to be included in the presentation. + * @param type Optional. The type of the presentation, can be a string or an array of strings. + * @param additionalData Optional additional data to be included in the presentation. + */ +export type VerifiablePresentationCreateOptions = { + holder: string, + vcJwts: string[], + type?: string | string[]; + additionalData?: Record +}; + +/** + * Options for signing a verifiable presentation. + * @param did - The holder DID of the presentation, represented as a PortableDid. + */ +export type VerifiablePresentationSignOptions = { + did: BearerDid; +}; + +/** + * `VerifiablePresentation` is a tamper-evident presentation encoded in such a way that authorship of the data + * can be trusted after a process of cryptographic verification. + * [W3C Verifiable Presentation Data Model](https://www.w3.org/TR/vc-data-model/#presentations). + * + * It provides functionalities to sign, verify, and create presentations, offering a concise API to + * work with JWT representations of verifiable presentations and ensuring that the signatures + * and claims within those JWTs can be validated. + * + * @property vpDataModel The [vpDataModel] instance representing the core data model of a verifiable presentation. + */ +export class VerifiablePresentation { + constructor(public vpDataModel: VpDataModel) {} + + get type(): string { + return this.vpDataModel.type![this.vpDataModel.type!.length - 1]; + } + + get holder(): string { + return this.vpDataModel.holder!.toString(); + } + + get verifiableCredential(): string[] { + return this.vpDataModel.verifiableCredential! as string[]; + } + + /** + * Signs the verifiable presentation and returns it as a signed JWT. + * + * @example + * ```ts + * const vpJwt = verifiablePresentation.sign({ did: myDid }); + * ``` + * + * @param options - The sign options used to sign the presentation. + * @returns The JWT representing the signed verifiable presentation. + */ + public async sign(options: VerifiablePresentationSignOptions): Promise { + const vpJwt: string = await Jwt.sign({ + signerDid : options.did, + payload : { + vp : this.vpDataModel, + iss : options.did.uri, + sub : options.did.uri, + } + }); + + return vpJwt; + } + + /** + * Converts the current object to its JSON representation. + * + * @returns The JSON representation of the object. + */ + public toString(): string { + return JSON.stringify(this.vpDataModel); + } + + /** + * Create a [VerifiablePresentation] based on the provided parameters. + * + * @example + * ```ts + * const vp = await VerifiablePresentation.create({ + * type: 'PresentationSubmission', + * holder: 'did:ex:holder', + * vcJwts: vcJwts, + * additionalData: { 'arbitrary': 'data' } + * }) + * ``` + * + * @param options - The options to use when creating the Verifiable Presentation. + * @returns A [VerifiablePresentation] instance. + */ + public static async create(options: VerifiablePresentationCreateOptions): Promise { + const { type, holder, vcJwts, additionalData } = options; + + if (additionalData) { + const jsonData = JSON.parse(JSON.stringify(additionalData)); + + if (typeof jsonData !== 'object') { + throw new Error('Expected data to be parseable into a JSON object'); + } + } + + if(!holder) { + throw new Error('Holder must be defined'); + } + + if(typeof holder !== 'string') { + throw new Error('Holder must be of type string'); + } + + const vpDataModel: VpDataModel = { + '@context' : [DEFAULT_CONTEXT], + type : Array.isArray(type) + ? [DEFAULT_VP_TYPE, ...type] + : (type ? [DEFAULT_VP_TYPE, type] : [DEFAULT_VP_TYPE]), + id : `urn:uuid:${cryptoUtils.randomUuid()}`, + holder : holder, + verifiableCredential : vcJwts, + ...additionalData, + }; + + validatePayload(vpDataModel); + + return new VerifiablePresentation(vpDataModel); + } + + /** + * Verifies the integrity and authenticity of a Verifiable Presentation (VP) encoded as a JSON Web Token (JWT). + * + * This function performs several crucial validation steps to ensure the trustworthiness of the provided VP: + * - Parses and validates the structure of the JWT. + * - Ensures the presence of critical header elements `alg` and `kid` in the JWT header. + * - Resolves the Decentralized Identifier (DID) and retrieves the associated DID Document. + * - Validates the DID and establishes a set of valid verification method IDs. + * - Identifies the correct Verification Method from the DID Document based on the `kid` parameter. + * - Verifies the JWT's signature using the public key associated with the Verification Method. + * + * If any of these steps fail, the function will throw a [Error] with a message indicating the nature of the failure. + * + * @example + * ```ts + * try { + * VerifiablePresentation.verify({ vpJwt: signedVpJwt }) + * console.log("VC Verification successful!") + * } catch (e: Error) { + * console.log("VC Verification failed: ${e.message}") + * } + * ``` + * + * @param vpJwt The Verifiable Presentation in JWT format as a [string]. + * @throws Error if the verification fails at any step, providing a message with failure details. + * @throws Error if critical JWT header elements are absent. + */ + public static async verify({ vpJwt }: { + vpJwt: string + }) { + const { payload } = await Jwt.verify({ jwt: vpJwt }); + const vp = payload['vp'] as VpDataModel; + if (!vp) { + throw new Error('vp property missing.'); + } + + validatePayload(vp); + + for (const vcJwt of vp.verifiableCredential!) { + await Jwt.verify({ jwt: vcJwt as string }); + } + + return { + issuer : payload.iss!, + subject : payload.sub!, + vc : payload['vp'] as VpDataModel + }; + } + + /** + * Parses a JWT into a [VerifiablePresentation] instance. + * + * @example + * ```ts + * const vp = VerifiablePresentation.parseJwt({ vpJwt: signedVpJwt }) + * ``` + * + * @param vpJwt The verifiable presentation JWT as a [String]. + * @returns A [VerifiablePresentation] instance derived from the JWT. + */ + public static parseJwt({ vpJwt }: { vpJwt: string }): VerifiablePresentation { + const parsedJwt = Jwt.parse({ jwt: vpJwt }); + const vpDataModel: VpDataModel = parsedJwt.decoded.payload['vp'] as VpDataModel; + + if(!vpDataModel) { + throw Error('Jwt payload missing vp property'); + } + + return new VerifiablePresentation(vpDataModel); + } +} + +/** + * Validates the structure and integrity of a Verifiable Presentation payload. + * + * @param vp - The Verifiable Presentaation object to validate. + * @throws Error if any validation check fails. + */ +function validatePayload(vp: VpDataModel): void { + SsiValidator.validateContext(vp['@context']); + SsiValidator.validateVpType(vp.type!); +} \ No newline at end of file diff --git a/packages/credentials/tests/verifiable-credential.spec.ts b/packages/credentials/tests/verifiable-credential.spec.ts index 00beb160b..629428542 100644 --- a/packages/credentials/tests/verifiable-credential.spec.ts +++ b/packages/credentials/tests/verifiable-credential.spec.ts @@ -140,6 +140,24 @@ describe('Verifiable Credential Tests', async() => { } }); + it('should throw an error if issuer is not string', async () => { + const subjectDid = issuerDid.uri; + + const anyTypeIssuer: any = DidKey.create(); + + try { + await VerifiableCredential.create({ + type : 'StreetCred', + issuer : anyTypeIssuer, + subject : subjectDid, + data : new StreetCredibility('high', true), + }); + expect.fail(); + } catch(e: any) { + expect(e.message).to.include('Issuer and subject must be of type string'); + } + }); + it('should throw an error if data is not parseable into a JSON object', async () => { const issuerDid = 'did:example:issuer'; const subjectDid = 'did:example:subject'; diff --git a/packages/credentials/tests/verifiable-presentation.spec.ts b/packages/credentials/tests/verifiable-presentation.spec.ts new file mode 100644 index 000000000..d9bbaccd9 --- /dev/null +++ b/packages/credentials/tests/verifiable-presentation.spec.ts @@ -0,0 +1,146 @@ +import type { BearerDid } from '@web5/dids'; + +import { expect } from 'chai'; +import { DidKey } from '@web5/dids'; + +import { Jwt } from '../src/jwt.js'; +import { VerifiablePresentation } from '../src/verifiable-presentation.js'; +import { PresentationSubmission } from '@sphereon/pex-models'; + +const validVcJwt = 'eyJraWQiOiJkaWQ6a2V5OnpRM3NoZ0NqVmZucldxOUw3cjFRc3oxcmlRUldvb3pid2dKYkptTGdxRFB2OXNnNGIjelEzc' + +'2hnQ2pWZm5yV3E5TDdyMVFzejFyaVFSV29vemJ3Z0piSm1MZ3FEUHY5c2c0YiIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2SyJ9.eyJpc3Mi' + +'OiJkaWQ6a2V5OnpRM3NoZ0NqVmZucldxOUw3cjFRc3oxcmlRUldvb3pid2dKYkptTGdxRFB2OXNnNGIiLCJzdWIiOiJkaWQ6a2V5OnpRM3No' + +'d2Q0eVVBZldnZkdFUnFVazQ3eEc5NXFOVXNpc0Q3NzZKTHVaN3l6OW5RaWoiLCJpYXQiOjE3MDQ5MTgwODMsInZjIjp7IkBjb250ZXh0Ijpb' + +'Imh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJTdHJlZXRD' + +'cmVkIl0sImlkIjoidXJuOnV1aWQ6NTU2OGQyZTEtYjA0NS00MTQ3LTkxNjUtZTU3YTIxMGM2ZGVlIiwiaXNzdWVyIjoiZGlkOmtleTp6UTNz' + +'aGdDalZmbnJXcTlMN3IxUXN6MXJpUVJXb296YndnSmJKbUxncURQdjlzZzRiIiwiaXNzdWFuY2VEYXRlIjoiMjAyNC0wMS0xMFQyMDoyMToy' + +'M1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6ImRpZDprZXk6elEzc2h3ZDR5VUFmV2dmR0VScVVrNDd4Rzk1cU5Vc2lzRDc3NkpMdVo3' + +'eXo5blFpaiIsImxvY2FsUmVzcGVjdCI6ImhpZ2giLCJsZWdpdCI6dHJ1ZX19fQ.Bx0JrQERWRLpYeg3TnfrOIo4zexo3q1exPZ-Ej6j0T0YO' + +'BVZaZ9-RqpiAM-fHKrdGUzVyXr77pOl7yGgwIO90g'; + +describe('Verifiable Credential Tests', () => { + let holderDid: BearerDid; + + beforeEach(async () => { + holderDid = await DidKey.create(); + }); + + describe('Verifiable Presentation (VP)', () => { + it('create simple vp', async () => { + const vcJwts = ['vcjwt1']; + + const vp = await VerifiablePresentation.create({ + holder : holderDid.uri, + vcJwts : vcJwts + }); + + expect(vp.holder).to.equal(holderDid.uri); + expect(vp.type).to.equal('VerifiablePresentation'); + expect(vp.vpDataModel.verifiableCredential).to.not.be.undefined; + expect(vp.vpDataModel.verifiableCredential).to.deep.equal(vcJwts); + }); + + it('create and sign vp with did:key', async () => { + const vp = await VerifiablePresentation.create({ + holder : holderDid.uri, + vcJwts : [validVcJwt] + }); + + const vpJwt = await vp.sign({ did: holderDid }); + + await VerifiablePresentation.verify({ vpJwt }); + + const parsedVp = await VerifiablePresentation.parseJwt({ vpJwt }); + + expect(vpJwt).to.not.be.undefined; + expect(parsedVp.holder).to.equal(holderDid.uri); + expect(parsedVp.type).to.equal('VerifiablePresentation'); + expect(parsedVp.vpDataModel.verifiableCredential).to.not.be.undefined; + expect(parsedVp.vpDataModel.verifiableCredential).to.deep.equal([validVcJwt]); + }); + + it('create and sign presentatin submission vp', async () => { + const presentationSubmission: PresentationSubmission = { + id : 'presentationSubmissionId', + definition_id : 'definitionId', + descriptor_map : [ + { + id : 'descriptorId', + format : 'format', + path : 'path' + } + ] + }; + + const vp = await VerifiablePresentation.create({ + holder : holderDid.uri, + vcJwts : [validVcJwt], + additionalData : { + presentation_submission: presentationSubmission + }, + type: 'PresentationSubmission' + }); + + const vpJwt = await vp.sign({ did: holderDid }); + + await VerifiablePresentation.verify({ vpJwt }); + + const parsedVp = await VerifiablePresentation.parseJwt({ vpJwt }); + + expect(vpJwt).to.not.be.undefined; + expect(parsedVp.holder).to.equal(holderDid.uri); + expect(parsedVp.type).to.equal('PresentationSubmission'); + expect(parsedVp.vpDataModel.verifiableCredential).to.not.be.undefined; + expect(parsedVp.vpDataModel.verifiableCredential).to.deep.equal([validVcJwt]); + }); + + it('parseJwt throws ParseException if argument is not a valid JWT', async () => { + expect(() => + VerifiablePresentation.parseJwt({ vpJwt: 'hi' }) + ).to.throw('Malformed JWT'); + }); + + it('parseJwt checks if missing vp property', async () => { + const did = await DidKey.create(); + const jwt = await Jwt.sign({ + signerDid : did, + payload : { + iss : did.uri, + sub : did.uri + } + }); + + expect(() => + VerifiablePresentation.parseJwt({ vpJwt: jwt }) + ).to.throw('Jwt payload missing vp property'); + }); + + it('should throw an error if holder is not defined', async () => { + try { + await VerifiablePresentation.create({ + holder : '', + vcJwts : [validVcJwt] + }); + + expect.fail(); + } catch(e: any) { + expect(e.message).to.include('Holder must be defined'); + } + }); + + it('should throw an error if holder is not a string', async () => { + const anyTypeHolder: any = DidKey.create(); + + try { + await VerifiablePresentation.create({ + holder : anyTypeHolder, + vcJwts : [validVcJwt] + }); + + expect.fail(); + } catch(e: any) { + expect(e.message).to.include('Holder must be of type string'); + } + }); + }); +}); \ No newline at end of file