diff --git a/package.json b/package.json index 6f196ab..dc98984 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@digitalbazaar/http-client": "^4.0.0", "@digitalbazaar/mocha-w3c-interop-reporter": "^1.6.0", "@digitalbazaar/multikey-context": "^2.0.1", + "@digitalbazaar/security-document-loader": "^3.0.0", "@digitalbazaar/vc": "^7.0.0", "@digitalcredentials/did-context": "^1.0.0", "base58-universal": "^2.0.0", diff --git a/tests/15-rdfc-di-verify.js b/tests/15-rdfc-di-verify.js index f9be558..e08b9f1 100644 --- a/tests/15-rdfc-di-verify.js +++ b/tests/15-rdfc-di-verify.js @@ -31,6 +31,9 @@ for(const vcVersion of vectors.vcTypes) { tags }) }); + const optionalTests = { + proofChain: true + }; // options for the DI Verifier Suite checkDataIntegrityProofVerifyErrors({ implemented: match, @@ -43,5 +46,6 @@ for(const vcVersion of vectors.vcTypes) { testVector: document, keyType: 'P-256' }, + optionalTests }); } diff --git a/tests/75-proof-chains.js b/tests/75-proof-chains.js new file mode 100644 index 0000000..2af5109 --- /dev/null +++ b/tests/75-proof-chains.js @@ -0,0 +1,76 @@ +/*! + * Copyright 2024 Digital Bazaar, Inc. + * SPDX-License-Identifier: BSD-3-Clause + */ +import { + addProof, + createVc, + generateProofId +} from './vc-issuer/index.js'; +import { + setupReportableTestSuite, + setupRow, + verifyError, + verifySuccess +} from './helpers.js'; +import {endpoints} from 'vc-test-suite-implementations'; + +const cryptosuites = [ + 'ecdsa-rdfc-2019', +]; + +const {match: issuers} = endpoints.filterByTag({ + tags: cryptosuites, + property: 'issuers' +}); +issuers; + +const {match: verifiers} = endpoints.filterByTag({ + tags: cryptosuites, + property: 'verifiers' +}); + +describe('Proof Chains', function() { + setupReportableTestSuite(this); + for(const [columnId, {endpoints}] of verifiers) { + describe(columnId, function() { + const [verifier] = endpoints; + let issuedCredential; + let issuedProofSet; + let issuedProofChain; + let negativeFixture; + before(async function() { + issuedCredential = await createVc(); + issuedProofChain = await addProof( + structuredClone(issuedCredential), issuedCredential.proof[0].id); + issuedProofSet = await addProof( + structuredClone(issuedCredential)); + issuedProofSet; + }); + beforeEach(setupRow); + it('If a proof with id value equal to the value of previousProof ' + + 'does not exist in allProofs, an error MUST be raised and SHOULD ' + + 'convey an error type of PROOF_VERIFICATION_ERROR.', + async function() { + this.test.link = 'https://www.w3.org/TR/vc-data-integrity/#verify-proof-sets-and-chains'; + await verifySuccess(verifier, issuedProofChain); + + negativeFixture = structuredClone(issuedProofChain); + negativeFixture.proof[1].id = generateProofId(); + await verifyError(verifier, negativeFixture); + }); + it('If any element of previousProof list has an id attribute ' + + 'value that does not match the id attribute value of any ' + + 'element of allProofs, an error MUST be raised and SHOULD ' + + 'convey an error type of PROOF_VERIFICATION_ERROR.', + async function() { + this.test.link = 'https://www.w3.org/TR/vc-data-integrity/#verify-proof-sets-and-chains'; + await verifySuccess(verifier, issuedProofChain); + + negativeFixture = structuredClone(issuedProofChain); + negativeFixture.proof[1].id = generateProofId(); + await verifyError(verifier, negativeFixture); + }); + }); + } +}); diff --git a/tests/vc-issuer/documentLoader.js b/tests/vc-issuer/documentLoader.js new file mode 100644 index 0000000..3c25ec8 --- /dev/null +++ b/tests/vc-issuer/documentLoader.js @@ -0,0 +1,33 @@ +/*! + * Copyright (c) 2023-2024 Digital Bazaar, Inc. All rights reserved. + */ +import dataIntegrityContext from '@digitalbazaar/data-integrity-context'; +import multikeyContext from '@digitalbazaar/multikey-context'; +import {named} from '@digitalbazaar/credentials-context'; +import {securityLoader} from '@digitalbazaar/security-document-loader'; + +export const loader = securityLoader(); + +loader.addStatic( + named.get('v2').id, + named.get('v2').context +); + +loader.addStatic( + 'https://www.w3.org/ns/credentials/examples/v2', + { + '@context': { + '@vocab': 'https://www.w3.org/ns/credentials/examples#' + } + } +); + +loader.addStatic( + dataIntegrityContext.constants.CONTEXT_URL, + dataIntegrityContext.contexts.get(dataIntegrityContext.constants.CONTEXT_URL) +); + +loader.addStatic( + multikeyContext.constants.CONTEXT_URL, + multikeyContext.contexts.get(multikeyContext.constants.CONTEXT_URL) +); diff --git a/tests/vc-issuer/index.js b/tests/vc-issuer/index.js new file mode 100644 index 0000000..da7eb61 --- /dev/null +++ b/tests/vc-issuer/index.js @@ -0,0 +1,109 @@ +/*! + * Copyright 2024 Digital Bazaar, Inc. + * SPDX-License-Identifier: BSD-3-Clause + */ +import * as base58 from 'base58-universal'; +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; +import * as rdfCanonize from 'rdf-canonize'; +import crypto from 'crypto'; +import jsonld from 'jsonld'; +import {loader} from './documentLoader.js'; + +const documentLoader = loader.build(); +const publicKeyMultibase = 'zDnaekGZTbQBerwcehBSXLqAg6s55hVEBms1zFy89VHXtJSa9'; +const secretKeyMultibase = 'z42tqZ5smVag3DtDhjY9YfVwTMyVHW6SCHJi2ZMrD23DGYS3'; +const controller = `did:key:${publicKeyMultibase}`; + +export function generateProofId() { + return `urn:uuid:${crypto.randomUUID()}`; +} + +const dataIntegrityProof = { + type: 'DataIntegrityProof', + cryptosuite: 'ecdsa-rdfc-2019', + proofPurpose: 'assertionMethod', + verificationMethod: `${controller}#${publicKeyMultibase}`, +}; + +// create the keypair to use when signing +const keyPair = await EcdsaMultikey.from({ + '@context': 'https://w3id.org/security/multikey/v1', + id: `${controller}#${publicKeyMultibase}`, + type: 'Multikey', + controller, + publicKeyMultibase, + secretKeyMultibase +}); + +// create the unsigned credential +const unsignedCredential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + issuer: controller, + credentialSubject: {name: 'Alice'} +}; + +export async function createVc() { + return addProof(unsignedCredential); +} + +export async function addProof(credential, previousProof = null) { + const proofSet = credential?.proof || []; + const unsecuredDocument = structuredClone(credential); + delete unsecuredDocument.proof; + const proofOptions = structuredClone(dataIntegrityProof); + if(previousProof) { + // const allProofs = []; + const matchingProofs = proofSet.filter(entry => entry.id === previousProof); + unsecuredDocument.proof = matchingProofs; + proofOptions.previousProof = previousProof; + } + + const securedDocument = structuredClone(unsecuredDocument); + const proof = await createProof(unsecuredDocument, proofOptions); + proofSet.push(proof); + securedDocument.proof = proofSet; + + return securedDocument; +} + +export async function createProof(unsecuredDocument, options) { + // https://www.w3.org/TR/vc-di-ecdsa/#create-proof-ecdsa-rdfc-2019 + options.id = generateProofId(); + const proof = structuredClone(options); + + options['@context'] = unsecuredDocument['@context']; + + const proofConfig = await canonize(options); + const proofConfigHash = + crypto.createHash('sha256').update(proofConfig).digest('hex'); + + const transformedData = await canonize(unsecuredDocument); + const transformedDataHash = + crypto.createHash('sha256').update(transformedData).digest('hex'); + + const hashData = proofConfigHash + transformedDataHash; + const proofbytes = await keyPair.signer().sign( + {data: Uint8Array.from(Buffer.from(hashData, 'hex'))}); + + proof.proofValue = `z${base58.encode(proofbytes)}`; + + return proof; +} + +async function canonize(input) { + const options = { + algorithm: 'RDFC-1.0', + base: null, + documentLoader, + safe: true, + skipExpansion: false, + produceGeneralizedRdf: false, + rdfDirection: 'i18n-datatype', + messageDigestAlgorithm: 'SHA-256', + }; + const dataset = await jsonld.toRDF(input, options); + delete options.produceGeneralizedRdf; + options.format = 'application/n-quads'; + return rdfCanonize.canonize(dataset, options); +}