Merge remote-tracking branch 'origin/main' into polyfill-starts-with

* origin/main: (23 commits)
  Fix typo in expiry date (10-23 -> 10-22), bump page rev
  name fix for photo receipts
  updated save photo headings and added expiry
  Fix for pdfjs type imports
  tuned the dates heading / details a bit.
  added expiry date to pass
  added expiration date and default to ON logic
  Fix input element not accepting PDFs on older iOS devices
  Fix Save Photo not working on iOS due to html-to-image error when handling   on that platform.
  cleaned up console logging to be ready for main branch merge
  pre-merge with main updates
  Added Saskatchewan SHC issuer info
  Stop splitting vaccinename so we can detect AZ correctly
  special handling of shc code from QC
  Failed to locate ByteRange is an expected error
  specify error correction level of QR code in photos to make it compatible with BC scanner app
  functional shc (including photo)
  add to wallet working now - working on photos portion
  verifyJWS working now... picked up latest issuer code
  SHC - Smart Health Card support. Parse QR code from PNG image and parse it into Payload
  ...
This commit is contained in:
Ryan Slobojan 2021-10-05 12:34:35 -04:00
commit 33af99c529
16 changed files with 848 additions and 308 deletions

View File

@ -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 {
<input type='file'
id='file'
accept="application/pdf"
accept="application/pdf,.png"
ref={inputFile}
style={{display: 'none'}}
/>

View File

@ -36,7 +36,7 @@ function Page(props: PageProps): JSX.Element {
<a href="https://github.com/billylo1/covidpass" className="underline">{t('common:gitHub')}</a>
<a href="https://vaccine-ontario.ca" className="underline">{t('common:returnToMainSite')}</a>
</nav>
<div className="flex pt-4 flex-row space-x-4 justify-center text-md flex-wrap">Last updated: 2021-09-29 (v1.9.12)</div>
<div className="flex pt-4 flex-row space-x-4 justify-center text-md flex-wrap">Last updated: 2021-10-05 (v1.9.15)</div>
</footer>
</main>
</div>
@ -48,14 +48,12 @@ function Page(props: PageProps): JSX.Element {
<tbody>
<tr>
<td><img src='shield4.svg' width='50' height='50' /></td>
<td style={{fontSize: 20, width: 280}}><span><b>&nbsp;&nbsp;Vaccination Receipt</b></span></td>
<td style={{fontSize: 20, width: 280}}><span style={{marginLeft: '11px'}}><b>Vaccination Receipt</b></span></td>
</tr>
</tbody>
</table>
<br/>
<br/>
<br/>
<div style={{height:12}}><b>VACCINE</b></div>
<div id='vaccineName' style={{fontSize:28}}></div>
<br/>
@ -64,12 +62,20 @@ function Page(props: PageProps): JSX.Element {
<tbody>
<tr>
<td style={{width: 220}}><b>AUTHORIZED ORGANIZATION</b></td>
<td><b>DATE</b></td>
<td><b>VACC. DATE</b></td>
</tr>
<tr>
<td id='organization' style={{width: 220}}></td>
<td id='vaccinationDate' style={{width:120}}></td>
</tr>
<tr id='extraRow2' hidden>
<td id='organization2' style={{width: 220}}></td>
<td id='vaccinationDate2' style={{width:120}}></td>
</tr>
<tr id='extraRow1' hidden>
<td id='organization1' style={{width: 220}}></td>
<td id='vaccinationDate1' style={{width:120}}></td>
</tr>
<tr style={{height: 20}}></tr>
<tr>
<td><b>NAME</b></td>
@ -79,16 +85,24 @@ function Page(props: PageProps): JSX.Element {
<td id='name' style={{fontSize: 12}}></td>
<td id='dob' style={{fontSize: 12}}></td>
</tr>
<tr style={{height: 20}}></tr>
<tr>
<td><b></b></td>
<td><b>QR CODE EXPIRY</b></td>
</tr>
<tr>
<td id='null' style={{fontSize: 12}}></td>
<td id='expiry' style={{fontSize: 12}}>2021-10-22</td>
</tr>
</tbody>
</table>
<br/>
<br/>
<br/>
<br/>
<div id='qrcode' style={{width:'63%', display:'block', marginLeft: 'auto', marginRight: 'auto'}}></div>
<br/>
<br/>
</div>
<canvas id="canvas" />
</div>
)
}

101
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -74,12 +74,12 @@ function Index(): JSX.Element {
<Card content={
<div><p>{t('common:subtitle')}</p><br /><p>{t('common:subtitle2')}</p><br />
<b>{displayPassCount}</b><br/><br/>
Sept 29 afternoon update:
Oct 3 evening update:
<br />
<br />
<ul className="list-decimal list-outside" style={{ marginLeft: '20px' }}>
<li>You can now select which page to import for multi-page receipts</li>
<li>System reminders (e.g. unsupported browsers) are now on the top to improve ease of use</li>
<li>Added expiration date to Apple Wallet pass so it aligns with the province's schedule.</li>
<li>On Oct 22, we will update this tool as well so you can import the official QR code into your mobile wallet too.</li>
</ul><br />
<p>{t('common:continueSpirit')}</p>
<br />

View File

@ -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 ❤️.

View File

@ -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<Receipt> {
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<Receipt> = {};
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<Uint8Array> = 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;
}

76
src/issuers.js Normal file
View File

@ -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,
};

View File

@ -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<QrCode>;
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;
}
}

109
src/passphoto-common.ts Normal file
View File

@ -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<PackageResult> {
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}
}
}

