Skip to content

Commit

Permalink
updates to verify (#421)
Browse files Browse the repository at this point in the history
* updates to verify

* merge

* updates

* remove comment

* merge and timestamp update

* lint update

* updates
  • Loading branch information
nitro-neal authored Mar 15, 2024
1 parent 535c112 commit 5564b32
Show file tree
Hide file tree
Showing 8 changed files with 350 additions and 15 deletions.
52 changes: 52 additions & 0 deletions packages/credentials/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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());
}
4 changes: 2 additions & 2 deletions packages/credentials/src/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`);
}
}
Expand Down
86 changes: 80 additions & 6 deletions packages/credentials/src/verifiable-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
}),
}
});

Expand Down Expand Up @@ -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
Expand All @@ -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
};
}

Expand All @@ -227,6 +299,8 @@ export class VerifiableCredential {
throw Error('Jwt payload missing vc property');
}

validatePayload(vcDataModel);

return new VerifiableCredential(vcDataModel);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/credentials/src/verifiable-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)
}
});

Expand Down Expand Up @@ -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 {
Expand Down
64 changes: 61 additions & 3 deletions packages/credentials/tests/jwt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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}`;
Expand All @@ -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 });
}
}
});
});
});
45 changes: 45 additions & 0 deletions packages/credentials/tests/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isValidXmlSchema112Timestamp,
getFutureXmlSchema112Timestamp,
getCurrentXmlSchema112Timestamp,
isValidRFC3339Timestamp,
} from '../src/utils.js';

describe('CredentialsUtils', () => {
Expand Down Expand Up @@ -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;
});
});
});
Loading

0 comments on commit 5564b32

Please sign in to comment.