diff --git a/components/Form.tsx b/components/Form.tsx index 2e622cf..d28cd72 100644 --- a/components/Form.tsx +++ b/components/Form.tsx @@ -96,7 +96,7 @@ function Form(): JSX.Element { // Add event listener to listen for file change events useEffect(() => { if (inputFile && inputFile.current) { - inputFile.current.addEventListener('input', () => { + inputFile.current.addEventListener('change', () => { let selectedFile = inputFile.current.files[0]; if (selectedFile !== undefined) { setFileLoading(true); @@ -117,16 +117,18 @@ function Form(): JSX.Element { async function getPayload(file){ try { - const payload = await getPayloadBodyFromFile(file, COLORS.GREEN); + const payload = await getPayloadBodyFromFile(file); setPayloadBody(payload); setFileLoading(false); setFile(file); - if (Object.keys(payload.receipts).length === 1) { - setSelectedDose(parseInt(Object.keys(payload.receipts)[0])); - }else{ - setShowDoseOption(true); - } + if (payload.rawData.length == 0) { + if (Object.keys(payload.receipts).length === 1) { + setSelectedDose(parseInt(Object.keys(payload.receipts)[0])); + } else { + setShowDoseOption(true); + } + } } catch (e) { setFile(file); setFileLoading(false); @@ -253,21 +255,30 @@ function Form(): JSX.Element { try { if (payloadBody) { - const passName = payloadBody.receipts[selectedDose].name.replace(' ', '-'); - const vaxName = payloadBody.receipts[selectedDose].vaccineName.replace(' ', '-'); - const passDose = payloadBody.receipts[selectedDose].numDoses; + + let selectedReceipt; + if (payloadBody.rawData.length > 0) { // shc stuff + const sortedKeys = Object.keys(payloadBody.receipts).sort(); // pickup the last key in the receipt table + const lastKey = sortedKeys[sortedKeys.length - 1]; + selectedReceipt = payloadBody.receipts[lastKey]; + } else { + selectedReceipt = payloadBody.receipts[selectedDose]; + } + const passName = selectedReceipt.name.replace(' ', '-'); + const vaxName = selectedReceipt.vaccineName.replace(' ', '-'); + const passDose = selectedReceipt.numDoses; const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.pkpass`; - //console.log('> increment count'); + console.log('> increment count'); await incrementCount(); - // console.log('> generatePass'); + console.log('> generatePass'); const pass = await PassData.generatePass(payloadBody, selectedDose); - //console.log('> create blob'); + console.log('> create blob'); const passBlob = new Blob([pass], {type: "application/vnd.apple.pkpass"}); - //console.log(`> save blob as ${covidPassFilename}`); + console.log(`> save blob as ${covidPassFilename}`); saveAs(passBlob, covidPassFilename); setSaveLoading(false); } @@ -308,13 +319,24 @@ function Form(): JSX.Element { } try { - const passName = payloadBody.receipts[selectedDose].name.replace(' ', '-'); - const vaxName = payloadBody.receipts[selectedDose].vaccineName.replace(' ', '-'); - const passDose = payloadBody.receipts[selectedDose].numDoses; + + let selectedReceipt; + if (payloadBody.rawData.length > 0) { // shc stuff + const sortedKeys = Object.keys(payloadBody.receipts).sort(); // pickup the last key in the receipt table + const lastKey = sortedKeys[sortedKeys.length - 1]; + selectedReceipt = payloadBody.receipts[lastKey]; + setSelectedDose(Number(lastKey)); + } else { + selectedReceipt = payloadBody.receipts[selectedDose]; + } + const passName = selectedReceipt.name.replace(' ', '-'); + const vaxName = selectedReceipt.vaccineName.replace(' ', '-'); + const passDose = selectedReceipt.numDoses; const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.png`; await incrementCount(); - let photoBlob = await Photo.generatePass(payloadBody, selectedDose); + + let photoBlob = await Photo.generatePass(payloadBody, passDose); saveAs(photoBlob, covidPassFilename); // need to clean up @@ -420,7 +442,7 @@ function Form(): JSX.Element { diff --git a/components/Page.tsx b/components/Page.tsx index 36d2d0b..79fa7c1 100644 --- a/components/Page.tsx +++ b/components/Page.tsx @@ -36,7 +36,7 @@ function Page(props: PageProps): JSX.Element { {t('common:gitHub')} {t('common:returnToMainSite')} -
Last updated: 2021-09-29 (v1.9.12)
+
Last updated: 2021-10-05 (v1.9.15)
@@ -48,14 +48,12 @@ function Page(props: PageProps): JSX.Element { -   Vaccination Receipt + Vaccination Receipt

-
-
VACCINE

@@ -64,12 +62,20 @@ function Page(props: PageProps): JSX.Element { AUTHORIZED ORGANIZATION - DATE + VACC. DATE + + + + + + + + NAME @@ -79,16 +85,24 @@ function Page(props: PageProps): JSX.Element { + + + + QR CODE EXPIRY + + + + 2021-10-22 +

-
-


+ ) } diff --git a/package-lock.json b/package-lock.json index eb56add..e534b2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "next-i18next": "^8.5.1", "next-seo": "^4.26.0", "node-fetch": "^2.6.1", + "node-jose": "^2.0.0", "node-pdf-verifier": "^1.0.1", "pdfjs-dist": "^2.5.207", "pngjs": "^6.0.0", @@ -49,6 +50,7 @@ }, "devDependencies": { "@types/pako": "^1.0.1", + "@types/pngjs": "^6.0.1", "@types/react": "^17.0.11", "autoprefixer": "^10.0.4", "postcss": "^8.1.10", @@ -499,6 +501,15 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/@types/pngjs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.1.tgz", + "integrity": "sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.3", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", @@ -912,6 +923,14 @@ } ] }, + "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/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -1733,6 +1752,11 @@ "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", "integrity": "sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw=" }, + "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", @@ -2710,6 +2734,11 @@ "integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=", "dev": true }, + "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/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3062,6 +3091,31 @@ "he": "1.2.0" } }, + "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-libs-browser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", @@ -5666,6 +5720,15 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/pngjs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.1.tgz", + "integrity": "sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/prop-types": { "version": "15.7.3", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", @@ -6010,6 +6073,11 @@ "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==" + }, "big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -6704,6 +6772,11 @@ "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", "integrity": "sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw=" }, + "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", @@ -7422,6 +7495,11 @@ "integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=", "dev": true }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7683,6 +7761,29 @@ "he": "1.2.0" } }, + "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-libs-browser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", diff --git a/package.json b/package.json index ba56bbf..5047545 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,9 @@ "next-i18next": "^8.5.1", "next-seo": "^4.26.0", "node-fetch": "^2.6.1", + "node-jose": "^2.0.0", "node-pdf-verifier": "^1.0.1", - "pdfjs-dist": "^2.5.207", + "pdfjs-dist": "^2.6.347", "pngjs": "^6.0.0", "qrcode": "^1.4.4", "react": "^17.0.2", @@ -55,6 +56,7 @@ }, "devDependencies": { "@types/pako": "^1.0.1", + "@types/pngjs": "^6.0.1", "@types/react": "^17.0.11", "autoprefixer": "^10.0.4", "postcss": "^8.1.10", diff --git a/pages/index.tsx b/pages/index.tsx index 03ccd21..5a25978 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -74,12 +74,12 @@ function Index(): JSX.Element {

