diff --git a/CHANGELOG.md b/CHANGELOG.md index fe01218..3344f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # @digitalbazaar/data-integrity Changelog +## 2.4.0 - + +### Added +- `verify()` now checks that `created`, if present, is properly formatted. + ## 2.3.0 - 2024-08-26 ### Changed diff --git a/lib/DataIntegrityProof.js b/lib/DataIntegrityProof.js index 0bfd650..64389d6 100644 --- a/lib/DataIntegrityProof.js +++ b/lib/DataIntegrityProof.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. */ import * as base58btc from 'base58-universal'; import * as base64url from 'base64url-universal'; @@ -20,6 +20,16 @@ const PROOF_TYPE = 'DataIntegrityProof'; const VC_2_0_CONTEXT = 'https://www.w3.org/ns/credentials/v2'; export class DataIntegrityProof extends LinkedDataProof { + /** + * The constructor for the DataIntegrityProof Class. + * + * @param {object} options - Options for the Class. + * @param {object} [options.signer] - A signer for the suite. + * @param {string|Date|number} [options.date] - A date to use for `created`. + * @param {object} options.cryptosuite - A compliant cryptosuite. + * @param {boolean} [options.legacyContext = false] - Toggles between + * the current DI context and a legacy DI context. + */ constructor({signer, date, cryptosuite, legacyContext = false} = {}) { super({type: PROOF_TYPE}); const { @@ -292,7 +302,11 @@ export class DataIntegrityProof extends LinkedDataProof { if(!verified) { throw new Error('Invalid signature.'); } - + if(proof.created !== undefined) { + if(!util.isW3cDate(proof.created)) { + throw new Error('Invalid XML TimeStamp'); + } + } return {verified: true, verificationMethod}; } catch(error) { return {verified: false, error}; diff --git a/lib/util.js b/lib/util.js index c3cdca9..9cfa30f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -20,6 +20,30 @@ export const w3cDate = date => { return str.slice(0, - 5) + 'Z'; }; +export const timezoneOffset = new RegExp( + '(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))$'); + +// Z and T must be uppercase +// xml schema date time RegExp +// @see https://www.w3.org/TR/xmlschema11-2/#dateTime +export const XMLDateTimeRegExp = new RegExp( + '-?([1-9][0-9]{3,}|0[0-9]{3})' + + '-(0[1-9]|1[0-2])' + + '-(0[1-9]|[12][0-9]|3[01])' + + 'T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?|(24:00:00(\.0+)?))' + + '(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))?'); +export const isW3cDate = timeStamp => XMLDateTimeRegExp.test(timeStamp); + +export const convertTimeStamp = timestamp => { + if(!timestamp) { + throw new Error(`Unexpected timestamp ("${timestamp}") received.`); + } + if(!timezoneOffset.test(timestamp)) { + return new Date(`${timestamp}Z`); + } + return new Date(timestamp); +}; + /** * Concatenates two Uint8Arrays. * diff --git a/test/DataIntegrityProof.spec.js b/test/DataIntegrityProof.spec.js index f800c58..9d279de 100644 --- a/test/DataIntegrityProof.spec.js +++ b/test/DataIntegrityProof.spec.js @@ -10,7 +10,9 @@ import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; import { credential, credentialWithLegacyContext, - ed25519MultikeyKeyPair + ed25519MultikeyKeyPair, + signedCredentialCreatedNoOffset, + signedCredentialWithInvalidCreated } from './mock-data.js'; import {DataIntegrityProof} from '../lib/index.js'; import { @@ -247,7 +249,7 @@ describe('DataIntegrityProof', () => { }); it('should fail to sign with undefined term', async () => { - const unsignedCredential = JSON.parse(JSON.stringify(credential)); + const unsignedCredential = structuredClone(credential); unsignedCredential.undefinedTerm = 'foo'; const keyPair = await Ed25519Multikey.from({...ed25519MultikeyKeyPair}); @@ -271,7 +273,7 @@ describe('DataIntegrityProof', () => { }); it('should fail to sign with relative type URL', async () => { - const unsignedCredential = JSON.parse(JSON.stringify(credential)); + const unsignedCredential = structuredClone(credential); unsignedCredential.type.push('UndefinedType'); const keyPair = await Ed25519Multikey.from({...ed25519MultikeyKeyPair}); @@ -295,7 +297,7 @@ describe('DataIntegrityProof', () => { }); it('should fail to sign with custom "createVerifyData"', async () => { - const unsignedCredential = JSON.parse(JSON.stringify(credential)); + const unsignedCredential = structuredClone(credential); const brokenCryptosuite = { ...eddsa2022CryptoSuite, async createVerifyData() { @@ -324,7 +326,7 @@ describe('DataIntegrityProof', () => { }); it('should fail to sign with custom "createProofValue"', async () => { - const unsignedCredential = JSON.parse(JSON.stringify(credential)); + const unsignedCredential = structuredClone(credential); const brokenCryptosuite = { ...eddsa2022CryptoSuite, async createProofValue() { @@ -517,8 +519,7 @@ describe('DataIntegrityProof', () => { const suite = new DataIntegrityProof({ cryptosuite: eddsa2022CryptoSuite }); - const signedCredentialCopy = - JSON.parse(JSON.stringify(signedCredential)); + const signedCredentialCopy = structuredClone(signedCredential); // intentionally modify proofValue type to not be string signedCredentialCopy.proof.proofValue = {}; @@ -541,8 +542,7 @@ describe('DataIntegrityProof', () => { const suite = new DataIntegrityProof({ cryptosuite: eddsa2022CryptoSuite }); - const signedCredentialCopy = - JSON.parse(JSON.stringify(signedCredential)); + const signedCredentialCopy = structuredClone(signedCredential); // intentionally modify proofValue to be undefined signedCredentialCopy.proof.proofValue = undefined; @@ -566,8 +566,7 @@ describe('DataIntegrityProof', () => { const suite = new DataIntegrityProof({ cryptosuite: eddsa2022CryptoSuite }); - const signedCredentialCopy = - JSON.parse(JSON.stringify(signedCredential)); + const signedCredentialCopy = structuredClone(signedCredential); // intentionally modify proofValue to not start with 'z' signedCredentialCopy.proof.proofValue = 'a'; @@ -591,8 +590,7 @@ describe('DataIntegrityProof', () => { const suite = new DataIntegrityProof({ cryptosuite: eddsa2022CryptoSuite }); - const signedCredentialCopy = - JSON.parse(JSON.stringify(signedCredential)); + const signedCredentialCopy = structuredClone(signedCredential); // intentionally modify proof type to be InvalidSignature2100 signedCredentialCopy.proof.type = 'InvalidSignature2100'; @@ -607,5 +605,54 @@ describe('DataIntegrityProof', () => { expect(result.verified).to.be.false; expect(errors[0].name).to.equal('NotFoundError'); }); + it('should fail verification if proof created is not XMLSCHEMA11-2', + async function() { + const signedCredentialCopy = structuredClone( + signedCredentialWithInvalidCreated); + const suite = new DataIntegrityProof({ + cryptosuite: eddsa2022CryptoSuite + }); + const result = await jsigs.verify(signedCredentialCopy, { + suite, + purpose: new AssertionProofPurpose(), + documentLoader + }); + should.exist(result, 'Expected verification results to exist.'); + should.exist( + result.verified, + 'Expected verification results to have property verified.' + ); + result.verified.should.equal( + false, + 'Expected credential with non XMLSCHEMA11-2 created to not verify.' + ); + }); + it('should interpret proof created as UTC if incorrectly serialized', + async function() { + // this is a little hard to test so we just assume + // a datetime with out an offset is accepted + const suite = new DataIntegrityProof({ + cryptosuite: eddsa2022CryptoSuite + }); + const signedCredentialCopy = structuredClone( + signedCredentialCreatedNoOffset); + const positiveResult = await jsigs.verify(signedCredentialCopy, { + suite, + purpose: new AssertionProofPurpose(), + documentLoader + }); + should.exist( + positiveResult, + 'Expected positive verification result to exist.' + ); + should.exist( + positiveResult.verified, + 'Expected positive result to have property verified.' + ); + positiveResult.verified.should.equal( + true, + 'Expected created to be interpreted as a UTC date in the past.' + ); + }); }); }); diff --git a/test/mock-data.js b/test/mock-data.js index 22549eb..03167f6 100644 --- a/test/mock-data.js +++ b/test/mock-data.js @@ -67,3 +67,65 @@ export const credentialWithLegacyContext = { alumniOf: 'Example University' } }; + +export const signedCredentialWithInvalidCreated = { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + { + AlumniCredential: 'https://schema.org#AlumniCredential', + alumniOf: 'https://schema.org#alumniOf' + }, + 'https://w3id.org/security/data-integrity/v2' + ], + id: 'http://example.edu/credentials/1872', + type: [ + 'VerifiableCredential', + 'AlumniCredential' + ], + issuer: 'https://example.edu/issuers/565049', + issuanceDate: '2010-01-01T19:23:24Z', + credentialSubject: { + id: 'https://example.edu/students/alice', + alumniOf: 'Example University' + }, + proof: { + created: 'May-23-2022', + type: 'DataIntegrityProof', + verificationMethod: 'https://example.edu/issuers/565049#z6MkwXG2WjeQnNxSoynSGYU8V9j3QzP3JSqhdmkHc6SaVWoT', + cryptosuite: 'eddsa-2022', + proofPurpose: 'assertionMethod', + proofValue: 'z4sKdR9XJ1CuUKTzjRqWDZXTPr6HRwPaLTkqw9Co3RXmDGsyfbs5czpzaMr' + + 'idAyd4Kq14hW5rxHuEjkMMpVcNTZN4' + } +}; + +export const signedCredentialCreatedNoOffset = { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + { + AlumniCredential: 'https://schema.org#AlumniCredential', + alumniOf: 'https://schema.org#alumniOf' + }, + 'https://w3id.org/security/data-integrity/v2' + ], + id: 'http://example.edu/credentials/1872', + type: [ + 'VerifiableCredential', + 'AlumniCredential' + ], + issuer: 'https://example.edu/issuers/565049', + issuanceDate: '2010-01-01T19:23:24Z', + credentialSubject: { + id: 'https://example.edu/students/alice', + alumniOf: 'Example University' + }, + proof: { + created: '2024-09-03T14:13:10', + type: 'DataIntegrityProof', + verificationMethod: 'https://example.edu/issuers/565049#z6MkwXG2WjeQnNxSoynSGYU8V9j3QzP3JSqhdmkHc6SaVWoT', + cryptosuite: 'eddsa-2022', + proofPurpose: 'assertionMethod', + proofValue: 'z2RkqdgtNvAhi7PK99ZVJqDHyMXNwYjg7hBP2XC4uxH17zrdAf2YqSDteu' + + 'ALnyq4yMpGteVzPV3CjZY4mbRqfMVxd' + } +};