diff --git a/.gitignore b/.gitignore index c87bac8..390eaa9 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ android/app/google-services.json dist/ coverage/ +*.tgz diff --git a/.npmignore b/.npmignore index e7e5bed..36fddc9 100644 --- a/.npmignore +++ b/.npmignore @@ -81,4 +81,5 @@ lint-staged.config.js .nvmrc .prettierrc babel.config.js -.editorconfig \ No newline at end of file +.editorconfig +src diff --git a/README.md b/README.md index d722c99..a08f685 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Digital Covid Ceritifcate Decoder -A node module for decoding DCC certs, validaiong signature and optionally running country specific business rules +A node module for decoding DCC certs, validating signature and optionally running country specific business rules For more on DCC see: - https://github.com/ehn-dcc-development @@ -78,7 +78,7 @@ Decodes a DCC qr code. Will accept input as string. Returns a promise which incl const result = await decodeAndValidateRules({source: 'HC1:....', dccData: {dcc...}}, ruleCountry: 'IE'}); ``` -Decodes a DCC qr code an then runs the provided business rules against the DCC data. Will accept input as string. Returns a promise which includes the raw cert data, populated cert, cert type and any error. +Decodes a DCC or Smart Health qr code and then runs the provided business rules against the DCC data. Will accept input as string. Returns a promise which includes the raw cert data, populated cert, cert type and any error. --- diff --git a/package-lock.json b/package-lock.json index 748c64f..e0e7183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,12 @@ { "name": "dcc-decoder", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "0.6.2", + "name": "dcc-decoder", + "version": "0.6.3", "license": "Apache-2.0", "dependencies": { "base45-js": "github:covidgreen/base45-js", @@ -13,9 +14,11 @@ "certlogic-js": "^0.9.2", "cose-js": "^0.6.0", "node-fetch": "^2.6.1", + "node-jose": "^2.0.0", "node-rsa": "^1.1.1", "pako": "^1.0.11", - "tiny-json-validator": "^2.0.0" + "tiny-json-validator": "^2.0.0", + "zlib": "^1.0.5" }, "devDependencies": { "@babel/plugin-transform-runtime": "^7.15.0", @@ -23,6 +26,7 @@ "@babel/preset-typescript": "^7.15.0", "@types/jest": "^26.0.23", "@types/node": "^15.12.4", + "@types/node-jose": "^1.1.8", "@typescript-eslint/eslint-plugin": "^3.1.0", "@typescript-eslint/parser": "^3.1.0", "babel-jest": "^27.0.6", @@ -3436,6 +3440,15 @@ "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==", "dev": true }, + "node_modules/@types/node-jose": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@types/node-jose/-/node-jose-1.1.8.tgz", + "integrity": "sha512-AFcArbplUaO+DqGVEPaiz/guw3uUA+dRHjaj26EEDF0DmTEPUd3dEdfdJMUx4kD65EAR3TnI1iHIcb31+Ko87Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -4201,6 +4214,33 @@ "node": ">= 0.6.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bignumber.js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-4.1.0.tgz", @@ -4291,6 +4331,29 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5137,6 +5200,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -6734,6 +6802,25 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -10536,8 +10623,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -10645,6 +10731,11 @@ "node": ">=8" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -10880,6 +10971,14 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/node-hkdf-sync": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-hkdf-sync/-/node-hkdf-sync-1.0.0.tgz", @@ -10897,6 +10996,31 @@ "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", "dev": true }, + "node_modules/node-jose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-2.0.0.tgz", + "integrity": "sha512-j8zoFze1gijl8+DK/dSXXqX7+o2lMYv1XS+ptnXgGV/eloQaqq1YjNtieepbKs9jBS4WTnMOqyKSaQuunJzx0A==", + "dependencies": { + "base64url": "^3.0.1", + "buffer": "^5.5.0", + "es6-promise": "^4.2.8", + "lodash": "^4.17.15", + "long": "^4.0.0", + "node-forge": "^0.10.0", + "pako": "^1.0.11", + "process": "^0.11.10", + "uuid": "^3.3.3" + } + }, + "node_modules/node-jose/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", @@ -11641,6 +11765,14 @@ "node": ">=8" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -14147,6 +14279,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zlib": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zlib/-/zlib-1.0.5.tgz", + "integrity": "sha1-bnyXL8NxxkWmr7A6sUdp3vEU/MA=", + "hasInstallScript": true, + "engines": { + "node": ">=0.2.0" + } } }, "dependencies": { @@ -16691,6 +16832,15 @@ "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==", "dev": true }, + "@types/node-jose": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@types/node-jose/-/node-jose-1.1.8.tgz", + "integrity": "sha512-AFcArbplUaO+DqGVEPaiz/guw3uUA+dRHjaj26EEDF0DmTEPUd3dEdfdJMUx4kD65EAR3TnI1iHIcb31+Ko87Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -17250,6 +17400,16 @@ "version": "git+https://git@github.com/covidgreen/base45-js.git#51f67c8eb497abc5e3e4ce8687d0026d2d5a3e7c", "from": "base45-js@github:covidgreen/base45-js" }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "bignumber.js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-4.1.0.tgz", @@ -17321,6 +17481,15 @@ "node-int64": "^0.4.0" } }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -17990,6 +18159,11 @@ "is-symbol": "^1.0.2" } }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -19204,6 +19378,11 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -22100,8 +22279,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.debounce": { "version": "4.0.8", @@ -22187,6 +22365,11 @@ } } }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -22379,6 +22562,11 @@ } } }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + }, "node-hkdf-sync": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-hkdf-sync/-/node-hkdf-sync-1.0.0.tgz", @@ -22393,6 +22581,29 @@ "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", "dev": true }, + "node-jose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-2.0.0.tgz", + "integrity": "sha512-j8zoFze1gijl8+DK/dSXXqX7+o2lMYv1XS+ptnXgGV/eloQaqq1YjNtieepbKs9jBS4WTnMOqyKSaQuunJzx0A==", + "requires": { + "base64url": "^3.0.1", + "buffer": "^5.5.0", + "es6-promise": "^4.2.8", + "lodash": "^4.17.15", + "long": "^4.0.0", + "node-forge": "^0.10.0", + "pako": "^1.0.11", + "process": "^0.11.10", + "uuid": "^3.3.3" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, "node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", @@ -22954,6 +23165,11 @@ } } }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=" + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -24931,6 +25147,11 @@ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true + }, + "zlib": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zlib/-/zlib-1.0.5.tgz", + "integrity": "sha1-bnyXL8NxxkWmr7A6sUdp3vEU/MA=" } } } diff --git a/package.json b/package.json index 09bb72f..e3707b4 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,18 @@ "lint:fix": "eslint . --fix --ext .js,.ts", "typecheck": "tsc" }, + "files": ["dist"], "dependencies": { "base45-js": "github:covidgreen/base45-js", "cbor": "^3.0.3", "certlogic-js": "^0.9.2", "cose-js": "^0.6.0", "node-fetch": "^2.6.1", + "node-jose": "^2.0.0", "node-rsa": "^1.1.1", "pako": "^1.0.11", - "tiny-json-validator": "^2.0.0" + "tiny-json-validator": "^2.0.0", + "zlib": "^1.0.5" }, "devDependencies": { "@babel/plugin-transform-runtime": "^7.15.0", @@ -23,6 +26,7 @@ "@babel/preset-typescript": "^7.15.0", "@types/jest": "^26.0.23", "@types/node": "^15.12.4", + "@types/node-jose": "^1.1.8", "@typescript-eslint/eslint-plugin": "^3.1.0", "@typescript-eslint/parser": "^3.1.0", "babel-jest": "^27.0.6", diff --git a/src/index.ts b/src/index.ts index 61654bf..b48c507 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { decodeShc } from './shc' import { ValueSetsComputed, RuleSet } from './rules-runner/types' import decodeQR from './verifier' import { runRuleSet } from './rules-runner' @@ -67,7 +68,12 @@ const decodeOnly = async (inputs: { const results: VerificationResult[] = [] for (const source of inputs.source) { - const result = await decodeQR(source, dcc.signingKeys) + let result + if (source.startsWith('shc:/')) { + result = await decodeShc(source, dcc.signingKeys) + } else { + result = await decodeQR(source, dcc.signingKeys) + } if (result.rawCert) { result.cert = populateCertValues( diff --git a/src/shc/codes.ts b/src/shc/codes.ts new file mode 100644 index 0000000..7117262 --- /dev/null +++ b/src/shc/codes.ts @@ -0,0 +1,34 @@ +export const vaccineCodes: Record< + string, + { + name: string + ma: string + mp: string + vp: string + } +> = { + 207: { + name: 'Moderna', + ma: 'ORG-100031184', + mp: 'EU/1/20/1507', + vp: '1119349007', + }, + 208: { + name: 'Pfizer', + ma: 'ORG-100030215', + mp: '', + vp: '', + }, + 210: { + name: 'AstraZeneca', + ma: 'ORG-100001699', + mp: 'Covishield', + vp: '1119349007', + }, + 212: { + name: 'Janssen', + ma: 'ORG-100001417', + mp: 'EU/1/20/1525', + vp: '1119349007', + }, +} diff --git a/src/shc/index.ts b/src/shc/index.ts new file mode 100644 index 0000000..2216b88 --- /dev/null +++ b/src/shc/index.ts @@ -0,0 +1,80 @@ +import { + CertificateContent, + CERT_TYPE, + VaccinationGroup, + VerificationResult, +} from '../' +import * as errors from '../types/errors' + +import { vaccineCodes } from './codes' +import { + numericShcToJwt, + parseJwtHeader, + parseJwtPayload, + verifySignature, +} from './parser' + +function convertShc(payload): CertificateContent { + const obj = payload.vc.credentialSubject.fhirBundle + const doses = obj.entry.flatMap(e => + e.resource.resourceType.toLowerCase() === 'immunization' ? [e.resource] : [] + ) + const lastDose = doses[doses.length - 1] + + const dob: string = obj.entry[0].resource.birthDate + + const vax = vaccineCodes[lastDose.vaccineCode.coding[0].code] + + const tg = '840539006' + const vp = vax.vp + const mp = vax.mp + const ma = vax.ma + const dn = doses.length + const sd = 2 + const dt: string = lastDose.occurrenceDateTime + const co = 'US' + const is = payload.iss + const ci = '' + + const fn: string = obj.entry[0].resource.name[0].family + const gn: string = obj.entry[0].resource.name[0].given.join(' ') + const fnt: string = fn // TODO: how to convert this + const gnt: string = gn // TODO: how to convert this + + const v: VaccinationGroup = { tg, vp, mp, dn, sd, dt, co, is, ci, ma } + + const cert = { + ver: '1.0.0', + nam: { fn, gn, fnt, gnt }, + dob, + v: [v], + } + + return cert +} + +/** + * Extract data from a raw 'shc://' string + * @param {string} rawSHC The raw 'shc://' string (from a QR code) + * @return The header, payload and verification result of the SHC + */ +export const decodeShc = async (rawSHC, keys): Promise => { + const jwt = numericShcToJwt(rawSHC) + const splitJwt = jwt.split('.') + const header = parseJwtHeader(splitJwt[0]) + const payload = parseJwtPayload(splitJwt[1]) + const key = keys.find(k => k.kid === header.kid) + + if (!key) throw new Error('No key') + + const result = await verifySignature(jwt, key) + + if (!result) { + return { error: errors.invalidSignature() } + } + + return { + rawCert: convertShc(payload), + type: CERT_TYPE.VACCINE, + } +} diff --git a/src/shc/parser.ts b/src/shc/parser.ts new file mode 100644 index 0000000..a45b6d5 --- /dev/null +++ b/src/shc/parser.ts @@ -0,0 +1,65 @@ +import zlib from 'zlib' + +import jose from 'node-jose' + +import { SigningKey } from '../types' + +/** + * Convert a SHC raw string to a standard JWT + * @param {string} rawSHC The raw 'shc://' string (from a QR code) + * @return {string} The encoded JWT + */ +export function numericShcToJwt(rawSHC) { + if (rawSHC.startsWith('shc:/')) { + rawSHC = rawSHC.split('/')[1] + } + + return rawSHC + .match(/(..?)/g) + .map(number => String.fromCharCode(parseInt(number, 10) + 45)) + .join('') +} + +/** + * Decode the JWT header and return it as an object + * @param {string} header Base64 encoded header + * @return {object} The decoded header + */ +export function parseJwtHeader(header) { + const headerData = Buffer.from(header, 'base64').toString() + return JSON.parse(headerData) +} + +/** + * Decode and extract the JWT payload + * @param {string} payload Base64 encoded + zlib compressed jwt payload + * @return {object} The decoded payload + */ +export function parseJwtPayload(payload) { + const buffer = Buffer.from(payload, 'base64') + const payloadJson = zlib.inflateRawSync(buffer).toString() + return JSON.parse(payloadJson) +} + +/** + * Verify the signature of a JWT with the given keys. + * Tries all and returns true if it finds the right one. + * + * @param {string} jwt JWT to verify + * @param {string} issuer The expected issuer of the JWT + * @return boolean + */ +export async function verifySignature( + jwt: string, + key: SigningKey +): Promise { + try { + const keystore = await jose.JWK.asKey(key) + const result = await jose.JWS.createVerify(keystore).verify(jwt) + + if (result) return true + } catch (err) { + console.log('Sig failed', key.kid, err) + return false + } +} diff --git a/src/types/hcert.ts b/src/types/hcert.ts index 5a20db7..f5cd494 100644 --- a/src/types/hcert.ts +++ b/src/types/hcert.ts @@ -67,10 +67,10 @@ export type VaccinationGroup = { ma: string /** Dose Number */ - dn: string + dn: number /** Total Series of Doses */ - sd: string + sd: number /** Date of Vaccination */ dt: string diff --git a/test/index.test.js b/test/index.test.js index bb31911..7c1d6b9 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -25,6 +25,9 @@ const RSA_SIGNED = const INVALID_BASE_45 = 'HC1:NCFOXN%TSMAHN-H/RCMPQ5GE5I00H9GBH3QNAD6.LQLX85ZS GJTSJ4NKP1HCV4*XUA2PSGH.+H$NI4L6F$S-N1FYBRR1$Q1+GOF+P$HQPHQHTQ.SQ6$PUKRN95404.W7UX4795L*KDYPWGO+9AZDOHCRL35IWMSDOP7OQ+M70AK$8 96XY4SBLU96:/6N9R%EPL8RY9DOA60-K.IA.C8KRDL4O54O4IGUJKJGI0JAXD15IAXMFU*GSHGRKMXGG6DBYCBMQN:HG5PAHGG8KES/F-1JW-K%B3A9ENO4B-S-*O4-G1FD/U47HAE1MI4OE0G1:HHD4AB874MM-6B:HKJSQ.TAG3CR1638W9AV88G64PB4VHRY2EK03NFJL4M10KP3AT2VK LT5GGFV85I0*10W2ZXJSBTMFW*+KM2T8-CXR32BMF7RAEAYKMWHE/NH UP4SNGENEWUY97 -3YM0.HAM:D:00ZY35XRT1100MFKWEWYHKCZIZJ0CAQYIKOZIZJ0DAQCDQGAE62ORR7HL0POYQCGMGBBEQFCBCDCT4VYFQZEPYFXK69ZRV9T8IY6NWAYEJF0VXVKNFCG/X9UIG5HFWRPEDW2EPW00S1K83I3900:919FUCKYOUALLGPSUPPORTERS9IKM24N21AW7ZUR2L013300EUISGOEINGDOWNNAZZYFUCKERS666I3AK96XYR01300STOREMYCODEMUTHERFUCKERANDGOTOHELLI5340XRT09SHUZVB33IR0IPUTAVIRUSHERE83H73KFI280I23KL0YRZT91Y0WITHLOVEFROMHA1*PWEOXCI/QXRLOZKN0LM*03I2RWT0Z' +const SHC_VALID = + 'shc:/5676290952432060346029243740446031222959532654603460292540772804336028702864716745222809286163253965327733625577553804275342714338436329382363443605120941396322376459324542254455655576412333223439554353057060573601104131295371707424350341440668413725243407636806333162405228322239437067573022667054424260032138366304235012095910303626706822272722356636732733641057662022662468302631703611044450583112092674000531653766521222282604696435713025634141597476703203425265084569396357690454432644743809583524574412365006585822625307053027367038726211260407557777620940200377003462260443310476126063232623414312244132102229583567704040536355215759004465766758625339736373093235224135233741244238435009006426746954522411376154072243567757315955292655102821034259335344706020257358125638396436567173435935585769077222432150562831072203646300453857322758622440456707663369034361035872080552623853406975753763626344393437724052370075703724741257730450640839316530086064102462635026406327110061761121582520624140200856755234507020113777556373115258213763273971273971772765046037544139297067587521252732305465566204376275716103293045572509595950386431301075097166400955435957321063646760365341326169505031526930044325315711677712695525610452567554773452602674620037715968364010083608406341637768721173223909277306425644330004603762552308377176690342683834313269253668326925671167562267257206581064224533743005316009713730220352006564560909410909004050560025083856507604437345393539112136013441345230695612436029057427362373357627113303296863406906427021721038665403776336545337694027695632636308313223082176323936600310277467245753060053672905073755304034453720' + describe('Validating QR Codes', () => { let dccDataSet @@ -49,6 +52,37 @@ describe('Validating QR Codes', () => { } }) + describe('Decode SHC data', () => { + it('SHC with valid sig', async () => { + const result = await decodeAndValidateRules({ + source: [SHC_VALID], + dccData: dccDataSet, + }) + + expect(result.rawCert).toEqual({ + ver: '1.0.0', + nam: { fn: 'Cabrera', gn: 'Dominic', fnt: 'Cabrera', gnt: 'Dominic' }, + dob: '1983-03-17', + v: [ + { + tg: '840539006', + vp: '1119349007', + mp: 'EU/1/20/1507', + dn: 2, + sd: 2, + dt: '2021-01-03', + co: 'US', + is: 'https://ekeys.ny.gov/epass/doh/dvc/2021', + ci: '', + ma: 'ORG-100031184', + }, + ], + }) + }) + + // add more cases... + }) + describe('Decode from qr data', () => { it('Decode from a vaccine cert ', async () => { const result = await decodeOnly({