{t('common:subtitle')}


{t('common:subtitle2')}


{displayPassCount}

- Sept 29 afternoon update: + Oct 3 evening update:


{t('common:continueSpirit')}


diff --git a/public/locales/en/common.yml b/public/locales/en/common.yml index 3551e5e..59db56d 100644 --- a/public/locales/en/common.yml +++ b/public/locales/en/common.yml @@ -1,6 +1,6 @@ title: Vaccination Receipt to Wallet -subtitle: This utility (created by volunteers) converts your vaccination receipt from Ontario Ministry of Health to an Apple Wallet pass for easy access in the interim. -subtitle2: Once Ontario's official QR code is available on Oct 22, you will be able to update your Apple Wallet pass by visiting this site again. +subtitle: This utility (created by volunteers) converts your vaccination receipt from Ontario Ministry of Health to an Apple Wallet pass for easy access in the interim. Android users can create a photo pass while we await Google Pay COVID Card API Access from Google. +subtitle2: Once Ontario's official QR code is available on Oct 22, you will be able to update your Apple Wallet pass or Android photo pass by visiting this site again. update1Date: Sep 23 Updates update1: Thanks so much for all the encouragements and suggestions to make this better. We plan to keep enhancing this to help more Canadians. Stay tuned! continueSpirit: Continuing the spirit of ❤️ @VaxHuntersCanada ❤️. diff --git a/src/decode.ts b/src/decode.ts index ae20528..563c736 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -1,48 +1,107 @@ -// Taken from https://github.com/ehn-dcc-development/ehn-sign-verify-javascript-trivial/blob/main/cose_verify.js -// and https://github.com/ehn-dcc-development/dgc-check-mobile-app/blob/main/src/app/cose-js/sign.js +// adapted from https://github.com/fproulx/shc-covid19-decoder/blob/main/src/shc.js -import base45 from 'base45'; -import pako from 'pako'; -import cbor from 'cbor-js'; +const jsQR = require("jsqr"); +const zlib = require("zlib"); +import {Receipt, HashTable} from "./payload"; -export function typedArrayToBufferSliced(array: Uint8Array): ArrayBuffer { - return array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset); +export function getQRFromImage(imageData) { + return jsQR( + new Uint8ClampedArray(imageData.data.buffer), + imageData.width, + imageData.height + ); } -export function typedArrayToBuffer(array: Uint8Array): ArrayBuffer { - var buffer = new ArrayBuffer(array.length); +// vaccine codes based on Alex Dunae's findings +// https://gist.github.com/alexdunae/49cc0ea95001da3360ad6896fa5677ec +// http://mchp-appserv.cpe.umanitoba.ca/viewConcept.php?printer=Y&conceptID=1514 - array.map(function (value, i) { - return buffer[i] = value; - }) +// .vc.credentialSubject.fhirBundle.entry +export function decodedStringToReceipt(decoded: object) : HashTable { - return array.buffer; -} + const codeToVaccineName = { + '28581000087106': 'PFIZER', + '28951000087107': 'JANSSEN', + '28761000087108': 'ASTRAZENECA', + '28571000087109': 'MODERNA' + } -export function decodeData(data: string): Object { + const cvxCodeToVaccineName = { // https://www2a.cdc.gov/vaccines/iis/iisstandards/vaccines.asp?rpt=cvx + '208': 'PFIZER', + '212': 'JANSSEN', + '210': 'ASTRAZENECA', + '207': 'MODERNA' + } - if (data.startsWith('HC1')) { - data = data.substring(3); + // console.log(decoded); + const shcResources = decoded['vc'].credentialSubject.fhirBundle.entry; + let issuer; + if (decoded['iss'].includes('quebec.ca')) { + issuer = 'qc'; + } + if (decoded['iss'].includes('ontariohealth.ca')) { + issuer = 'on'; + } + if (decoded['iss'] == "https://smarthealthcard.phsa.ca/v1/issuer") { + issuer = 'bc'; + } - if (data.startsWith(':')) { - data = data.substring(1); + let name = ''; + let dateOfBirth; + let receipts : HashTable = {}; + + const numResources = shcResources.length; + for (let i = 0; i < numResources; i++) { + const resource = shcResources[i]['resource']; + if (resource["resourceType"] == 'Patient') { + if (name.length > 0) + name += '\n'; + + for (const nameField of resource.name) { + for (const given of nameField.given) { + name += (given + ' ') + } + name += (nameField.family); + } + dateOfBirth = resource['birthDate']; + } + if (resource["resourceType"] == 'Immunization') { + let vaccineName : string; + let organizationName : string; + let vaccinationDate : string; + for (const vaccineCodes of resource.vaccineCode.coding) { + if (vaccineCodes.system.includes("snomed.info")) { //bc + vaccineName = codeToVaccineName[vaccineCodes.code]; + if (vaccineName == undefined) + vaccineName = 'Unknown - ' + vaccineCodes.code; + } else if (vaccineCodes.system == "http://hl7.org/fhir/sid/cvx") { //qc + vaccineName = cvxCodeToVaccineName[vaccineCodes.code]; + if (vaccineName == undefined) + vaccineName = 'Unknown - ' + vaccineCodes.code; + } + } + + let performers = resource['performer']; // BC + let receiptNumber; + if (issuer == 'bc') { + performers = resource['performer']; + receiptNumber = shcResources[i]['fullUrl'].split(':')[1]; + for (let j = 0; j < performers.length; j++) { + const performer = performers[j]; + organizationName = performer.actor.display; + } + } + if (issuer == 'qc') { + organizationName = resource['location'].display; // QC + receiptNumber = resource['protocolApplied'].doseNumber; + } + vaccinationDate = resource.occurrenceDateTime; + + const receipt = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, receiptNumber, organizationName); + // console.log(receipt); + receipts[receiptNumber] = receipt; } } + return receipts; - var arrayBuffer: Uint8Array = base45.decode(data); - - if (arrayBuffer[0] == 0x78) { - arrayBuffer = pako.inflate(arrayBuffer); - } - - var payloadArray: Array = cbor.decode(typedArrayToBuffer(arrayBuffer)); - - if (!Array.isArray(payloadArray) || payloadArray.length !== 4) { - throw new Error('decodingFailed'); - } - - var plaintext: Uint8Array = payloadArray[2]; - var decoded: Object = cbor.decode(typedArrayToBufferSliced(plaintext)); - - return decoded; } \ No newline at end of file diff --git a/src/issuers.js b/src/issuers.js new file mode 100644 index 0000000..e87321b --- /dev/null +++ b/src/issuers.js @@ -0,0 +1,76 @@ +const issuers = [ + { + id: "ca.qc", + iss: "https://covid19.quebec.ca/PreuveVaccinaleApi/issuer", + keys: [ + { kid: "qFdl0tDZK9JAWP6g9_cAv57c3KWxMKwvxCrRVSzcxvM", + alg: "ES256", kty: "EC", crv: "P-256", use: "sig", + x: "XSxuwW_VI_s6lAw6LAlL8N7REGzQd_zXeIVDHP_j_Do", + y: "88-aI4WAEl4YmUpew40a9vq_w5OcFvsuaKMxJRLRLL0" }, + ] + }, + { + id: "us.ca", + iss: "https://myvaccinerecord.cdph.ca.gov/creds", + keys: [ + { kid: "7JvktUpf1_9NPwdM-70FJT3YdyTiSe2IvmVxxgDSRb0", + alg: "ES256", kty: "EC", crv: "P-256", use: "sig", + x: "3dQz5ZlbazChP3U7bdqShfF0fvSXLXD9WMa1kqqH6i4", + y: "FV4AsWjc7ZmfhSiHsw2gjnDMKNLwNqi2jMLmJpiKWtE" }, + ] + }, + { + id: "us.ny", + iss: "https://ekeys.ny.gov/epass/doh/dvc/2021", + keys: [ + { kid: "9ENs36Gsu-GmkWIyIH9XCozU9BFhLeaXvwrT3B97Wok", + alg: "ES256", kty: "EC", crv: "P-256", use: "sig", + x: "--M0AedrNg31sHZGAs6qg7WU9LwnDCMWmd6KjiKfrZU", + y: "rSf2dKerJFW3-oUNcvyrI2x39hV2EbazORZvh44ukjg" }, + ] + }, + { + id: "us.la", + iss: "https://healthcardcert.lawallet.com", + keys: [ + { kid: "UOvXbgzZj4zL-lt1uJVS_98NHQrQz48FTdqQyNEdaNE", + alg: "ES256", kty: "EC", crv: "P-256", use: "sig", + x: "n1PxhSk7DQj8ZBK3VIfwhlcN__QG357gbiTfZYt1gn8", + y: "ZDGv5JYe4mCm75HCsHG8UkIyffr1wcZMwJjH8v5cGCc" }, + ] + }, + { + id: "ca.yt", + iss: "https://pvc.service.yukon.ca/issuer", + keys: [ + { kid: "UnHGY-iyCIr__dzyqcxUiApMwU9lfeXnzT2i5Eo7TvE", + alg: "ES256", kty: "EC", crv: "P-256", use: "sig", + x: "wCeT9rdLYTpOK52OK0-oRbwDxbljJdNiDuxPsPt_1go", + y: "IgFPi1OrHtJWJGwPMvlueeHmULUKEpScgpQtoHNjX-Q" }, + ] + }, + { + id: "ca.bc", + iss: "https://smarthealthcard.phsa.ca/v1/issuer", + keys: [ + { kid: "XCqxdhhS7SWlPqihaUXovM_FjU65WeoBFGc_ppent0Q", + alg: "ES256", kty: "EC", crv: "P-256", use: "sig", + x: "xscSbZemoTx1qFzFo-j9VSnvAXdv9K-3DchzJvNnwrY", + y: "jA5uS5bz8R2nxf_TU-0ZmXq6CKWZhAG1Y4icAx8a9CA" }, + ] + }, + { + id: "ca.sk", + iss: "https://skphr.prd.telushealthspace.com", + keys: [ + { kid: "xOqUO82bEz8APn_5wohZZvSK4Ui6pqWdSAv5BEhkes0", + alg: "ES256", kty: "EC", crv: "P-256", use: "sig", + x: "Hk4ktlNfoIIo7jp5I8cefp54Ils3TsKvKXw_E9CGIPE", + y: "7hVieFGuHJeaNRCxVgKeVpoxDJevytgoCxqVZ6cfcdk" }, + ] + }, +]; + +module.exports = { + issuers, +}; diff --git a/src/pass.ts b/src/pass.ts index bbe6048..fec58ab 100644 --- a/src/pass.ts +++ b/src/pass.ts @@ -5,26 +5,10 @@ import {Constants} from "./constants"; import {Payload, PayloadBody, PassDictionary} from "./payload"; import * as Sentry from '@sentry/react'; import { QRCodeMatrixUtil } from '@zxing/library'; +import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common'; const crypto = require('crypto') -enum QrFormat { - PKBarcodeFormatQR = 'PKBarcodeFormatQR', - PKBarcodeFormatPDF417 = 'PKBarcodeFormatPDF417' -} - -enum Encoding { - utf8 = "utf-8", - iso88591 = "iso-8859-1" -} - -interface QrCode { - message: string; - format: QrFormat; - messageEncoding: Encoding; - // altText: string; -} - interface SignData { PassJsonHash: string; useBlackVersion: boolean; @@ -46,6 +30,7 @@ export class PassData { barcodes: Array; barcode: QrCode; generic: PassDictionary; + expirationDate: string; // Generates a sha1 hash from a given buffer private static getBufferHash(buffer: Buffer | string): string { @@ -85,67 +70,12 @@ export class PassData { // Create Payload try { - const payload: Payload = new Payload(payloadBody, numDose); - - payload.serialNumber = uuid4(); - - // register record - - const clonedReceipt = Object.assign({}, payload.receipt); - delete clonedReceipt.name; - delete clonedReceipt.dateOfBirth; - clonedReceipt["serialNumber"] = payload.serialNumber; - clonedReceipt["type"] = 'applewallet'; - - let requestOptions = { - method: 'POST', // *GET, POST, PUT, DELETE, etc. - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(clonedReceipt) // body data type must match "Content-Type" header - } - - // console.log('registering ' + JSON.stringify(clonedReceipt, null, 2)); - const configResponse = await fetch('/api/config'); - - const configResponseJson = await configResponse.json(); - - const verifierHost = configResponseJson.verifierHost; - const registrationHost = configResponseJson.registrationHost; - let functionSuffix = configResponseJson.functionSuffix; - - if (functionSuffix == undefined) - functionSuffix = ''; - - const registerUrl = `${registrationHost}/register${functionSuffix}`; - // console.log(registerUrl); - - const response = await fetch(registerUrl, requestOptions); - const responseJson = await response.json(); - - console.log(JSON.stringify(responseJson,null,2)); - - if (responseJson["result"] != 'OK') { - console.error(responseJson); - return Promise.reject(); - } - - const encodedUri = `serialNumber=${encodeURIComponent(payload.serialNumber)}&vaccineName=${encodeURIComponent(payload.receipt.vaccineName)}&vaccinationDate=${encodeURIComponent(payload.receipt.vaccinationDate)}&organization=${encodeURIComponent(payload.receipt.organization)}&dose=${encodeURIComponent(payload.receipt.numDoses)}`; - const qrCodeUrl = `${verifierHost}/verify?${encodedUri}`; - - // console.log(qrCodeUrl); - - // Create QR Code Object - const qrCode: QrCode = { - message: qrCodeUrl, - format: QrFormat.PKBarcodeFormatQR, - messageEncoding: Encoding.iso88591, - // altText : payload.rawData - - } - + + const results = await PassPhotoCommon.preparePayload(payloadBody, numDose); + const payload = results.payload; // Create pass data - const pass: PassData = new PassData(payload, qrCode); + + const pass: PassData = new PassData(results.payload, results.qrCode); // Create new zip const zip = [] as { path: string; data: Buffer | string }[]; @@ -197,8 +127,7 @@ export class PassData { return createZip(zip); } catch (e) { - Sentry.captureException(e); - return Promise.reject(); + return Promise.reject(e); } } @@ -211,5 +140,6 @@ export class PassData { this.barcode = qrCode; this.generic = payload.generic; this.sharingProhibited = true; + this.expirationDate = payload.expirationDate; } } diff --git a/src/passphoto-common.ts b/src/passphoto-common.ts new file mode 100644 index 0000000..771369a --- /dev/null +++ b/src/passphoto-common.ts @@ -0,0 +1,109 @@ +import {toBuffer as createZip} from 'do-not-zip'; +import {v4 as uuid4} from 'uuid'; + +import {Constants} from "./constants"; +import {Payload, PayloadBody, PassDictionary} from "./payload"; +import * as Sentry from '@sentry/react'; +import { QRCodeMatrixUtil } from '@zxing/library'; + +export enum QrFormat { + PKBarcodeFormatQR = 'PKBarcodeFormatQR', + PKBarcodeFormatPDF417 = 'PKBarcodeFormatPDF417' +} + +export enum Encoding { + utf8 = "utf-8", + iso88591 = "iso-8859-1" +} + +export interface QrCode { + message: string; + format: QrFormat; + messageEncoding: Encoding; + // altText: string; +} + +export interface PackageResult { + payload: Payload; + qrCode: QrCode; +} + +export class PassPhotoCommon { + + static async preparePayload(payloadBody: PayloadBody, numDose: number) : Promise { + + console.log('preparePayload'); + + // console.log(JSON.stringify(payloadBody, null, 2), numDose); + + const payload: Payload = new Payload(payloadBody, numDose); + + payload.serialNumber = uuid4(); + let qrCodeMessage; + + if (payloadBody.rawData.startsWith('shc:/')) { + + qrCodeMessage = payloadBody.rawData; + + } else { + + // register record + + const clonedReceipt = Object.assign({}, payloadBody.receipts[numDose]); + delete clonedReceipt.name; + delete clonedReceipt.dateOfBirth; + clonedReceipt["serialNumber"] = payload.serialNumber; + clonedReceipt["type"] = 'applewallet'; + + let requestOptions = { + method: 'POST', // *GET, POST, PUT, DELETE, etc. + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(clonedReceipt) // body data type must match "Content-Type" header + } + + // console.log('registering ' + JSON.stringify(clonedReceipt, null, 2)); + const configResponse = await fetch('/api/config'); + + const configResponseJson = await configResponse.json(); + + const verifierHost = configResponseJson.verifierHost; + const registrationHost = configResponseJson.registrationHost; + let functionSuffix = configResponseJson.functionSuffix; + + if (functionSuffix == undefined) + functionSuffix = ''; + + const registerUrl = `${registrationHost}/register${functionSuffix}`; + // console.log(registerUrl); + + const response = await fetch(registerUrl, requestOptions); + const responseJson = await response.json(); + + // console.log(JSON.stringify(responseJson,null,2)); + + if (responseJson["result"] != 'OK') { + console.error(responseJson); + return Promise.reject(); + } + + const encodedUri = `serialNumber=${encodeURIComponent(payload.serialNumber)}&vaccineName=${encodeURIComponent(payloadBody.receipts[numDose].vaccineName)}&vaccinationDate=${encodeURIComponent(payloadBody.receipts[numDose].vaccinationDate)}&organization=${encodeURIComponent(payloadBody.receipts[numDose].organization)}&dose=${encodeURIComponent(payloadBody.receipts[numDose].numDoses)}`; + const qrCodeUrl = `${verifierHost}/verify?${encodedUri}`; + qrCodeMessage = qrCodeUrl; + // console.log(qrCodeUrl); + } + + // Create QR Code Object + const qrCode: QrCode = { + message: qrCodeMessage, + format: QrFormat.PKBarcodeFormatQR, + messageEncoding: Encoding.iso88591, + // altText : payload.rawData + + } + + return {payload: payload, qrCode: qrCode} + + } +} \ No newline at end of file diff --git a/src/payload.ts b/src/payload.ts index 8e92b85..735ec7c 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -1,5 +1,6 @@ import {Constants} from "./constants"; import {COLORS} from "./colors"; +import { TEXT_ALIGN } from "html2canvas/dist/types/css/property-descriptors/text-align"; export class Receipt { constructor(public name: string, public vaccinationDate: string, public vaccineName: string, public dateOfBirth: string, public numDoses: number, public organization: string) {}; @@ -36,7 +37,7 @@ export interface PayloadBody { export class Payload { - receipt: Receipt; + receipts: HashTable; rawData: string; backgroundColor: string; labelColor: string; @@ -45,61 +46,125 @@ export class Payload { img2x: Buffer; serialNumber: string; generic: PassDictionary; + expirationDate: string; constructor(body: PayloadBody, numDose: number) { - // Get name and date of birth information - const name = body.receipts[numDose].name; - const dateOfBirth = body.receipts[numDose].dateOfBirth; - const vaccineName = body.receipts[numDose].vaccineName; + let generic: PassDictionary = { + headerFields: [], + primaryFields: [], + secondaryFields: [], + auxiliaryFields: [], + backFields: [] + } + this.backgroundColor = COLORS.YELLOW; + this.labelColor = COLORS.WHITE + this.foregroundColor = COLORS.WHITE + this.img1x = Constants.img1xWhite + this.img2x = Constants.img2xWhite + + let fullyVaccinated = false; + var keys = Object.keys(body.receipts).reverse(); + + if (body.rawData.length > 0) { // SHC contains multiple receipts + for (let k of keys) { + fullyVaccinated = processReceipt(body.receipts[k], generic); + if (fullyVaccinated) { + this.backgroundColor = COLORS.GREEN; + } + } + } else { + fullyVaccinated = processReceipt(body.receipts[numDose], generic); + if (fullyVaccinated) { + this.backgroundColor = COLORS.GREEN; + } + } + + this.receipts = body.receipts; + this.rawData = body.rawData; + this.generic = generic; + if (body.rawData.length == 0) { // Ontario special handling + this.expirationDate = '2021-10-22T23:59:59-04:00'; + generic.auxiliaryFields.push({ + key: "expiry", + label: "QR code expiry", + value: '2021-10-22' + }) + } + } +} + +function processReceipt(receipt: Receipt, generic: PassDictionary) : boolean { + + console.log('processing receipt #' + receipt.numDoses); + + const name = receipt['name']; + const dateOfBirth = receipt.dateOfBirth; + const numDoses = receipt.numDoses; + const vaccineName = receipt.vaccineName.toLocaleUpperCase(); let vaccineNameProper = vaccineName.charAt(0) + vaccineName.substr(1).toLowerCase(); if (vaccineName.includes('PFIZER')) vaccineNameProper = 'Pfizer (Comirnaty)' if (vaccineName.includes('MODERNA')) - vaccineNameProper = 'Moderna (SpikeVax)' - // vaccineNameProper = 'Pfizer (Comirnaty)' + vaccineNameProper = 'Moderna (SpikeVax)' if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD')) vaccineNameProper = 'AstraZeneca (Vaxzevria)' - let doseVaccine = "#" + String(body.receipts[numDose].numDoses) + ": " + vaccineNameProper; - - if (name == undefined) { - throw new Error('nameMissing'); - } - if (dateOfBirth == undefined) { - throw new Error('dobMissing'); + let doseVaccine = "#" + String(receipt.numDoses) + ": " + vaccineNameProper; + let fullyVaccinated = false; + + if (receipt.numDoses > 1 || + vaccineName.toLowerCase().includes('janssen') || + vaccineName.toLowerCase().includes('johnson') || + vaccineName.toLowerCase().includes('j&j')) { + fullyVaccinated = true; } - const generic: PassDictionary = { - headerFields: [ - ], - primaryFields: [ + if (generic.primaryFields.length == 0) { + generic.primaryFields.push( { - key: "vaccine", - label: "Vaccine", - value: doseVaccine, + key: "vaccine", + label: "Vaccine", + value: doseVaccine } + ) + } - ], - secondaryFields: [ - { + let fieldToPush = generic.secondaryFields; + if (fieldToPush.length > 0) { + fieldToPush = generic.backFields; + generic.headerFields.push({ + key: "extra", + label: "More", + value: "(i)", + "textAlignment" : "PKTextAlignmentCenter" + }); + generic.backFields.push({ + key: "vaccine" + numDoses, + label: `Vaccine (Dose ${numDoses})`, + value: receipt.vaccineName + }) + } + + fieldToPush.push( + { key: "issuer", label: "Authorized Organization", - value: body.receipts[numDose].organization - }, - + value: receipt.organization + }, { key: "dov", - label: "Date", - value: body.receipts[numDose].vaccinationDate, - // textAlignment: TextAlignment.right + label: "Vacc. Date", + value: receipt.vaccinationDate, } - ], - auxiliaryFields: [ - { + ); + + if (generic.auxiliaryFields.length == 0) { + generic.auxiliaryFields.push( + { key: "name", label: "Name", value: name @@ -108,33 +173,8 @@ export class Payload { key: "dob", label: "Date of Birth", value: dateOfBirth - } - ], - backFields: [ - - //TODO: add url link back to grassroots site - - ] + }); } - // Set Values - this.receipt = body.receipts[numDose]; - this.rawData = body.rawData; - - if (body.receipts[numDose].numDoses > 1 || body.receipts[numDose].vaccineName.toLowerCase().includes('janssen') || body.receipts[numDose].vaccineName.toLowerCase().includes('johnson') || body.receipts[numDose].vaccineName.toLowerCase().includes('j&j')) { - this.backgroundColor = COLORS.GREEN; - } else { - this.backgroundColor = COLORS.YELLOW; - } - - this.labelColor = COLORS.WHITE - this.foregroundColor = COLORS.WHITE - this.img1x = Constants.img1xWhite - this.img2x = Constants.img2xWhite - this.generic = generic; - + return fullyVaccinated; } - - - -} \ No newline at end of file diff --git a/src/photo.ts b/src/photo.ts index 4ea9bd2..0ae97d8 100644 --- a/src/photo.ts +++ b/src/photo.ts @@ -4,25 +4,11 @@ import {v4 as uuid4} from 'uuid'; import {BrowserQRCodeSvgWriter} from "@zxing/browser"; import { toPng, toJpeg, toBlob, toPixelData, toSvg } from 'html-to-image'; import * as Sentry from '@sentry/react'; - -enum QrFormat { - PKBarcodeFormatQR = 'PKBarcodeFormatQR', - PKBarcodeFormatPDF417 = 'PKBarcodeFormatPDF417' -} - -enum Encoding { - utf8 = "utf-8", - iso88591 = "iso-8859-1" -} - -interface QrCode { - message: string; - format: QrFormat; - messageEncoding: Encoding; - // altText: string; -} +import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common'; +import { EncodeHintType } from "@zxing/library"; export class Photo { + logoText: string = Constants.NAME; organizationName: string = Constants.NAME; description: string = Constants.NAME; @@ -33,99 +19,73 @@ export class Photo { barcodes: Array; barcode: QrCode; - - static async generatePass(payloadBody: PayloadBody, numDose: number): Promise { // Create Payload try { - const payload: Payload = new Payload(payloadBody, numDose); + console.log('generatePass'); + const results = await PassPhotoCommon.preparePayload(payloadBody, numDose); + + const payload = results.payload; + const qrCode = results.qrCode; - payload.serialNumber = uuid4(); - - // register record - - const clonedReceipt = Object.assign({}, payload.receipt); - delete clonedReceipt.name; - delete clonedReceipt.dateOfBirth; - clonedReceipt["serialNumber"] = payload.serialNumber; - clonedReceipt["type"] = 'photo'; - - let requestOptions = { - method: 'POST', // *GET, POST, PUT, DELETE, etc. - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(clonedReceipt) // body data type must match "Content-Type" header + let receipt; + if (results.payload.rawData.length == 0) { + receipt = results.payload.receipts[numDose]; + } else { + receipt = results.payload.receipts[numDose]; } - console.log('registering ' + JSON.stringify(clonedReceipt, null, 2)); - const configResponse = await fetch('/api/config') - const verifierHost = (await configResponse.json()).verifierHost - - // const verifierHost = 'https://verifier.vaccine-ontario.ca'; - - const response = await fetch('https://us-central1-grassroot-verifier.cloudfunctions.net/register', requestOptions); - const responseJson = await response.json(); - - console.log(JSON.stringify(responseJson,null,2)); - - if (responseJson["result"] != 'OK') - return Promise.reject(); - - const encodedUri = `serialNumber=${encodeURIComponent(payload.serialNumber)}&vaccineName=${encodeURIComponent(payload.receipt.vaccineName)}&vaccinationDate=${encodeURIComponent(payload.receipt.vaccinationDate)}&organization=${encodeURIComponent(payload.receipt.organization)}&dose=${encodeURIComponent(payload.receipt.numDoses)}`; - const qrCodeUrl = `${verifierHost}/verify?${encodedUri}`; - - // Create QR Code Object - const qrCode: QrCode = { - message: qrCodeUrl, - format: QrFormat.PKBarcodeFormatQR, - messageEncoding: Encoding.iso88591, - // altText : payload.rawData - - } - - // Create photo - // const photo: Photo = new Photo(payload, qrCode); - - // const body = domTree.getElementById('main'); const body = document.getElementById('pass-image'); body.hidden = false; body.style.backgroundColor = payload.backgroundColor - const name = payload.receipt.name; - const dateOfBirth = payload.receipt.dateOfBirth; - const vaccineName = payload.receipt.vaccineName; + const vaccineName = receipt.vaccineName.toLocaleUpperCase(); let vaccineNameProper = vaccineName.charAt(0) + vaccineName.substr(1).toLowerCase(); - + if (vaccineName.includes('PFIZER')) vaccineNameProper = 'Pfizer (Comirnaty)' - + if (vaccineName.includes('MODERNA')) vaccineNameProper = 'Moderna (SpikeVax)' - + if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD')) - vaccineNameProper = 'AstraZeneca (Vaxzevria)' - - let doseVaccine = "#" + String(payload.receipt.numDoses) + ": " + vaccineNameProper; + vaccineNameProper = 'AstraZeneca (Vaxzevria)' + + let doseVaccine = "#" + String(receipt.numDoses) + ": " + vaccineNameProper; document.getElementById('vaccineName').innerText = doseVaccine; - document.getElementById('vaccinationDate').innerText = payload.receipt.vaccinationDate; - document.getElementById('organization').innerText = payload.receipt.organization; - document.getElementById('name').innerText = payload.receipt.name; - document.getElementById('dob').innerText = payload.receipt.dateOfBirth; + + document.getElementById('vaccinationDate').innerText = receipt.vaccinationDate; + document.getElementById('organization').innerText = receipt.organization; + document.getElementById('name').innerText = receipt.name; + document.getElementById('dob').innerText = receipt.dateOfBirth; + + if ((results.payload.rawData.length != 0) && (numDose > 1)) { + for (let i = 1; i < numDose; i++) { + + console.log(i); + + receipt = results.payload.receipts[i]; + + document.getElementById('extraRow' + i ).hidden = false; + document.getElementById('vaccinationDate' + i).innerText = receipt.vaccinationDate; + document.getElementById('organization' + i).innerText = receipt.organization; + } + } const codeWriter = new BrowserQRCodeSvgWriter(); - const svg = codeWriter.write(qrCode.message,200,200); + const hints : Map = new Map().set(EncodeHintType.ERROR_CORRECTION,'L'); + const svg = codeWriter.write(qrCode.message,200,200, hints); svg.setAttribute('style','background-color: white'); document.getElementById('qrcode').appendChild(svg); const blobPromise = toBlob(body); return blobPromise; + } catch (e) { - Sentry.captureException(e); - return Promise.reject(); + return Promise.reject(e); } } diff --git a/src/process.ts b/src/process.ts index 3b98cc6..13d9c36 100644 --- a/src/process.ts +++ b/src/process.ts @@ -1,10 +1,14 @@ import {PayloadBody, Receipt, HashTable} from "./payload"; -import * as PdfJS from 'pdfjs-dist' +import * as PdfJS from 'pdfjs-dist/legacy/build/pdf' +import jsQR, {QRCode} from "jsqr"; +import { getCertificatesInfoFromPDF } from "@ninja-labs/verify-pdf"; // ES6 import {COLORS} from "./colors"; -import { getCertificatesInfoFromPDF } from "@ninja-labs/verify-pdf"; // ES6 import * as Sentry from '@sentry/react'; +import * as Decode from './decode'; +import {getScannedJWS, verifyJWS, decodeJWS} from "./shc"; +import { PNG } from 'pngjs/browser'; -import { TextItem } from "pdfjs-dist/types/display/api"; +import { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api'; // import {PNG} from 'pngjs' // import {decodeData} from "./decode"; @@ -12,33 +16,72 @@ import { TextItem } from "pdfjs-dist/types/display/api"; PdfJS.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PdfJS.version}/pdf.worker.js` -export async function getPayloadBodyFromFile(file: File, color: COLORS): Promise { +export async function getPayloadBodyFromFile(file: File): Promise { // Read file const fileBuffer = await file.arrayBuffer(); - let receipts: HashTable; + let rawData = ''; // unused at the moment, the original use was to store the QR code from issuer switch (file.type) { case 'application/pdf': - receipts = await loadPDF(fileBuffer) + const receiptType = await detectReceiptType(fileBuffer); + console.log(`receiptType = ${receiptType}`); + if (receiptType == 'ON') { + receipts = await loadPDF(fileBuffer) // receipt type is needed to decide if digital signature checking is needed + } else { + const shcData = await processSHC(fileBuffer); + receipts = shcData.receipts; + rawData = shcData.rawData; + } break default: throw Error('invalidFileType') } - const rawData = ''; // unused at the moment, the original use was to store the QR code from issuer - return { receipts: receipts, rawData: rawData } + + } -async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise> { +async function detectReceiptType(fileBuffer : ArrayBuffer): Promise { + + // Ontario has 'COVID-19 vaccination receipt' + // BC has BC Vaccine Card + + console.log('detectPDFTypeAndProcess'); + + const typedArray = new Uint8Array(fileBuffer); + let loadingTask = PdfJS.getDocument(typedArray); + const pdfDocument = await loadingTask.promise; + const pdfPage = await pdfDocument.getPage(1); //first page + const content = await pdfPage.getTextContent(); + const numItems = content.items.length; + if (numItems == 0) { // QC has no text items + console.log('detected QC'); + return Promise.resolve('SHC'); + } else { + for (let i = 0; i < numItems; i++) { + let item = content.items[i] as TextItem; + const value = item.str; + // console.log(value); + if (value.includes('BC Vaccine Card')) { + console.log('detected BC'); + return Promise.resolve('SHC'); + } + } + } + return Promise.resolve('ON'); + +} + +async function loadPDF(fileBuffer : ArrayBuffer): Promise> { try { - const certs = getCertificatesInfoFromPDF(signedPdfBuffer); + const certs = getCertificatesInfoFromPDF(fileBuffer); const result = certs[0]; const refcert = '-----BEGIN CERTIFICATE-----\r\n'+ @@ -95,7 +138,7 @@ async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise> { @@ -152,7 +196,6 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise { + + const pdfScale = 2; + + const canvas = document.getElementById('canvas'); + const canvasContext = canvas.getContext('2d'); + const viewport = pdfPage.getViewport({scale: pdfScale}) + + // Set correct canvas width / height + canvas.width = viewport.width + canvas.height = viewport.height + + // render PDF + const renderTask = pdfPage.render({ + canvasContext: canvasContext, + viewport, + }) + + await renderTask.promise; + + // Return PDF Image Data + return canvasContext.getImageData(0, 0, canvas.width, canvas.height) + +} + +async function processSHC(fileBuffer : ArrayBuffer) : Promise { + + console.log('processSHC'); + + try { + const typedArray = new Uint8Array(fileBuffer); + let loadingTask = PdfJS.getDocument(typedArray); + + const pdfDocument = await loadingTask.promise; + // Load all dose numbers + const pdfPage = await pdfDocument.getPage(1); + const imageData = await getImageDataFromPdf(pdfPage); + const code : QRCode = await Decode.getQRFromImage(imageData); + let rawData = code.data; + const jws = getScannedJWS(rawData); + + let decoded = await decodeJWS(jws); + + // console.log(decoded); + + const verified = verifyJWS(jws, decoded.iss); + + if (verified) { + let receipts = Decode.decodedStringToReceipt(decoded); + console.log(receipts); + return Promise.resolve({receipts: receipts, rawData: rawData}); + + } else { + return Promise.reject(`Issuer ${decoded.iss} cannot be verified.`); + } + + } catch (e) { + Promise.reject(e); + } } diff --git a/src/sentry.ts b/src/sentry.ts index d3d2785..3f8e182 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -3,12 +3,14 @@ import { Integrations } from '@sentry/tracing'; export const initSentry = () => { SentryModule.init({ - release: 'grassroots_covidpass@1.9.12', // App version. Needs to be manually updated as we go unless we make the build smarter + release: 'grassroots_covidpass@1.9.15', // App version. Needs to be manually updated as we go unless we make the build smarter dsn: 'https://7120dcf8548c4c5cb148cdde2ed6a778@o1015766.ingest.sentry.io/5981424', integrations: [ new Integrations.BrowserTracing(), ], - attachStacktrace: true + attachStacktrace: true, + tracesSampleRate: 0.5 + }); console.log('sentry initialized'); diff --git a/src/shc.js b/src/shc.js new file mode 100644 index 0000000..5e60e16 --- /dev/null +++ b/src/shc.js @@ -0,0 +1,78 @@ +const jose = require("node-jose"); +const jsQR = require("jsqr"); +const zlib = require("zlib"); +const { issuers } = require("./issuers"); + +function getQRFromImage(imageData) { + return jsQR( + new Uint8ClampedArray(imageData.data.buffer), + imageData.width, + imageData.height + ); +} + +function getScannedJWS(shcString) { + try { + return shcString + .match(/^shc:\/(.+)$/)[1] + .match(/(..?)/g) + .map((num) => String.fromCharCode(parseInt(num, 10) + 45)) + .join(""); + } catch (e) { + error = new Error("parsing shc string failed"); + error.cause = e; + throw error; + } +} + +function verifyJWS(jws, iss) { + const issuer = issuers.find(el => el.iss === iss); + if (!issuer) { + error = new Error("Unknown issuer " + iss); + error.customMessage = true; + return Promise.reject(error); + } + return jose.JWK.asKeyStore({ keys: issuer.keys }).then(function (keyStore) { + const { verify } = jose.JWS.createVerify(keyStore); + console.log("jws", jws); + return verify(jws); + }); +} + +function decodeJWS(jws) { + try { + const payload = jws.split(".")[1]; + return decodeJWSPayload(Buffer.from(payload, "base64")); + } catch (e) { + error = new Error("decoding payload failed"); + error.cause = e; + throw error; + } +} + +function decodeJWSPayload(decodedPayload) { + return new Promise((resolve, reject) => { + zlib.inflateRaw(decodedPayload, function (err, decompressedResult) { + if (typeof err === "object" && err) { + console.log("Unable to decompress"); + reject(err); + } else { + try { + console.log(decompressedResult); + scannedResult = decompressedResult.toString("utf8"); + resolve(JSON.parse(scannedResult)); + } catch (e) { + reject(e); + } + } + }); + }); +} + +module.exports = { + getQRFromImage, + getScannedJWS, + verifyJWS, + decodeJWS, + decodeJWSPayload, +}; diff --git a/yarn.lock b/yarn.lock index bfac999..2fb2fa4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -286,6 +286,13 @@ "resolved" "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz" "version" "4.0.0" +"@types/pngjs@^6.0.1": + "integrity" "sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==" + "resolved" "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.1.tgz" + "version" "6.0.1" + dependencies: + "@types/node" "*" + "@types/prop-types@*": "integrity" "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==" "resolved" "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz" @@ -625,6 +632,11 @@ "resolved" "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" "version" "1.5.1" +"base64url@^3.0.1": + "integrity" "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + "resolved" "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz" + "version" "3.0.1" + "big.js@^5.2.2": "integrity" "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" "resolved" "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" @@ -789,7 +801,7 @@ "ieee754" "^1.1.4" "isarray" "^1.0.0" -"buffer@^5.4.3", "buffer@5.6.0": +"buffer@^5.4.3", "buffer@^5.5.0", "buffer@5.6.0": "integrity" "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==" "resolved" "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz" "version" "5.6.0" @@ -1305,6 +1317,11 @@ "resolved" "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz" "version" "1.1.0" +"es6-promise@^4.2.8": + "integrity" "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + "resolved" "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz" + "version" "4.2.8" + "escalade@^3.1.1": "integrity" "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" "resolved" "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz" @@ -2013,11 +2030,16 @@ "resolved" "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz" "version" "4.5.2" -"lodash@^4.17.13", "lodash@^4.17.21": +"lodash@^4.17.13", "lodash@^4.17.15", "lodash@^4.17.21": "integrity" "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "resolved" "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" "version" "4.17.21" +"long@^4.0.0": + "integrity" "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + "resolved" "https://registry.npmjs.org/long/-/long-4.0.0.tgz" + "version" "4.0.0" + "loose-envify@^1.1.0", "loose-envify@^1.4.0": "integrity" "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==" "resolved" "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" @@ -2227,6 +2249,21 @@ dependencies: "he" "1.2.0" +"node-jose@^2.0.0": + "integrity" "sha512-j8zoFze1gijl8+DK/dSXXqX7+o2lMYv1XS+ptnXgGV/eloQaqq1YjNtieepbKs9jBS4WTnMOqyKSaQuunJzx0A==" + "resolved" "https://registry.npmjs.org/node-jose/-/node-jose-2.0.0.tgz" + "version" "2.0.0" + 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-libs-browser@^2.2.1": "integrity" "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==" "resolved" "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz" @@ -2375,7 +2412,7 @@ "resolved" "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" "version" "2.2.0" -"pako@~1.0.5": +"pako@^1.0.11", "pako@~1.0.5": "integrity" "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" "resolved" "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz" "version" "1.0.11" @@ -3383,6 +3420,11 @@ dependencies: "base64-arraybuffer" "^1.0.1" +"uuid@^3.3.3": + "integrity" "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + "resolved" "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz" + "version" "3.4.0" + "uuid@^8.3.2": "integrity" "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" "resolved" "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"