Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

updates to verify #421

Merged
merged 11 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { 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 @@
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 @@
* - 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 @@
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');
}

Check warning on line 226 in packages/credentials/src/verifiable-credential.ts

View check run for this annotation

Codecov / codecov/patch

packages/credentials/src/verifiable-credential.ts#L225-L226

Added lines #L225 - L226 were not covered by tests

// 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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not optional!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol ok, just saw this: - https://www.w3.org/TR/vc-data-model/#jwt-decoding

If iss is present, the value MUST be used to set the issuer [property](https://www.w3.org/TR/vc-data-model/#dfn-property) of the new [credential](https://www.w3.org/TR/vc-data-model/#dfn-credential) JSON object or the holder [property](https://www.w3.org/TR/vc-data-model/#dfn-property) of the new [presentation](https://www.w3.org/TR/vc-data-model/#dfn-presentations) JSON object.


// 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');
}

Check warning on line 248 in packages/credentials/src/verifiable-credential.ts

View check run for this annotation

Codecov / codecov/patch

packages/credentials/src/verifiable-credential.ts#L247-L248

Added lines #L247 - L248 were not covered by tests

// 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');
}

Check warning on line 258 in packages/credentials/src/verifiable-credential.ts

View check run for this annotation

Codecov / codecov/patch

packages/credentials/src/verifiable-credential.ts#L257-L258

Added lines #L257 - L258 were not covered by tests

// 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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if there's no jti? shoudl we err?

Copy link
Contributor Author

@nitro-neal nitro-neal Feb 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

so it seems like all of them are optional even iss, but yea iss is the only one we require currently

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 @@
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
Loading