diff --git a/packages/credentials/src/utils.ts b/packages/credentials/src/utils.ts index a49862f89..4bb705d4c 100644 --- a/packages/credentials/src/utils.ts +++ b/packages/credentials/src/utils.ts @@ -16,6 +16,30 @@ export function getCurrentXmlSchema112Timestamp(): string { return new Date().toISOString().replace(/\.\d+Z$/, 'Z'); } +/** + * Converts a UNIX timestamp to an XML Schema 1.1.2 compliant date-time string, omitting milliseconds. + * + * This function takes a UNIX timestamp (number of seconds since the UNIX epoch) as input and converts it + * to a date-time string formatted according to XML Schema 1.1.2 specifications, specifically omitting + * the milliseconds component from the standard ISO 8601 format. This is useful for generating + * timestamps for verifiable credentials and other applications requiring precision to the second + * without the need for millisecond granularity. + * + * @param timestampInSeconds The UNIX timestamp to convert, measured in seconds. + * @example + * ```ts + * const issuanceDate = getXmlSchema112Timestamp(1633036800); // "2021-10-01T00:00:00Z" + * ``` + * + * @returns A date-time string in the format "yyyy-MM-ddTHH:mm:ssZ", compliant with XML Schema 1.1.2, based on the provided UNIX timestamp. + */ +export function getXmlSchema112Timestamp(timestampInSeconds: number): string { + const date = new Date(timestampInSeconds * 1000); + + // Format the date to an ISO string and then remove milliseconds + return date.toISOString().replace(/\.\d{3}/, ''); +} + /** * Calculates a future timestamp in XML Schema 1.1.2 date-time format based on a given number of * seconds. @@ -59,5 +83,33 @@ export function isValidXmlSchema112Timestamp(timestamp: string): boolean { const date = new Date(timestamp); + return !isNaN(date.getTime()); +} + +/** + * Validates a timestamp string against the RFC 3339 format. + * + * This function checks whether the provided timestamp string conforms to the + * RFC 3339 standard, which includes full date and time representations with + * optional fractional seconds and a timezone offset. The format allows for + * both 'Z' (indicating UTC) and numeric timezone offsets (e.g., "-07:00", "+05:30"). + * This validation ensures that the timestamp is not only correctly formatted + * but also represents a valid date and time. + * + * @param timestamp - The timestamp string to validate. + * @returns `true` if the timestamp is valid and conforms to RFC 3339, `false` otherwise. + */ +export function isValidRFC3339Timestamp(timestamp: string): boolean { + // RFC 3339 format: yyyy-MM-ddTHH:mm:ss[.fractional-seconds]Z or yyyy-MM-ddTHH:mm:ss[.fractional-seconds]±HH:mm + // This regex matches both 'Z' for UTC and timezone offsets like '-07:00' + const regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + if (!regex.test(timestamp)) { + return false; + } + + // Parsing the timestamp to a Date object to check validity + const date = new Date(timestamp); + + // Checking if the date is an actual date return !isNaN(date.getTime()); } \ No newline at end of file diff --git a/packages/credentials/src/validators.ts b/packages/credentials/src/validators.ts index 637487ee7..7d682e3f3 100644 --- a/packages/credentials/src/validators.ts +++ b/packages/credentials/src/validators.ts @@ -9,7 +9,7 @@ import { VerifiableCredential } from './verifiable-credential.js'; -import { isValidXmlSchema112Timestamp } from './utils.js'; +import { isValidRFC3339Timestamp, isValidXmlSchema112Timestamp } from './utils.js'; import { DEFAULT_VP_TYPE } from './verifiable-presentation.js'; export class SsiValidator { @@ -49,7 +49,7 @@ export class SsiValidator { } static validateTimestamp(timestamp: string) { - if(!isValidXmlSchema112Timestamp(timestamp)){ + if(!isValidXmlSchema112Timestamp(timestamp) && !isValidRFC3339Timestamp(timestamp)){ throw new Error(`timestamp is not valid xml schema 112 timestamp`); } } diff --git a/packages/credentials/src/verifiable-credential.ts b/packages/credentials/src/verifiable-credential.ts index e4ff03924..39cd7540f 100644 --- a/packages/credentials/src/verifiable-credential.ts +++ b/packages/credentials/src/verifiable-credential.ts @@ -5,7 +5,7 @@ import { utils as cryptoUtils } from '@web5/crypto'; import { Jwt } from './jwt.js'; import { SsiValidator } from './validators.js'; -import { getCurrentXmlSchema112Timestamp } from './utils.js'; +import { getCurrentXmlSchema112Timestamp, getXmlSchema112Timestamp } from './utils.js'; export const DEFAULT_VC_CONTEXT = 'https://www.w3.org/2018/credentials/v1'; export const DEFAULT_VC_TYPE = 'VerifiableCredential'; @@ -91,8 +91,14 @@ export class VerifiableCredential { signerDid : options.did, payload : { vc : this.vcDataModel, - iss : this.issuer, + nbf : Math.floor(new Date(this.vcDataModel.issuanceDate).getTime() / 1000), + jti : this.vcDataModel.id, + iss : options.did.uri, sub : this.subject, + iat : Math.floor(Date.now() / 1000), + ...(this.vcDataModel.expirationDate && { + exp: Math.floor(new Date(this.vcDataModel.expirationDate).getTime() / 1000), + }), } }); @@ -174,7 +180,19 @@ export class VerifiableCredential { * - 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. + * If any of these steps fail, the function will throw a [Error] with a message indicating the nature of the failure: + * - exp MUST represent the expirationDate property, encoded as a UNIX timestamp (NumericDate). + * - iss MUST represent the issuer property of a verifiable credential or the holder property of a verifiable presentation. + * - nbf MUST represent issuanceDate, encoded as a UNIX timestamp (NumericDate). + * - jti MUST represent the id property of the verifiable credential or verifiable presentation. + * - sub MUST represent the id property contained in the credentialSubject. + * + * Once the verifications are successful, when recreating the VC data model object, this function will: + * - If exp is present, the UNIX timestamp MUST be converted to an [XMLSCHEMA11-2] date-time, and MUST be used to set the value of the expirationDate property of credentialSubject of the new JSON object. + * - If iss is present, the value MUST be used to set the issuer property of the new credential JSON object or the holder property of the new presentation JSON object. + * - If nbf is present, the UNIX timestamp MUST be converted to an [XMLSCHEMA11-2] date-time, and MUST be used to set the value of the issuanceDate property of the new JSON object. + * - If sub is present, the value MUST be used to set the value of the id property of credentialSubject of the new credential JSON object. + * - If jti is present, the value MUST be used to set the value of the id property of the new JSON object. * * @example * ```ts @@ -194,17 +212,71 @@ export class VerifiableCredential { vcJwt: string }) { const { payload } = await Jwt.verify({ jwt: vcJwt }); - const vc = payload['vc'] as VcDataModel; + const { exp, iss, nbf, jti, sub, vc } = payload; + if (!vc) { throw new Error('vc property missing.'); } - validatePayload(vc); + const vcTyped: VcDataModel = payload['vc'] as VcDataModel; + + // exp MUST represent the expirationDate property, encoded as a UNIX timestamp (NumericDate). + if(exp && vcTyped.expirationDate && exp !== Math.floor(new Date(vcTyped.expirationDate).getTime() / 1000)) { + throw new Error('Verification failed: exp claim does not match expirationDate'); + } + + // If exp is present, the UNIX timestamp MUST be converted to an [XMLSCHEMA11-2] date-time, and MUST be used to set the value of the expirationDate property of credentialSubject of the new JSON object. + if(exp) { + vcTyped.expirationDate = getXmlSchema112Timestamp(exp); + } + + if (!iss) throw new Error('Verification failed: iss claim is required'); + + // iss MUST represent the issuer property of a verifiable credential or the holder property of a verifiable presentation. + if (iss !== vcTyped.issuer) { + throw new Error('Verification failed: iss claim does not match expected issuer'); + } + + // nbf cannot represent time in the future + if(nbf && nbf > Math.floor(Date.now() / 1000)) { + throw new Error('Verification failed: nbf claim is in the future'); + } + + // nbf MUST represent issuanceDate, encoded as a UNIX timestamp (NumericDate). + if(nbf && vcTyped.issuanceDate && nbf !== Math.floor(new Date(vcTyped.issuanceDate).getTime() / 1000)) { + throw new Error('Verification failed: nbf claim does not match issuanceDate'); + } + + // If nbf is present, the UNIX timestamp MUST be converted to an [XMLSCHEMA11-2] date-time, and MUST be used to set the value of the issuanceDate property of the new JSON object. + if(nbf) { + vcTyped.issuanceDate = getXmlSchema112Timestamp(nbf); + } + + // sub MUST represent the id property contained in the credentialSubject. + if(sub && !Array.isArray(vcTyped.credentialSubject) && sub !== vcTyped.credentialSubject.id) { + throw new Error('Verification failed: sub claim does not match credentialSubject.id'); + } + + // If sub is present, the value MUST be used to set the value of the id property of credentialSubject of the new credential JSON object. + if(sub && !Array.isArray(vcTyped.credentialSubject)) { + vcTyped.credentialSubject.id = sub; + } + + // jti MUST represent the id property of the verifiable credential or verifiable presentation. + if(jti && jti !== vcTyped.id) { + throw new Error('Verification failed: jti claim does not match id'); + } + + if(jti) { + vcTyped.id = jti; + } + + validatePayload(vcTyped); return { issuer : payload.iss!, subject : payload.sub!, - vc : payload['vc'] as VcDataModel + vc : vcTyped }; } @@ -227,6 +299,8 @@ export class VerifiableCredential { throw Error('Jwt payload missing vc property'); } + validatePayload(vcDataModel); + return new VerifiableCredential(vcDataModel); } } diff --git a/packages/credentials/src/verifiable-presentation.ts b/packages/credentials/src/verifiable-presentation.ts index 7d1ec96ad..f507f3220 100644 --- a/packages/credentials/src/verifiable-presentation.ts +++ b/packages/credentials/src/verifiable-presentation.ts @@ -5,7 +5,8 @@ import { utils as cryptoUtils } from '@web5/crypto'; import { Jwt } from './jwt.js'; import { SsiValidator } from './validators.js'; -import { DEFAULT_VC_CONTEXT } from './verifiable-credential.js'; + +import { VerifiableCredential, DEFAULT_VC_CONTEXT } from './verifiable-credential.js'; export const DEFAULT_VP_TYPE = 'VerifiablePresentation'; @@ -82,6 +83,8 @@ export class VerifiablePresentation { vp : this.vpDataModel, iss : options.did.uri, sub : options.did.uri, + jti : this.vpDataModel.id, + iat : Math.floor(Date.now() / 1000) } }); @@ -187,7 +190,7 @@ export class VerifiablePresentation { validatePayload(vp); for (const vcJwt of vp.verifiableCredential!) { - await Jwt.verify({ jwt: vcJwt as string }); + await VerifiableCredential.verify({ vcJwt: vcJwt as string }); } return { diff --git a/packages/credentials/tests/jwt.spec.ts b/packages/credentials/tests/jwt.spec.ts index 452c27051..23320aa3e 100644 --- a/packages/credentials/tests/jwt.spec.ts +++ b/packages/credentials/tests/jwt.spec.ts @@ -6,6 +6,9 @@ import { Ed25519 } from '@web5/crypto'; import { DidJwk, DidKey, PortableDid } from '@web5/dids'; import { Jwt } from '../src/jwt.js'; +import JwtVerifyTestVector from '../../../web5-spec/test-vectors/vc_jwt/verify.json' assert { type: 'json' }; +import JwtDecodeTestVector from '../../../web5-spec/test-vectors/vc_jwt/decode.json' assert { type: 'json' }; +import { VerifiableCredential } from '../src/verifiable-credential.js'; describe('Jwt', () => { describe('parse()', () => { @@ -89,7 +92,7 @@ describe('Jwt', () => { const header: JwtHeaderParams = { typ: 'JWT', alg: 'ES256K', kid: did.uri }; const base64UrlEncodedHeader = Convert.object(header).toBase64Url(); - const payload: JwtPayload = { iat: Math.floor(Date.now() / 1000) }; + const payload: JwtPayload = { iat: Math.floor(Date.now() / 1000), iss: did.uri, sub: did.uri }; const base64UrlEncodedPayload = Convert.object(payload).toBase64Url(); try { @@ -105,7 +108,7 @@ describe('Jwt', () => { const header: JwtHeaderParams = { typ: 'JWT', alg: 'ES256', kid: did.document.verificationMethod![0].id }; const base64UrlEncodedHeader = Convert.object(header).toBase64Url(); - const payload: JwtPayload = { iat: Math.floor(Date.now() / 1000) }; + const payload: JwtPayload = { iat: Math.floor(Date.now() / 1000), iss: did.uri, sub: did.uri }; const base64UrlEncodedPayload = Convert.object(payload).toBase64Url(); try { @@ -155,7 +158,7 @@ describe('Jwt', () => { const header: JwtHeaderParams = { typ: 'JWT', alg: 'EdDSA', kid: did.document.verificationMethod![0].id }; const base64UrlEncodedHeader = Convert.object(header).toBase64Url(); - const payload: JwtPayload = { iat: Math.floor(Date.now() / 1000) }; + const payload: JwtPayload = { iat: Math.floor(Date.now() / 1000), iss: did.uri, sub: did.uri }; const base64UrlEncodedPayload = Convert.object(payload).toBase64Url(); const toSign = `${base64UrlEncodedHeader}.${base64UrlEncodedPayload}`; @@ -173,4 +176,59 @@ describe('Jwt', () => { expect(verifyResult.payload).to.deep.equal(payload); }); }); + + describe('Web5TestVectorsVcJwt', () => { + it('decode', async () => { + const vectors = JwtDecodeTestVector.vectors; + + for (const vector of vectors) { + const { input, errors, errorMessage } = vector; + + if (errors) { + let errorOccurred = false; + try { + VerifiableCredential.parseJwt({ vcJwt: input }); + } catch (e: any) { + errorOccurred = true; + expect(e.message).to.not.be.null; + if(errorMessage && errorMessage['web5-js']) { + expect(e.message).to.include(errorMessage['web5-js']); + } + } + if (!errorOccurred) { + throw new Error('Verification should have failed but didn\'t.'); + } + } else { + VerifiableCredential.parseJwt({ vcJwt: input }); + } + } + }); + + it('verify', async () => { + const vectors = JwtVerifyTestVector.vectors; + + for (const vector of vectors) { + const { input, errors, errorMessage } = vector; + + if (errors) { + let errorOccurred = false; + try { + await VerifiableCredential.verify({ vcJwt: input }); + } catch (e: any) { + errorOccurred = true; + expect(e.message).to.not.be.null; + if(errorMessage && errorMessage['web5-js']) { + expect(e.message).to.include(errorMessage['web5-js']); + } + } + if (!errorOccurred) { + throw new Error('Verification should have failed but didn\'t.'); + } + } else { + // Expecting successful verification + await VerifiableCredential.verify({ vcJwt: input }); + } + } + }); + }); }); \ No newline at end of file diff --git a/packages/credentials/tests/utils.spec.ts b/packages/credentials/tests/utils.spec.ts index 2858c794e..d420e6bbf 100644 --- a/packages/credentials/tests/utils.spec.ts +++ b/packages/credentials/tests/utils.spec.ts @@ -4,6 +4,7 @@ import { isValidXmlSchema112Timestamp, getFutureXmlSchema112Timestamp, getCurrentXmlSchema112Timestamp, + isValidRFC3339Timestamp, } from '../src/utils.js'; describe('CredentialsUtils', () => { @@ -50,4 +51,48 @@ describe('CredentialsUtils', () => { expect(result).to.be.false; }); }); + + describe('isValidRFC3339Timestamp', () => { + it('validates correctly formatted timestamps without fractional seconds and with Z timezone', () => { + const timestamp = '2023-07-31T12:34:56Z'; + const result = isValidRFC3339Timestamp(timestamp); + expect(result).to.be.true; + }); + + it('validates correctly formatted timestamps with fractional seconds and Z timezone', () => { + const timestampWithFractionalSeconds = '2023-07-31T12:34:56.789Z'; + const result = isValidRFC3339Timestamp(timestampWithFractionalSeconds); + expect(result).to.be.true; + }); + + it('validates correctly formatted timestamps with timezone offset', () => { + const timestampWithOffset = '2023-07-31T12:34:56-07:00'; + const result = isValidRFC3339Timestamp(timestampWithOffset); + expect(result).to.be.true; + }); + + it('rejects incorrectly formatted timestamps', () => { + const badTimestamp = '2023-07-31 12:34:56'; + const result = isValidRFC3339Timestamp(badTimestamp); + expect(result).to.be.false; + }); + + it('rejects non-timestamp strings', () => { + const notATimestamp = 'This is definitely not a timestamp'; + const result = isValidRFC3339Timestamp(notATimestamp); + expect(result).to.be.false; + }); + + it('rejects empty string', () => { + const emptyString = ''; + const result = isValidRFC3339Timestamp(emptyString); + expect(result).to.be.false; + }); + + it('validates correctly formatted timestamps with fractional seconds and timezone offset', () => { + const timestampWithFractionalSecondsAndOffset = '2023-07-31T12:34:56.789+02:00'; + const result = isValidRFC3339Timestamp(timestampWithFractionalSecondsAndOffset); + expect(result).to.be.true; + }); + }); }); \ 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 629428542..0017dfa86 100644 --- a/packages/credentials/tests/verifiable-credential.spec.ts +++ b/packages/credentials/tests/verifiable-credential.spec.ts @@ -7,6 +7,7 @@ import { DidDht, DidKey, DidIon, DidJwk } from '@web5/dids'; import { Jwt } from '../src/jwt.js'; import { VerifiableCredential } from '../src/verifiable-credential.js'; import CredentialsVerifyTestVector from '../../../web5-spec/test-vectors/credentials/verify.json' assert { type: 'json' }; +import { getCurrentXmlSchema112Timestamp, getXmlSchema112Timestamp } from '../src/utils.js'; describe('Verifiable Credential Tests', async() => { let issuerDid: BearerDid; @@ -90,6 +91,32 @@ describe('Verifiable Credential Tests', async() => { } }); + it('create and sign kyc vc with did:jwk', async () => { + const did = await DidJwk.create(); + + const vc = await VerifiableCredential.create({ + type : 'KnowYourCustomerCred', + subject : did.uri, + issuer : did.uri, + expirationDate : getXmlSchema112Timestamp(2687920690), // 2055-03-05 + data : { + country: 'us' + } + }); + + const vcJwt = await vc.sign({ did }); + + 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('KnowYourCustomerCred'); + expect(currentVc.vcDataModel.issuanceDate).to.not.be.undefined; + expect(currentVc.vcDataModel.credentialSubject).to.deep.equal({ id: did.uri, country: 'us'}); + } + }); + it('create and sign vc with did:ion', async () => { const did = await DidIon.create(); @@ -158,6 +185,25 @@ describe('Verifiable Credential Tests', async() => { } }); + it('should throw and error if wrong issuer', async () => { + const issuerDid = await DidKey.create(); + const vc = await VerifiableCredential.create({ + type : 'StreetCred', + issuer : 'did:fakeissuer:123', + subject : 'did:subject:123', + data : new StreetCredibility('high', true), + }); + + const vcJwt = await vc.sign({ did: issuerDid }); + + try { + await VerifiableCredential.verify({ vcJwt }); + expect.fail(); + } catch(e: any) { + expect(e.message).to.include('Verification failed: iss claim does not match expected issuer'); + } + }); + 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'; @@ -324,7 +370,7 @@ describe('Verifiable Credential Tests', async() => { const did = await DidKey.create(); const jwt = await Jwt.sign({ - payload : { jti: 'hi' }, + payload : { jti: 'hi', iss: did.uri, sub: did.uri }, signerDid : did }); @@ -335,6 +381,63 @@ describe('Verifiable Credential Tests', async() => { } }); + it('verify works with RFC3339 vcjwt', async () => { + const didIssuer = await DidKey.create(); + const didSubject = await DidKey.create(); + + const vc = await VerifiableCredential.create({ + type : 'TBDeveloperCredential', + subject : didSubject.uri, + issuer : didIssuer.uri, + issuanceDate : new Date().toISOString(), + data : { + username: 'nitro' + } + }); + + const vcJwt = await vc.sign({ did: didIssuer }); + await VerifiableCredential.verify({ vcJwt }); + }); + + it('verify works with XmlSchema112 vcjwt', async () => { + const didIssuer = await DidKey.create(); + const didSubject = await DidKey.create(); + + const vc = await VerifiableCredential.create({ + type : 'TBDeveloperCredential', + subject : didSubject.uri, + issuer : didIssuer.uri, + issuanceDate : getCurrentXmlSchema112Timestamp(), + data : { + username: 'nitro' + } + }); + + const vcJwt = await vc.sign({ did: didIssuer }); + await VerifiableCredential.verify({ vcJwt }); + }); + + it('create throws with with invalid issuance date vcjwt', async () => { + const didIssuer = await DidKey.create(); + const didSubject = await DidKey.create(); + + try { + await VerifiableCredential.create({ + type : 'TBDeveloperCredential', + subject : didSubject.uri, + issuer : didIssuer.uri, + issuanceDate : 'July 20, 2024, 15:45:30 GMT+02:00', + data : { + username: 'nitro' + } + }); + expect.fail(); + } catch(e: any) { + expect(e).to.not.be.null; + expect(e.message).to.include('timestamp is not valid'); + } + }); + it('verify throws exception if vc property is invalid', async () => { const did = await DidKey.create(); diff --git a/web5-spec b/web5-spec index 1e498f37f..2a30f682d 160000 --- a/web5-spec +++ b/web5-spec @@ -1 +1 @@ -Subproject commit 1e498f37f07a1f29c89c10b8a59c7ed9b7d54050 +Subproject commit 2a30f682df78077296a63babb6d173d0554b6683