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 4 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
2 changes: 2 additions & 0 deletions packages/credentials/src/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
import { Convert } from '@web5/common';
import { LocalKeyManager as CryptoApi } from '@web5/crypto';
import { DidDht, DidIon, DidKey, DidJwk, DidWeb, DidResolver, utils as didUtils } from '@web5/dids';
import { VcDataModel } from './verifiable-credential.js';
import { VpDataModel } from './verifiable-presentation.js';

const crypto = new CryptoApi();

Expand Down
27 changes: 27 additions & 0 deletions packages/credentials/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,33 @@ 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 nbf 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(nbf: number): string {
// Convert nbf from seconds to milliseconds and create a new Date object
const date = new Date(nbf * 1000);

// Format the date to an ISO string and then remove milliseconds
const issuanceDate = date.toISOString().replace(/\.\d{3}/, '');
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved

return issuanceDate;
}

/**
* Calculates a future timestamp in XML Schema 1.1.2 date-time format based on a given number of
* seconds.
Expand Down
87 changes: 81 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_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,72 @@ 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);
}

// TODO: iss is optional?
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');
}

// 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) {
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 +300,8 @@ export class VerifiableCredential {
throw Error('Jwt payload missing vc property');
}

validatePayload(vcDataModel);

return new VerifiableCredential(vcDataModel);
}
}
Expand Down
5 changes: 4 additions & 1 deletion packages/credentials/src/verifiable-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { utils as cryptoUtils } from '@web5/crypto';

import { Jwt } from './jwt.js';
import { SsiValidator } from './validators.js';
import { VerifiableCredential } from './verifiable-credential.js';

export const DEFAULT_CONTEXT = 'https://www.w3.org/2018/credentials/v1';
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
66 changes: 63 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,61 @@ 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']) {
console.log(e.message)
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']) {
console.log(e.message)
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 });
}
}
});
});
});
21 changes: 20 additions & 1 deletion packages/credentials/tests/verifiable-credential.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,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';
Expand Down Expand Up @@ -324,7 +343,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
});

Expand Down
2 changes: 1 addition & 1 deletion web5-spec
Loading