├── .eslintrc.cjs ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── karma.conf.cjs ├── lib ├── RevocationList.js └── index.js ├── package.json └── tests ├── .eslintrc.cjs ├── 20-RevocationList.spec.js ├── 30-main.spec.js └── test-mocha.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'digitalbazaar', 8 | 'digitalbazaar/jsdoc', 9 | 'digitalbazaar/module' 10 | ], 11 | rules: { 12 | 'unicorn/prefer-node-protocol': 'error' 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 10 9 | strategy: 10 | matrix: 11 | node-version: [20.x] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: npm install 19 | - name: Run eslint 20 | run: npm run lint 21 | test-node: 22 | needs: [lint] 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 10 25 | strategy: 26 | matrix: 27 | node-version: [18.x, 20.x] 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Use Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: ${{ matrix.node-version }} 34 | - run: npm install 35 | - name: Run test with Node.js ${{ matrix.node-version }} 36 | run: npm run test-node 37 | test-karma: 38 | needs: [lint] 39 | runs-on: ubuntu-latest 40 | timeout-minutes: 10 41 | strategy: 42 | matrix: 43 | node-version: [20.x] 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | - run: npm install 51 | - name: Run karma tests 52 | run: npm run test-karma 53 | coverage: 54 | needs: [test-node, test-karma] 55 | runs-on: ubuntu-latest 56 | timeout-minutes: 10 57 | strategy: 58 | matrix: 59 | node-version: [20.x] 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Use Node.js ${{ matrix.node-version }} 63 | uses: actions/setup-node@v4 64 | with: 65 | node-version: ${{ matrix.node-version }} 66 | - run: npm install 67 | - name: Generate coverage report 68 | run: npm run coverage-ci 69 | - name: Upload coverage to Codecov 70 | uses: codecov/codecov-action@v4 71 | with: 72 | file: ./coverage/lcov.info 73 | fail_ci_if_error: true 74 | token: ${{ secrets.CODECOV_TOKEN }} 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.sw[nop] 3 | *~ 4 | .cache 5 | .nyc_output 6 | .project 7 | .settings 8 | .vscode 9 | TAGS 10 | coverage 11 | dist 12 | node_modules 13 | reports 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @digitalbazaar/vc-revocation-list ChangeLog 2 | 3 | ## 7.0.0 - 2024-08-02 4 | 5 | ### Changed 6 | - **BREAKING**: Require Node.js 18+. 7 | - **BREAKING**: Update dependencies. 8 | - Support added for VC 2.0. 9 | - `@digitalbazaar/vc@7`. 10 | - Update test and dev dependencies. 11 | 12 | ## 6.0.0 - 2023-01-08 13 | 14 | ### Changed 15 | - **BREAKING**: Use little-endian bit order in bitstrings. Previous versions 16 | used little-endian order internally for the bytes used to represent the 17 | bitstring, but big-endian order for the bits. This makes the endianness 18 | consistently little endian. Any legacy status lists that depended on the old 19 | order will be incompatible with this version. 20 | 21 | ### Removed 22 | - **BREAKING**: Remove support for node 14. Node 16+ required. 23 | 24 | ## 5.0.1 - 2023-01-04 25 | 26 | ### Fixed 27 | - Fix import example in readme to be namespaced. 28 | 29 | ## 5.0.0 - 2022-10-25 30 | 31 | ### Changed 32 | - **BREAKING**: Use `@digitalbazaar/vc@5` to get better safe mode 33 | protections. 34 | 35 | ## 4.0.0 - 2022-06-16 36 | 37 | ### Changed 38 | - **BREAKING**: Rename package to `@digitalbazaar/vc-revocation-list`. 39 | - **BREAKING**: Convert to module (ESM). 40 | - **BREAKING**: Require Node.js >=14. 41 | - Update dependencies. 42 | - Lint module. 43 | 44 | ## 3.0.0 - 2021-05-24 45 | 46 | ### Changed 47 | - **BREAKING**: Revocation list credentials must have the same `issuer` value 48 | as the credential to be revoked. 49 | - This requirement can be removed by setting the new `verifyMatchingIssuers` 50 | parameter to the `checkStatus` API to `false`. 51 | - Clean-up and update test and regular dependencies. 52 | 53 | ## 2.0.0 - 2020-07-17 54 | 55 | ### Changed 56 | - **BREAKING**: Remove `Bitstring` export. `Bitstring` is now available via 57 | the npm package `@digitalbazaar/bitstring`. 58 | 59 | ## 1.1.1 - 2020-07-17 60 | 61 | ### Fixed 62 | - Replace the `Bitstring` export that was accidentally removed in v1.1.0. 63 | 64 | ## 1.1.0 - 2020-07-14 65 | 66 | ### Changed 67 | - Replace internal bitstring implementation with @digitalbazaar/bitstring. 68 | 69 | ## 1.0.0 - 2020-04-29 70 | 71 | ### Added 72 | - Add core files. 73 | 74 | - See git history for changes previous to this release. 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2021, Digital Bazaar, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @digitalbazaar/vc-revocation-list 2 | 3 | [Verifiable Credential Revocation List 2020](https://github.com/w3c-ccg/vc-status-rl-2020) 4 | 5 | ### Creating a RevocationList2020Credential 6 | 7 | ```js 8 | import rl from '@digitalbazaar/vc-revocation-list'; 9 | import jsigs from 'jsonld-signatures'; 10 | import {Ed25519KeyPair} from 'crypto-ld'; 11 | import * as vc from '@digitalbazaar/vc'; 12 | const documentLoader = require('./path-to/document-loader.js'); 13 | 14 | const key = new Ed25519KeyPair({ 15 | id: 'did:key:z6MknUVLM84Eo5mQswCqP7f6oNER84rmVKkCvypob8UtBC8K#z6MknUVLM84Eo5mQswCqP7f6oNER84rmVKkCvypob8UtBC8K', 16 | controller: 'did:key:z6MknUVLM84Eo5mQswCqP7f6oNER84rmVKkCvypob8UtBC8K', 17 | type: 'Ed25519VerificationKey2018', 18 | privateKeyBase58: 'CoZphRAfAVPqx9f54MRUBtmjD4uY6KPxQQKsE3frUbZ269tBD4AdTQAVbXHHgpewh4BunoXK8dotcUJ6JXhZPsh', 19 | publicKeyBase58: '92EHksooTYGwmSN8hYhFxGgRJVav5SVrExuskrWsFyLw' 20 | }); 21 | const suite = new Ed25519Signature2018({ 22 | key, 23 | date: '2019-12-11T03:50:55Z', 24 | }); 25 | const id = 'https://example.com/credentials/status/3'; 26 | const list = await rl.createList({ length: 100000 }); 27 | const encodedList = await list.encode(); 28 | const rlCredential = { 29 | '@context': [ 30 | 'https://www.w3.org/2018/credentials/v1', 31 | 'https://w3id.org/vc-revocation-list-2020/v1' 32 | ], 33 | id, 34 | issuer: 'did:key:z6MknUVLM84Eo5mQswCqP7f6oNER84rmVKkCvypob8UtBC8K', 35 | issuanceDate: '2020-03-10T04:24:12.164Z', 36 | type: ['VerifiableCredential', 'RevocationList2020Credential'], 37 | credentialSubject: { 38 | id: `${id}#list`, 39 | type: 'RevocationList2020', 40 | encodedList, 41 | }, 42 | }; 43 | let verifiableCredential = await vc.issue({ 44 | credential: { ...rlCredential }, 45 | suite, 46 | documentLoader, 47 | }); 48 | ``` 49 | 50 | ### Created a Credential which uses a RevocationList2020 51 | 52 | ```js 53 | // see imports above 54 | const credential = { 55 | '@context': [ 56 | 'https://www.w3.org/2018/credentials/v1', 57 | 'https://www.w3.org/2018/credentials/examples/v1', 58 | 'https://w3id.org/vc-revocation-list-2020/v1', 59 | ], 60 | id: 'https://example.com/credentials/3732', 61 | type: ['VerifiableCredential', 'UniversityDegreeCredential'], 62 | issuer: 'did:web:did.actor:alice', 63 | issuanceDate: '2020-03-10T04:24:12.164Z', 64 | credentialStatus: { 65 | id: 'https://example.com/credentials/status/3#94567', 66 | type: 'RevocationList2020Status', 67 | revocationListIndex: '94567', 68 | revocationListCredential: 69 | 'https://did.actor/alice/credentials/status/3', 70 | }, 71 | credentialSubject: { 72 | id: 'did:web:did.actor:bob', 73 | degree: { 74 | type: 'BachelorDegree', 75 | name: 'Bachelor of Science and Arts', 76 | }, 77 | }, 78 | }; 79 | let verifiableCredential = await vc.issue({ 80 | credential: { ...credential }, 81 | suite, 82 | documentLoader, 83 | }); 84 | ``` 85 | -------------------------------------------------------------------------------- /karma.conf.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. 3 | */ 4 | module.exports = function(config) { 5 | 6 | config.set({ 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | // frameworks to use 11 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 12 | frameworks: ['mocha', 'chai'], 13 | 14 | // list of files / patterns to load in the browser 15 | files: [ 16 | 'tests/*.spec.js' 17 | ], 18 | 19 | // list of files to exclude 20 | exclude: [], 21 | 22 | // preprocess matching files before serving them to the browser 23 | // preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 24 | preprocessors: { 25 | 'tests/*.js': ['webpack', 'sourcemap'] 26 | }, 27 | 28 | webpack: { 29 | mode: 'development', 30 | devtool: 'inline-source-map' 31 | }, 32 | 33 | // test results reporter to use 34 | // possible values: 'dots', 'progress' 35 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 36 | //reporters: ['progress'], 37 | reporters: ['mocha'], 38 | 39 | // web server port 40 | port: 9876, 41 | 42 | // enable / disable colors in the output (reporters and logs) 43 | colors: true, 44 | 45 | // level of logging 46 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || 47 | // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 48 | logLevel: config.LOG_INFO, 49 | 50 | // enable / disable watching file and executing tests whenever any 51 | // file changes 52 | autoWatch: false, 53 | 54 | // start these browsers 55 | // browser launchers: https://npmjs.org/browse/keyword/karma-launcher 56 | //browsers: ['ChromeHeadless', 'Chrome', 'Firefox', 'Safari'], 57 | browsers: ['ChromeHeadless'], 58 | 59 | // Continuous Integration mode 60 | // if true, Karma captures browsers, runs the tests and exits 61 | singleRun: true, 62 | 63 | // Concurrency level 64 | // how many browser should be started simultaneous 65 | concurrency: Infinity, 66 | 67 | // Mocha 68 | client: { 69 | mocha: { 70 | // increase from default 2s 71 | timeout: 10000, 72 | reporter: 'html' 73 | //delay: true 74 | } 75 | } 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /lib/RevocationList.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. 3 | */ 4 | import {Bitstring} from '@digitalbazaar/bitstring'; 5 | 6 | export default class RevocationList { 7 | constructor({length, buffer} = {}) { 8 | this.bitstring = new Bitstring({length, buffer}); 9 | this.length = this.bitstring.length; 10 | } 11 | 12 | setRevoked(index, revoked) { 13 | if(typeof revoked !== 'boolean') { 14 | throw new TypeError('"revoked" must be a boolean.'); 15 | } 16 | return this.bitstring.set(index, revoked); 17 | } 18 | 19 | isRevoked(index) { 20 | return this.bitstring.get(index); 21 | } 22 | 23 | async encode() { 24 | return this.bitstring.encodeBits(); 25 | } 26 | 27 | static async decode({encodedList}) { 28 | const buffer = await Bitstring.decodeBits({encoded: encodedList}); 29 | return new RevocationList({buffer}); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. 3 | */ 4 | import RevocationList from './RevocationList.js'; 5 | import {verifyCredential as vcVerifyCredential} from '@digitalbazaar/vc'; 6 | 7 | const CONTEXTS = { 8 | VC_V1: 'https://www.w3.org/2018/credentials/v1', 9 | RL_V1: 'https://w3id.org/vc-revocation-list-2020/v1' 10 | }; 11 | 12 | export async function createList({length}) { 13 | return new RevocationList({length}); 14 | } 15 | 16 | export async function decodeList({encodedList}) { 17 | return RevocationList.decode({encodedList}); 18 | } 19 | 20 | export async function createCredential({id, list}) { 21 | const encodedList = await list.encode(); 22 | return { 23 | '@context': [CONTEXTS.VC_V1, CONTEXTS.RL_V1], 24 | id, 25 | type: ['VerifiableCredential', 'RevocationList2020Credential'], 26 | credentialSubject: { 27 | id: `${id}#list`, 28 | type: 'RevocationList2020', 29 | encodedList 30 | } 31 | }; 32 | } 33 | 34 | export async function checkStatus({ 35 | credential, 36 | documentLoader, 37 | suite, 38 | verifyRevocationListCredential = true, 39 | verifyMatchingIssuers = true 40 | } = {}) { 41 | let result; 42 | try { 43 | result = await _checkStatus({ 44 | credential, 45 | documentLoader, 46 | suite, 47 | verifyRevocationListCredential, 48 | verifyMatchingIssuers, 49 | }); 50 | } catch(error) { 51 | result = { 52 | verified: false, 53 | error, 54 | }; 55 | } 56 | return result; 57 | } 58 | 59 | export function statusTypeMatches({credential} = {}) { 60 | if(!(credential && typeof credential === 'object')) { 61 | throw new TypeError('"credential" must be an object.'); 62 | } 63 | // check for expected contexts 64 | const {'@context': contexts} = credential; 65 | if(!Array.isArray(contexts)) { 66 | throw new TypeError('"@context" must be an array.'); 67 | } 68 | if(contexts[0] !== CONTEXTS.VC_V1) { 69 | throw new Error(`The first "@context" value must be "${CONTEXTS.VC_V1}".`); 70 | } 71 | const {credentialStatus} = credential; 72 | if(!credentialStatus) { 73 | // no status; no match 74 | return false; 75 | } 76 | if(typeof credentialStatus !== 'object') { 77 | // bad status 78 | throw new Error('"credentialStatus" is invalid.'); 79 | } 80 | if(!contexts.includes(CONTEXTS.RL_V1)) { 81 | // context not present, no match 82 | return false; 83 | } 84 | if(credentialStatus.type !== 'RevocationList2020Status') { 85 | // status type does not match 86 | return false; 87 | } 88 | return true; 89 | } 90 | 91 | export function assertRevocationList2020Context({credential} = {}) { 92 | if(!(credential && typeof credential === 'object')) { 93 | throw new TypeError('"credential" must be an object.'); 94 | } 95 | // check for expected contexts 96 | const {'@context': contexts} = credential; 97 | if(!Array.isArray(contexts)) { 98 | throw new TypeError('"@context" must be an array.'); 99 | } 100 | if(contexts[0] !== CONTEXTS.VC_V1) { 101 | throw new Error(`The first "@context" value must be "${CONTEXTS.VC_V1}".`); 102 | } 103 | if(!contexts.includes(CONTEXTS.RL_V1)) { 104 | throw new TypeError(`"@context" must include "${CONTEXTS.RL_V1}".`); 105 | } 106 | } 107 | 108 | export function getCredentialStatus({credential} = {}) { 109 | if(!(credential && typeof credential === 'object')) { 110 | throw new TypeError('"credential" must be an object.'); 111 | } 112 | assertRevocationList2020Context({credential}); 113 | // get and validate status 114 | if(!(credential.credentialStatus && 115 | typeof credential.credentialStatus === 'object')) { 116 | throw new Error('"credentialStatus" is missing or invalid.'); 117 | } 118 | const {credentialStatus} = credential; 119 | if(credentialStatus.type !== 'RevocationList2020Status') { 120 | throw new Error( 121 | '"credentialStatus" type is not "RevocationList2020Status".'); 122 | } 123 | if(typeof credentialStatus.revocationListCredential !== 'string') { 124 | throw new TypeError( 125 | '"credentialStatus" revocationListCredential must be a string.'); 126 | } 127 | 128 | return credentialStatus; 129 | } 130 | 131 | async function _checkStatus({ 132 | credential, 133 | documentLoader, 134 | suite, 135 | verifyRevocationListCredential, 136 | verifyMatchingIssuers 137 | }) { 138 | if(!(credential && typeof credential === 'object')) { 139 | throw new TypeError('"credential" must be an object.'); 140 | } 141 | if(typeof documentLoader !== 'function') { 142 | throw new TypeError('"documentLoader" must be a function.'); 143 | } 144 | if(verifyRevocationListCredential && !(suite && ( 145 | isArrayOfObjects(suite) || 146 | (!Array.isArray(suite) && typeof suite === 'object')))) { 147 | throw new TypeError('"suite" must be an object or an array of objects.'); 148 | } 149 | 150 | const credentialStatus = getCredentialStatus({credential}); 151 | 152 | // get RL position 153 | // TODO: bikeshed name 154 | const {revocationListIndex} = credentialStatus; 155 | const index = parseInt(revocationListIndex, 10); 156 | if(isNaN(index)) { 157 | throw new TypeError('"revocationListIndex" must be an integer.'); 158 | } 159 | 160 | // retrieve RL VC 161 | let rlCredential; 162 | try { 163 | ({document: rlCredential} = await documentLoader( 164 | credentialStatus.revocationListCredential)); 165 | } catch(e) { 166 | const err = new Error( 167 | 'Could not load "RevocationList2020Credential"; ' + 168 | `reason: ${e.message}`); 169 | err.cause = e; 170 | throw err; 171 | } 172 | 173 | // verify RL VC 174 | if(verifyRevocationListCredential) { 175 | const verifyResult = await vcVerifyCredential({ 176 | credential: rlCredential, 177 | suite, 178 | documentLoader 179 | }); 180 | if(!verifyResult.verified) { 181 | const {error: e} = verifyResult; 182 | let msg = '"RevocationList2020Credential" not verified'; 183 | if(e) { 184 | msg += `; reason: ${e.message}`; 185 | } else { 186 | msg += '.'; 187 | } 188 | const err = new Error(msg); 189 | if(e) { 190 | err.cause = verifyResult.error; 191 | } 192 | throw err; 193 | } 194 | } 195 | 196 | // ensure that the issuer of the verifiable credential matches 197 | // the issuer of the revocationListCredential 198 | if(verifyMatchingIssuers) { 199 | // covers both the URI and object cases 200 | const credentialIssuer = 201 | typeof credential.issuer === 'object' ? 202 | credential.issuer.id : credential.issuer; 203 | const revocationListCredentialIssuer = 204 | typeof rlCredential.issuer === 'object' ? 205 | rlCredential.issuer.id : rlCredential.issuer; 206 | 207 | if(!(credentialIssuer && revocationListCredentialIssuer) || 208 | (credentialIssuer !== revocationListCredentialIssuer)) { 209 | throw new Error('Issuers of the revocation credential and verifiable ' + 210 | 'credential do not match.'); 211 | } 212 | } 213 | 214 | if(!rlCredential.type.includes('RevocationList2020Credential')) { 215 | throw new Error('"RevocationList2020Credential" type is not valid.'); 216 | } 217 | 218 | // get JSON RevocationList 219 | const {credentialSubject: rl} = rlCredential; 220 | if(rl.type !== 'RevocationList2020') { 221 | throw new Error('"RevocationList2020" type is not valid.'); 222 | } 223 | 224 | // decode list from RL VC 225 | const {encodedList} = rl; 226 | let list; 227 | try { 228 | list = await decodeList({encodedList}); 229 | } catch(e) { 230 | const err = new Error( 231 | `Could not decode encoded revocation list; reason: ${e.message}`); 232 | err.cause = e; 233 | throw err; 234 | } 235 | 236 | // check VC's RL index for revocation status 237 | const verified = !list.isRevoked(index); 238 | 239 | // TODO: return anything else? returning `rlCredential` may be too unwieldy 240 | // given its potentially large size 241 | return {verified}; 242 | } 243 | 244 | function isArrayOfObjects(x) { 245 | return Array.isArray(x) && x.length > 0 && 246 | x.every(x => x && typeof x === 'object'); 247 | } 248 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@digitalbazaar/vc-revocation-list", 3 | "version": "7.0.1-0", 4 | "description": "Verifiable Credentials Revocation List 2020", 5 | "license": "BSD-3-Clause", 6 | "type": "module", 7 | "exports": "./lib/index.js", 8 | "files": [ 9 | "lib/**/*.js" 10 | ], 11 | "scripts": { 12 | "test": "npm run test-node", 13 | "test-node": "cross-env NODE_ENV=test mocha --preserve-symlinks -t 30000 -A -R ${REPORTER:-spec} --require tests/test-mocha.js tests/*.spec.js", 14 | "test-karma": "karma start karma.conf.cjs", 15 | "coverage": "cross-env NODE_ENV=test c8 npm run test-node", 16 | "coverage-ci": "cross-env NODE_ENV=test c8 --reporter=lcovonly --reporter=text-summary --reporter=text npm run test-node", 17 | "coverage-report": "c8 report", 18 | "lint": "eslint --ext .cjs,.js ." 19 | }, 20 | "dependencies": { 21 | "@digitalbazaar/bitstring": "^3.1.0", 22 | "@digitalbazaar/vc": "^7.0.0" 23 | }, 24 | "devDependencies": { 25 | "c8": "^10.1.2", 26 | "chai": "^4.5.0", 27 | "cross-env": "^7.0.3", 28 | "eslint": "^8.57.0", 29 | "eslint-config-digitalbazaar": "^5.2.0", 30 | "eslint-plugin-jsdoc": "^48.11.0", 31 | "eslint-plugin-unicorn": "^55.0.0", 32 | "jsonld-signatures": "^11.3.0", 33 | "karma": "^6.4.4", 34 | "karma-chai": "^0.1.0", 35 | "karma-chrome-launcher": "^3.2.0", 36 | "karma-mocha": "^2.0.1", 37 | "karma-mocha-reporter": "^2.2.5", 38 | "karma-sourcemap-loader": "^0.4.0", 39 | "karma-webpack": "^5.0.1", 40 | "mocha": "^10.7.0", 41 | "mocha-lcov-reporter": "^1.3.0", 42 | "vc-revocation-list-context": "^1.0.0", 43 | "webpack": "^5.93.0" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "https://github.com/digitalbazaar/vc-revocation-list" 48 | }, 49 | "keywords": [ 50 | "vc", 51 | "verifiable credential", 52 | "revocation list", 53 | "bitstring", 54 | "RevocationList2020", 55 | "VerifiableCredential" 56 | ], 57 | "author": { 58 | "name": "Digital Bazaar, Inc.", 59 | "email": "support@digitalbazaar.com", 60 | "url": "https://digitalbazaar.com/" 61 | }, 62 | "bugs": { 63 | "url": "https://github.com/digitalbazaar/vc-revocation-list/issues" 64 | }, 65 | "homepage": "https://github.com/digitalbazaar/vc-revocation-list", 66 | "engines": { 67 | "node": ">=18" 68 | }, 69 | "c8": { 70 | "reporter": [ 71 | "lcov", 72 | "text-summary", 73 | "text" 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | mocha: true 4 | }, 5 | globals: { 6 | should: true 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /tests/20-RevocationList.spec.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved. 3 | */ 4 | import RevocationList from '../lib/RevocationList.js'; 5 | 6 | const encodedList100k = 7 | 'H4sIAAAAAAAAA-3BMQEAAADCoPVPbQsvoAAAAAAAAAAAAAAAAP4GcwM92tQwAAA'; 8 | const encodedList100KWith50KthRevoked = 9 | 'H4sIAAAAAAAAA-3OMQ0AAAgDsElHOh72EJJWQRMAAAAAAIDWXAcAAAAAAIDHFvRitn7UMAAA'; 10 | 11 | describe('RevocationList', () => { 12 | it('should create an instance', async () => { 13 | const list = new RevocationList({length: 8}); 14 | list.length.should.equal(8); 15 | }); 16 | 17 | it('should fail to create an instance if no length', async () => { 18 | let err; 19 | try { 20 | new RevocationList(); 21 | } catch(e) { 22 | err = e; 23 | } 24 | should.exist(err); 25 | err.name.should.equal('TypeError'); 26 | }); 27 | 28 | it('should encode', async () => { 29 | const list = new RevocationList({length: 100000}); 30 | const encodedList = await list.encode(); 31 | encodedList.should.equal(encodedList100k); 32 | }); 33 | 34 | it('should decode', async () => { 35 | const list = await RevocationList.decode({encodedList: encodedList100k}); 36 | list.length.should.equal(100000); 37 | }); 38 | 39 | it('should mark a credential revoked', async () => { 40 | const list = new RevocationList({length: 8}); 41 | list.isRevoked(0).should.equal(false); 42 | list.isRevoked(1).should.equal(false); 43 | list.isRevoked(2).should.equal(false); 44 | list.isRevoked(3).should.equal(false); 45 | list.isRevoked(4).should.equal(false); 46 | list.isRevoked(5).should.equal(false); 47 | list.isRevoked(6).should.equal(false); 48 | list.isRevoked(7).should.equal(false); 49 | list.setRevoked(4, true); 50 | list.isRevoked(0).should.equal(false); 51 | list.isRevoked(1).should.equal(false); 52 | list.isRevoked(2).should.equal(false); 53 | list.isRevoked(3).should.equal(false); 54 | list.isRevoked(4).should.equal(true); 55 | list.isRevoked(5).should.equal(false); 56 | list.isRevoked(6).should.equal(false); 57 | list.isRevoked(7).should.equal(false); 58 | }); 59 | 60 | it('should fail to mark a credential revoked', async () => { 61 | const list = new RevocationList({length: 8}); 62 | let err; 63 | try { 64 | list.setRevoked(0); 65 | } catch(e) { 66 | err = e; 67 | } 68 | should.exist(err); 69 | err.name.should.equal('TypeError'); 70 | }); 71 | 72 | it('should fail to get a credential status out of range', async () => { 73 | const list = new RevocationList({length: 8}); 74 | let err; 75 | try { 76 | list.isRevoked(8); 77 | } catch(e) { 78 | err = e; 79 | } 80 | should.exist(err); 81 | err.name.should.equal('Error'); 82 | }); 83 | 84 | it('should mark a credential revoked, encode and decode', async () => { 85 | const list = new RevocationList({length: 100000}); 86 | list.isRevoked(50000).should.equal(false); 87 | list.setRevoked(50000, true); 88 | list.isRevoked(50000).should.equal(true); 89 | const encodedList = await list.encode(); 90 | encodedList.should.equal(encodedList100KWith50KthRevoked); 91 | const decodedList = await RevocationList.decode({encodedList}); 92 | decodedList.isRevoked(50000).should.equal(true); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/30-main.spec.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. 3 | */ 4 | import { 5 | assertRevocationList2020Context, getCredentialStatus 6 | } from '../lib/index.js'; 7 | import { 8 | checkStatus, createCredential, createList, decodeList, statusTypeMatches 9 | } from '../lib/index.js'; 10 | import {constants, contexts} from 'vc-revocation-list-context'; 11 | import {defaultDocumentLoader} from '@digitalbazaar/vc'; 12 | import jsigs from 'jsonld-signatures'; 13 | 14 | const {extendContextLoader} = jsigs; 15 | 16 | const VC_RL_CONTEXT_URL = constants.VC_REVOCATION_LIST_CONTEXT_V1_URL; 17 | const VC_RL_CONTEXT = contexts.get(VC_RL_CONTEXT_URL); 18 | 19 | const encodedList100k = 20 | 'H4sIAAAAAAAAA-3BMQEAAADCoPVPbQsvoAAAAAAAAAAAAAAAAP4GcwM92tQwAAA'; 21 | const encodedList100KWith50KthRevoked = 22 | 'H4sIAAAAAAAAA-3OMQ0AAAgDsElHOh72EJJWQRMAAAAAAIDWXAcAAAAAAIDHFvRitn7UMAAA'; 23 | 24 | const documents = new Map(); 25 | documents.set(VC_RL_CONTEXT_URL, VC_RL_CONTEXT); 26 | 27 | const RLC = { 28 | '@context': [ 29 | 'https://www.w3.org/2018/credentials/v1', 30 | VC_RL_CONTEXT_URL 31 | ], 32 | id: 'https://example.com/status/1', 33 | issuer: 'did:key:z6MknUVLM84Eo5mQswCqP7f6oNER84rmVKkCvypob8UtBC8K', 34 | issuanceDate: '2020-03-10T04:24:12.164Z', 35 | type: ['VerifiableCredential', 'RevocationList2020Credential'], 36 | credentialSubject: { 37 | id: `https://example.com/status/1#list`, 38 | type: 'RevocationList2020', 39 | encodedList: encodedList100KWith50KthRevoked 40 | } 41 | }; 42 | documents.set(RLC.id, RLC); 43 | 44 | const documentLoader = extendContextLoader(async url => { 45 | const doc = documents.get(url); 46 | if(doc) { 47 | return { 48 | contextUrl: null, 49 | documentUrl: url, 50 | document: doc 51 | }; 52 | } 53 | return defaultDocumentLoader(url); 54 | }); 55 | 56 | describe('main', () => { 57 | it('should create a list', async () => { 58 | const list = await createList({length: 8}); 59 | list.length.should.equal(8); 60 | }); 61 | 62 | it('should fail to create a list if no length', async () => { 63 | let err; 64 | try { 65 | await createList(); 66 | } catch(e) { 67 | err = e; 68 | } 69 | should.exist(err); 70 | err.name.should.equal('TypeError'); 71 | }); 72 | 73 | it('should decode a list', async () => { 74 | const list = await decodeList({encodedList: encodedList100k}); 75 | list.length.should.equal(100000); 76 | }); 77 | 78 | it('should create a RevocationList2020Credential credential', async () => { 79 | const id = 'https://example.com/status/1'; 80 | const list = await createList({length: 100000}); 81 | const credential = await createCredential({id, list}); 82 | credential.should.be.an('object'); 83 | credential.should.deep.equal({ 84 | '@context': [ 85 | 'https://www.w3.org/2018/credentials/v1', 86 | VC_RL_CONTEXT_URL 87 | ], 88 | id, 89 | type: ['VerifiableCredential', 'RevocationList2020Credential'], 90 | credentialSubject: { 91 | id: `${id}#list`, 92 | type: 'RevocationList2020', 93 | encodedList: encodedList100k 94 | } 95 | }); 96 | }); 97 | 98 | it('should indicate that the status type matches', async () => { 99 | const credential = { 100 | '@context': [ 101 | 'https://www.w3.org/2018/credentials/v1', 102 | VC_RL_CONTEXT_URL 103 | ], 104 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 105 | type: ['VerifiableCredential', 'example:TestCredential'], 106 | credentialSubject: { 107 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 108 | 'example:test': 'foo' 109 | }, 110 | credentialStatus: { 111 | id: 'https://example.com/status/1#67342', 112 | type: 'RevocationList2020Status', 113 | revocationListIndex: '67342', 114 | revocationListCredential: RLC.id 115 | }, 116 | issuer: RLC.issuer, 117 | }; 118 | const result = statusTypeMatches({credential}); 119 | result.should.equal(true); 120 | }); 121 | 122 | it('should indicate that the status type does not match', async () => { 123 | const credential = { 124 | '@context': [ 125 | 'https://www.w3.org/2018/credentials/v1', 126 | VC_RL_CONTEXT_URL 127 | ], 128 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 129 | type: ['VerifiableCredential', 'example:TestCredential'], 130 | credentialSubject: { 131 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 132 | 'example:test': 'foo' 133 | }, 134 | credentialStatus: { 135 | id: 'https://example.com/status/1#67342', 136 | type: 'NotMatch', 137 | revocationListIndex: '67342', 138 | revocationListCredential: RLC.id 139 | }, 140 | issuer: RLC.issuer, 141 | }; 142 | const result = statusTypeMatches({credential}); 143 | result.should.equal(false); 144 | }); 145 | 146 | it('should verify the status of a credential', async () => { 147 | const credential = { 148 | '@context': [ 149 | 'https://www.w3.org/2018/credentials/v1', 150 | VC_RL_CONTEXT_URL 151 | ], 152 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 153 | type: ['VerifiableCredential', 'example:TestCredential'], 154 | credentialSubject: { 155 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 156 | 'example:test': 'foo' 157 | }, 158 | credentialStatus: { 159 | id: 'https://example.com/status/1#67342', 160 | type: 'RevocationList2020Status', 161 | revocationListIndex: '67342', 162 | revocationListCredential: RLC.id 163 | }, 164 | issuer: RLC.issuer, 165 | }; 166 | const result = await checkStatus({ 167 | credential, documentLoader, verifyRevocationListCredential: false}); 168 | result.verified.should.equal(true); 169 | }); 170 | 171 | it('should fail to verify status with incorrect status type', async () => { 172 | const credential = { 173 | '@context': [ 174 | 'https://www.w3.org/2018/credentials/v1', 175 | VC_RL_CONTEXT_URL 176 | ], 177 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 178 | type: ['VerifiableCredential', 'example:TestCredential'], 179 | credentialSubject: { 180 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 181 | 'example:test': 'foo' 182 | }, 183 | credentialStatus: { 184 | id: 'https://example.com/status/1#67342', 185 | type: 'ex:NonmatchingStatusType', 186 | revocationListIndex: '67342', 187 | revocationListCredential: RLC.id 188 | }, 189 | issuer: RLC.issuer, 190 | }; 191 | const result = await checkStatus({ 192 | credential, documentLoader, verifyRevocationListCredential: false}); 193 | result.verified.should.equal(false); 194 | }); 195 | 196 | it('should fail to verify status with missing index', async () => { 197 | const credential = { 198 | '@context': [ 199 | 'https://www.w3.org/2018/credentials/v1', 200 | VC_RL_CONTEXT_URL 201 | ], 202 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 203 | type: ['VerifiableCredential', 'example:TestCredential'], 204 | credentialSubject: { 205 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 206 | 'example:test': 'foo' 207 | }, 208 | credentialStatus: { 209 | id: 'https://example.com/status/1#67342', 210 | type: 'RevocationList2020Status', 211 | revocationListCredential: RLC.id 212 | }, 213 | issuer: RLC.issuer, 214 | }; 215 | const result = await checkStatus({ 216 | credential, documentLoader, verifyRevocationListCredential: false}); 217 | result.verified.should.equal(false); 218 | }); 219 | 220 | it('should fail to verify status with missing list credential', async () => { 221 | const credential = { 222 | '@context': [ 223 | 'https://www.w3.org/2018/credentials/v1', 224 | VC_RL_CONTEXT_URL 225 | ], 226 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 227 | type: ['VerifiableCredential', 'example:TestCredential'], 228 | credentialSubject: { 229 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 230 | 'example:test': 'foo' 231 | }, 232 | credentialStatus: { 233 | id: 'https://example.com/status/1#67342', 234 | type: 'RevocationList2020Status', 235 | revocationListIndex: '67342' 236 | }, 237 | issuer: RLC.issuer, 238 | }; 239 | const result = await checkStatus({ 240 | credential, documentLoader, verifyRevocationListCredential: false}); 241 | result.verified.should.equal(false); 242 | }); 243 | 244 | it('should fail to verify status for revoked credential', async () => { 245 | const credential = { 246 | '@context': [ 247 | 'https://www.w3.org/2018/credentials/v1', 248 | VC_RL_CONTEXT_URL 249 | ], 250 | id: 'urn:uuid:e74fb1d6-7926-11ea-8e11-10bf48838a41', 251 | type: ['VerifiableCredential', 'example:TestCredential'], 252 | credentialSubject: { 253 | id: 'urn:uuid:011e064e-7927-11ea-8975-10bf48838a41', 254 | 'example:test': 'bar' 255 | }, 256 | credentialStatus: { 257 | id: 'https://example.com/status/1#50000', 258 | type: 'RevocationList2020Status', 259 | revocationListIndex: '50000', 260 | revocationListCredential: RLC.id 261 | }, 262 | issuer: RLC.issuer, 263 | }; 264 | const result = await checkStatus({ 265 | credential, documentLoader, verifyRevocationListCredential: false}); 266 | result.verified.should.equal(false); 267 | }); 268 | 269 | it('should fail to verify status on missing "credential" param', async () => { 270 | let err; 271 | let result; 272 | try { 273 | result = await checkStatus({ 274 | documentLoader, verifyRevocationListCredential: false}); 275 | result.verified.should.equal(false); 276 | } catch(e) { 277 | err = e; 278 | } 279 | should.not.exist(err); 280 | should.exist(result); 281 | result.should.be.an('object'); 282 | result.should.have.property('verified'); 283 | result.verified.should.be.a('boolean'); 284 | result.verified.should.be.false; 285 | result.should.have.property('error'); 286 | result.error.should.be.instanceof(TypeError); 287 | result.error.message.should.contain('"credential" must be an object'); 288 | }); 289 | 290 | it('should fail to verify if credential is not an object for ' + 291 | 'statusTypeMatches"', async () => { 292 | let err; 293 | let result; 294 | try { 295 | result = statusTypeMatches({credential: ''}); 296 | } catch(e) { 297 | err = e; 298 | } 299 | should.exist(err); 300 | should.not.exist(result); 301 | err.should.be.instanceof(TypeError); 302 | err.message.should.contain('"credential" must be an object'); 303 | }); 304 | 305 | it('should fail to verify if credential is not an object for ' + 306 | '"assertRevocationList2020Context"', async () => { 307 | let err; 308 | let result; 309 | try { 310 | result = assertRevocationList2020Context({credential: ''}); 311 | } catch(e) { 312 | err = e; 313 | } 314 | should.exist(err); 315 | should.not.exist(result); 316 | err.should.be.instanceof(TypeError); 317 | err.message.should.contain('"credential" must be an object'); 318 | }); 319 | 320 | it('should fail to verify if credential is not an object for ' + 321 | '"getCredentialStatus"', async () => { 322 | let err; 323 | let result; 324 | try { 325 | result = getCredentialStatus({credential: ''}); 326 | } catch(e) { 327 | err = e; 328 | } 329 | should.exist(err); 330 | should.not.exist(result); 331 | err.should.be.instanceof(TypeError); 332 | err.message.should.contain('"credential" must be an object'); 333 | }); 334 | 335 | it('should fail to verify if @context is not an array in ' + 336 | '"statusTypeMatches"', async () => { 337 | const id = 'https://example.com/status/1'; 338 | const list = await createList({length: 100000}); 339 | const credential = await createCredential({id, list}); 340 | let err; 341 | let result; 342 | try { 343 | // change the @context property to a string 344 | credential['@context'] = id; 345 | result = statusTypeMatches({credential}); 346 | } catch(e) { 347 | err = e; 348 | } 349 | should.exist(err); 350 | should.not.exist(result); 351 | err.should.be.instanceof(TypeError); 352 | err.message.should.contain('"@context" must be an array'); 353 | }); 354 | 355 | it('should fail when the first "@context" value is unexpected in ' + 356 | 'statusTypeMatches"', async () => { 357 | const id = 'https://example.com/status/1'; 358 | const list = await createList({length: 100000}); 359 | const credential = await createCredential({id, list}); 360 | let err; 361 | let result; 362 | try { 363 | // change the @context property intentionally to an unexpected value 364 | credential['@context'][0] = 'https://example.com/test/1'; 365 | result = statusTypeMatches({credential}); 366 | } catch(e) { 367 | err = e; 368 | } 369 | should.exist(err); 370 | should.not.exist(result); 371 | err.should.be.instanceof(Error); 372 | err.message.should.contain('first "@context" value'); 373 | }); 374 | 375 | it('should fail to verify if @context is not an array in ' + 376 | '"assertRevocationList2020Context"', async () => { 377 | const id = 'https://example.com/status/1'; 378 | const list = await createList({length: 100000}); 379 | const credential = await createCredential({id, list}); 380 | let err; 381 | let result; 382 | try { 383 | // change the @context property to a string 384 | credential['@context'] = 'https://example.com/status/1'; 385 | result = assertRevocationList2020Context({credential}); 386 | } catch(e) { 387 | err = e; 388 | } 389 | should.exist(err); 390 | should.not.exist(result); 391 | err.should.be.instanceof(TypeError); 392 | err.message.should.contain('"@context" must be an array'); 393 | }); 394 | 395 | it('should fail when the first "@context" value is unexpected in' + 396 | '"assertRevocationList2020Context"', async () => { 397 | const id = 'https://example.com/status/1'; 398 | const list = await createList({length: 100000}); 399 | const credential = await createCredential({id, list}); 400 | let err; 401 | let result; 402 | try { 403 | // change the @context property intentionally to an unexpected value 404 | credential['@context'][0] = 'https://example.com/test/1'; 405 | result = assertRevocationList2020Context({credential}); 406 | } catch(e) { 407 | err = e; 408 | } 409 | should.exist(err); 410 | should.not.exist(result); 411 | err.should.be.instanceof(Error); 412 | err.message.should.contain('first "@context" value'); 413 | }); 414 | 415 | it('should fail when "credentialStatus" does not ' + 416 | 'exist in "statusTypeMatches"', async () => { 417 | const id = 'https://example.com/status/1'; 418 | const list = await createList({length: 100000}); 419 | const credential = await createCredential({id, list}); 420 | let err; 421 | let result; 422 | try { 423 | // remove required credentialStatus property 424 | delete credential.credentialStatus; 425 | result = statusTypeMatches({credential}); 426 | } catch(e) { 427 | err = e; 428 | } 429 | should.not.exist(err); 430 | result.should.equal(false); 431 | }); 432 | 433 | it('should fail when "credentialStatus" is not an object in ' + 434 | '"statusTypeMatches"', async () => { 435 | const id = 'https://example.com/status/1'; 436 | const list = await createList({length: 100000}); 437 | const credential = await createCredential({id, list}); 438 | let err; 439 | let result; 440 | try { 441 | // change credentialStatus to a string type 442 | credential.credentialStatus = 'https://example.com/status/1#50000'; 443 | result = statusTypeMatches({credential}); 444 | } catch(e) { 445 | err = e; 446 | } 447 | should.exist(err); 448 | should.not.exist(result); 449 | err.should.be.instanceof(Error); 450 | err.message.should.contain('"credentialStatus" is invalid'); 451 | }); 452 | 453 | it('should return false when "CONTEXTS.RL_V1" is not in ' + 454 | '"@contexts" in "statusTypeMatches"', async () => { 455 | const id = 'https://example.com/status/1'; 456 | const list = await createList({length: 100000}); 457 | const credential = await createCredential({id, list}); 458 | let err; 459 | let result; 460 | try { 461 | delete credential['@context'][1]; 462 | credential.credentialStatus = { 463 | id: 'https://example.com/status/1#50000', 464 | type: 'RevocationList2020Status', 465 | revocationListIndex: '50000', 466 | revocationListCredential: RLC.id 467 | }; 468 | result = statusTypeMatches({credential}); 469 | } catch(e) { 470 | err = e; 471 | } 472 | should.not.exist(err); 473 | result.should.equal(false); 474 | }); 475 | 476 | it('should fail when "CONTEXTS.RL_V1" is not in "@contexts" ' + 477 | 'in "assertRevocationList2020"', async () => { 478 | const id = 'https://example.com/status/1'; 479 | const list = await createList({length: 100000}); 480 | const credential = await createCredential({id, list}); 481 | let err; 482 | let result; 483 | try { 484 | delete credential['@context'][1]; 485 | result = assertRevocationList2020Context({credential}); 486 | } catch(e) { 487 | err = e; 488 | } 489 | should.exist(err); 490 | should.not.exist(result); 491 | err.should.be.instanceof(TypeError); 492 | err.message.should.contain('@context" must include'); 493 | }); 494 | 495 | it('should fail when credentialStatus is not an object for ' + 496 | '"getCredentialStatus"', async () => { 497 | const id = 'https://example.com/status/1'; 498 | const list = await createList({length: 100000}); 499 | const credential = await createCredential({id, list}); 500 | let err; 501 | let result; 502 | try { 503 | delete credential.credentialStatus; 504 | result = getCredentialStatus({credential}); 505 | } catch(e) { 506 | err = e; 507 | } 508 | should.exist(err); 509 | should.not.exist(result); 510 | err.should.be.instanceof(Error); 511 | err.message.should.contain('"credentialStatus" is missing or invalid'); 512 | }); 513 | 514 | it('should fail to verify when documentLoader is not a function', 515 | async () => { 516 | const credential = { 517 | '@context': [ 518 | 'https://www.w3.org/2018/credentials/v1', 519 | VC_RL_CONTEXT_URL 520 | ], 521 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 522 | type: ['VerifiableCredential', 'example:TestCredential'], 523 | credentialSubject: { 524 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 525 | 'example:test': 'foo' 526 | }, 527 | credentialStatus: { 528 | id: 'https://example.com/status/1#67342', 529 | type: 'RevocationList2020Status', 530 | revocationListCredential: RLC.id 531 | } 532 | }; 533 | const documentLoader = 'https://example.com/status/1'; 534 | let err; 535 | let result; 536 | try { 537 | result = await checkStatus({ 538 | credential, documentLoader, verifyRevocationListCredential: false}); 539 | } catch(e) { 540 | err = e; 541 | } 542 | should.not.exist(err); 543 | should.exist(result); 544 | result.should.be.an('object'); 545 | result.should.have.property('verified'); 546 | result.verified.should.be.a('boolean'); 547 | result.verified.should.be.false; 548 | result.should.have.property('error'); 549 | result.error.should.be.instanceof(TypeError); 550 | result.error.message.should.contain( 551 | '"documentLoader" must be a function'); 552 | }); 553 | 554 | it('should fail to verify when suite is not an object or array of ' + 555 | 'objects in checkStatus', async () => { 556 | const credential = { 557 | '@context': [ 558 | 'https://www.w3.org/2018/credentials/v1', 559 | VC_RL_CONTEXT_URL 560 | ], 561 | id: 'urn:uuid:e74fb1d6-7926-11ea-8e11-10bf48838a41', 562 | type: ['VerifiableCredential', 'example:TestCredential'], 563 | credentialSubject: { 564 | id: 'urn:uuid:011e064e-7927-11ea-8975-10bf48838a41', 565 | 'example:test': 'bar' 566 | }, 567 | credentialStatus: { 568 | id: 'https://example.com/status/1#50000', 569 | type: 'RevocationList2020Status', 570 | revocationListIndex: '50000', 571 | revocationListCredential: RLC.id 572 | } 573 | }; 574 | const documentLoader = extendContextLoader(async url => { 575 | const doc = documents.get(url); 576 | if(doc) { 577 | return { 578 | contextUrl: null, 579 | documentUrl: url, 580 | document: doc 581 | }; 582 | } 583 | return defaultDocumentLoader(url); 584 | }); 585 | const suite = '{}'; 586 | let err; 587 | let result; 588 | try { 589 | result = await checkStatus({ 590 | credential, documentLoader, suite, verifyRevocationListCredential: true 591 | }); 592 | } catch(e) { 593 | err = e; 594 | } 595 | should.not.exist(err); 596 | should.exist(result); 597 | result.should.be.an('object'); 598 | result.should.have.property('verified'); 599 | result.verified.should.be.a('boolean'); 600 | result.verified.should.be.false; 601 | result.should.have.property('error'); 602 | result.error.should.be.instanceof(TypeError); 603 | result.error.message.should.contain( 604 | '"suite" must be an object or an array of objects'); 605 | }); 606 | 607 | it('should fail to verify when "RevocationList2020Credential" ' + 608 | 'is not valid', async () => { 609 | const credential = { 610 | '@context': [ 611 | 'https://www.w3.org/2018/credentials/v1', 612 | VC_RL_CONTEXT_URL 613 | ], 614 | id: 'urn:uuid:e74fb1d6-7926-11ea-8e11-10bf48838a41', 615 | issuer: 'exampleissuer', 616 | issuanceDate: '2020-03-10T04:24:12.164Z', 617 | type: ['VerifiableCredential', 'RevocationList2020Credential'], 618 | credentialSubject: { 619 | id: 'urn:uuid:011e064e-7927-11ea-8975-10bf48838a41', 620 | 'example:test': 'bar' 621 | }, 622 | credentialStatus: { 623 | id: 'https://example.com/status/1#50000', 624 | type: 'RevocationList2020Status', 625 | revocationListIndex: 50000, 626 | revocationListCredential: RLC.id 627 | } 628 | }; 629 | let err; 630 | let result; 631 | try { 632 | delete credential.type[1]; 633 | result = await checkStatus({ 634 | credential, documentLoader, 635 | suite: {}, verifyRevocationListCredential: true 636 | }); 637 | } catch(e) { 638 | err = e; 639 | } 640 | should.not.exist(err); 641 | should.exist(result); 642 | result.should.be.an('object'); 643 | result.should.have.property('verified'); 644 | result.verified.should.be.a('boolean'); 645 | result.verified.should.be.false; 646 | result.should.have.property('error'); 647 | result.error.should.be.instanceof(Error); 648 | result.error.message.should.contain( 649 | '"RevocationList2020Credential" not verified'); 650 | }); 651 | 652 | it('should fail to verify for non-matching credential issuers', async () => { 653 | const credential = { 654 | '@context': [ 655 | 'https://www.w3.org/2018/credentials/v1', 656 | VC_RL_CONTEXT_URL, 657 | ], 658 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 659 | type: ['VerifiableCredential', 'example:TestCredential'], 660 | credentialSubject: { 661 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 662 | 'example:test': 'foo', 663 | }, 664 | credentialStatus: { 665 | id: 'https://example.com/status/1#67342', 666 | type: 'RevocationList2020Status', 667 | revocationListIndex: '67342', 668 | revocationListCredential: RLC.id, 669 | }, 670 | // this issuer does not match the issuer for the mock RLC specified 671 | // by `RLC.id` above 672 | issuer: 'did:example:1234', 673 | }; 674 | const result = await checkStatus({ 675 | credential, 676 | documentLoader, 677 | verifyRevocationListCredential: false, 678 | verifyMatchingIssuers: true, 679 | }); 680 | result.verified.should.equal(false); 681 | result.error.message.should.equal('Issuers of the revocation credential ' + 682 | 'and verifiable credential do not match.'); 683 | }); 684 | 685 | it('should allow different issuers if verifyMatchingIssuers is false', 686 | async () => { 687 | const credential = { 688 | '@context': [ 689 | 'https://www.w3.org/2018/credentials/v1', 690 | VC_RL_CONTEXT_URL, 691 | ], 692 | id: 'urn:uuid:a0418a78-7924-11ea-8a23-10bf48838a41', 693 | type: ['VerifiableCredential', 'example:TestCredential'], 694 | credentialSubject: { 695 | id: 'urn:uuid:4886029a-7925-11ea-9274-10bf48838a41', 696 | 'example:test': 'foo', 697 | }, 698 | credentialStatus: { 699 | id: 'https://example.com/status/1#67342', 700 | type: 'RevocationList2020Status', 701 | revocationListIndex: '67342', 702 | revocationListCredential: RLC.id, 703 | }, 704 | // this issuer does not match the issuer for the mock RLC specified 705 | // by `RLC.id` above 706 | issuer: 'did:example:1234', 707 | }; 708 | const result = await checkStatus({ 709 | credential, 710 | documentLoader, 711 | verifyRevocationListCredential: false, 712 | // this flag is set to allow different values for credential.issuer and 713 | // RLC.issuer 714 | verifyMatchingIssuers: false, 715 | }); 716 | result.verified.should.equal(true); 717 | }); 718 | }); 719 | -------------------------------------------------------------------------------- /tests/test-mocha.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | global.should = chai.should(); 3 | --------------------------------------------------------------------------------