diff --git a/packages/credentials/src/types.ts b/packages/credentials/src/types.ts index 216d42244..327449f31 100644 --- a/packages/credentials/src/types.ts +++ b/packages/credentials/src/types.ts @@ -1,10 +1,16 @@ -import { ICredential, IIssuer, ICredentialStatus, ICredentialSubject} from '@sphereon/ssi-types'; +import { ICredential, IIssuer, ICredentialStatus, ICredentialSubject, ICredentialSchemaType} from '@sphereon/ssi-types'; /** * @see {@link https://www.w3.org/TR/vc-data-model/#credentials | VC data model} */ export type { ICredential as VerifiableCredential }; + +/** + * @see {@link https://www.w3.org/TR/vc-data-model/#data-schemas | Data schemas} + */ +export type { ICredentialSchemaType as CredentialSchemaType }; + /** * The issuer of a {@link VerifiableCredential}. * The value of the issuer property must be either a URI or an object containing an `id` property. diff --git a/packages/credentials/src/utils.ts b/packages/credentials/src/utils.ts index 33ca6c65f..929fbb059 100644 --- a/packages/credentials/src/utils.ts +++ b/packages/credentials/src/utils.ts @@ -4,9 +4,18 @@ export function getCurrentXmlSchema112Timestamp() : string { } export function getFutureXmlSchema112Timestamp(secondsInFuture: number): string { - // Create a new Date object for the current time plus the specified number of seconds - const futureDate = new Date(Date.now() + secondsInFuture * 1000); // convert seconds to milliseconds - - // Omit the milliseconds part from toISOString() output + const futureDate = new Date(Date.now() + secondsInFuture * 1000); return futureDate.toISOString().replace(/\.\d+Z$/, 'Z'); +} + +export function isValidXmlSchema112Timestamp(timestamp: string): boolean { + // Format: yyyy-MM-ddTHH:mm:ssZ + const regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; + if (!regex.test(timestamp)) { + return false; + } + + const date = new Date(timestamp); + + return !isNaN(date.getTime()); } \ No newline at end of file diff --git a/packages/credentials/tests/utils.spec.ts b/packages/credentials/tests/utils.spec.ts index dc7ed7ddf..3c425ab7c 100644 --- a/packages/credentials/tests/utils.spec.ts +++ b/packages/credentials/tests/utils.spec.ts @@ -1,17 +1,47 @@ import { expect } from 'chai'; -import {getCurrentXmlSchema112Timestamp, getFutureXmlSchema112Timestamp} from '../src/utils.js'; +import {getCurrentXmlSchema112Timestamp, getFutureXmlSchema112Timestamp, isValidXmlSchema112Timestamp} from '../src/utils.js'; describe('credentials utils', () => { - it('gets correct time', () => { - const timestamp = getCurrentXmlSchema112Timestamp(); - expect(timestamp).to.not.be.undefined; - expect(timestamp).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + describe('getCurrentXmlSchema112Timestamp', () => { + it('gets correct time', () => { + const timestamp = getCurrentXmlSchema112Timestamp(); + expect(timestamp).to.not.be.undefined; + expect(timestamp).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + + }); + }); + describe('getFutureXmlSchema112Timestamp', () => { + it('gets correct time', () => { + const timestamp = getFutureXmlSchema112Timestamp(123); + expect(timestamp).to.not.be.undefined; + expect(timestamp).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + }); }); - it('gets correct time', () => { - const timestamp = getFutureXmlSchema112Timestamp(123); - expect(timestamp).to.not.be.undefined; - expect(timestamp).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + describe('validateXmlSchema112Timestamp', () => { + it('validates correctly formatted timestamps', () => { + const timestamp = '2023-07-31T12:34:56Z'; + const result = isValidXmlSchema112Timestamp(timestamp); + expect(result).to.be.true; + }); + + it('rejects incorrectly formatted timestamps', () => { + const badTimestamp = '2023-07-31T12:34:56.789Z'; // includes milliseconds + const result = isValidXmlSchema112Timestamp(badTimestamp); + expect(result).to.be.false; + }); + + it('rejects non-timestamps', () => { + const notATimestamp = 'This is definitely not a timestamp'; + const result = isValidXmlSchema112Timestamp(notATimestamp); + expect(result).to.be.false; + }); + + it('rejects empty string', () => { + const emptyString = ''; + const result = isValidXmlSchema112Timestamp(emptyString); + expect(result).to.be.false; + }); }); }); \ No newline at end of file diff --git a/packages/web5-user-agent/src/web5-user-agent.ts b/packages/web5-user-agent/src/web5-user-agent.ts index f3ad56970..4203d5d19 100644 --- a/packages/web5-user-agent/src/web5-user-agent.ts +++ b/packages/web5-user-agent/src/web5-user-agent.ts @@ -205,8 +205,12 @@ export class Web5UserAgent implements Web5Agent { } async processVcRequest(request: ProcessVcRequest): Promise { + if (!request.vc) { + throw new Error(`must have vc to process vc request`); + } let kid = request.kid; + if (!kid) { const didResolution = await this.didResolver.resolve(request.author); @@ -229,8 +233,39 @@ export class Web5UserAgent implements Web5Agent { const vcJwt = await this.#sign(request.vc as VerifiableCredential, kid); - const messageOptions: Partial = { ...{ schema: 'vc/vc', dataFormat: 'application/vc+jwt' } }; - const dataBlob = new Blob([vcJwt], { type: 'text/plain' }); + let schema; + + if (request.vc.credentialSchema) { + let credentialSchema = request.vc.credentialSchema; + if (typeof credentialSchema === 'string') { + schema = credentialSchema; + } else if (Array.isArray(credentialSchema)) { + for (let item of credentialSchema) { + if (typeof item !== 'string' && item.id) { + schema = item.id; + break; + } + } + } else if (typeof credentialSchema === 'object' && credentialSchema.id) { + schema = credentialSchema.id; + } + } else if (!schema && request.vc['@context']) { + let context = request.vc['@context']; + + if (typeof context === 'string' && context !== 'https://www.w3.org/2018/credentials/v1') { + schema = context; + } else if (Array.isArray(context)) { + let filteredContext = context.filter(e => typeof e === 'string' && e !== 'https://www.w3.org/2018/credentials/v1'); + if (filteredContext.length > 0) { + schema = filteredContext[0]; + } + } else if (typeof context === 'object' && context.name && context.name !== 'https://www.w3.org/2018/credentials/v1') { + schema = context.name; + } + } + + const messageOptions: Partial = { ...{ schema, dataFormat: 'application/vc+jwt' } }; + const dataBlob = new Blob([vcJwt], { type: 'application/vc+jwt' }); const dwnResponse = await this.processDwnRequest({ author : request.author, @@ -343,8 +378,6 @@ export class Web5UserAgent implements Web5Agent { return { message: dwnMessage.toJSON(), dataStream: readableStream }; } - - // TODO: have issuer did key already stored in key manager and use that instead of generating a new one async #sign(vc: VerifiableCredential, kid: string): Promise { const keyPair = await this.keyManager.getKey({keyRef: kid}) as ManagedKeyPair; diff --git a/packages/web5-user-agent/tests/common/web5-user-agent.spec.ts b/packages/web5-user-agent/tests/common/web5-user-agent.spec.ts index e3916610e..fda6482f3 100644 --- a/packages/web5-user-agent/tests/common/web5-user-agent.spec.ts +++ b/packages/web5-user-agent/tests/common/web5-user-agent.spec.ts @@ -193,4 +193,29 @@ describe('Web5UserAgent', () => { expect(dataBytes).to.eql(value); }); }); + + describe('sendVcRequest', () => { + it('throws an exception method not implemented.', async () => { + try { + const vc = { + id : '123', + '@context' : ['https://www.w3.org/2018/credentials/v1'], + credentialSubject : {firstName: 'Bob'}, + type : ['VerifiableCredential'], + issuer : did, + issuanceDate : '2023-07-31T12:34:56Z', + }; + + await testAgent.agent.sendVcRequest({ + author : did, + target : did, + vc : vc + }); + + expect.fail(); + } catch(e) { + expect(e.message).to.include(`Method not implemented.`); + } + }); + }); }); \ No newline at end of file diff --git a/packages/web5-user-agent/tests/node/web5-user-agent.spec.ts b/packages/web5-user-agent/tests/node/web5-user-agent.spec.ts index a8ddb1ac6..2a5a594ac 100644 --- a/packages/web5-user-agent/tests/node/web5-user-agent.spec.ts +++ b/packages/web5-user-agent/tests/node/web5-user-agent.spec.ts @@ -21,4 +21,8 @@ describe('[Node only] Web5UserAgent', () => { describe('processDwnRequest', () => { xit('can accept Blobs'); }); + + describe('processVcRequest', () => { + xit('can accept Vcs'); + }); }); \ No newline at end of file diff --git a/packages/web5/src/vc-api.ts b/packages/web5/src/vc-api.ts index 52d3719d7..6b7b55d91 100644 --- a/packages/web5/src/vc-api.ts +++ b/packages/web5/src/vc-api.ts @@ -1,12 +1,20 @@ -import { Web5Agent, VcResponse } from '@tbd54566975/web5-agent'; -import type { VerifiableCredential } from '../../credentials/src/types.js'; - -import { getCurrentXmlSchema112Timestamp } from '../../credentials/src/utils.js'; import { v4 as uuidv4 } from 'uuid'; + +import { Web5Agent, VcResponse } from '@tbd54566975/web5-agent'; import { UnionMessageReply, RecordsWriteMessage } from '@tbd54566975/dwn-sdk-js'; +import type { VerifiableCredential, CredentialSchemaType } from '@tbd54566975/credentials/'; +import { getCurrentXmlSchema112Timestamp, isValidXmlSchema112Timestamp } from '@tbd54566975/credentials'; + import { Record } from './record.js'; +export type VcCreateRequest = { + credentialSubject: any; + kid?: string; + credentialSchema?: CredentialSchemaType | CredentialSchemaType[]; + expirationDate?: string; +} + export type VcCreateResponse = { status: UnionMessageReply['status']; record?: Record @@ -22,26 +30,31 @@ export class VcApi { this.#connectedDid = connectedDid; } - // TODO: Add CreateOptions for more robust VC creation - async create(credentialSubject: any, kid?: string): Promise { - if (!credentialSubject || typeof credentialSubject !== 'object') { + async create(request: VcCreateRequest): Promise { + if (!request.credentialSubject || typeof request.credentialSubject !== 'object') { throw new Error('credentialSubject not valid'); } + if (request?.expirationDate && !isValidXmlSchema112Timestamp(request?.expirationDate)) { + throw new Error('expirationDate not valid'); + } + const vc: VerifiableCredential = { id : uuidv4(), '@context' : ['https://www.w3.org/2018/credentials/v1'], - credentialSubject : credentialSubject, + credentialSubject : request.credentialSubject, type : ['VerifiableCredential'], issuer : this.#connectedDid , issuanceDate : getCurrentXmlSchema112Timestamp(), + credentialSchema : request?.credentialSchema, + expirationDate : request?.expirationDate, }; const agentResponse: VcResponse = await this.#web5Agent.processVcRequest({ author : this.#connectedDid, target : this.#connectedDid, vc : vc, - kid : kid + kid : request.kid }); const { message, reply: { status } } = agentResponse; @@ -51,7 +64,7 @@ export class VcApi { if (200 <= status.code && status.code <= 299) { const recordOptions = { author : this.#connectedDid, - encodedData : new Blob([agentResponse.vcJwt], { type: 'text/plain' }), + encodedData : new Blob([agentResponse.vcJwt], { type: 'application/vc+jwt' }), target : this.#connectedDid, ...responseMessage, }; diff --git a/packages/web5/tests/test-utils/test-user-agent.ts b/packages/web5/tests/test-utils/test-user-agent.ts index 67f3442c2..cc3758c01 100644 --- a/packages/web5/tests/test-utils/test-user-agent.ts +++ b/packages/web5/tests/test-utils/test-user-agent.ts @@ -168,6 +168,8 @@ export class TestAgent { connections : [appDidState.id], }); + // TODO: Import did auth key into key manager (but will have to convert format from jwk to key material) + return { did: profile.did.id }; } diff --git a/packages/web5/tests/web5-vc.spec.ts b/packages/web5/tests/web5-vc.spec.ts index 28a9ff72c..5b32a755d 100644 --- a/packages/web5/tests/web5-vc.spec.ts +++ b/packages/web5/tests/web5-vc.spec.ts @@ -2,10 +2,10 @@ import { expect } from 'chai'; import * as testProfile from './fixtures/test-profiles.js'; -import { VcApi } from '../src/vc-api.js'; +import { VcApi, VcCreateRequest } from '../src/vc-api.js'; import { TestAgent, TestProfileOptions } from './test-utils/test-user-agent.js'; -// import jwt from 'jsonwebtoken'; +import jwt from 'jsonwebtoken'; let did: string; let vcApi: VcApi; @@ -21,9 +21,6 @@ describe('web5.vc', () => { await testAgent.clearStorage(); testProfileOptions = await testProfile.ion.with.dwn.service.and.authorization.keys(); - // TODO: Store this key in keymanager - // console.log(testProfileOptions.profileDidOptions.keys[0]); - ({ did } = await testAgent.createProfile(testProfileOptions)); vcApi = new VcApi(testAgent.agent, did); @@ -38,27 +35,64 @@ describe('web5.vc', () => { describe('create', () => { it('valid vc', async () => { const credentialSubject = {firstName: 'alice'}; - const result = await vcApi.create(credentialSubject, testAgent.signKeyPair.privateKey.id); - // const resultRecord = await result.record?.data.text(); - // console.log({resultRecord}); + const vcCreateRequest: VcCreateRequest = { credentialSubject: credentialSubject, kid: testAgent.signKeyPair.privateKey.id}; + const result = await vcApi.create(vcCreateRequest); - // const decoded = jwt.decode(resultRecord, { complete: true }); - // console.log(decoded); + const resultRecord = await result.record?.data.text(); + const decodedVc = jwt.decode(resultRecord, { complete: true }); expect(result.status.code).to.equal(202); expect(result.status.detail).to.equal('Accepted'); expect(result.record).to.exist; - // expect(resultRecord).to.deep.equal(result.vcJwt); - // expect(decoded.payload.id).to.equal(result.vc.id); - // expect(decoded.payload.credentialSubject).to.deep.equal(result.vc.credentialSubject); - // expect(decoded.payload.issuer).to.deep.equal(result.vc.issuer); + expect(resultRecord).to.equal(result.vcJwt); + expect(decodedVc.payload.vc.credentialSubject).to.deep.equal(credentialSubject); + }); + + it('simple schema string', async () => { + const credentialSubject = {firstName: 'alice'}; + + const vcCreateRequest: VcCreateRequest = { credentialSubject: credentialSubject, kid: testAgent.signKeyPair.privateKey.id, credentialSchema: 'https://schema.org/Person'}; + const result = await vcApi.create(vcCreateRequest); + + const resultRecord = await result.record?.data.text(); + const decodedVc = jwt.decode(resultRecord, { complete: true }); + + expect(result.status.code).to.equal(202); + expect(decodedVc.payload.vc.credentialSchema).to.equal('https://schema.org/Person'); + expect(result.record?.schema).to.equal('https://schema.org/Person'); + }); + + it('simple schema type', async () => { + const credentialSubject = {firstName: 'alice'}; + + const vcCreateRequest: VcCreateRequest = { credentialSubject: credentialSubject, kid: testAgent.signKeyPair.privateKey.id, credentialSchema: {id: 'https://schema.org/Person', type: 'JsonSchemaValidator2018'}}; + const result = await vcApi.create(vcCreateRequest); + + const resultRecord = await result.record?.data.text(); + const decodedVc = jwt.decode(resultRecord, { complete: true }); + + expect(result.status.code).to.equal(202); + expect(decodedVc.payload.vc.credentialSchema).to.deep.equal({id: 'https://schema.org/Person', type: 'JsonSchemaValidator2018'}); + expect(result.record?.schema).to.equal('https://schema.org/Person'); + }); + + it('invalid expiration date', async () => { + const credentialSubject = {firstName: 'alice'}; + try { + const vcCreateRequest: VcCreateRequest = { credentialSubject: credentialSubject, kid: testAgent.signKeyPair.privateKey.id, expirationDate: 'badexpirationdate'}; + await vcApi.create(vcCreateRequest); + expect.fail(); + } catch(e) { + expect(e.message).to.include('expirationDate not valid'); + } }); it('invalid credential subject', async () => { const credentialSubject = 'badcredsubject'; try { - await vcApi.create(credentialSubject, testAgent.signKeyPair.privateKey.id); + const vcCreateRequest: VcCreateRequest = { credentialSubject: credentialSubject, kid: testAgent.signKeyPair.privateKey.id}; + await vcApi.create(vcCreateRequest); expect.fail(); } catch(e) { expect(e.message).to.include('credentialSubject not valid');