├── .editorconfig ├── .env.example ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows-a │ ├── deploy-gh-pages.yml │ └── lint-and-test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── build-and-publish ├── build.ts ├── jest.config.js ├── package.json ├── src ├── base32.test.ts ├── browser.ts ├── cbor.test.ts ├── cbor.ts ├── cborTypes.ts ├── coseTypes.ts ├── crypto.ts ├── cwt.ts ├── cwtTypes.ts ├── did.test.ts ├── did.ts ├── elliptic.test.ts ├── exampleDIDDocument.json ├── generalTypes.ts ├── jtiCti.test.ts ├── jtiCti.ts ├── liveDIDDocument.json ├── main.test.ts ├── main.ts ├── mine.test.ts ├── mineDIDDocument.json ├── minePrivateKey.json ├── node.ts ├── util.ts └── violation.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = true 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LIVE_COVID_PASS_URI= 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected Behavior 2 | 3 | 4 | ## Actual Behavior 5 | 6 | 7 | ## Steps to Reproduce the Problem 8 | 9 | 1. 10 | 1. 11 | 1. 12 | 13 | ## Specifications 14 | 15 | - Version: 16 | - Platform: 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | > It's a good idea to open an issue first for discussion. 4 | 5 | - [ ] Tests pass 6 | - [ ] Appropriate changes to README are included in PR 7 | -------------------------------------------------------------------------------- /.github/workflows-a/deploy-gh-pages.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Builds the docs and deploys to GitHub pages 3 | # 4 | # https://github.com/actions/setup-node 5 | # Using https://github.com/marketplace/actions/deploy-to-github-pages 6 | name: Deploy to Github pages 7 | 8 | on: 9 | push: 10 | #tags: 11 | # - v* 12 | branches: 13 | - master 14 | 15 | jobs: 16 | deploy_pages: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-node@v2 22 | with: 23 | node-version: "12" 24 | cache: "yarn" 25 | - run: yarn install 26 | - run: yarn docs 27 | 28 | - run: touch docs/.nojekyll 29 | - name: Deploy docs 🚀 30 | uses: JamesIves/github-pages-deploy-action@releases/v3 31 | with: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | BRANCH: gh-pages # The branch the action should deploy to. 34 | FOLDER: docs # The folder the action should deploy. 35 | -------------------------------------------------------------------------------- /.github/workflows-a/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build_and_test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | nodejs: [10, 12, 14] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | # https://github.com/actions/setup-node 17 | - uses: actions/setup-node@v2-beta 18 | with: 19 | node-version: ${{ matrix.nodejs }} 20 | 21 | - run: yarn install 22 | - run: yarn add -D esbuild 23 | - run: yarn test 24 | - run: yarn lint 25 | - run: yarn build-all 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /lib/ 4 | /dist/ 5 | /docs/ 6 | .idea/* 7 | 8 | .DS_Store 9 | coverage 10 | *.log 11 | .npmrc 12 | .env 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.github/ 2 | /.vscode/ 3 | /node_modules/ 4 | /build/ 5 | /tmp/ 6 | .idea/* 7 | /docs/ 8 | 9 | coverage 10 | *.log 11 | .gitlab-ci.yml 12 | 13 | package-lock.json 14 | build-and-publish 15 | build.ts 16 | jest.config.js 17 | /*.tgz 18 | /tmp* 19 | /mnt/ 20 | /package/ 21 | /src/ 22 | /patches/ 23 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Will Seagar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NZCP.js   [![latest version badge]][npm] [![license badge]][license] [![downloads badge]][npm] 2 | 3 | [latest version badge]: https://img.shields.io/npm/v/@vaxxnz/nzcp 4 | [license badge]: https://img.shields.io/npm/l/@vaxxnz/nzcp 5 | [downloads badge]: https://img.shields.io/npm/dw/@vaxxnz/nzcp 6 | [npm]: https://www.npmjs.com/package/@vaxxnz/nzcp 7 | [license]: https://github.com/vaxxnz/nzcp-js/blob/main/LICENSE 8 | 9 | A JavaScript implementation of [NZ COVID Pass](https://github.com/minhealthnz/nzcovidpass-spec) verification, New Zealand's proof of COVID-19 vaccination solution, written in TypeScript. All contributions welcome 🥳 10 | 11 | We also have a [Rust implementation](https://github.com/vaxxnz/nzcp-rust/) available. 12 | 13 | > This library can be used for both in browser and Node.js. 14 | 15 | ## Install 16 | 17 | ```bash 18 | # NPM 19 | npm i @vaxxnz/nzcp 20 | 21 | # Yarn 22 | yarn add @vaxxnz/nzcp 23 | ``` 24 | 25 | ## Projects 26 | 27 | This library is current used in 28 | 29 | - [Vaxxed.as](https://vaxxed.as) An offline NZ COVID Pass verifier by @rafcontreras 30 | - [Hello Club](https://helloclub.com) A club and member management platform with self verification of vaccination status 31 | 32 | ## Demo 33 | 34 | - [Node.js demo on REPL.it](https://replit.com/@noway1/NZCPjs-demo) 35 | - [React.js demo on CodeSandbox](https://codesandbox.io/s/nzcpjs-demo-4vjgb) 36 | 37 | ## Usage 38 | 39 | ### Online 40 | 41 | ```javascript 42 | import { verifyPassURI } from "@vaxxnz/nzcp"; 43 | 44 | // Verify a live New Zealand COVID-19 Pass, resolving the DID document 45 | const result = await verifyPassURI("NZCP:/1/2KCEVIQEIVVWK6..."); 46 | ``` 47 | 48 | ### Offline 49 | 50 | ```javascript 51 | import { verifyPassURIOffline } from "@vaxxnz/nzcp"; 52 | 53 | // Verify a live New Zealand COVID-19 Pass, using a prefetched DID document 54 | const result = verifyPassURIOffline("NZCP:/1/2KCEVIQEIVVWK6..."); 55 | ``` 56 | 57 | ### Successful Verification 58 | 59 | On **successful** verification of the given pass, the `verifyPassURI` method returns the following result: 60 | 61 | ```javascript 62 | { 63 | "success": true, // Verification Outcome 64 | "violates": null, // Error object if code is invalid 65 | "expires": 2031-11-02T20:05:30.000Z, // Expiration date 66 | "validFrom": 2021-11-02T20:05:30.000Z, // Date when pass becomes valid 67 | "credentialSubject": { // Pass holder's details 68 | "givenName": "Emily", // Pass holder's given name 69 | "familyName": "Example", // Pass holder's family name 70 | "dob": "1970-01-01" // Pass holder's date of birth 71 | }, 72 | "raw": { // raw data returned by CWTClaims 73 | "jti": "urn:uuid:...", 74 | "iss": "did:web:nzcp.identity.health.nz", 75 | "nbf": 1635883530, 76 | "exp": 1951416330, 77 | "vc": { 78 | '@context': [ ... ], 79 | "version": '1.0.0', 80 | "type": [ ... ], 81 | "credentialSubject": { ... } 82 | } 83 | } 84 | } 85 | ``` 86 | 87 | ### Unsuccessful Verification 88 | 89 | On **unsuccessful** verification of the given pass, the `verifyPassURI` method returns the following result: 90 | 91 | ```javascript 92 | { 93 | "success": false, // Verification Outcome 94 | "violates": { // Error information 95 | "message": "Error..", // Friendly Error Message 96 | "section": "0.0", // Section of official specs under violation 97 | "link": "https://..", // Link to specifications breached 98 | "description": "The QR.." // Simplified error message 99 | }, 100 | "expires": null, // Nothing due to error 101 | "validFrom": null, // Nothing due to error 102 | "credentialSubject": null // No pass holder data due to error 103 | "raw": null // No raw data due to error 104 | } 105 | ``` 106 | 107 | 108 | ### Advanced Usage 109 | 110 | These examples show how to configure the library to supply your own trusted issuers or DID documents. This will allow you to use the library with the [example COVID Passes from the spec](https://nzcp.covid19.health.nz/#valid-worked-example). 111 | 112 | 113 | The following example shows how to use the example trusted issuer for online verification: 114 | 115 | ```javascript 116 | import { verifyPassURI, TRUSTED_ISSUERS } from "@vaxxnz/nzcp"; 117 | 118 | // Trusted issuer for the example COVID Passes 119 | // "did:web:nzcp.covid19.health.nz" 120 | const exampleTrustedIssuer = TRUSTED_ISSUERS.MOH_EXAMPLE; 121 | 122 | // Alternatively you could supply a trusted issuer for live COVID Passes 123 | // If you omit the trusted issuer, the library will use the live DID document 124 | // "did:web:nzcp.identity.health.nz" 125 | const liveTrustedIssuer = TRUSTED_ISSUERS.MOH_LIVE; 126 | 127 | const result = await verifyPassURI( 128 | "NZCP:/1/2KCEVIQEIVVWK6...", // COVID-19 Pass to be verified 129 | { trustedIssuer: exampleTrustedIssuer } // Supply your own trusted issuer to overwrite the default 130 | ); 131 | ``` 132 | 133 | The following example shows how use the example DID document for offline verification: 134 | 135 | ```javascript 136 | import { verifyPassURIOffline, DID_DOCUMENTS } from "@vaxxnz/nzcp"; 137 | 138 | // DID Document for the example COVID Passes 139 | // Prefetched version of https://nzcp.covid19.health.nz/.well-known/did.json 140 | const exampleDIDDocument = DID_DOCUMENTS.MOH_EXAMPLE; 141 | 142 | // Alternatively you could supply a DID document for live COVID Passes 143 | // If you omit the DID Document, the library will use the live DID document 144 | // Prefetched version of https://nzcp.identity.health.nz/.well-known/did.json 145 | const liveTrustedIssuer = DID_DOCUMENTS.MOH_LIVE; 146 | 147 | const result = verifyPassURIOffline( 148 | "NZCP:/1/2KCEVIQEIVVWK6...", // COVID-19 Pass to be verified 149 | { didDocument: exampleDIDDocument } // Supply your own DID document to overwrite the default 150 | ); 151 | ``` 152 | 153 | ## Online VS Offline 154 | 155 | Currently for a Node.js/React Native project we recomend using `verifyPassURI` and for a browser based application to use `verifyPassURIOffline`. 156 | 157 | The difference between the `verifyPassURI` and `verifyPassURIOffline` interfaces is: 158 | - `verifyPassURI`: This will resolve the DID document (which contains the Ministry of Health public key) from the web according to https://nzcp.covid19.health.nz/#ref:DID-CORE and then validate the DID document is from the MoH trusted issuer. 159 | - `verifyPassURIOffline`: This will use a prefetched version of https://nzcp.identity.health.nz/.well-known/did.json to verify against 160 | 161 | There is a CORS policy on https://nzcp.identity.health.nz/.well-known/did.json which makes it currently unable to be fetched from the browser. The only option for browser based verifiers is currently to use the `verifyPassURIOffline` function. The Ministry Of Health is aware of this issue and is working to resolve it. 162 | 163 | Offline scanners or scanners opperating in poor network conditions will also need to use `verifyPassURIOffline`. Since `verifyPassURI` requires an Internet connection to resolve the DID document. 164 | 165 | NZCP.js has decided to support both use cases but which one to use is a decision that the user of this library is in the best position to make. If you have a network connection and want to be completely correct (and to specification) use `verifyPassURI`. If you want speed, don't have a network connection or don't mind using a prefetched DID document, use `verifyPassURIOffline`. 166 | 167 | If you want to supply your own trusted issuer or DID document parameters, you can follow the Advanced Usage guide above. 168 | 169 | ## React Native 170 | 171 | The library runs well with a few polyfills. An example of which polyfills you might have to set up [can be found here](https://github.com/vaxxnz/nzcp-js/issues/2#issuecomment-972808289). 172 | 173 | ## Support 174 | 175 | See something that can be improved? [Report an Issue](https://github.com/vaxxnz/nzcp-js/issues) or contact us to [report a security concern](mailto:info@vaxx.nz). 176 | 177 | Want to help us build a better library? We welcome contributions via [pull requests](https://github.com/vaxxnz/nzcp-js/pulls) and welcome you to our wider [Vaxx.nz](https://vaxx.nz) community on Discord: [Join our Discord community](https://discord.gg/nkbnqhR8A8). 178 | 179 | --- 180 | 181 | ## NPM 182 | 183 | [@vaxxnz/nzcp](https://www.npmjs.com/package/@vaxxnz/nzcp) 184 | 185 | ## Contribute 186 | 187 | ```bash 188 | # Install dependencies 189 | yarn install 190 | ``` 191 | 192 | ```bash 193 | # Use developer scripts 194 | yarn lint 195 | yarn build-all 196 | ``` 197 | 198 | ## Run tests 199 | - Create `.env` in the root directory of the project 200 | - see `.env.example` for an example. 201 | - Run `yarn test` or `yarn test-watch` 202 | -------------------------------------------------------------------------------- /build-and-publish: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | yarn build-all 3 | echo '@vaxxnz:registry=https://registry.npmjs.org' > .npmrc 4 | npm publish 5 | echo '@vaxxnz:registry=https://npm.pkg.github.com' > .npmrc 6 | npm publish 7 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import { build } from "esbuild"; 2 | async function main() { 3 | const resultBrowser = await build({ 4 | entryPoints: ["src/browser.ts"], 5 | bundle: true, 6 | outfile: "dist/esbuild/browser.js", 7 | sourcemap: "both", 8 | minify: true, 9 | platform: "browser", 10 | format: "cjs", 11 | target: "es6", 12 | }); 13 | console.log("resultBrowser", resultBrowser); 14 | 15 | const resultNode = await build({ 16 | entryPoints: ["src/node.ts"], 17 | bundle: true, 18 | outfile: "dist/esbuild/node.js", 19 | sourcemap: "both", 20 | minify: true, 21 | platform: "node", 22 | format: "cjs", 23 | target: "es6", 24 | }); 25 | console.log("resultNode", resultNode); 26 | } 27 | main(); 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | roots: ['/src'], 4 | testMatch: [ 5 | "**/__tests__/**/*.+(ts|tsx|js)", 6 | "**/?(*.)+(spec|test).+(ts|tsx|js)" 7 | ], 8 | transform: { 9 | "^.+\\.(ts|tsx)$": "ts-jest" 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vaxxnz/nzcp", 3 | "version": "1.2.0", 4 | "description": "A JavaScript implementation of the NZ COVID Pass verification", 5 | "contributors": [ 6 | "Will Seagar ", 7 | "Ilia Sidorenko " 8 | ], 9 | "repository": "git://github.com/vaxxnz/nzcp-js.git", 10 | "license": "MIT", 11 | "keywords": [ 12 | "typescript", 13 | "esbuild", 14 | "covid", 15 | "new zealand", 16 | "nz", 17 | "covid pass", 18 | "vaccine pass", 19 | "vaccine certificate", 20 | "sdk", 21 | "nzcp", 22 | "verifier", 23 | "validator" 24 | ], 25 | "main": "./dist/esbuild/node.js", 26 | "types": "./dist/tsc/main.d.ts", 27 | "browser": "./dist/esbuild/browser.js", 28 | "scripts": { 29 | "lint": "eslint src/ --ext .js,.jsx,.ts,.tsx", 30 | "test": "jest", 31 | "test-watch": "jest --watch", 32 | "clean": "rm -rf dist build package", 33 | "docs": "typedoc --entryPoints src/main.ts", 34 | "build-all": "yarn clean && tsc --emitDeclarationOnly -p tsconfig.json && yarn ts-node build.ts", 35 | "build-and-publish": "./build-and-publish" 36 | }, 37 | "devDependencies": { 38 | "@types/elliptic": "^6.4.14", 39 | "@types/jest": "^26.0.21", 40 | "@types/node": "^15.0.1", 41 | "@types/node-fetch": "^2.5.6", 42 | "@typescript-eslint/eslint-plugin": "^4.19.0", 43 | "@typescript-eslint/parser": "^4.19.0", 44 | "base64-arraybuffer": "^1.0.2", 45 | "did-resolver": "^3.1.3", 46 | "dotenv": "^10.0.0", 47 | "elliptic": "^6.5.4", 48 | "esbuild": "^0.11.11", 49 | "eslint": "^7.22.0", 50 | "jest": "^26.6.3", 51 | "js-sha256": "^0.9.0", 52 | "postinstall-postinstall": "^2.1.0", 53 | "rfc4648": "^1.5.0", 54 | "ts-jest": "^26.5.4", 55 | "ts-node": "^9.1.1", 56 | "typedoc": "^0.20.35", 57 | "typescript": "^4.2.3", 58 | "web-did-resolver": "^2.0.7" 59 | }, 60 | "dependencies": {} 61 | } 62 | -------------------------------------------------------------------------------- /src/base32.test.ts: -------------------------------------------------------------------------------- 1 | import { base32 } from "rfc4648"; 2 | 3 | test("Base32 library works", async () => { 4 | const base32Example = 5 | "2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVAYFE6VGU4MCDGK7DHLLYWHVPUS2YIDJOA6Y524TD3AZRM263WTY2BE4DPKIF27WKF3UDNNVSVWRDYIYVJ65IRJJJ6Z25M2DO4YZLBHWFQGVQR5ZLIWEQJOZTS3IQ7JTNCFDX"; 6 | 7 | const res = base32.parse(base32Example); 8 | 9 | const base32String = base32.stringify(res); 10 | expect(base32String).toBe(base32Example); 11 | }); 12 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is the entrypoint of browser builds. 3 | * The code executes when loaded in a browser. 4 | */ 5 | import { verifyPassURI, verifyPassURIOffline, DID_DOCUMENTS, TRUSTED_ISSUERS } from "./main"; 6 | export { verifyPassURI, verifyPassURIOffline, DID_DOCUMENTS, TRUSTED_ISSUERS }; 7 | -------------------------------------------------------------------------------- /src/cbor.test.ts: -------------------------------------------------------------------------------- 1 | import { base32 } from "rfc4648"; 2 | import { decodeCOSE } from "./cbor"; 3 | 4 | test("CBOR library decodes", async () => { 5 | const res = base32.parse( 6 | "2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVAYFE6VGU4MCDGK7DHLLYWHVPUS2YIDJOA6Y524TD3AZRM263WTY2BE4DPKIF27WKF3UDNNVSVWRDYIYVJ65IRJJJ6Z25M2DO4YZLBHWFQGVQR5ZLIWEQJOZTS3IQ7JTNCFDX" 7 | ); 8 | 9 | const cborobj = decodeCOSE(res); 10 | 11 | expect(cborobj.err).toEqual(undefined); 12 | expect(cborobj.tag).toEqual(18); 13 | expect(cborobj.value.length).toEqual(4); 14 | }); 15 | -------------------------------------------------------------------------------- /src/cbor.ts: -------------------------------------------------------------------------------- 1 | // centralized place where cbor is included, in case we need to patch it 2 | import { Data } from "./cborTypes"; 3 | import { DecodedCOSEStructure } from "./coseTypes"; 4 | 5 | // author: putara 6 | // https://github.com/putara/nzcp/blob/master/verifier.js 7 | class Stream { 8 | data: Uint8Array; 9 | ptr: number; 10 | len: number; 11 | 12 | constructor(data: Uint8Array) { 13 | this.data = data; 14 | this.ptr = 0; 15 | this.len = data.length; 16 | } 17 | getc() { 18 | if (this.ptr >= this.len) { 19 | throw new Error("invalid data"); 20 | } 21 | return this.data[this.ptr++]; 22 | } 23 | ungetc() { 24 | if (this.ptr <= 0) { 25 | throw new Error("invalid data"); 26 | } 27 | --this.ptr; 28 | } 29 | chop(len: number) { 30 | if (len < 0) { 31 | throw new Error("invalid length"); 32 | } 33 | if (this.ptr + len > this.len) { 34 | throw new Error("invalid data"); 35 | } 36 | const out = this.data.subarray(this.ptr, this.ptr + len); 37 | this.ptr += len; 38 | return out; 39 | } 40 | } 41 | 42 | // RFC 7049 43 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 44 | function decodeCBORStream(stream: Stream) { 45 | function decodeUint(stream: Stream, v: number) { 46 | let x = v & 31; 47 | if (x <= 23) { 48 | // small 49 | } else if (x === 24) { 50 | // 8-bit 51 | x = stream.getc(); 52 | } else if (x === 25) { 53 | // 16-bit 54 | x = stream.getc() << 8; 55 | x |= stream.getc(); 56 | } else if (x === 26) { 57 | // 32-bit 58 | x = stream.getc() << 24; 59 | x |= stream.getc() << 16; 60 | x |= stream.getc() << 8; 61 | x |= stream.getc(); 62 | } else if (x === 27) { 63 | // 64-bit 64 | x = stream.getc() << 56; 65 | x |= stream.getc() << 48; 66 | x |= stream.getc() << 40; 67 | x |= stream.getc() << 32; 68 | x |= stream.getc() << 24; 69 | x |= stream.getc() << 16; 70 | x |= stream.getc() << 8; 71 | x |= stream.getc(); 72 | } else { 73 | throw new Error("invalid data"); 74 | } 75 | return x; 76 | } 77 | function decode(stream: Stream, isKeyString?: boolean): Data { 78 | const v = stream.getc(); 79 | const type = v >> 5; 80 | if (type === 0) { 81 | // positive int 82 | return decodeUint(stream, v); 83 | } else if (type === 1) { 84 | // negative int 85 | return ~decodeUint(stream, v); 86 | } else if (type === 2) { 87 | // byte array 88 | return stream.chop(decodeUint(stream, v)); 89 | } else if (type === 3) { 90 | // utf-8 string 91 | return new TextDecoder("utf-8").decode( 92 | stream.chop(decodeUint(stream, v)) 93 | ); 94 | } else if (type === 4) { 95 | // array 96 | const d = new Array(decodeUint(stream, v)) 97 | .fill(undefined) 98 | .map(() => decode(stream)); 99 | return d; 100 | } else if (type === 5) { 101 | // object 102 | const dMap: Map = new Map(); 103 | const dObj: { [key: string]: Data } = {}; 104 | const len = decodeUint(stream, v); 105 | for (let i = 0; i < len; ++i) { 106 | const key = decode(stream); 107 | const value = decode(stream, typeof key === "string"); 108 | dMap.set(key, value); 109 | dObj[`${key}`] = value; 110 | } 111 | return isKeyString ? dObj : dMap; 112 | } 113 | return null 114 | } 115 | return decode(stream); 116 | } 117 | 118 | const encodeBytes = (data: Uint8Array | never[]) => { 119 | const x = data.length; 120 | if (x === 0) { 121 | return [0x40]; 122 | } else if (x <= 23) { 123 | // small 124 | return [0x40 + x, ...data]; 125 | } else if (x < 256) { 126 | // 8-bit 127 | return [0x40 + 24, x, ...data]; 128 | } else if (x < 65536) { 129 | // 16-bit 130 | return [0x40 + 25, x >> 8, x & 0xff, ...data]; 131 | } // leave 32-bit and 64-bit unimplemented 132 | throw new Error("Too big data"); 133 | }; 134 | 135 | export function encodeToBeSigned(bodyProtected: Uint8Array, payload: Uint8Array): Uint8Array { 136 | const sig_structure = new Uint8Array([ 137 | // array w/ 4 items 138 | 0x84, 139 | // #1: context: "Signature1" 140 | 0x6a, 141 | 0x53, 142 | 0x69, 143 | 0x67, 144 | 0x6e, 145 | 0x61, 146 | 0x74, 147 | 0x75, 148 | 0x72, 149 | 0x65, 150 | 0x31, 151 | // #2: body_protected: CWT headers 152 | ...encodeBytes(bodyProtected), 153 | // #3: external_aad: empty 154 | ...encodeBytes([]), 155 | // #4: payload: CWT claims 156 | ...encodeBytes(payload), 157 | ]); 158 | const ToBeSigned = sig_structure; 159 | return ToBeSigned; 160 | } 161 | 162 | function decodeCOSEStream(stream: Stream) { 163 | const vtag = stream.getc(); 164 | const tag = vtag & 31; 165 | 166 | try { 167 | if (vtag !== 0xD2) { 168 | throw new Error('invalid data'); 169 | } 170 | const data = decodeCBORStream(stream); 171 | if (!(data instanceof Array)) { 172 | throw new Error('invalid data'); 173 | } 174 | 175 | const data1 = data[1]; 176 | if (!(data1 instanceof Map)) { 177 | throw new Error('invalid data'); 178 | } 179 | 180 | if (!(data instanceof Array) || data.length !== 4 || !(data[0] instanceof Uint8Array) || typeof data1 !== 'object' || Object.keys(data1).length !== 0 || !(data[2] instanceof Uint8Array) || !(data[3] instanceof Uint8Array)) { 181 | throw new Error('invalid data'); 182 | } 183 | 184 | return { 185 | tag, 186 | value: [ 187 | data[0], 188 | data[1], 189 | data[2], 190 | data[3], 191 | ], 192 | err: undefined, 193 | }; 194 | } 195 | catch (err) { 196 | return { 197 | tag, 198 | value: [], 199 | err, 200 | } 201 | } 202 | } 203 | 204 | export const decodeCBOR = (buf: Uint8Array): Data => { 205 | const data = decodeCBORStream(new Stream(buf)) 206 | return data 207 | }; 208 | 209 | export const decodeCOSE = (buf: Uint8Array): DecodedCOSEStructure => { 210 | const data = decodeCOSEStream(new Stream(buf)) 211 | return data 212 | }; 213 | -------------------------------------------------------------------------------- /src/cborTypes.ts: -------------------------------------------------------------------------------- 1 | export type Data = string | number | Uint8Array | Data[] | Map | { [key: string]: Data } | null; -------------------------------------------------------------------------------- /src/coseTypes.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "./cborTypes"; 2 | 3 | type DecodedCOSEValue = Data[]; 4 | 5 | interface DecodedCOSEStructureSuccess { 6 | tag: number 7 | value: DecodedCOSEValue 8 | err: Error 9 | } 10 | 11 | interface DecodedCOSEStructureError { 12 | tag: number 13 | value: DecodedCOSEValue 14 | err: undefined 15 | } 16 | 17 | export type DecodedCOSEStructure = DecodedCOSEStructureSuccess | DecodedCOSEStructureError 18 | -------------------------------------------------------------------------------- /src/crypto.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from "js-sha256"; 2 | import elliptic from "elliptic"; 3 | import { DecodedCOSEStructure } from "./coseTypes"; 4 | import { encodeToBeSigned } from "./cbor"; 5 | import { decode } from 'base64-arraybuffer' 6 | import { toHex } from "./util"; 7 | 8 | const EC = elliptic.ec; 9 | const ec = new EC("p256"); 10 | 11 | export function validateCOSESignature( 12 | decodedCOSEStructure: DecodedCOSEStructure, 13 | publicKeyJwt: JsonWebKey 14 | ): boolean { 15 | // protected is a typescript keyword 16 | const [protected_, , payload_, signature_] = decodedCOSEStructure.value; 17 | 18 | // verified at a earlier point... 19 | if (!publicKeyJwt.x || !publicKeyJwt.y) { 20 | return false; 21 | } 22 | 23 | const xBuf = new Uint8Array(decode(publicKeyJwt.x.replace(/-/g, '+').replace(/_/g, '/'))) 24 | const yBuf = new Uint8Array(decode(publicKeyJwt.y.replace(/-/g, '+').replace(/_/g, '/'))) 25 | 26 | // 1) '04' + hex string of x + hex string of y 27 | const publicKeyHex = `04${toHex(xBuf)}${toHex(yBuf)}`; 28 | const key = ec.keyFromPublic(publicKeyHex, "hex"); 29 | // Sig_structure = [ 30 | // context : "Signature" / "Signature1" / "CounterSignature", 31 | // body_protected : empty_or_serialized_map, 32 | // ? sign_protected : empty_or_serialized_map, 33 | // external_aad : bstr, 34 | // payload : bstr 35 | // ] 36 | 37 | const ToBeSigned = encodeToBeSigned(protected_ as Uint8Array, payload_ as Uint8Array); 38 | const messageHash = sha256.digest(ToBeSigned); 39 | const signature = { 40 | r: (signature_ as Uint8Array).slice(0, (signature_ as Uint8Array).length / 2), 41 | s: (signature_ as Uint8Array).slice((signature_ as Uint8Array).length / 2), 42 | }; 43 | const result = key.verify(messageHash, signature); 44 | return result; 45 | } 46 | -------------------------------------------------------------------------------- /src/cwt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CWTClaims, 3 | RawCWTClaims, 4 | RawCWTHeaders, 5 | UnvalidatedCWTClaims, 6 | UnvalidatedCWTHeaders, 7 | VC, 8 | } from "./cwtTypes"; 9 | import { decodeCtiToJti } from "./jtiCti"; 10 | import { currentTimestamp } from "./util"; 11 | import { Violation } from "./violation"; 12 | 13 | export function parseCWTClaims( 14 | rawCWTClaims: RawCWTClaims 15 | ): UnvalidatedCWTClaims { 16 | // Section 2.1.0.1.5 17 | // The claim key for cti of 7 MUST be used 18 | const ctiClaimRaw = rawCWTClaims.get(7); 19 | let jti: string | undefined; 20 | if (ctiClaimRaw) { 21 | // Section 2.1.1.2 22 | // CWT Token ID claim MUST be a valid UUID in the form of a URI as specified by [RFC4122] 23 | try { 24 | const jtiResult = decodeCtiToJti(ctiClaimRaw as Uint8Array); 25 | jti = jtiResult; 26 | } catch (error) { 27 | // continue parsing, but don't set jti 28 | } 29 | } 30 | 31 | // Section 2.1.0.2.5 32 | // The claim key for iss of 1 MUST be used 33 | const issClaimRaw = rawCWTClaims.get(1); 34 | let iss: string | undefined; 35 | if (issClaimRaw && typeof issClaimRaw === "string") { 36 | iss = issClaimRaw.toString(); 37 | } 38 | 39 | // Section 2.1.0.3.5 40 | // The claim key for nbf of 5 MUST be used 41 | const nbfClaimRaw = rawCWTClaims.get(5); 42 | let nbf: number | undefined; 43 | if (nbfClaimRaw) { 44 | if (typeof nbfClaimRaw === "number") { 45 | nbf = nbfClaimRaw; 46 | } 47 | } 48 | 49 | // Section 2.1.0.4.5 50 | // The claim key for exp of 4 MUST be used 51 | const expClaimRaw = rawCWTClaims.get(4); 52 | let exp: number | undefined; 53 | if (expClaimRaw) { 54 | if (typeof expClaimRaw === "number") { 55 | exp = expClaimRaw; 56 | } 57 | } 58 | 59 | // Section 2.1.0.5.3 60 | // The vc claim is currrently unregistered and therefore MUST be encoded as a Major Type 3 string as defined by [RFC7049]. 61 | // That is automatically handled by CBOR library. 62 | const vcClaimRaw = rawCWTClaims.get("vc"); 63 | let vc: VC | undefined; 64 | if (vcClaimRaw) { 65 | vc = vcClaimRaw as VC; 66 | } 67 | 68 | return { jti, iss, nbf, exp, vc }; 69 | } 70 | 71 | // parse CWT claims 72 | // https://nzcp.covid19.health.nz/#cwt-claims 73 | export function validateCWTClaims(cwtClaims: UnvalidatedCWTClaims): CWTClaims { 74 | // Section 2.1.0.1.5 75 | // The claim key for cti of 7 MUST be used 76 | if (cwtClaims.jti) { 77 | // pass 78 | } else { 79 | throw new Violation({ 80 | message: "CWT Token ID claim MUST be present", 81 | section: "2.1.0.1.1", 82 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 83 | description: "The COVID Pass is malformed or has been modified.", 84 | }); 85 | } 86 | 87 | // Section 2.1.0.2.5 88 | // The claim key for iss of 1 MUST be used 89 | if (cwtClaims.iss) { 90 | // pass 91 | } else { 92 | throw new Violation({ 93 | message: "Issuer claim MUST be present", 94 | section: "2.1.0.2.1", 95 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 96 | description: "The COVID Pass is malformed or has been modified.", 97 | }); 98 | } 99 | // Section 2.1.0.3.5 100 | // The claim key for nbf of 5 MUST be used 101 | if (cwtClaims.nbf) { 102 | // pass 103 | } else { 104 | throw new Violation({ 105 | message: 106 | "Not Before claim MUST be present and MUST be a timestamp encoded as an integer in the NumericDate format (as specified in [RFC8392] section 2)", 107 | section: "2.1.0.3.1", 108 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 109 | description: "The COVID Pass is malformed or has been modified.", 110 | }); 111 | } 112 | 113 | // Section 2.1.0.4.5 114 | // The claim key for exp of 4 MUST be used 115 | if (cwtClaims.exp) { 116 | // pass 117 | } else { 118 | throw new Violation({ 119 | message: 120 | "Not Before claim MUST be present and MUST be a timestamp encoded as an integer in the NumericDate format (as specified in [RFC8392] section 2)", 121 | section: "2.1.0.4.1", 122 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 123 | description: "The COVID Pass is malformed or has been modified.", 124 | }); 125 | } 126 | 127 | // TODO: what section number? 128 | if (currentTimestamp() >= cwtClaims.nbf) { 129 | // pass 130 | } else { 131 | throw new Violation({ 132 | message: 133 | "The current datetime is after or equal to the value of the `nbf` claim", 134 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 135 | section: "2.1.0.3.3", 136 | description: "The COVID Pass is not yet activated.", 137 | }); 138 | } 139 | 140 | // TODO: what section number? 141 | if (currentTimestamp() < cwtClaims.exp) { 142 | // pass 143 | } else { 144 | throw new Violation({ 145 | message: "The current datetime is before the value of the `exp` claim", 146 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 147 | section: "2.1.0.4.3", 148 | description: "The COVID Pass has expired.", 149 | }); 150 | } 151 | 152 | // Section 2.1.0.5.3 153 | // The vc claim is currrently unregistered and therefore MUST be encoded as a Major Type 3 string as defined by [RFC7049]. 154 | if (cwtClaims.vc) { 155 | // pass 156 | } else { 157 | throw new Violation({ 158 | message: "Verifiable Credential CWT claim MUST be present", 159 | section: "2.1.0.5.1", 160 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 161 | description: "The COVID Pass is malformed or has been modified.", 162 | }); 163 | } 164 | 165 | // https://nzcp.covid19.health.nz/#verifiable-credential-claim-structure 166 | if ( 167 | // Section 2.3.2.1 168 | // This property MUST be present and its value MUST be an array of strings 169 | cwtClaims.vc["@context"] instanceof Array && 170 | // Section 2.3.2.3 171 | // The first value MUST equal `https://www.w3.org/2018/credentials/v1` 172 | cwtClaims.vc["@context"][0] === "https://www.w3.org/2018/credentials/v1" && 173 | // Section 2.3.3-2.3.4 174 | // The following is an example including an additional JSON-LD context entry that defines the additional vocabulary specific to the New Zealand COVID Pass. 175 | cwtClaims.vc["@context"][1] === "https://nzcp.covid19.health.nz/contexts/v1" 176 | ) { 177 | // pass 178 | } else { 179 | throw new Violation({ 180 | message: 181 | "Verifiable Credential JSON-LD Context property doesn't conform to New Zealand COVID Pass example", 182 | link: "https://nzcp.covid19.health.nz/#verifiable-credential-claim-structure", 183 | section: "2.3.2", 184 | description: "The COVID Pass is malformed or has been modified.", 185 | }); 186 | } 187 | 188 | if ( 189 | // Section 2.3.5.1 190 | // This property MUST be present and its value MUST be an array 191 | cwtClaims.vc.type instanceof Array && 192 | // Section 2.3.5.2 193 | // Whose first element is VerifiableCredential and second element corresponds to one defined in the pass types section 194 | cwtClaims.vc.type[0] === "VerifiableCredential" && 195 | // Section 2.4.3 196 | // https://nzcp.covid19.health.nz/#pass-types 197 | // For the purposes of the New Zealand COVID Pass the Verifiable Credential MUST also include one of the following types. 198 | // - PublicCovidPass 199 | cwtClaims.vc.type[1] === "PublicCovidPass" 200 | ) { 201 | // pass 202 | } else { 203 | throw new Violation({ 204 | message: 205 | "Verifiable Credential Type property doesn't conform to New Zealand COVID Pass example", 206 | link: "https://nzcp.covid19.health.nz/#verifiable-credential-claim-structure", 207 | section: "2.3.5", 208 | description: "The COVID Pass is malformed or has been modified.", 209 | }); 210 | } 211 | 212 | // Section 2.3.8 213 | // Verifiable Credential Version property MUST be 1.0.0 214 | if (cwtClaims.vc.version === "1.0.0") { 215 | // pass 216 | } else { 217 | throw new Violation({ 218 | message: "Verifiable Credential Version property MUST be 1.0.0", 219 | link: "https://nzcp.covid19.health.nz/#verifiable-credential-claim-structure", 220 | section: "2.3.8", 221 | description: "The QR code is not a valid NZ COVID Pass.", 222 | }); 223 | } 224 | 225 | // Section 2.3.9 226 | // Verifiable Credential Credential Subject property MUST be present 227 | if (cwtClaims.vc.credentialSubject) { 228 | // and its value MUST be a JSON object with properties determined by the declared pass type for the pass 229 | if (!cwtClaims.vc.credentialSubject.givenName) { 230 | throw new Violation({ 231 | message: "Missing REQUIRED 'givenName' in credentialSubject property", 232 | link: "https://nzcp.covid19.health.nz/#publiccovidpass", 233 | section: "2.4.1.2.1", 234 | description: '"Given Name" missing from NZ COVID Pass.', 235 | }); 236 | } 237 | if (!cwtClaims.vc.credentialSubject.dob) { 238 | throw new Violation({ 239 | message: "Missing REQUIRED 'dob' in credentialSubject property", 240 | link: "https://nzcp.covid19.health.nz/#publiccovidpass", 241 | section: "2.4.1.2.2", 242 | description: '"Date of Birth" missing from NZ COVID Pass.', 243 | }); 244 | } 245 | } else { 246 | throw new Violation({ 247 | message: 248 | "Verifiable Credential Credential Subject property MUST be present", 249 | link: "https://nzcp.covid19.health.nz/#verifiable-credential-claim-structure", 250 | section: "2.3.9", 251 | description: "The COVID Pass is malformed or has been modified.", 252 | }); 253 | } 254 | 255 | return { 256 | jti: cwtClaims.jti, 257 | iss: cwtClaims.iss, 258 | nbf: cwtClaims.nbf, 259 | exp: cwtClaims.exp, 260 | vc: cwtClaims.vc, 261 | }; 262 | } 263 | 264 | // Section 2.2 265 | // CWT Headers 266 | // https://nzcp.covid19.health.nz/#cwt-headers 267 | export function parseCWTHeaders( 268 | rawCWTHeaders: RawCWTHeaders 269 | ): UnvalidatedCWTHeaders { 270 | // Section 2.2.1 271 | // The claim key of 4 is used to identify `kid` claim 272 | const CWTHeaderKid = rawCWTHeaders.get(4); 273 | // Section 2.2.2 274 | // The claim key of 1 is used to identify `alg` claim 275 | const CWTHeaderAlg = rawCWTHeaders.get(1); 276 | // Section 2.2.1 277 | // `kid` value MUST be encoded as a Major Type 3 278 | const kid = CWTHeaderKid ? new TextDecoder("utf-8").decode(CWTHeaderKid as Uint8Array) : undefined; 279 | // Section 2.2.2 280 | // `alg` claim value MUST be set to the value corresponding to ES256 algorithm registration, which is the numeric value of -7 281 | const alg = CWTHeaderAlg === -7 ? "ES256" : undefined; 282 | return { kid, alg }; 283 | } 284 | -------------------------------------------------------------------------------- /src/cwtTypes.ts: -------------------------------------------------------------------------------- 1 | export interface CredentialSubject { 2 | givenName: string; 3 | familyName: string; 4 | dob: string; 5 | } 6 | 7 | export interface VC { 8 | "@context": string[]; 9 | version: string; 10 | type: string[]; 11 | credentialSubject: CredentialSubject; 12 | } 13 | 14 | export interface CWTClaims { 15 | iss: string; 16 | nbf: number; 17 | exp: number; 18 | vc: VC; 19 | jti: string; 20 | } 21 | 22 | export type UnvalidatedCWTClaims = Partial; 23 | 24 | export type RawCWTHeaders = Map; 25 | 26 | export type RawCWTClaims = Map< 27 | number | string, 28 | string | number | Uint8Array | unknown 29 | >; 30 | 31 | export interface CWTHeaders { 32 | kid: string; 33 | alg: string; 34 | } 35 | 36 | export type UnvalidatedCWTHeaders = Partial; 37 | -------------------------------------------------------------------------------- /src/did.test.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "did-resolver"; 2 | import { getResolver } from "web-did-resolver"; 3 | 4 | const webResolver = getResolver(); 5 | 6 | const didResolver = new Resolver({ 7 | ...webResolver, 8 | }); 9 | 10 | test("CBOR library works", async () => { 11 | const doc = await didResolver.resolve("did:web:nzcp.covid19.health.nz"); 12 | expect(doc).toBeTruthy(); 13 | expect(doc.didDocument).toBeTruthy(); 14 | expect(doc.didDocument?.id).toBe("did:web:nzcp.covid19.health.nz"); 15 | expect(doc.didDocumentMetadata).toBeTruthy(); 16 | expect(doc.didResolutionMetadata).toBeTruthy(); 17 | }); 18 | -------------------------------------------------------------------------------- /src/did.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, DIDResolutionResult } from "did-resolver"; 2 | import { getResolver } from "web-did-resolver"; 3 | 4 | const webResolver = getResolver(); 5 | 6 | const didResolver = new Resolver({ 7 | ...webResolver, 8 | //...you can flatten multiple resolver methods into the Resolver 9 | }); 10 | 11 | async function resolve(did: string): Promise { 12 | const doc = await didResolver.resolve(did); 13 | return doc; 14 | } 15 | 16 | export default { resolve }; 17 | -------------------------------------------------------------------------------- /src/elliptic.test.ts: -------------------------------------------------------------------------------- 1 | import elliptic from "elliptic"; 2 | 3 | const fromHexString = (hexString: string) => 4 | new Uint8Array( 5 | (hexString.match(/.{1,2}/g) ?? []).map((byte) => parseInt(byte, 16)) 6 | ); 7 | 8 | test("elliptic library works", async () => { 9 | const publicKeyHex = 10 | "04cd147e5c6b02a75d95bdb82e8b80c3e8ee9caa685f3ee5cc862d4ec4f97cefad22fe5253a16e5be4d1621e7f18eac995c57f82917f1a9150842383f0b4a4dd3d"; 11 | const messageHashHex = 12 | "0513bb48e77bcfa51209a78d3224b0b2f1a29a9b9c0eff2263b6d08156aee72a"; 13 | const signatureRHex = 14 | "f6a9a841a390a40bd5cee4434cccdb7499d9461840f5c8dff436cba0698b1ab2"; 15 | const signatureSHex = 16 | "4dca052720b9f581200bebac2fff1afa159ce42aeb38d558df9413899db48271"; 17 | 18 | const signature = { 19 | r: fromHexString(signatureRHex), 20 | s: fromHexString(signatureSHex), 21 | }; 22 | 23 | const EC = elliptic.ec; 24 | const ec = new EC("p256"); 25 | const key = ec.keyFromPublic(publicKeyHex, "hex"); 26 | const result = key.verify(fromHexString(messageHashHex), signature); 27 | expect(result).toBe(true); 28 | }); 29 | -------------------------------------------------------------------------------- /src/exampleDIDDocument.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://w3.org/ns/did/v1", 3 | "id": "did:web:nzcp.covid19.health.nz", 4 | "verificationMethod": [ 5 | { 6 | "id": "did:web:nzcp.covid19.health.nz#key-1", 7 | "controller": "did:web:nzcp.covid19.health.nz", 8 | "type": "JsonWebKey2020", 9 | "publicKeyJwk": { 10 | "kty": "EC", 11 | "crv": "P-256", 12 | "x": "zRR-XGsCp12Vvbgui4DD6O6cqmhfPuXMhi1OxPl8760", 13 | "y": "Iv5SU6FuW-TRYh5_GOrJlcV_gpF_GpFQhCOD8LSk3T0" 14 | } 15 | } 16 | ], 17 | "assertionMethod": [ 18 | "did:web:nzcp.covid19.health.nz#key-1" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/generalTypes.ts: -------------------------------------------------------------------------------- 1 | import { CredentialSubject, CWTClaims, UnvalidatedCWTClaims } from "./cwtTypes"; 2 | 3 | export interface Violates { 4 | message: string; 5 | section: string; 6 | link: string; 7 | description?: string; 8 | } 9 | 10 | export type VerificationResult = 11 | | { success: true; violates: null; expires: Date; validFrom: Date; credentialSubject: CredentialSubject; raw: CWTClaims } 12 | | { success: false; violates: Violates; expires: Date | null; validFrom: Date | null; credentialSubject: CredentialSubject | null; raw: UnvalidatedCWTClaims | null }; 13 | -------------------------------------------------------------------------------- /src/jtiCti.test.ts: -------------------------------------------------------------------------------- 1 | import { decodeCtiToJti } from "./jtiCti"; 2 | import { Violation } from "./violation"; 3 | 4 | // Tests to verify Section 2.1.1 and RFC4122 are implemented properly 5 | // https://nzcp.covid19.health.nz/#mapping-jti-cti 6 | // https://datatracker.ietf.org/doc/html/rfc4122 7 | 8 | // Properly formed CWT Token ID with 16 octets 9 | test("properly formed cti is decode into jti", async () => { 10 | const cti = new Uint8Array([0x60, 0xa4, 0xf5, 0x4d, 0x4e, 0x30, 0x43, 0x32, 0xbe, 0x33, 0xad, 0x78, 0xb1, 0xea, 0xfa, 0x4b]) 11 | const result = decodeCtiToJti(cti); 12 | expect(result).toBe("urn:uuid:60a4f54d-4e30-4332-be33-ad78b1eafa4b"); 13 | }); 14 | 15 | // Malformed CWT Token ID with 8 octets 16 | test("malformed cti returns unsuccessful result", async () => { 17 | const cti = new Uint8Array([0x60, 0xa4, 0xf5, 0x4d, 0x4e, 0x30, 0x43, 0x32]) 18 | expect(() => decodeCtiToJti(cti)).toThrowError(Violation); 19 | }); 20 | -------------------------------------------------------------------------------- /src/jtiCti.ts: -------------------------------------------------------------------------------- 1 | import { toHex } from "./util"; 2 | import { Violation } from "./violation"; 3 | 4 | // Section 2.1.1 5 | // Decode CTI to JTI. Conforms to RFC4122 6 | // https://nzcp.covid19.health.nz/#mapping-jti-cti 7 | export function decodeCtiToJti(rawCti: Uint8Array): string { 8 | // Section 2.1.1.10.1 9 | // Parse the 16 byte value and convert to hexadecimal form 10 | if (rawCti.length !== 16) { 11 | throw new Violation({ 12 | message: `CTI must be 16 octets, but was ${rawCti.length} octets.`, 13 | section: "RFC4122.4.1", 14 | link: "https://datatracker.ietf.org/doc/html/rfc4122#section-4.1", 15 | description: "The COVID Pass is malformed or has been modified.", 16 | }); 17 | } 18 | const hexUuid = toHex(rawCti) 19 | 20 | // Section 2.1.1.10.2 21 | // In accordance with the ABNF syntax defined by [RFC4122] split the resulting hexadecimal string along the 4-2-2-2-6 hex octet pattern. 22 | // https://datatracker.ietf.org/doc/html/rfc4122#section-3 23 | // The formal definition of the UUID string representation is 24 | // provided by the following ABNF [7]: 25 | // 26 | // UUID = time-low "-" time-mid "-" 27 | // time-high-and-version "-" 28 | // clock-seq-and-reserved 29 | // clock-seq-low "-" node 30 | // time-low = 4hexOctet 31 | // time-mid = 2hexOctet 32 | // time-high-and-version = 2hexOctet 33 | // clock-seq-and-reserved = hexOctet 34 | // clock-seq-low = hexOctet 35 | // node = 6hexOctet 36 | // hexOctet = hexDigit hexDigit 37 | // hexDigit = 38 | // "0" / "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" / 39 | // "a" / "b" / "c" / "d" / "e" / "f" / 40 | // "A" / "B" / "C" / "D" / "E" / "F" 41 | const timeLow = hexUuid.slice(0, 8); 42 | const timeMid = hexUuid.slice(8, 12); 43 | const timeHighAndVersion = hexUuid.slice(12, 16); 44 | const clockSeqAndReserved = hexUuid.slice(16, 18); 45 | const clockSeqLow = hexUuid.slice(18, 20); 46 | const node = hexUuid.slice(20, 32); 47 | const uuid = `${timeLow}-${timeMid}-${timeHighAndVersion}-${clockSeqAndReserved}${clockSeqLow}-${node}`; 48 | 49 | // Section 2.1.1.10.3 50 | // Prepend the prefix of urn:uuid to the result obtained 51 | const jti = `urn:uuid:${uuid}`; 52 | return jti; 53 | } 54 | -------------------------------------------------------------------------------- /src/liveDIDDocument.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "did:web:nzcp.identity.health.nz", 3 | "@context": [ 4 | "https://w3.org/ns/did/v1", 5 | "https://w3id.org/security/suites/jws-2020/v1" 6 | ], 7 | "verificationMethod": [ 8 | { 9 | "id": "did:web:nzcp.identity.health.nz#z12Kf7UQ", 10 | "controller": "did:web:nzcp.identity.health.nz", 11 | "type": "JsonWebKey2020", 12 | "publicKeyJwk": { 13 | "kty": "EC", 14 | "crv": "P-256", 15 | "x": "DQCKJusqMsT0u7CjpmhjVGkHln3A3fS-ayeH4Nu52tc", 16 | "y": "lxgWzsLtVI8fqZmTPPo9nZ-kzGs7w7XO8-rUU68OxmI" 17 | } 18 | } 19 | ], 20 | "assertionMethod": [ 21 | "did:web:nzcp.identity.health.nz#z12Kf7UQ" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/main.test.ts: -------------------------------------------------------------------------------- 1 | import { DID_DOCUMENTS, TRUSTED_ISSUERS, verifyPassURI, verifyPassURIOffline } from "./main"; 2 | import dotenv from "dotenv"; 3 | 4 | // DID document which works with the example passes specified in v1 of NZ COVID Pass - Technical Specification 5 | // https://nzcp.covid19.health.nz/.well-known/did.json 6 | import exampleDIDDocument from "./exampleDIDDocument.json"; 7 | 8 | // DID document which works with the live passes specified in v1 of NZ COVID Pass - Technical Specification 9 | // https://nzcp.identity.health.nz/.well-known/did.json 10 | import liveDIDDocument from "./liveDIDDocument.json"; 11 | 12 | dotenv.config(); 13 | 14 | // This is the list of trusted issuers which works with the example passes specified in v1 of NZ COVID Pass - Technical Specification 15 | // https://nzcp.covid19.health.nz/ 16 | const exampleTrustedIssuers = ["did:web:nzcp.covid19.health.nz"]; 17 | 18 | // https://nzcp.covid19.health.nz/#valid-worked-example 19 | const EXAMPLE_PASS = 20 | "NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVAYFE6VGU4MCDGK7DHLLYWHVPUS2YIDJOA6Y524TD3AZRM263WTY2BE4DPKIF27WKF3UDNNVSVWRDYIYVJ65IRJJJ6Z25M2DO4YZLBHWFQGVQR5ZLIWEQJOZTS3IQ7JTNCFDX"; 21 | test("Valid pass is successful", async () => { 22 | const result = await verifyPassURI(EXAMPLE_PASS, { 23 | trustedIssuer: exampleTrustedIssuers, 24 | }); 25 | expect(result.success).toBe(true); 26 | expect(result.credentialSubject?.givenName).toBe("Jack"); 27 | expect(result.credentialSubject?.familyName).toBe("Sparrow"); 28 | expect(result.credentialSubject?.dob).toBe("1960-04-16"); 29 | expect(result.expires).toStrictEqual(new Date("2031-11-02T20:05:30.000Z")); 30 | expect(result.validFrom).toStrictEqual(new Date("2021-11-02T20:05:30.000Z")); 31 | expect(result.raw).toBeTruthy() 32 | }); 33 | 34 | // https://nzcp.covid19.health.nz/#bad-public-key 35 | const badPublicKeyPass = 36 | "NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVAY73U6TCQ3KF5KFML5LRCS5D3PCYIB2D3EOIIZRPXPUA2OR3NIYCBMGYRZUMBNBDMIA5BUOZKVOMSVFS246AMU7ADZXWBYP7N4QSKNQ4TETIF4VIRGLHOXWYMR4HGQ7KYHHU"; 37 | test("Bad Public Key pass is unsuccessful", async () => { 38 | const result = await verifyPassURI(badPublicKeyPass, { 39 | trustedIssuer: exampleTrustedIssuers, 40 | }); 41 | expect(result.success).toBe(false); 42 | expect(result.violates?.section).toBe("3"); 43 | }); 44 | 45 | // https://nzcp.covid19.health.nz/#public-key-not-found 46 | const publicKeyNotFoundPass = 47 | "NZCP:/1/2KCEVIQEIVVWK6JNGIASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVBMP3LEDMB4CLBS2I7IOYJZW46U2YIBCSOFZMQADVQGM3JKJBLCY7ATASDTUYWIP4RX3SH3IFBJ3QWPQ7FJE6RNT5MU3JHCCGKJISOLIMY3OWH5H5JFUEZKBF27OMB37H5AHF"; 48 | test("Public Key Not Found pass is unsuccessful", async () => { 49 | const result = await verifyPassURI(publicKeyNotFoundPass, { 50 | trustedIssuer: exampleTrustedIssuers, 51 | }); 52 | expect(result.success).toBe(false); 53 | expect(result.violates?.section).toBe("5.1.1"); 54 | }); 55 | 56 | // https://nzcp.covid19.health.nz/#modified-signature 57 | const modifiedSignaturePass = 58 | "NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVAYFE6VGU4MCDGK7DHLLYWHVPUS2YIAAAAAAAAAAAAAAAAC63WTY2BE4DPKIF27WKF3UDNNVSVWRDYIYVJ65IRJJJ6Z25M2DO4YZLBHWFQGVQR5ZLIWEQJOZTS3IQ7JTNCFDX"; 59 | test("Modified Signature pass is unsuccessful", async () => { 60 | const result = await verifyPassURI(modifiedSignaturePass, { 61 | trustedIssuer: exampleTrustedIssuers, 62 | }); 63 | expect(result.success).toBe(false); 64 | expect(result.violates?.section).toBe("3"); 65 | }); 66 | 67 | // https://nzcp.covid19.health.nz/#modified-payload 68 | const modifiedPayloadPass = 69 | "NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEOKKALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWKU3UMV3GK2TGMFWWS3DZJZQW2ZLDIRXWKY3EN5RGUMJZGYYC2MBUFUYTMB2QMCSPKTKOGBBTFPRTVV4LD2X2JNMEAAAAAAAAAAAAAAAABPN3J4NASOBXVEC5P3FC52BWW2ZK3IR4EMKU7OUIUUU7M5OWNBXOMMVQT3CYDKYI64VULCIEXMZZNUIPUZWRCR3Q"; 70 | test("Modified Payload pass is unsuccessful", async () => { 71 | const result = await verifyPassURI(modifiedPayloadPass, { 72 | trustedIssuer: exampleTrustedIssuers, 73 | }); 74 | expect(result.success).toBe(false); 75 | expect(result.violates?.section).toBe("3"); 76 | }); 77 | 78 | // https://nzcp.covid19.health.nz/#expired-pass 79 | const expiredPass = 80 | "NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUX5AM2FQIGTBPBPYWYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVA56TNJCCUN2NVK5NGAYOZ6VIWACYIBM3QXW7SLCMD2WTJ3GSEI5JH7RXAEURGATOHAHXC2O6BEJKBSVI25ICTBR5SFYUDSVLB2F6SJ63LWJ6Z3FWNHOXF6A2QLJNUFRQNTRU"; 81 | test("Expired Pass is unsuccessful", async () => { 82 | const result = await verifyPassURI(expiredPass, { 83 | trustedIssuer: exampleTrustedIssuers, 84 | }); 85 | expect(result.success).toBe(false); 86 | expect(result.violates?.section).toBe("2.1.0.4.3"); 87 | expect(result.credentialSubject?.dob).toBeTruthy() 88 | expect(result.expires).toStrictEqual(new Date("2021-10-26T20:05:31.000Z")); 89 | expect(result.validFrom).toStrictEqual(new Date("2020-11-02T20:05:31.000Z")); 90 | expect(result.raw).toBeTruthy() 91 | }); 92 | 93 | // https://nzcp.covid19.health.nz/#not-active-pass 94 | const notActivePass = 95 | "NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRU2XI5UFQIGTMZIQIWYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVA27NR3GFF4CCGWF66QGMJSJIF3KYID3KTKCBUOIKIC6VZ3SEGTGM3N2JTWKGDBAPLSG76Q3MXIDJRMNLETOKAUTSBOPVQEQAX25MF77RV6QVTTSCV2ZY2VMN7FATRGO3JATR"; 96 | test("Not Active pass is unsuccessful", async () => { 97 | const result = await verifyPassURI(notActivePass, { 98 | trustedIssuer: exampleTrustedIssuers, 99 | }); 100 | expect(result.success).toBe(false); 101 | expect(result.violates?.section).toBe("2.1.0.3.3"); 102 | expect(result.credentialSubject?.dob).toBeTruthy() 103 | expect(result.expires).toStrictEqual(new Date("2027-11-02T20:05:31.000Z")); 104 | expect(result.validFrom).toStrictEqual(new Date("2026-11-02T20:05:31.000Z")); 105 | expect(result.raw).toBeTruthy() 106 | }); 107 | 108 | // Custom Test: non base-32 string in the payload 109 | const notBase32 = 110 | "NZCP:/1/asdfghasSDFGHFDSADFGHFDSADFGHGFSDADFGBHFSADFGHFDSFGHFDDS0123456789"; 111 | test("Non base-32 string in the payload Pass is unsuccessful", async () => { 112 | const result = await verifyPassURI(notBase32, { 113 | trustedIssuer: exampleTrustedIssuers, 114 | }); 115 | expect(result.success).toBe(false); 116 | expect(result.violates?.section).toBe("4.7"); 117 | }); 118 | 119 | // Custom Test: not a string 120 | test("Non string uri unsuccesful", async () => { 121 | const result = await verifyPassURI(undefined as unknown as string, { 122 | trustedIssuer: exampleTrustedIssuers, 123 | }); 124 | expect(result.success).toBe(false); 125 | expect(result.violates?.section).toBe("4.3"); 126 | }); 127 | 128 | // Custom Test: BYO DID document 129 | test("Valid pass is successful with BYO DID document", async () => { 130 | const result = await verifyPassURIOffline( 131 | EXAMPLE_PASS, 132 | { trustedIssuer: exampleTrustedIssuers, didDocument: exampleDIDDocument } 133 | ); 134 | expect(result.success).toBe(true); 135 | expect(result.credentialSubject?.givenName).toBe("Jack"); 136 | expect(result.credentialSubject?.familyName).toBe("Sparrow"); 137 | expect(result.credentialSubject?.dob).toBe("1960-04-16"); 138 | expect(result.expires).toStrictEqual(new Date("2031-11-02T20:05:30.000Z")); 139 | expect(result.validFrom).toStrictEqual(new Date("2021-11-02T20:05:30.000Z")); 140 | expect(result.raw).toBeTruthy() 141 | }); 142 | const LIVE_PASS = process.env.LIVE_COVID_PASS_URI as string 143 | 144 | // Custom Test: Live pass 145 | test("Live pass is successful", async () => { 146 | const result = await verifyPassURI(LIVE_PASS); 147 | expect(result.success).toBe(true); 148 | }); 149 | 150 | // Custom Test: Live pass with BYO DID document 151 | test("Live pass is successful with BYO DID document", async () => { 152 | const result = await verifyPassURIOffline( 153 | LIVE_PASS, 154 | { didDocument: liveDIDDocument } 155 | ); 156 | expect(result.success).toBe(true); 157 | }); 158 | 159 | 160 | test("Standard usage, resolves DID document, resolves according to spec", async () => { 161 | const result = await verifyPassURI(LIVE_PASS); 162 | expect(result.success).toBe(true) 163 | }) 164 | 165 | test("Standard usage, resolves DID document, resolves according to spec. empty optionns", async () => { 166 | const result = await verifyPassURI(LIVE_PASS, {}); 167 | expect(result.success).toBe(true) 168 | }) 169 | 170 | test("Standard usage, resolves DID document, resolves according to spec. bad pass", async () => { 171 | const result = await verifyPassURI(EXAMPLE_PASS); 172 | expect(result.success).toBe(false) 173 | }) 174 | 175 | test("Standard usage, resolves DID document (same as previous call", async () => { 176 | const result = await verifyPassURI(LIVE_PASS, { trustedIssuer: TRUSTED_ISSUERS.MOH_LIVE }); 177 | expect(result.success).toBe(true) 178 | }) 179 | 180 | test("Standard usage, resolves DID document (same as previous call. bad pass", async () => { 181 | const result = await verifyPassURI(EXAMPLE_PASS, { trustedIssuer: TRUSTED_ISSUERS.MOH_LIVE }); 182 | expect(result.success).toBe(false) 183 | }) 184 | 185 | test("Standard usage, resolves DID document, use MoH test issuer", async () => { 186 | const result = await verifyPassURI(EXAMPLE_PASS, { trustedIssuer: TRUSTED_ISSUERS.MOH_EXAMPLE }); 187 | expect(result.success).toBe(true) 188 | }) 189 | 190 | test("Standard usage, resolves DID document, pass your own trusted issuers to be string|string[]", async () => { 191 | const result = await verifyPassURI(LIVE_PASS, { trustedIssuer: [TRUSTED_ISSUERS.MOH_LIVE] }); 192 | expect(result.success).toBe(true) 193 | }) 194 | 195 | test("Standard usage, resolves DID document, allowed trustedIssuer to be string|string[]", async () => { 196 | const result = await verifyPassURI(EXAMPLE_PASS, { trustedIssuer: [TRUSTED_ISSUERS.MOH_EXAMPLE] }); 197 | expect(result.success).toBe(true) 198 | }) 199 | 200 | test("offline usage, use hard coded DID document", () => { 201 | const result = verifyPassURIOffline(LIVE_PASS); 202 | expect(result.success).toBe(true) 203 | }) 204 | 205 | test("offline usage, use hard coded DID document. empty options", () => { 206 | const result = verifyPassURIOffline(LIVE_PASS, {}); 207 | expect(result.success).toBe(true) 208 | }) 209 | 210 | test("offline usage, use hard coded DID document. bad pass", () => { 211 | const result = verifyPassURIOffline(EXAMPLE_PASS); 212 | expect(result.success).toBe(false) 213 | }) 214 | 215 | test("offline usage, use hard coded DID document (same as the previous call)", () => { 216 | const result = verifyPassURIOffline(LIVE_PASS, { didDocument: DID_DOCUMENTS.MOH_LIVE }); 217 | expect(result.success).toBe(true) 218 | }) 219 | 220 | test("offline usage, use hard coded DID document, use MoH test DID document", () => { 221 | const result = verifyPassURIOffline(EXAMPLE_PASS, { didDocument: DID_DOCUMENTS.MOH_EXAMPLE }); 222 | expect(result.success).toBe(true) 223 | }) 224 | 225 | test("offline usage, pass your own DID document (needs a different interface since it won't resolve a promise) to be string|string[]", () => { 226 | const result = verifyPassURIOffline(LIVE_PASS, { didDocument: [DID_DOCUMENTS.MOH_LIVE] }); 227 | expect(result.success).toBe(true) 228 | }) 229 | 230 | test("offline usage, pass your own DID document, allow didDocument to be string|string[]", () => { 231 | const result = verifyPassURIOffline(EXAMPLE_PASS, { didDocument: [DID_DOCUMENTS.MOH_EXAMPLE] }); 232 | expect(result.success).toBe(true) 233 | }) 234 | 235 | 236 | test("offline usage, use hard coded DID document (same as the previous call). with trusted issuer", () => { 237 | const result = verifyPassURIOffline(LIVE_PASS, { didDocument: DID_DOCUMENTS.MOH_LIVE, trustedIssuer: TRUSTED_ISSUERS.MOH_LIVE }); 238 | expect(result.success).toBe(true) 239 | }) 240 | 241 | test("offline usage, use hard coded DID document (same as the previous call). with wrong trusted issuer", () => { 242 | const result = verifyPassURIOffline(LIVE_PASS, { didDocument: DID_DOCUMENTS.MOH_LIVE, trustedIssuer: TRUSTED_ISSUERS.MOH_EXAMPLE }); 243 | expect(result.success).toBe(false) 244 | }) 245 | 246 | 247 | test("offline usage, use hard coded DID document (same as the previous call). with trusted issuer array ", () => { 248 | const result = verifyPassURIOffline(LIVE_PASS, { didDocument: DID_DOCUMENTS.MOH_LIVE, trustedIssuer: [TRUSTED_ISSUERS.MOH_LIVE] }); 249 | expect(result.success).toBe(true) 250 | }) 251 | 252 | test("offline usage, use hard coded DID document (same as the previous call). with wrong trusted issuer array", () => { 253 | const result = verifyPassURIOffline(LIVE_PASS, { didDocument: DID_DOCUMENTS.MOH_LIVE, trustedIssuer: [TRUSTED_ISSUERS.MOH_EXAMPLE] }); 254 | expect(result.success).toBe(false) 255 | }) 256 | 257 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { base32 } from "rfc4648"; 2 | import did from "./did"; 3 | import { addBase32Padding } from "./util"; 4 | import { validateCOSESignature } from "./crypto"; 5 | import { parseCWTClaims, parseCWTHeaders, validateCWTClaims } from "./cwt"; 6 | import { VerificationResult, Violates } from "./generalTypes"; 7 | import { decodeCBOR, decodeCOSE } from "./cbor"; 8 | import { CredentialSubject, CWTClaims, CWTHeaders, UnvalidatedCWTClaims } from "./cwtTypes"; 9 | import { DIDDocument } from "did-resolver"; 10 | import exampleDIDDocument from "./exampleDIDDocument.json"; 11 | import liveDIDDocument from "./liveDIDDocument.json"; 12 | import { Violation } from "./violation"; 13 | import { DecodedCOSEStructure } from "./coseTypes"; 14 | 15 | // https://nzcp.covid19.health.nz/#did-document 16 | // The following is the DID Documents for the NZCP DID. 17 | const DID_DOCUMENTS = { 18 | MOH_LIVE: liveDIDDocument, 19 | MOH_EXAMPLE: exampleDIDDocument, 20 | }; 21 | 22 | // https://nzcp.covid19.health.nz/#trusted-issuers 23 | // The following is the live trusted issuer identifier for New Zealand Covid Passes. 24 | const TRUSTED_ISSUERS = { 25 | MOH_LIVE: "did:web:nzcp.identity.health.nz", 26 | MOH_EXAMPLE: "did:web:nzcp.covid19.health.nz", 27 | }; 28 | 29 | // The function below implements v1 of NZ COVID Pass - Technical Specification 30 | // https://nzcp.covid19.health.nz/ 31 | 32 | export { VerificationResult, CredentialSubject, Violates, DIDDocument }; 33 | export { DID_DOCUMENTS, TRUSTED_ISSUERS }; 34 | 35 | export type VerifyPassURIOfflineOptions = { 36 | trustedIssuer?: string | string[]; 37 | didDocument?: DIDDocument | DIDDocument[]; 38 | }; 39 | 40 | export const verifyPassURIOffline = ( 41 | uri: string, 42 | options?: VerifyPassURIOfflineOptions 43 | ): VerificationResult => { 44 | const didDocuments = 45 | options && options.didDocument 46 | ? Array.isArray(options.didDocument) 47 | ? options.didDocument 48 | : [options.didDocument] 49 | : [DID_DOCUMENTS.MOH_LIVE as DIDDocument]; 50 | 51 | // by default trust whatever issuers you specify in didDocuments 52 | const defaultTrustedIssuers = didDocuments.map( 53 | (didDocument) => didDocument.id 54 | ); 55 | 56 | const trustedIssuers = 57 | options && options.trustedIssuer 58 | ? Array.isArray(options.trustedIssuer) 59 | ? options.trustedIssuer 60 | : [options.trustedIssuer] 61 | : defaultTrustedIssuers; 62 | 63 | try { 64 | const decodedCOSEStructure = getCOSEStructure(uri); 65 | const cwtHeaders = getCWTHeaders(decodedCOSEStructure); 66 | const unvalidatedCWTClaims = getUnvalidatedCWTClaims(decodedCOSEStructure); 67 | const iss = getIss(unvalidatedCWTClaims, trustedIssuers); 68 | const didDocument = didDocuments.find((d) => d.id === iss) ?? null; 69 | const cwtClaims = getCWTClaims( 70 | iss, 71 | cwtHeaders, 72 | unvalidatedCWTClaims, 73 | didDocument, 74 | decodedCOSEStructure 75 | ); 76 | return { 77 | success: true, 78 | violates: null, 79 | expires: new Date(cwtClaims.exp * 1000), 80 | validFrom: new Date(cwtClaims.nbf * 1000), 81 | credentialSubject: cwtClaims.vc.credentialSubject, 82 | raw: cwtClaims, 83 | }; 84 | } catch (err) { 85 | const error = err as Error; 86 | if ("violates" in error) { 87 | const violation = error as Violation; 88 | const cwtClaims = violation.cwtClaims; 89 | return { 90 | success: false, 91 | violates: violation.violates, 92 | expires: cwtClaims?.exp ? new Date(cwtClaims?.exp * 1000) : null, 93 | validFrom: cwtClaims?.nbf ? new Date(cwtClaims?.nbf * 1000) : null, 94 | credentialSubject: cwtClaims?.vc?.credentialSubject ?? null, 95 | raw: cwtClaims, 96 | }; 97 | } else { 98 | return { 99 | success: false, 100 | violates: { message: err.message, section: "unknown", link: "" }, 101 | expires: null, 102 | validFrom: null, 103 | credentialSubject: null, 104 | raw: null, 105 | }; 106 | } 107 | } 108 | }; 109 | 110 | export type VerifyPassURIOptions = { 111 | trustedIssuer?: string | string[]; 112 | }; 113 | 114 | export const verifyPassURI = async ( 115 | uri: string, 116 | options?: VerifyPassURIOptions 117 | ): Promise => { 118 | const trustedIssuers = 119 | options && options.trustedIssuer 120 | ? Array.isArray(options.trustedIssuer) 121 | ? options.trustedIssuer 122 | : [options.trustedIssuer] 123 | : [TRUSTED_ISSUERS.MOH_LIVE]; 124 | 125 | try { 126 | const decodedCOSEStructure = getCOSEStructure(uri); 127 | const cwtHeaders = getCWTHeaders(decodedCOSEStructure); 128 | const unvalidatedCWTClaims = getUnvalidatedCWTClaims(decodedCOSEStructure); 129 | const iss = getIss(unvalidatedCWTClaims, trustedIssuers); 130 | 131 | const didResult = await did.resolve(iss); 132 | if (didResult.didResolutionMetadata.error) { 133 | // an error came back from the offical DID reference implementation 134 | // this handles a bunch of clauses in https://nzcp.covid19.health.nz/#issuer-identifier 135 | throw new Violation({ 136 | message: didResult.didResolutionMetadata.error, 137 | link: "https://nzcp.covid19.health.nz/#ref:DID-CORE", 138 | section: "DID-CORE.1", 139 | description: "Could not resolve trusted issuer.", 140 | }); 141 | } 142 | 143 | const cwtClaims = getCWTClaims( 144 | iss, 145 | cwtHeaders, 146 | unvalidatedCWTClaims, 147 | didResult.didDocument, 148 | decodedCOSEStructure 149 | ); 150 | return { 151 | success: true, 152 | violates: null, 153 | expires: new Date(cwtClaims.exp * 1000), 154 | validFrom: new Date(cwtClaims.nbf * 1000), 155 | credentialSubject: cwtClaims.vc.credentialSubject, 156 | raw: cwtClaims, 157 | }; 158 | } catch (err) { 159 | const error = err as Error; 160 | if ("violates" in error) { 161 | const violation = error as Violation; 162 | const cwtClaims = violation.cwtClaims; 163 | return { 164 | success: false, 165 | violates: violation.violates, 166 | expires: cwtClaims?.exp ? new Date(cwtClaims?.exp * 1000) : null, 167 | validFrom: cwtClaims?.nbf ? new Date(cwtClaims?.nbf * 1000) : null, 168 | credentialSubject: cwtClaims?.vc?.credentialSubject ?? null, 169 | raw: cwtClaims, 170 | }; 171 | } else { 172 | return { 173 | success: false, 174 | violates: { message: err.message, section: "unknown", link: "" }, 175 | expires: null, 176 | validFrom: null, 177 | credentialSubject: null, 178 | raw: null, 179 | }; 180 | } 181 | } 182 | }; 183 | 184 | // TODO: add tests for every error path 185 | 186 | /** 187 | * gets COSE Structure from URI 188 | * @param uri the COVID-19 Passport URI to be verified 189 | * @returns {DecodedCOSEStructure} the COSE structure 190 | */ 191 | const getCOSEStructure = (uri: string): DecodedCOSEStructure => { 192 | // Section 4: 2D Barcode Encoding 193 | // Decoding the payload of the QR Code 194 | // https://nzcp.covid19.health.nz/#2d-barcode-encoding 195 | 196 | // Section 4.3 197 | // QR code payload MUST be a string 198 | if (typeof uri !== "string") { 199 | throw new Violation({ 200 | message: "The payload of the QR Code MUST be a string", 201 | section: "4.3", 202 | link: "https://nzcp.covid19.health.nz/#2d-barcode-encoding", 203 | description: "The COVID Pass is malformed or has been modified.", 204 | }); 205 | } 206 | // Section 4.4 207 | // Parse the form of QR Code payload 208 | const payloadRegex = /(NZCP:\/)(\d+)\/([A-Za-z2-7=]+)/; 209 | const payloadMatch = uri.match(payloadRegex); 210 | if (!payloadMatch) { 211 | throw new Violation({ 212 | message: 213 | "The payload of the QR Code MUST be in the form `NZCP://`", 214 | section: "4.4", 215 | link: "https://nzcp.covid19.health.nz/#2d-barcode-encoding", 216 | description: "The QR code is not a valid NZ COVID Pass.", 217 | }); 218 | } 219 | 220 | const [, payloadPrefix, versionIdentifier, base32EncodedCWT] = payloadMatch; 221 | 222 | // Section 4.5 223 | // Check if the payload received from the QR Code begins with the prefix NZCP:/, if it does not then fail. 224 | if (payloadPrefix !== "NZCP:/") { 225 | throw new Violation({ 226 | message: 227 | "The payload of the QR Code MUST begin with the prefix of `NZCP:/`", 228 | section: "4.5", 229 | link: "https://nzcp.covid19.health.nz/#2d-barcode-encoding", 230 | description: "The QR code is not a valid NZ COVID Pass.", 231 | }); 232 | } 233 | 234 | // Section 4.6 235 | // Parse the character(s) (representing the version-identifier) as an unsigned integer following the NZCP:/ 236 | // suffix and before the next slash character (/) encountered. If this errors then fail. 237 | // If the value returned is un-recognized as a major protocol version supported by the verifying software then fail. 238 | // NOTE - for instance in this version of the specification this value MUST be 1. 239 | if (versionIdentifier !== "1") { 240 | throw new Violation({ 241 | message: 242 | "The version-identifier portion of the payload for the specification MUST be 1", 243 | section: "4.6", 244 | link: "https://nzcp.covid19.health.nz/#2d-barcode-encoding", 245 | description: "The QR code is not a valid NZ COVID Pass.", 246 | }); 247 | } 248 | 249 | // Section 4.7 250 | // With the remainder of the payload following the / after the version-identifier, attempt to decode it using base32 as defined by 251 | // [RFC4648] NOTE add back in padding if required, if an error is encountered during decoding then fail. 252 | let uint8array: Uint8Array; 253 | try { 254 | uint8array = base32.parse( 255 | // from https://nzcp.covid19.health.nz/#2d-barcode-encoding 256 | // Some base32 decoding implementations may fail to decode a base32 string that is missing the required padding as defined by [RFC4648]. 257 | // [addBase32Padding] is a simple javascript snippet designed to show how an implementor can add the required padding to a base32 string. 258 | addBase32Padding(base32EncodedCWT) 259 | ); 260 | } catch (error) { 261 | throw new Violation({ 262 | message: "The payload of the QR Code MUST be base32 encoded", 263 | section: "4.7", 264 | link: "https://nzcp.covid19.health.nz/#2d-barcode-encoding", 265 | description: "The COVID Pass is malformed or has been modified.", 266 | }); 267 | } 268 | 269 | // With the decoded payload attempt to decode it as COSE_Sign1 CBOR structure, if an error is encountered during decoding then fail. 270 | 271 | // Decoding this byte string as a CBOR structure and rendering it via the expanded form shown throughout [RFC7049] yields the following. 272 | // Let this result be known as the Decoded COSE structure. 273 | // d2 -- Tag #18 274 | // 84 -- Array, 4 items 275 | // 4a -- Bytes, length: 10 276 | // a204456b65792d310126 -- [0], a204456b65792d310126 277 | // a0 -- [1], {} 278 | // 59 -- Bytes, length next 2 bytes 279 | // 011f -- Bytes, length: 287 280 | // a501781e6469643a7765623a6e7a63702e636f76696431392e6865616c74682e6e7a051a61819a0a041a7450400a627663a46840636f6e7465787482782668747470733a2f2f7777772e77332e6f72672f323031382f63726564656e7469616c732f7631782a68747470733a2f2f6e7a63702e636f76696431392e6865616c74682e6e7a2f636f6e74657874732f76316776657273696f6e65312e302e306474797065827456657269666961626c6543726564656e7469616c6f5075626c6963436f766964506173737163726564656e7469616c5375626a656374a369676976656e4e616d65644a61636b6a66616d696c794e616d656753706172726f7763646f626a313936302d30342d3136075060a4f54d4e304332be33ad78b1eafa4b -- [2], a501781e6469643a7765623a6e7a63702e636f76696431392e6865616c74682e6e7a051a61819a0a041a7450400a627663a46840636f6e7465787482782668747470733a2f2f7777772e77332e6f72672f323031382f63726564656e7469616c732f7631782a68747470733a2f2f6e7a63702e636f76696431392e6865616c74682e6e7a2f636f6e74657874732f76316776657273696f6e65312e302e306474797065827456657269666961626c6543726564656e7469616c6f5075626c6963436f766964506173737163726564656e7469616c5375626a656374a369676976656e4e616d65644a61636b6a66616d696c794e616d656753706172726f7763646f626a313936302d30342d3136075060a4f54d4e304332be33ad78b1eafa4b 281 | // 58 -- Bytes, length next 1 byte 282 | // 40 -- Bytes, length: 64 283 | const decodedCOSEStructure = decodeCOSE(uint8array); 284 | 285 | return decodedCOSEStructure; 286 | }; 287 | 288 | const getCWTHeaders = ( 289 | decodedCOSEStructure: DecodedCOSEStructure 290 | ): Partial => { 291 | // Decoding the byte string present in the first element of the Decoded COSE structure, as a CBOR structure and rendering it via the expanded form yields the following. 292 | // Let this result be known as the Decoded CWT protected headers. 293 | // a2 -- Map, 2 pairs 294 | // 04 -- {Key:0}, 4 295 | // 45 -- Bytes, length: 5 296 | // 6b65792d31 -- {Val:0}, 6b65792d31 297 | // 01 -- {Key:1}, 1 298 | // 26 -- {Val:1}, -7 299 | const decodedCWTProtectedHeaders = decodeCBOR( 300 | decodedCOSEStructure.value[0] as Uint8Array 301 | ) as Map; 302 | 303 | const cwtHeaders = parseCWTHeaders(decodedCWTProtectedHeaders); 304 | 305 | // Section 7.1 306 | // https://nzcp.covid19.health.nz/#steps-to-verify-a-new-zealand-covid-pass 307 | // With the headers returned from the COSE_Sign1 decoding step, check for the presence 308 | // of the required headers as defined in the data model section, if these conditions are not meet then fail. 309 | if (cwtHeaders.kid) { 310 | // pass 311 | } else { 312 | throw new Violation({ 313 | message: 314 | "`kid` header MUST be present in the protected header section of the `COSE_Sign1` structure", 315 | section: "2.2.1.1", 316 | link: "https://nzcp.covid19.health.nz/#cwt-headers", 317 | description: "The COVID Pass is malformed or has been modified.", 318 | }); 319 | } 320 | if (cwtHeaders.alg === "ES256") { 321 | // pass 322 | } else { 323 | throw new Violation({ 324 | message: 325 | "`alg` claim value MUST be present in the protected header section of the `COSE_Sign1` structure and MUST be set to the value corresponding to `ES256` algorithm registration", 326 | section: "2.2.2.2", 327 | link: "https://nzcp.covid19.health.nz/#cwt-headers", 328 | description: "The COVID Pass is malformed or has been modified.", 329 | }); 330 | } 331 | return cwtHeaders; 332 | }; 333 | const getUnvalidatedCWTClaims = ( 334 | decodedCOSEStructure: DecodedCOSEStructure 335 | ): UnvalidatedCWTClaims => { 336 | const rawCWTClaims = decodeCBOR( 337 | decodedCOSEStructure.value[2] as Uint8Array 338 | ) as Map; 339 | 340 | const cwtClaims = parseCWTClaims(rawCWTClaims); 341 | return cwtClaims; 342 | }; 343 | 344 | const getIss = ( 345 | unvalidatedCWTClaims: UnvalidatedCWTClaims, 346 | trustedIssuers: string[] 347 | ): string => { 348 | const iss = unvalidatedCWTClaims.iss; 349 | 350 | // Section 2.1.0.2.1 351 | // Issuer claim MUST be present 352 | if (!iss) { 353 | throw new Violation({ 354 | message: "Issuer claim MUST be present", 355 | section: "2.1.0.2.1", 356 | link: "https://nzcp.covid19.health.nz/#cwt-claims", 357 | description: "The COVID Pass is malformed or has been modified.", 358 | }); 359 | } 360 | 361 | // TODO: section number? 362 | // // Validate that the iss claim in the decoded CWT payload is an issuer you trust refer to the trusted issuers section for a trusted list, if not then fail. 363 | // are we supporting other issuers? 364 | if (!trustedIssuers.includes(iss)) { 365 | throw new Violation({ 366 | message: 367 | "`iss` value reported in the pass does not match one listed in the trusted issuers", 368 | link: "https://nzcp.covid19.health.nz/#trusted-issuers", 369 | section: "6.3", 370 | description: "The COVID Pass was not issued by a trusted issuer.", 371 | }); 372 | } 373 | return iss; 374 | }; 375 | 376 | const getCWTClaims = ( 377 | iss: string, 378 | cwtHeaders: Partial, 379 | unvalidatedCWTClaims: UnvalidatedCWTClaims, 380 | didDocument: DIDDocument | null, 381 | decodedCOSEStructure: DecodedCOSEStructure 382 | ): CWTClaims => { 383 | const absoluteKeyReference = `${iss}#${cwtHeaders.kid}`; 384 | 385 | // 5.1.1 386 | // The public key referenced by the decoded CWT MUST be listed/authorized under the assertionMethod verification relationship in the resolved DID document. 387 | if (!didDocument?.assertionMethod) { 388 | throw new Violation({ 389 | message: 390 | "The public key referenced by the decoded CWT MUST be listed/authorized under the assertionMethod verification relationship in the resolved DID document.", 391 | link: "https://nzcp.covid19.health.nz/#did-document", 392 | section: "5.1.1", 393 | description: "The COVID Pass is malformed or has been modified.", 394 | }); 395 | } 396 | let assertionMethod = didDocument.assertionMethod; 397 | if (typeof assertionMethod === "string") { 398 | assertionMethod = [assertionMethod]; 399 | } 400 | if (!assertionMethod.includes(absoluteKeyReference)) { 401 | throw new Violation({ 402 | message: 403 | "The public key referenced by the decoded CWT MUST be listed/authorized under the assertionMethod verification relationship in the resolved DID document.", 404 | link: "https://nzcp.covid19.health.nz/#did-document", 405 | section: "5.1.1", 406 | description: "The COVID Pass is malformed or has been modified.", 407 | }); 408 | } 409 | // Not in NZCP spec but implied.. If theres an assertionMethod there should be a matching verification method 410 | if (!didDocument.verificationMethod) { 411 | throw new Violation({ 412 | message: "No matching verificationMethod method for the assertionMethod", 413 | link: "https://nzcp.covid19.health.nz/#ref:DID-CORE", 414 | section: "DID-CORE.2", 415 | description: "The COVID Pass is malformed or has been modified.", 416 | }); 417 | } 418 | const verificationMethod = didDocument.verificationMethod.find( 419 | (v) => v.id === absoluteKeyReference 420 | ); 421 | if (!verificationMethod) { 422 | throw new Violation({ 423 | message: "No matching verificationMethod for the assertionMethod", 424 | link: "https://nzcp.covid19.health.nz/#ref:DID-CORE", 425 | section: "DID-CORE.2", 426 | description: "The COVID Pass is malformed or has been modified.", 427 | }); 428 | } 429 | 430 | const publicKeyJwk = verificationMethod?.publicKeyJwk; 431 | 432 | // 5.1.2 (Note: Spec is written pretty hard to code against here... trying todo by best, could probably build the PK here?) 433 | // The public key referenced by the decoded CWT MUST be a valid P-256 public key suitable for usage with the 434 | // Elliptic Curve Digital Signature Algorithm (ECDSA) as defined in (ISO/IEC 14888–3:2006) section 2.3. 435 | 436 | if (!publicKeyJwk || !publicKeyJwk?.x || !publicKeyJwk?.y) { 437 | throw new Violation({ 438 | message: 439 | "The public key referenced by the decoded CWT MUST be a valid P-256 public key", 440 | link: "https://nzcp.covid19.health.nz/#did-document", 441 | section: "5.1.2", 442 | description: "The COVID Pass is malformed or has been modified.", 443 | }); 444 | } 445 | 446 | // 5.1.3 TODO: check that publicKeyJwt is a valid JWK 447 | // The expression of the public key referenced by the decoded CWT MUST be in the form of a JWK as per [RFC7517]. 448 | if (verificationMethod?.type !== "JsonWebKey2020") { 449 | throw new Violation({ 450 | message: 451 | "The expression of the public key referenced by the decoded CWT MUST be in the form of a JWK as per [RFC7517].", 452 | link: "https://nzcp.covid19.health.nz/#did-document", 453 | section: "5.1.3", 454 | description: "The COVID Pass is malformed or has been modified.", 455 | }); 456 | } 457 | 458 | // TODO: 5.1.4 (Note: Seems more of a spec for a signer rather than a verifier... will do later) 459 | // This public key JWK expression MUST NOT publish any JSON Web Key Parameters that are classified as “Private” under the “Parameter Information Class” category of the JSON Web Key Parameters IANA registry. 460 | 461 | // 5.1.5 462 | // This public key JWK expression MUST set a crv property which has a value of P-256. Additionally, the JWK MUST have a kty property set to EC. 463 | 464 | if (publicKeyJwk.crv !== "P-256" || publicKeyJwk.kty !== "EC") { 465 | throw new Violation({ 466 | message: 467 | "This public key JWK expression MUST set a crv property which has a value of P-256. Additionally, the JWK MUST have a kty property set to EC.", 468 | link: "https://nzcp.covid19.health.nz/#did-document", 469 | section: "5.1.5", 470 | description: "The COVID Pass is malformed or has been modified.", 471 | }); 472 | } 473 | 474 | // From section 3 "New Zealand COVID Passes MUST use Elliptic Curve Digital Signature Algorithm" 475 | // this is hardcoded in validateCOSESignature 476 | 477 | // From section 3 "all New Zealand COVID Passes MUST use the COSE_Sign1 structure" 478 | // this structure is hardcoded in validateCOSESignature 479 | 480 | const result = validateCOSESignature(decodedCOSEStructure, publicKeyJwk); 481 | 482 | if (!result) { 483 | // exact wording is: "Verifying parties MUST validate the digital signature on a New Zealand COVID Pass and MUST reject passes that fail this check as being invalid." 484 | throw new Violation({ 485 | message: "Retrieved public key does not validate `COSE_Sign1` structure", 486 | link: "https://nzcp.covid19.health.nz/#cryptographic-digital-signature-algorithm-selection", 487 | section: "3", 488 | description: "The COVID Pass is malformed or has been modified.", 489 | }); 490 | } 491 | 492 | // TODO: section number? 493 | // With the payload returned from the COSE_Sign1 decoding, check if it is a valid CWT containing the claims defined in the data model section, if these conditions are not meet then fail. 494 | try { 495 | const validatedCwtClaims = validateCWTClaims(unvalidatedCWTClaims); 496 | return validatedCwtClaims; 497 | } catch (e) { 498 | if ("violates" in e) { 499 | throw new Violation( 500 | (e as Violation).violates, 501 | unvalidatedCWTClaims 502 | ); 503 | } else { 504 | throw e; 505 | } 506 | } 507 | }; 508 | -------------------------------------------------------------------------------- /src/mine.test.ts: -------------------------------------------------------------------------------- 1 | import { verifyPassURIOffline } from "./main"; 2 | import mineDIDDocument from "./mineDIDDocument.json"; 3 | 4 | 5 | const PATRICK_1_PASS = "NZCP:/1/2KCEPIQEIIYDCAJGUBMQCH5FAF4B4ZDJMQ5HOZLCHJXHUY3QFZRW65TJMQYTSLTIMVQWY5DIFZXHUBA2MO5T6BQFDJQ5UC4GA5IA33GGYDRPERINRHQ3AMZOLCPSYYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFYWG4TFMRSW45DJMFWFG5LCNJSWG5FDMNSG6YTKGE4TMMBNGA2C2MJWNJTGC3LJNR4U4YLNMVSFG5DBOJUWO2LWMVXE4YLNMVTVAYLUOJUWG23EOR4XAZMCORLGK4TJMZUWCYTMMVBXEZLEMVXHI2LBNRXVA5LCNRUWGQ3POZUWIUDBONZWO5TFOJZWS33OMUYS4MBOGBMEBPT7R252WX6NX7IL2MVG36JG5NRZK5BE2H53DDVDUGJXDGNEYAB5DXCIOG4E5NRSDSI3F5ABJCEOLZL76427NM7POQG46ES6ND2V3GMQ====" 6 | 7 | const PATRICK_2_PASS = "NZCP:/1/2KCEPIQEIIYDCAJGUBMQCH5FAF4B4ZDJMQ5HOZLCHJXHUY3QFZRW65TJMQYTSLTIMVQWY5DIFZXHUBA2MO5T6BQFDJQ5UC4GA5IA33GGYDRPERINRHQ3AMZOLCPSYYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFYWG4TFMRSW45DJMFWFG5LCNJSWG5FDMNSG6YTKGE4TMMBNGA2C2MJWNJTGC3LJNR4U4YLNMVSFG5DBOJUWO2LWMVXE4YLNMVTVAYLUOJUWG23EOR4XAZMCORLGK4TJMZUWCYTMMVBXEZLEMVXHI2LBNRXVA5LCNRUWGQ3POZUWIUDBONZWO5TFOJZWS33OMUYS4MBOGBMEBKBZ2PWB4B6KBSOYXQQM6FXCW327HOLOLSMEHLMYQHCIEIX6SCWIHO5ZLXBBIIOELNL55LHMFYG4ASJSFODWMIBD7JAAJIMWBMYU7DFQ====" 8 | 9 | 10 | test("PATRICK_1_PASS is successful", async () => { 11 | const result = await verifyPassURIOffline(PATRICK_1_PASS, { 12 | trustedIssuer: mineDIDDocument.id, 13 | didDocument: mineDIDDocument, 14 | }); 15 | expect(result.success).toBe(true); 16 | expect(result.credentialSubject?.givenName).toBe("Patrick"); 17 | expect(result.credentialSubject?.familyName).toBe("Star"); 18 | 19 | }); 20 | 21 | 22 | test("PATRICK_2_PASS is successful", async () => { 23 | const result = await verifyPassURIOffline(PATRICK_2_PASS, { 24 | trustedIssuer: mineDIDDocument.id, 25 | didDocument: mineDIDDocument, 26 | }); 27 | expect(result.success).toBe(true); 28 | expect(result.credentialSubject?.givenName).toBe("Patrick"); 29 | expect(result.credentialSubject?.familyName).toBe("Star"); 30 | 31 | }); 32 | -------------------------------------------------------------------------------- /src/mineDIDDocument.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://w3.org/ns/did/v1", 3 | "id": "did:web:nzcp.covid19.health.nz", 4 | "verificationMethod": [ 5 | { 6 | "id": "did:web:nzcp.covid19.health.nz#01", 7 | "controller": "did:web:nzcp.covid19.health.nz", 8 | "type": "JsonWebKey2020", 9 | "publicKeyJwk": { 10 | "crv": "P-256", 11 | "kid": "01", 12 | "kty": "EC", 13 | "x": "BTq6ynU_JdIBStxsRmEDKkdMOT2X945TBThWDIcNOe0", 14 | "y": "F-4hndWkMHzA9riKl9jqbocBUREam6zPVzPsUiOxDYw" 15 | } 16 | } 17 | ], 18 | "assertionMethod": [ 19 | "did:web:nzcp.covid19.health.nz#01" 20 | ] 21 | } -------------------------------------------------------------------------------- /src/minePrivateKey.json: -------------------------------------------------------------------------------- 1 | { 2 | "crv": "P-256", 3 | "d": "axTx0mW8Ljd_E0WWNUnK-j-amZBWRxBaPSNRB9TRjRk", 4 | "key_ops": [ 5 | "sign" 6 | ], 7 | "kid": "01", 8 | "kty": "EC", 9 | "x": "BTq6ynU_JdIBStxsRmEDKkdMOT2X945TBThWDIcNOe0", 10 | "y": "F-4hndWkMHzA9riKl9jqbocBUREam6zPVzPsUiOxDYw", 11 | "alg": "ES256" 12 | } -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is the entrypoint of node builds. 3 | * The code executes when loaded in a node. 4 | */ 5 | import { verifyPassURI, verifyPassURIOffline, DID_DOCUMENTS, TRUSTED_ISSUERS } from "./main"; 6 | export { verifyPassURI, verifyPassURIOffline, DID_DOCUMENTS, TRUSTED_ISSUERS }; 7 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // convert js timestamp to unix timestamp 2 | export function currentTimestamp(): number { 3 | return Date.now() / 1000; 4 | } 5 | 6 | // from https://nzcp.covid19.health.nz/#adding-base32-padding 7 | export function addBase32Padding(base32InputNoPadding: string): string { 8 | let result = base32InputNoPadding; 9 | while (result.length % 8 !== 0) { 10 | result += "="; 11 | } 12 | return result; 13 | } 14 | 15 | export function toHex(buffer: Uint8Array) { 16 | function i2hex(i: number) { 17 | return ('0' + i.toString(16)).slice(-2); 18 | } 19 | const hex = Array.from(buffer).map(i2hex).join(''); 20 | return hex 21 | } -------------------------------------------------------------------------------- /src/violation.ts: -------------------------------------------------------------------------------- 1 | import { UnvalidatedCWTClaims } from "./cwtTypes"; 2 | import { Violates } from "./generalTypes"; 3 | 4 | type ViolationOptions = Violates; 5 | 6 | export class Violation extends Error { 7 | violates: Violates; 8 | cwtClaims: UnvalidatedCWTClaims | null 9 | constructor(options: ViolationOptions, cwtClaims: UnvalidatedCWTClaims | null = null) { 10 | super(options.message); 11 | this.violates = options; 12 | this.cwtClaims = cwtClaims; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "declaration": true, 8 | "strict": false, 9 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 10 | "strictNullChecks": true /* Enable strict null checks. */, 11 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 12 | "noUnusedLocals": true /* Report errors on unused locals. */, 13 | "noUnusedParameters": true /* Report errors on unused parameters. */, 14 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 15 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 16 | "importHelpers": true, 17 | "skipLibCheck": true, 18 | "esModuleInterop": true, 19 | "allowSyntheticDefaultImports": true, 20 | "experimentalDecorators": true, 21 | "sourceMap": true, 22 | "outDir": "./dist/tsc/", 23 | "types": ["node", "jest"], 24 | "lib": ["ES2020", "DOM"] 25 | }, 26 | "include": ["src/**/*.ts"], 27 | "exclude": ["node_modules", "**/*.test.ts"] 28 | } 29 | --------------------------------------------------------------------------------