View File

@ -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<Receipt>;
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;
}
}

View File

@ -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<QrCode>;
barcode: QrCode;
static async generatePass(payloadBody: PayloadBody, numDose: number): Promise<Blob> {
// 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<EncodeHintType,any> = 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);
}
}

View File

@ -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<PayloadBody> {
export async function getPayloadBodyFromFile(file: File): Promise<PayloadBody> {
// Read file
const fileBuffer = await file.arrayBuffer();
let receipts: HashTable<Receipt>;
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<HashTable<Receipt>> {
async function detectReceiptType(fileBuffer : ArrayBuffer): Promise<string> {
// 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<HashTable<Receipt>> {
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<HashTable<Receipt
if (( issuedpemCertificate )) {
//console.log('getting receipt details inside PDF');
const receipt = await getPdfDetails(signedPdfBuffer);
const receipt = await getPdfDetails(fileBuffer);
// console.log(JSON.stringify(receipt, null, 2));
return Promise.resolve(receipt);
@ -110,18 +153,19 @@ async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise<HashTable<Receipt
console.error('invalid certificate');
return Promise.reject(`invalid certificate + ${JSON.stringify(result)}`);
}
} catch (e) {
console.error(e);
Sentry.captureException(e);
if (e.message.includes('Failed to locate ByteRange')) {
e.message = 'Sorry. Selected PDF file is not digitally signed. Please download official copy from Step 1 and retry. Thanks.'
} else {
Sentry.captureException(e);
}
return Promise.reject(e);
}
}
async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt>> {
@ -152,7 +196,6 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt
}
if (value.includes('Product name')) {
vaccineName = (content.items[i+1] as TextItem).str;
vaccineName = vaccineName.split(' ')[0];
}
if (value.includes('Date of birth'))
dateOfBirth = (content.items[i+1] as TextItem).str;
@ -162,6 +205,7 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt
numDoses = Number(value.split(' ')[3]);
}
receiptObj[numDoses] = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization);
console.log(receiptObj[numDoses]);
}
return Promise.resolve(receiptObj);
@ -169,5 +213,66 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt
Sentry.captureException(e);
return Promise.reject(e);
}
}
async function getImageDataFromPdf(pdfPage: PDFPageProxy): Promise<ImageData> {
const pdfScale = 2;
const canvas = <HTMLCanvasElement>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<any> {
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);
}
}

View File

@ -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');

78
src/shc.js Normal file
View File

@ -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,
};

View File

@ -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"