diff --git a/tests/suites/algorithms-sd.js b/tests/suites/algorithms-sd.js index 89724739..7ccbae2a 100644 --- a/tests/suites/algorithms-sd.js +++ b/tests/suites/algorithms-sd.js @@ -15,6 +15,7 @@ import {createInitialVc} from '../helpers.js'; import {expect} from 'chai'; import {getMultiKey} from '../vc-generator/key-gen.js'; import {getSuites} from './helpers.js'; +import {invalidCborTagProxy} from './proxies.js'; export function sd2023Algorithms({ credential, @@ -161,8 +162,11 @@ export function sd2023Algorithms({ 'NOT be used on any of the components. Append the produced encoded ' + 'value to proofValue.', async function() { this.test.link = 'https://w3c.github.io/vc-di-ecdsa/#selective-disclosure-functions:~:text=and%20mandatoryPointers.-,CBOR%2Dencode%20components%20per%20%5BRFC8949%5D%20where%20CBOR%20tagging%20MUST,-NOT%20be%20used'; - this.test.cell.skipMessage = 'Not Implemented'; - this.skip(); + await assertions.verificationFail({ + verifier, + credential: fixtures.get(keyType).get('invalidCborTag'), + reason: 'Should not verify proofValue created with cbor tag' + }); }); it('If the proofValue string does not start with u, indicating ' + 'that it is a multibase-base64url-no-pad-encoded value, an error ' + @@ -344,7 +348,7 @@ async function _setup({ const _credential = structuredClone(credential); _credential.issuer = keyPair.controller; credentials.set('invalidCreated', await issueCloned(invalidCreated({ - credential: structuredClone(_credential), + credential: _credential, ...getSuites({ signer, suiteName, @@ -352,5 +356,16 @@ async function _setup({ mandatoryPointers }) }))); + const cborTagSuites = getSuites({ + signer, + suiteName, + selectivePointers, + mandatoryPointers + }); + credentials.set('invalidCborTag', await issueCloned({ + credential: _credential, + suite: cborTagSuites.suite, + selectiveSuite: invalidCborTagProxy(cborTagSuites.selectiveSuite) + })); return credentials; } diff --git a/tests/suites/algorithms.js b/tests/suites/algorithms.js index f26df9a5..b4b1efb0 100644 --- a/tests/suites/algorithms.js +++ b/tests/suites/algorithms.js @@ -7,7 +7,7 @@ import { generators, issueCloned } from 'data-integrity-test-suite-assertion'; -import crypto from 'node:crypto'; +import {invalidHashProxy, unsafeProxy} from './proxies.js'; import {getMultiKey} from '../vc-generator/key-gen.js'; import {getSuites} from './helpers.js'; @@ -273,35 +273,6 @@ function _generateNoTypeCryptosuite({ return invalidCryptosuite({...noType, cryptosuiteName: ''}); } -function unsafeProxy(suite) { - if(typeof suite !== 'object') { - return suite; - } - // if the suite has a cryptosuite object proxy it - if(suite._cryptosuite) { - suite._cryptosuite = new Proxy(suite._cryptosuite, { - get(target, prop) { - if(prop === 'canonize') { - return function(doc, options) { - return target.canonize(doc, {...options, safe: false}); - }; - } - return Reflect.get(...arguments); - } - }); - } - return new Proxy(suite, { - get(target, prop) { - if(prop === 'canonize') { - return function(doc, options) { - return target.canonize(doc, {...options, safe: false}); - }; - } - return Reflect.get(...arguments); - } - }); -} - async function _commonSetup({ credential, mandatoryPointers, @@ -331,83 +302,3 @@ async function _commonSetup({ })); return credentials; } - -function invalidHashProxy({ - suiteName, - keyType, - suite, -}) { - if(typeof suite !== 'object') { - return suite; - } - if(suite._cryptosuite) { - if(suiteName !== 'ecdsa-rdfc-2019') { - throw new Error(`Unsupported suite ${suiteName}`); - } - suite._cryptosuite = new Proxy(suite._cryptosuite, { - get(target, prop) { - if(prop === 'createVerifyData') { - return async function({ - cryptosuite, document, proof, - documentLoader, dataIntegrityProof - } = {}) { - // this switch the hash to the wrong hash for that keyType - const algorithm = (keyType === 'P-256') ? 'sha384' : 'sha256'; - const c14nOptions = { - documentLoader, - safe: true, - base: null, - skipExpansion: false, - messageDigestAlgorithm: algorithm - }; - - // await both c14n proof hash and c14n document hash - const [proofHash, docHash] = await Promise.all([ - // canonize and hash proof - _canonizeProof(proof, { - document, cryptosuite, dataIntegrityProof, c14nOptions - }).then(c14nProofOptions => sha({ - algorithm, - string: c14nProofOptions - })), - // canonize and hash document - cryptosuite.canonize(document, c14nOptions).then( - c14nDocument => sha({algorithm, string: c14nDocument})) - ]); - // concatenate hash of c14n proof options and hash of c14n document - return _concat(proofHash, docHash); - }; - } - return Reflect.get(...arguments); - } - }); - } - return suite; -} - -function _concat(b1, b2) { - const rval = new Uint8Array(b1.length + b2.length); - rval.set(b1, 0); - rval.set(b2, b1.length); - return rval; -} - -export async function sha({algorithm, string}) { - return new Uint8Array(crypto.createHash(algorithm).update(string).digest()); -} - -async function _canonizeProof(proof, { - document, cryptosuite, dataIntegrityProof, c14nOptions -}) { - // `proofValue` must not be included in the proof options - proof = { - '@context': document['@context'], - ...proof - }; - dataIntegrityProof.ensureSuiteContext({ - document: proof, addSuiteContext: true - }); - delete proof.proofValue; - return cryptosuite.canonize(proof, c14nOptions); -} - diff --git a/tests/suites/proxies.js b/tests/suites/proxies.js new file mode 100644 index 00000000..81b97723 --- /dev/null +++ b/tests/suites/proxies.js @@ -0,0 +1,165 @@ +/*! + * Copyright 2024 Digital Bazaar, Inc. + * SPDX-License-Identifier: BSD-3-Clause + */ +import crypto from 'node:crypto'; +import {stubDerive} from './stubs.js'; +/** + * Creates a proxy of an object with stubs. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy + * + * @param {object} options - Options to use. + * @param {object} options.original - The original object. + * @param {object} options.stubs - Stubs to replace the original objects + * properties and methods. + * + * @returns {Proxy} Returns a Proxy. + */ +export function createProxy({original, stubs}) { + if(typeof original !== 'object') { + throw new Error(`Expected parameter original to be an object received ` + + `${typeof original}`); + } + return new Proxy(original, { + get(target, prop) { + if(stubs[prop]) { + return stubs[prop]; + } + return Reflect.get(...arguments); + } + }); +} + +/** + * The major jsonld api suites use is canonize. + * This function intercepts calls on canonize and + * pass safe: false allowing for invalid jsonld to + * be issued. + * + * @param {object} suite - A DataIntegrityProof. + * + * @returns {Proxy} Returns a proxy of the proof. + */ +export function unsafeProxy(suite) { + if(typeof suite !== 'object') { + return suite; + } + // if the suite has a cryptosuite object proxy it + if(suite._cryptosuite) { + suite._cryptosuite = new Proxy(suite._cryptosuite, { + get(target, prop) { + if(prop === 'canonize') { + return function(doc, options) { + return target.canonize(doc, {...options, safe: false}); + }; + } + return Reflect.get(...arguments); + } + }); + } + return new Proxy(suite, { + get(target, prop) { + if(prop === 'canonize') { + return function(doc, options) { + return target.canonize(doc, {...options, safe: false}); + }; + } + return Reflect.get(...arguments); + } + }); +} + +//ecdsa-rdfc-2019 proxy +export function invalidHashProxy({ + suiteName, + keyType, + suite, +}) { + if(typeof suite !== 'object') { + return suite; + } + if(suite._cryptosuite) { + if(suiteName !== 'ecdsa-rdfc-2019') { + throw new Error(`Unsupported suite ${suiteName}`); + } + suite._cryptosuite = new Proxy(suite._cryptosuite, { + get(target, prop) { + if(prop === 'createVerifyData') { + return async function({ + cryptosuite, document, proof, + documentLoader, dataIntegrityProof + } = {}) { + // this switch the hash to the wrong hash for that keyType + const algorithm = (keyType === 'P-256') ? 'sha384' : 'sha256'; + const c14nOptions = { + documentLoader, + safe: true, + base: null, + skipExpansion: false, + messageDigestAlgorithm: algorithm + }; + + // await both c14n proof hash and c14n document hash + const [proofHash, docHash] = await Promise.all([ + // canonize and hash proof + _canonizeProof(proof, { + document, cryptosuite, dataIntegrityProof, c14nOptions + }).then(c14nProofOptions => sha({ + algorithm, + string: c14nProofOptions + })), + // canonize and hash document + cryptosuite.canonize(document, c14nOptions).then( + c14nDocument => sha({algorithm, string: c14nDocument})) + ]); + // concatenate hash of c14n proof options and hash of c14n document + return _concat(proofHash, docHash); + }; + } + return Reflect.get(...arguments); + } + }); + } + return suite; +} + +// ecdsa-rdfc-2019 concat 2 unit8Arrays together +function _concat(b1, b2) { + const rval = new Uint8Array(b1.length + b2.length); + rval.set(b1, 0); + rval.set(b2, b1.length); + return rval; +} + +// ecdsa-rdfc-2019 sha hashing function +export async function sha({algorithm, string}) { + return new Uint8Array(crypto.createHash(algorithm).update(string).digest()); +} + +// ecdsa-rdfc-2019 _canonizeProof method +async function _canonizeProof(proof, { + document, cryptosuite, dataIntegrityProof, c14nOptions +}) { + // `proofValue` must not be included in the proof options + proof = { + '@context': document['@context'], + ...proof + }; + dataIntegrityProof.ensureSuiteContext({ + document: proof, addSuiteContext: true + }); + delete proof.proofValue; + return cryptosuite.canonize(proof, c14nOptions); +} + +export function invalidCborTagProxy(suite) { + const stubs = {derive: stubDerive}; + if(suite._cryptosuite) { + suite._cryptosuite = createProxy({ + original: suite._cryptosuite, + stubs + }); + } + return suite; +} diff --git a/tests/suites/stubs.js b/tests/suites/stubs.js new file mode 100644 index 00000000..63b95458 --- /dev/null +++ b/tests/suites/stubs.js @@ -0,0 +1,243 @@ +/*! + * Copyright 2024 Digital Bazaar, Inc. + * SPDX-License-Identifier: BSD-3-Clause + */ +import * as base64url from 'base64url-universal'; +import * as cborg from 'cborg'; +import { + canonicalize, + canonicalizeAndGroup, + createHmac, + createHmacIdLabelMapFunction, + selectJsonLd, + stripBlankNodePrefixes +} from '@digitalbazaar/di-sd-primitives'; +import {Token, Type} from 'cborg'; + +const CBOR_PREFIX_BASE = new Uint8Array([0xd9, 0x5d, 0x00]); +const CBOR_PREFIX_DERIVED = new Uint8Array([0xd9, 0x5d, 0x01]); +// CBOR decoder for implementations that use tag 64 for Uint8Array instead +// of byte string major type 2 +const TAGS = []; +TAGS[64] = bytes => bytes; + +// Stubs the ecdsa-sd-2023 derive function +export async function stubDerive({ + cryptosuite, document, proofSet, + documentLoader, dataIntegrityProof +}) { + // find matching base `proof` in `proofSet` + const {options: {proofId}} = cryptosuite; + const baseProof = await _findProof({proofId, proofSet, dataIntegrityProof}); + // generate data for disclosure + const { + baseSignature, publicKey, signatures, labelMap, mandatoryIndexes, revealDoc + } = await _createDisclosureData( + {cryptosuite, document, proof: baseProof, documentLoader}); + + // create new disclosure proof + const newProof = {...baseProof}; + newProof.proofValue = await invalidSerializeDisclosureProofValue( + {baseSignature, publicKey, signatures, labelMap, mandatoryIndexes}); + + // attach proof to reveal doc w/o context + delete newProof['@context']; + revealDoc.proof = newProof; + return revealDoc; +} + +// ecdsa-sd-2023 method that uses invalid cbor tags +function invalidSerializeDisclosureProofValue({ + baseSignature, publicKey, signatures, labelMap, mandatoryIndexes +} = {}) { + const typeEncoders = { + Uint8Array(uint8Array) { + return [ + new Token(Type.tag, 2), + new Token(Type.bytes, uint8Array.map(b => b + 1)) + ]; + } + }; + // encode as multibase (base64url no pad) CBOR + const payload = [ + // Uint8Array + baseSignature, + // Uint8Array + publicKey, + // array of Uint8Arrays + signatures, + // Map of strings => strings compressed to ints => Uint8Arrays + _compressLabelMap(labelMap), + // array of numbers + mandatoryIndexes + ]; + const cbor = _concatBuffers([ + CBOR_PREFIX_DERIVED, cborg.encode(payload, {useMaps: true, typeEncoders}) + ]); + return `u${base64url.encode(cbor)}`; +} + +// ecdsa-sd-2023 derive helper +async function _createDisclosureData({ + cryptosuite, document, proof, documentLoader +}) { + + // 1. Parse base `proof` to get parameters for disclosure proof. + const { + baseSignature, publicKey, hmacKey, signatures, mandatoryPointers + } = await parseBaseProofValue({proof}); + + // 2. Ensure mandatory and / or selective data will be disclosed. + const {selectivePointers = []} = cryptosuite.options; + if(!(mandatoryPointers?.length > 0 || selectivePointers?.length > 0)) { + throw new Error('Nothing selected for disclosure.'); + } + + // 3. Create HMAC label replacement function from `hmacKey` to randomize + // bnode identifiers. + const hmac = await createHmac({key: hmacKey}); + const labelMapFactoryFunction = createHmacIdLabelMapFunction({hmac}); + + // 4. Canonicalize document with randomized bnode labels and group N-Quads + // by mandatory, selective, and combined pointers. + const options = {documentLoader}; + const combinedPointers = mandatoryPointers.concat(selectivePointers); + const { + groups: { + mandatory: mandatoryGroup, + selective: selectiveGroup, + combined: combinedGroup, + }, + labelMap + } = await canonicalizeAndGroup({ + document, + labelMapFactoryFunction, + groups: { + mandatory: mandatoryPointers, + selective: selectivePointers, + combined: combinedPointers + }, + options + }); + + // 5. Converting absolute indexes of mandatory N-Quads to relative indexes in + // the combined output to be revealed. + let relativeIndex = 0; + const mandatoryIndexes = []; + for(const absoluteIndex of combinedGroup.matching.keys()) { + if(mandatoryGroup.matching.has(absoluteIndex)) { + mandatoryIndexes.push(relativeIndex); + } + relativeIndex++; + } + + // 6. Filter signatures from `baseProof` to those matching non-mandatory + // absolute indexes and shifting by any absolute mandatory indexes that + // occur before each entry. + let index = 0; + const filteredSignatures = signatures.filter(() => { + while(mandatoryGroup.matching.has(index)) { + index++; + } + return selectiveGroup.matching.has(index++); + }); + + // 7. Produce reveal document using combination of mandatory and selective + // pointers. + const revealDoc = selectJsonLd({document, pointers: combinedPointers}); + + // 8. Canonicalize deskolemized N-Quads for the combined group to generate + // the canonical blank node labels a verifier will see. + let canonicalIdMap = new Map(); + await canonicalize( + combinedGroup.deskolemizedNQuads.join(''), + {...options, inputFormat: 'application/n-quads', canonicalIdMap}); + // implementation-specific bnode prefix fix + canonicalIdMap = stripBlankNodePrefixes(canonicalIdMap); + + // 9. Produce a blank node label map from the canonical blank node labels + // the verifier will see to the HMAC labels. + const verifierLabelMap = new Map(); + for(const [inputLabel, verifierLabel] of canonicalIdMap) { + verifierLabelMap.set(verifierLabel, labelMap.get(inputLabel)); + } + + // 10. Return data used by cryptosuite to disclose. + return { + baseSignature, publicKey, signatures: filteredSignatures, + labelMap: verifierLabelMap, mandatoryIndexes, + revealDoc + }; +} + +// ecdsa-sd-2023 helper function +function _concatBuffers(buffers) { + const bytes = new Uint8Array(buffers.reduce((acc, b) => acc + b.length, 0)); + let offset = 0; + for(const b of buffers) { + bytes.set(b, offset); + offset += b.length; + } + return bytes; +} + +// ecdsa-sd-2023 helper function +function _compressLabelMap(labelMap) { + const map = new Map(); + for(const [k, v] of labelMap.entries()) { + map.set(parseInt(k.slice(4), 10), base64url.decode(v.slice(1))); + } + return map; +} + +// ecdsa-sd-2023 proofValue function +function parseBaseProofValue({proof} = {}) { + try { + // decode from base64url + const proofValue = base64url.decode(proof.proofValue.slice(1)); + + const payload = proofValue.subarray(CBOR_PREFIX_BASE.length); + const [ + baseSignature, + publicKey, + hmacKey, + signatures, + mandatoryPointers + ] = cborg.decode(payload, {useMaps: true, tags: TAGS}); + + const params = { + baseSignature, publicKey, hmacKey, signatures, mandatoryPointers + }; + return params; + } catch(e) { + const err = new TypeError( + 'The proof does not include a valid "proofValue" property.'); + err.cause = e; + throw err; + } +} + +// ecdsa-sd-2023 +async function _findProof({proofId, proofSet, dataIntegrityProof}) { + let proof; + if(proofId) { + proof = proofSet.find(p => p.id === proofId); + } else { + // no `proofId` given, so see if a single matching proof exists + for(const p of proofSet) { + if(await dataIntegrityProof.matchProof({proof: p})) { + if(proof) { + // already matched + throw new Error( + 'Multiple matching proofs; a "proofId" must be specified.'); + } + proof = p; + } + } + } + if(!proof) { + throw new Error( + 'No matching base proof found from which to derive a disclosure proof.'); + } + return proof; +}