Merge branch 'main' into android

This commit is contained in:
Billy Lo 2021-10-16 15:50:55 -04:00
commit f080ca8618
28 changed files with 1529 additions and 682 deletions

View File

@ -21,19 +21,19 @@ yarn dev
### Run the Docker container
```sh
docker build . -t covidpass
docker run -t -i -p 3000:3000 covidpass
docker build . -t covidpass -t gcr.io/broadcast2patients/covidpass
docker run --rm -t -i -p 3000:3000 covidpass
```
### Integration with other repos required
Docs being developed/tested on Sep 26. Should be done tomorrow.
[setup.md](setup.md) has the details on how to bring the components together.
# FAQ
#### I do not want to trust a third party with my vaccination data, does this tool respect my privacy?
Processing of your data happens entirely in your browser and only a hashed representation is sent to the server for the signing step.
Processing of your data happens entirely in your browser and only a hashed representation is sent to the server for the signing step. For more details of this, please see https://toronto.ctvnews.ca/video?clipId=2294461
#### How do I make sure that nobody can access my vaccination pass from the lock screen (iOS)?

View File

@ -143,8 +143,7 @@ function Form(): JSX.Element {
// Don't report known errors to Sentry
if (!e.message.includes('invalidFileType') &&
!e.message.includes('not digitally signed') &&
!e.message.includes('No valid ON proof-of-vaccination')) {
!e.message.includes('No SHC QR code found')) {
Sentry.captureException(e);
}
@ -168,7 +167,9 @@ function Form(): JSX.Element {
async function gotoOntarioHealth(e) {
e.preventDefault();
window.open('https://covid19.ontariohealth.ca','_blank');
// window.open('https://covid19.ontariohealth.ca','_blank'); // this created many extra steps in mobile chrome to return to the grassroots main window... if user has many windows open, they get lost (BACK button on the same window is easier for user to return)
window.location.href = 'https://covid19.ontariohealth.ca';
}
async function goToFAQ(e) {
e.preventDefault();
@ -269,28 +270,26 @@ function Form(): JSX.Element {
if (payloadBody) {
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];
let filenameDetails = '';
if (payloadBody.rawData.length > 0) {
// This is an SHC receipt, so do our SHC thing
selectedReceipt = payloadBody.shcReceipt;
filenameDetails = selectedReceipt.cardOrigin.replace(' ', '-');
} else {
selectedReceipt = payloadBody.receipts[selectedDose];
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
const passDose = selectedReceipt.numDoses;
filenameDetails = `${vaxName}-${passDose}`;
}
const passName = selectedReceipt.name.replace(' ', '-');
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
const passDose = selectedReceipt.numDoses;
const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.pkpass`;
const covidPassFilename = `grassroots-receipt-${passName}-${filenameDetails}.pkpass`;
console.log('> increment count');
await incrementCount();
console.log('> generatePass');
const pass = await PassData.generatePass(payloadBody, selectedDose);
console.log('> create blob');
const passBlob = new Blob([pass], {type: "application/vnd.apple.pkpass"});
console.log(`> save blob as ${covidPassFilename}`);
await incrementCount();
saveAs(passBlob, covidPassFilename);
setSaveLoading(false);
}
@ -390,30 +389,40 @@ function Form(): JSX.Element {
try {
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));
let photoBlob: Blob;
let filenameDetails = '';
if (payloadBody.rawData.length > 0) {
// This is an SHC receipt, so do our SHC thing
selectedReceipt = payloadBody.shcReceipt;
photoBlob = await Photo.generateSHCPass(payloadBody);
filenameDetails = selectedReceipt.cardOrigin.replace(' ', '-');
} else {
// This is an old-style ON custom QR code Receipt
selectedReceipt = payloadBody.receipts[selectedDose];
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
const passDose = selectedReceipt.numDoses;
photoBlob = await Photo.generatePass(payloadBody, passDose);
filenameDetails = `${vaxName}-${passDose}`;
}
const passName = selectedReceipt.name.replace(' ', '-');
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
const passDose = selectedReceipt.numDoses;
const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.png`;
const covidPassFilename = `grassroots-receipt-${passName}-${filenameDetails}.png`;
await incrementCount();
let photoBlob = await Photo.generatePass(payloadBody, passDose);
saveAs(photoBlob, covidPassFilename);
// need to clean up
const qrcodeElement = document.getElementById('qrcode');
const svg = qrcodeElement.firstChild;
qrcodeElement.removeChild(svg);
const body = document.getElementById('pass-image');
body.hidden = true;
if (document.getElementById('qrcode').hasChildNodes()) {
document.getElementById('qrcode').firstChild.remove();
}
if (document.getElementById('shc-qrcode').hasChildNodes()) {
document.getElementById('shc-qrcode').firstChild.remove();
}
// Hide both our possible passes
document.getElementById('pass-image').hidden = true;
document.getElementById('shc-pass-image').hidden = true;
setSaveLoading(false);
} catch (e) {
@ -424,18 +433,6 @@ function Form(): JSX.Element {
setSaveLoading(false);
}
}
const verifierLink = () => <li className="flex flex-row items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mx-2 fill-current text-green-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<p>
{t('verifierLink')}&nbsp;
<Link href="https://verifier.vaccine-ontario.ca/">
<a className="underline">verifier.vaccine-ontario.ca </a>
</Link>
</p>
</li>
const setDose = (e) => {
setSelectedDose(e.target.value);
}
@ -450,15 +447,24 @@ function Form(): JSX.Element {
// setAddErrorMessage('Sorry. Apple Wallet pass can be added using Safari or Chrome only.');
// setIsDisabledAppleWallet(true);
// }
// if (isIOS && (!osVersion.includes('13') && !osVersion.includes('14') && !osVersion.includes('15'))) {
// setAddErrorMessage('Sorry, iOS 13+ is needed for the Apple Wallet functionality to work')
// setIsDisabledAppleWallet(true);
// }
if (isIOS && (!osVersion.startsWith('15'))) {
setAddErrorMessage('Sorry, iOS 15+ is needed for the Apple Wallet functionality to work with Smart Health Card')
setIsDisabledAppleWallet(true);
return;
}
if (isMacOs) {
setAddErrorMessage('Reminder: iOS 15+ is needed for the Apple Wallet functionality to work with Smart Health Card')
return;
}
if (isIOS && !isSafari) {
// setAddErrorMessage('Sorry, only Safari can be used to add a Wallet Pass on iOS');
setAddErrorMessage('Sorry, only Safari can be used to add a Wallet Pass on iOS');
setIsDisabledAppleWallet(true);
console.log('not safari')
return;
}
if (isAndroid) {
@ -526,7 +532,7 @@ function Form(): JSX.Element {
<input type='file'
id='file'
accept="application/pdf"
accept="application/pdf,.png,.jpg,.jpeg,.gif,.webp"
ref={inputFile}
style={{display: 'none'}}
/>
@ -579,20 +585,11 @@ function Form(): JSX.Element {
<Card step={showDoseOption ? '4' : '3'} heading={t('index:addToWalletHeader')} content={
<div className="space-y-5">
{/* <p>
{t('index:dataPrivacyDescription')}
<Link href="/privacy">
<a>
{t('index:privacyPolicy')}
</a>
</Link>.
</p> */}
<div>
<ul className="list-none">
<Check text={t('createdOnDevice')}/>
<Check text={t('piiNotSent')}/>
<Check text={t('openSourceTransparent')}/>
{verifierLink()}
</ul>
</div>

View File

@ -1,8 +1,6 @@
import React from "react";
import {useTranslation} from 'next-i18next';
import usePassCount from "../src/hooks/use_pass_count";
import Head from 'next/head'
import Logo from './Logo'
import Link from 'next/link'
@ -36,7 +34,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-10-06 (v1.9.23)</div>
<div className="flex pt-4 flex-row space-x-4 justify-center text-md flex-wrap">Last updated: 2021-10-16 (v2.0.5)</div>
</footer>
</main>
</div>
@ -102,7 +100,28 @@ function Page(props: PageProps): JSX.Element {
<br/>
<br/>
</div>
<canvas id="canvas" />
<div id="shc-pass-image" style={{backgroundColor: "white", color: "black", fontFamily: 'Arial', fontSize: 10, width: '350px', padding: '10px'}} hidden>
<table style={{verticalAlign: "middle"}}>
<tbody>
<tr>
<td><img src='shield-black.svg' width='50' height='50' /></td>
<td style={{fontSize: 20, width: 280}}>
<span style={{marginLeft: '11px', whiteSpace: 'nowrap'}}><b>COVID-19 Vaccination Card</b></span><br/>
<span style={{marginLeft: '11px'}}><b id='shc-card-origin'></b></span>
</td>
</tr>
</tbody>
</table>
<br/>
<br/>
<div id='shc-card-name' style={{fontSize:20, textAlign: 'center'}}></div>
<br/>
<br/>
<div id='shc-qrcode' style={{width:'63%', display:'block', marginLeft: 'auto', marginRight: 'auto'}}></div>
<br/>
<br/>
</div>
<canvas id="canvas" style={{display: 'none'}}/>
</div>
)
}

View File

@ -0,0 +1,97 @@
{
"iss": "https://smarthealthcard.phsa.ca/v1/issuer",
"nbf": 1630861482,
"vc": {
"type": [
"https://smarthealth.cards#covid19",
"https://smarthealth.cards#immunization",
"https://smarthealth.cards#health-card"
],
"credentialSubject": {
"fhirVersion": "4.0.1",
"fhirBundle": {
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"fullUrl": "resource:0",
"resource": {
"resourceType": "Patient",
"name": [
{
"family": "LASTNAME",
"given": [
"FIRSTNAME"
]
}
],
"birthDate": "YYYY-MM-DD"
}
},
{
"fullUrl": "resource:1",
"resource": {
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
},
{
"system": "http://snomed.info/sct",
"code": "28581000087106"
}
]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "YYYY-MM-DD",
"lotNumber": "SOME_STRING",
"performer": [
{
"actor": {
"display": "Pretty Location Display Name"
}
}
]
}
},
{
"fullUrl": "resource:2",
"resource": {
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
},
{
"system": "http://snomed.info/sct",
"code": "28581000087106"
}
]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "YYYY-MM-DD",
"lotNumber": "SOME_STRING",
"performer": [
{
"actor": {
"display": "Pretty Location Display Name"
}
}
]
}
}
]
}
}
}
}

View File

@ -0,0 +1,125 @@
{
"iss": "https://pvc.novascotia.ca/issuer",
"nbf": 1633170858.747,
"vc": {
"type": [
"https://smarthealth.cards#health-card",
"https://smarthealth.cards#immunization",
"https://smarthealth.cards#covid19"
],
"credentialSubject": {
"fhirVersion": "4.0.1",
"fhirBundle": {
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"fullUrl": "resource:0",
"resource": {
"resourceType": "Patient",
"name": [
{
"family": "LASTNAME",
"given": [
"FIRSTNAME"
]
}
],
"birthDate": "YYYY-MM-DD"
}
},
{
"fullUrl": "resource:1",
"resource": {
"resourceType": "Immunization",
"meta": {
"security": [
{
"system": "https://smarthealth.cards/ial",
"code": "IAL1.4"
}
]
},
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
},
{
"system": "http://snomed.info/sct",
"code": "28581000087106"
}
]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "YYYY-MM-DD",
"performer": [
{
"actor": {
"display": "NS, Canada"
}
}
],
"lotNumber": "SOME_STRING",
"manufacturer": {
"identifier": {
"system": "http://hl7.org/fhir/sid/mvx",
"value": "PFR"
}
}
}
},
{
"fullUrl": "resource:2",
"resource": {
"resourceType": "Immunization",
"meta": {
"security": [
{
"system": "https://smarthealth.cards/ial",
"code": "IAL1.4"
}
]
},
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
},
{
"system": "http://snomed.info/sct",
"code": "28581000087106"
}
]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "YYYY-MM-DD",
"performer": [
{
"actor": {
"display": "NS, Canada"
}
}
],
"lotNumber": "SOME_STRING",
"manufacturer": {
"identifier": {
"system": "http://hl7.org/fhir/sid/mvx",
"value": "PFR"
}
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,126 @@
{
"iss": "https://prd.pkey.dhdp.ontariohealth.ca",
"nbf": 1634270445.5,
"vc": {
"type": [
"https://smarthealth.cards#health-card",
"https://smarthealth.cards#immunization",
"https://smarthealth.cards#covid19"
],
"credentialSubject": {
"fhirVersion": "4.0.1",
"fhirBundle": {
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"fullUrl": "resource:0",
"resource": {
"resourceType": "Patient",
"name": [
{
"family": "LASTNAME",
"given": [
"FIRSTNAME",
"INITIAL_IF_ANY"
]
}
],
"birthDate": "YYYY-MM-DD"
}
},
{
"fullUrl": "resource:1",
"resource": {
"resourceType": "Immunization",
"meta": {
"security": [
{
"system": "https://smarthealth.cards/ial",
"code": "IAL1.4"
}
]
},
"status": "completed",
"manufacturer": {
"identifier": {
"system": "http://hl7.org/fhir/sid/mvx",
"value": "PFR"
}
},
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
},
{
"system": "http://snomed.info/sct",
"code": "28581000087106"
}
]
},
"occurrenceDateTime": "YYYY-MM-DD",
"lotNumber": "SOME_STRING",
"patient": {
"reference": "resource:0"
},
"performer": [
{
"actor": {
"display": "ON, Canada"
}
}
]
}
},
{
"fullUrl": "resource:2",
"resource": {
"resourceType": "Immunization",
"meta": {
"security": [
{
"system": "https://smarthealth.cards/ial",
"code": "IAL1.4"
}
]
},
"status": "completed",
"manufacturer": {
"identifier": {
"system": "http://hl7.org/fhir/sid/mvx",
"value": "PFR"
}
},
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
},
{
"system": "http://snomed.info/sct",
"code": "28581000087106"
}
]
},
"occurrenceDateTime": "YYYY-MM-DD",
"lotNumber": "SOME_STRING",
"patient": {
"reference": "resource:0"
},
"performer": [
{
"actor": {
"display": "ON, Canada"
}
}
]
}
}
]
}
}
}
}

View File

@ -0,0 +1,79 @@
{
"iss": "https://covid19.quebec.ca/PreuveVaccinaleApi/issuer",
"nbf": 1630525607,
"vc": {
"@context": [
"https://www.w3.org/2018/credentials/v1"
],
"type": [
"VerifiableCredential",
"https://smarthealth.cards#health-card",
"https://smarthealth.cards#covid19",
"https://smarthealth.cards#immunization"
],
"credentialSubject": {
"fhirVersion": "1.0.2",
"fhirBundle": {
"resourceType": "Bundle",
"type": "Collection",
"entry": [
{
"resource": {
"resourceType": "Patient",
"name": [
{
"family": "LASTNAME",
"given": [
"First Name"
]
}
],
"birthDate": "YYYY-MM-DD"
}
},
{
"resource": {
"resourceType": "Immunization",
"status": "Completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
}
]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "YYYY-MM-DD",
"lotNumber": "SOME_STRING",
"protocolApplied": {
"doseNumber": 1,
"targetDisease": {
"coding": [
{
"system": "http://browser.ihtsdotools.org/?perspective=full&conceptId1=840536004",
"code": "840536004"
}
]
}
},
"note": [
{
"text": "PB COVID-19"
}
],
"reported": false,
"wasNotGiven": false,
"location": {
"reference": "resource:0",
"display": "LOCATION DISPLAY NAME"
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,122 @@
{
"iss": "https://skphr.prd.telushealthspace.com",
"nbf": 1631923594.5185204,
"vc": {
"type": [
"VerifiableCredential",
"https://smarthealth.cards#immunization",
"https://smarthealth.cards#health-card",
"https://smarthealth.cards#covid19"
],
"credentialSubject": {
"fhirVersion": "4.0.1",
"fhirBundle": {
"resourceType": "Bundle",
"id": "00000000-0000-0000-0000-000000000000",
"type": "collection",
"entry": [
{
"fullUrl": "resource:0",
"resource": {
"resourceType": "Patient",
"name": [
{
"text": "LastName FirstName",
"family": "FirstName",
"given": [
"LastName"
]
}
],
"birthDate": "YYYY-MM-DD"
}
},
{
"fullUrl": "resource:1",
"resource": {
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
}
]
},
"patient": {
"reference": "resource:0"
},
"meta": {
"security": [
{
"system": "https://smarthealth.cards/ial",
"code": "IAL1.4"
}
]
},
"occurrenceDateTime": "YYYY-MM-DD",
"performer": [
{
"actor": {
"display": "Government of Saskatchewan"
}
}
],
"manufacturer": {
"identifier": {
"system": "http://fhir.ehealthsask.ca/organizationIdentifiers/EHS",
"value": "Pfizer"
}
},
"lotNumber": "SOME_STRING",
"expirationDate": "YYYY-MM-DD"
}
},
{
"fullUrl": "resource:2",
"resource": {
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
}
]
},
"patient": {
"reference": "resource:0"
},
"meta": {
"security": [
{
"system": "https://smarthealth.cards/ial",
"code": "IAL1.4"
}
]
},
"occurrenceDateTime": "YYYY-MM-DD",
"performer": [
{
"actor": {
"display": "Government of Saskatchewan"
}
}
],
"manufacturer": {
"identifier": {
"system": "http://fhir.ehealthsask.ca/organizationIdentifiers/EHS",
"value": "Pfizer"
}
},
"lotNumber": "SOME_STRING",
"expirationDate": "YYYY-MM-DD"
}
}
]
}
}
}
}

View File

@ -0,0 +1,89 @@
{
"iss": "https://myvaccinerecord.cdph.ca.gov/creds",
"nbf": 1628831358,
"vc": {
"type": [
"https://smarthealth.cards#health-card",
"https://smarthealth.cards#immunization",
"https://smarthealth.cards#covid19"
],
"credentialSubject": {
"fhirVersion": "4.0.1",
"fhirBundle": {
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"fullUrl": "resource:0",
"resource": {
"resourceType": "Patient",
"name": [
{
"family": "Lastname",
"given": [
"First Name"
]
}
],
"birthDate": "YYYY-MM-DD"
}
},
{
"fullUrl": "resource:1",
"resource": {
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
}
]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "YYYY-MM-DD",
"lotNumber": "SOME_STRING",
"performer": [
{
"actor": {
"display": "Pretty Location Display Name"
}
}
]
}
},
{
"fullUrl": "resource:2",
"resource": {
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
}
]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "YYYY-MM-DD",
"lotNumber": "SOME_STRING",
"performer": [
{
"actor": {
"display": "Pretty Location Display Name"
}
}
]
}
}
]
}
}
}
}

31
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "grassroots_covidpass",
"version": "1.9.7",
"version": "2.0.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
@ -12,6 +12,7 @@
"@google-pay/button-react": "^3.0.0",
"@headlessui/react": "^1.3.0",
"@ninja-labs/verify-pdf": "^0.3.9",
"@nuintun/qrcode": "^3.0.1",
"@sentry/browser": "^6.13.2",
"@sentry/integrations": "^6.13.2",
"@sentry/react": "^6.13.2",
@ -327,6 +328,19 @@
"node": ">= 8"
}
},
"node_modules/@nuintun/qrcode": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nuintun/qrcode/-/qrcode-3.0.1.tgz",
"integrity": "sha512-0dY347xYgJvmBP0I75UHeVEx7JeDczahhEYaS03uh9bhCqa3FycdtLPxWulQh/r3OsX5lpi+z3mEXfAaQtAPwQ==",
"dependencies": {
"tslib": "^2.0.1"
}
},
"node_modules/@nuintun/qrcode/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/@sentry/browser": {
"version": "6.13.2",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.13.2.tgz",
@ -5719,6 +5733,21 @@
"fastq": "^1.6.0"
}
},
"@nuintun/qrcode": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nuintun/qrcode/-/qrcode-3.0.1.tgz",
"integrity": "sha512-0dY347xYgJvmBP0I75UHeVEx7JeDczahhEYaS03uh9bhCqa3FycdtLPxWulQh/r3OsX5lpi+z3mEXfAaQtAPwQ==",
"requires": {
"tslib": "^2.0.1"
},
"dependencies": {
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"@sentry/browser": {
"version": "6.13.2",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.13.2.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "grassroots_covidpass",
"version": "1.10.0",
"version": "2.0.5",
"author": "Billy Lo <billy@vaccine-ontario.ca>",
"license": "MIT",
"private": false,
@ -18,6 +18,7 @@
"@google-pay/button-react": "^3.0.0",
"@headlessui/react": "^1.3.0",
"@ninja-labs/verify-pdf": "^0.3.9",
"@nuintun/qrcode": "^3.0.1",
"@sentry/browser": "^6.13.2",
"@sentry/integrations": "^6.13.2",
"@sentry/react": "^6.13.2",

View File

@ -4,32 +4,60 @@ import {serverSideTranslations} from 'next-i18next/serverSideTranslations';
import Page from '../components/Page'
import Card from '../components/Card'
interface link{
url: string;
text: string;
}
function linkToJSX(link: link): JSX.Element {
return <div style={{ display: 'inline' }} dangerouslySetInnerHTML={{ __html: `<a href="${link.url}" target="_blank" class="underline">${link.text}</a>` }}></div>
}
function urlParse(text: string, links: link[]): JSX.Element[] {
const el = text.split(/(%s)/).map(s => {
if (s.includes("%s")) {
return linkToJSX(links.shift());
} else {
return <>{s}</>
}
});
return el;
}
const CONSTANTS = {
grassrootsEmail: { url: 'mailto:grassroots@vaccine-ontario.ca', text: 'grassroots@vaccine-ontario.ca' },
verifier: { url: 'https://verifier.vaccine-ontario.ca/', text: 'verifier.vaccine-ontario.ca' },
twitter: { url: 'https://twitter.com/grassroots_team', text: '@grassroots_team' },
booking: { url: 'https://vaccine-ontario.ca/', text: 'vaccine-ontario.ca' },
verifyOntarioApp: { url: 'https://covid-19.ontario.ca/verify', text: 'Verify Ontario app' },
vaxHunters: { url: 'https://vaccinehunters.ca/', text: 'Vaccine Hunters Canada' },
}
function Faq(): JSX.Element {
const { t } = useTranslation(['common', 'index', 'faq']);
const questionList = [
{description: 'Which version of iOS does this support?', answer: 'iOS 13.7 is the minimum at the moment. We are looking for adjustments for older iOS versions, but it will take a bit of time.'},
{description: 'I\'m having issues with adding it to my iPhone 6.', answer: 'Unfortunately, the iPhone 6 supports up to iOS 12 while the minimum requirement for our app is iOS 13.7 however, we are looking for ways around this to make it more accessible. In the meantime you can try it on a computer or another device and save it as either a wallet card or a photo - if you save it as a card, you can then email it to your iPhone and you will be able to import it into Apple Wallet that way.'},
{description: "What are the supported browsers?", answer: 'For iPhones, only Safari is supported for importing to your Apple Wallet. For any other devices, we recommend that you save it as photo using your browser of choice. Browsers built internally into mobile apps (e.g. Facebook, Twitter, Instagram) are known to have issues.'},
{description: 'Which version of iOS does this support?', answer: 'Importing the new enhanced QR codes into Apple Wallet requires iOS 15+, for everyone else please create Photo cards'},
{description: 'I\'m having issues with adding a Wallet card to my older iPhone', answer: 'Unfortunately, the minimum requirement for importing the new QR code into Apple Wallet is iOS 15, which runs on iPhone 6s and newer devices - you will need to create a Photo card'},
{description: 'Why does my Apple Wallet QR code look so small and/or different from the original?', answer: 'Unfortunately we have no control over how Apple Wallet displays the QR code we give it - and in particular, Apple Wallet on iOS 13 and 14 does not draw these QR codes correctly. This is a known Apple bug and it seems unlikely at this point that they will fix it - we suggest using a Photo pass if you can\'t update to iOS 15 or later'},
{description: "What are the supported browsers?", answer: 'For iPhones, only Safari is supported for importing to your Apple Wallet. For any other devices, we recommend that you save it as photo using an updated Google Chrome. Browsers built internally into mobile apps (e.g. Facebook, Twitter, Instagram) are known to have issues.'},
{description: "How is my private information handled?", answer: 'Your proof-of-vaccination PDF (and most of the information in it) never leaves your device, and does NOT get sent to our server - the only information we send and store is non-personally-identifiable information such as vaccine type, date, and which organization gave you the vaccine. We share your concern about personal data being stored and lost, which is why we chose not to store or send any of it to our servers so there is no chance of it being lost or leaked.'},
{description: 'Do you have plans for Android support?', answer: 'Yes. We are working with Google to gain access to the APIs required. Meanwhile, you can also use this tool to download an Apple Wallet pass and import that into Google Pay Wallet using apps such as Pass2Pay or simply save it as a photo.'},
{description: 'Can you tell me about Android support?', answer: 'We are working with Google to gain access to the Google Pay COVID card APIs required to make Apple Wallet-equivalent passes for Android. Meanwhile, you can save a Photo card, or use our site to download an Apple Wallet pass and import that into Google Pay using apps such as Pass2Pay.'},
{description: 'I have a Red/White OHIP card. Can I still use this tool?', answer: 'Yes you can! Just call the Provincial Vaccine Contact Centre at 1-833-943-3900. The call centre agent can email you a copy of the receipt.'},
{description: 'I do not have a health card. Can I still use this tool?', answer: 'First contact your local public health unit to verify your identity and receive a COVIDcovid ID/Personal Access Code. You can then call the Provincial Vaccine Contact Centre at 1-833-943-3900 to get an email copy of your receipt.'},
{description: 'I\'m seeing an error message saying “Failed byte range verification." What do I do?', answer: 'If you see this error then please try re-downloading your receipt from the provincial proof-of-vaccination portal and trying again. We have received reports from some people that this has resolved the problem for them.'},
{description: 'What does the colour of the Apple Wallet pass mean?', answer: 'Dose 1 is shown as Orange; dose 2+ in green for easy differentiation without reading the text. For the Janssen (Johnson & Johnson) vaccine, dose 1 is shown as green.'},
{description: 'Should I use the official provincial apps when they come out on 22nd October?', answer: 'YES. Once the official QR code from the province is available, please come back to this site and you will be able to generate a new Apple Wallet pass which contains that new QR code'},
{description: 'How is the data on my vaccination receipt processed?', answer: 'Inside your local web browser, it checks the receipt for a digital signature from the provincial proof-of-vaccination system. If present, the receipt data is converted into Apple\'s format and then added into your iOS Wallet app.'},
{description: 'How can organizations validate this QR code?', answer: 'You can use our verifier app at verifier.vaccine-ontario.ca to verify these passes quickly if you are a business - you should also be able to use any normal QR code scanner to scan this code and it will take you to a verification site which tells you whether the receipt is valid or not'},
{description: 'Can I use the same iPhone to store passes for my entire family?', answer: 'Yes.'},
{description: 'Is this free and non-commercial?', answer: 'Similar to VaxHuntersCanada, there are no commercial interests. Just volunteers trying to do our part to help the community.'},
{description: 'How about support for other provinces?', answer: 'We will be investigating BC and Québec support shortly. If you are interested in contributing, please email us at grassroots@vaccine-ontario.ca'},
{description: 'How about Apple Watch?', answer: 'If you have iCloud sync enabled, you will see the pass on the watch too.'},
{description: 'Why have we taken time to build this?', answer: 'Gives Ontarians/organizations something easy to use (volunteered-developed, unofficial) until the official provincial app comes out in October.'},
{description: 'Who made this?', answer: 'The same group of volunteers who created the all-in-one vaccine appointment finding tool at vaccine-ontario.ca'},
{description: 'How can I stay up-to-date on your progress?', answer: 'We will post regular updates on Twitter @grassroots_team'},
{description: 'I have more questions. Can you please help me?', answer: 'Sure. Just email us at grassroots@vaccine-ontario.ca'}
{description: 'Why isn\'t the new Apple Wallet pass green/orange?', answer: 'Because we now allow importing of QR codes from many provinces and states, and those provinces and states have different eligibility rules, we can no longer reliably determine who is or is not valid at receipt import time.'},
{description: 'How is the data on my vaccination receipt processed?', answer: 'Inside your local web browser, it checks the uploaded PDF or image for a valid QR code. If present, the QR code is converted and either added to Apple Wallet or created as a photo depending on the option you choose.'},
{description: 'How can organizations validate this QR code?', answer: urlParse('The %s is the verification application for the new official Ontario QR codes. For our existing interim QR codes, you can use our web-based tool at %s',[CONSTANTS.verifyOntarioApp, CONSTANTS.verifier])},
{description: 'Can I use the same iPhone to store passes for my entire family?', answer: 'Yes. You can save multiple Wallet or Photo cards on your device without issue.'},
{description: 'Is this free and non-commercial?', answer: urlParse('Similar to %s, there are no commercial interests. Just volunteers trying to do our part to help the community.',[CONSTANTS.vaxHunters])},
{description: 'How about support for other provinces or US states?', answer: urlParse('We now have support for Ontario, British Columbia, Québec, Alberta, Saskatchewan, Nova Scotia, Yukon, Northwest Territories, California, New York, New Jersey, Louisiana, Hawaii, Virginia, and Utah QR codes. If you have a QR code that is not currently supported by our app, please contact us at %s', [CONSTANTS.grassrootsEmail])},
{description: 'How about Apple Watch?', answer: 'If you have iCloud sync enabled, you will see the pass on the watch too. Please be aware though that the new QR codes may be too large to display accurately on older Apple Watches due to their screen size.'},
{description: 'Why have we taken time to build this?', answer: 'We wanted to give people across Canada the ability to conveniently and securely add their vaccination QR code to their mobile devices to make it easier to present them, and also wanted to create a verifier tool which requires no app install and is convenient for anyone to use from a web browser on any device with a camera.'},
{description: 'Who made this?', answer: urlParse('The same group of volunteers who created the all-in-one vaccine appointment finding tool at %s', [CONSTANTS.booking])},
{description: 'How can I stay up-to-date on your progress?', answer: urlParse('We will post regular updates on Twitter %s', [CONSTANTS.twitter])},
{description: 'I have more questions. Can you please help me?', answer: urlParse('Sure. Just email us at %s', [CONSTANTS.grassrootsEmail]) }
];
return (
<Page content={
<Card step="?" heading={t('common:faq')} content={

View File

@ -28,10 +28,11 @@ function Index(): JSX.Element {
const deleteWarningMessage = (message: string) => _setWarningMessages(warningMessages.filter(item => item !== message));
useEffect(() => {
if ((isIOS && !isMacOs) && !isSafari) setWarningMessage("iPhone users, only Safari is supported at the moment. Please switch to Safari to prevent any unexpected errors.")
if ((isIOS && !isMacOs) && !isSafari)
setWarningMessage("iPhone users, only Safari is supported at the moment. Please switch to Safari to prevent any unexpected errors.")
else {
if (isAndroid) {
if (Number(osVersion) > 8) {
if (Number(osVersion.split('.')[0]) >= 8) {
setWarningMessage("Hi, Android users, check out our new Add to Google Pay button...")
} else {
setWarningMessage("Sorry, Add to Google Pay is only available to Android 8.1+.")
@ -41,10 +42,8 @@ function Index(): JSX.Element {
}, []);
// If you previously created a vaccination receipt before Sept. 23rd and need to add your date of birth on your vaccination receipt, please reimport your Ministry of Health official vaccination receipt again below and the date of birth will now be visible on the created receipt
const title = 'Grassroots - Import vaccination receipt to your mobile wallet (iPhone/Android)';
const description = 'A non-commercial tool, built by volunteers to make it easier to show your vaccination records';
const title = 'Grassroots - vaccination QR Code import for Apple and Android devices. Supports BC AB SK MB ON QC NS YK NT NY NJ CA LA VA HI UT';
const description = 'Grassroots imports vaccination QR codes and stores them on Apple and Android devices in a convenient, secure, and privacy-respecting way. Supports SHC QR codes from BC AB SK MB ON QC NS YK NT NY NJ CA LA VA HI UT';
return (
<>
@ -55,14 +54,14 @@ function Index(): JSX.Element {
url: 'https://grassroots.vaccine-ontario.ca/',
title: title,
description: description,
// images: [
// {
// url: 'https://covidpass.marvinsextro.de/thumbnail.png',
// width: 1000,
// height: 500,
// alt: description,
// }
// ],
images: [
{
url: 'https://grassroots.vaccine-ontario.ca/grassroots.jpg',
width: 400,
height: 400,
alt: description,
}
],
site_name: title,
}}
twitter={{
@ -79,16 +78,21 @@ function Index(): JSX.Element {
<Card content={
<div><p>{t('common:subtitle')}</p><br /><p>{t('common:subtitle2')}</p><br />
<b>{displayPassCount}</b><br/><br/>
Oct 10 afternoon update:
<b>MAJOR NEW RELEASE! </b>Oct 15 evening update:
<br />
<br />
<ul className="list-decimal list-outside" style={{ marginLeft: '20px' }}>
<li>Support for Google Pay Wallet (compatible with Android 8.1+)</li>
<li>You can now import the new enhanced receipt from Ontario onto your Apple or Android devices</li>
<li>Support released for importing Ontario, British Columbia, Alberta, Saskatchewan, Nova Scotia, Québec, Yukon, California, New York, and Louisiana SHC QR codes</li>
<li>Support released for importing QR codes from images as well as from PDFs</li>
<li>Support for creating our previous interim QR codes has been removed - now that the official Ontario QR code is being released and the gap is filled, our QR codes are no longer needed</li>
<li>Support released for importing Manitoba's (new unannounced) QR codes, as well as for Northwest Territories, New Jersey, Hawaii, Virginia, and Utah SHC QR codes</li>
</ul><br />
<p>{t('common:continueSpirit')}</p>
<br />
<Link href="https://www.youtube.com/watch?v=AIrG5Qbjptg">
<a className="underline" target="_blank">
{/* <a className="underline" target="_blank"> */}
<a className="underline">
Click here for a video demo
</a>
</Link>&nbsp;

BIN
public/grassroots.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,7 +1,7 @@
title: Vaccination Receipt to Wallet
subtitle: This utility (created by volunteers) converts your vaccination receipt to mobile wallet format (iPhone and Android) for easy access.
subtitle2: It does not store personally identifiable information such as name or date of birth. Source code is available to the public for verification.
continueSpirit: Continuing the spirit of ❤️ @VaxHuntersCanada ❤️.
subtitle: This utility (created by volunteers) copies your proof-of-vaccination QR code into either Apple Wallet (for iOS 15+) or Google Pay Wallet (for Android 8+) and a Photo pass (for all others) for easy access.
subtitle2: We now support importing QR codes from Ontario, British Columbia, Québec, Alberta, Nova Scotia, Saskatchewan, Yukon, Northwest Territories, California, New York, Louisiana, New Jersey, Hawaii, Virginia, and Utah!
continueSpirit: Continuing the spirit of ❤️ @VaxHuntersCanada ❤️
privacyPolicy: Privacy Policy
donate: Sponsor
gitHub: GitHub

View File

@ -1,46 +1,27 @@
iosHint: On iOS, please use Safari.
iosHint: On iOS, please use Safari
errorClose: Close
selectCertificate: Select vaccination receipt (PDF)
selectCertificate: Select vaccination receipt (PDF or image)
selectCertificateDescription: |
Press "Select File", "Browse..." and select the PDF file you have saved in Step 1.
selectCertificateReminder: |
Reminder : Receipts directly downloaded from the provincial web site is required.
Press "Select File", "Browse..." and select the PDF file or picture you have saved in Step 1
#stopCamera: Stop Camera
#startCamera: Start Camera
openFile: Select File
#foundQrCode: Found QR Code!
downloadReceipt: Download official receipt from Ontario Ministry of Health
visit: Visit
downloadReceipt: Download or take a picture of your QR code
visit: If you are in Ontario, please visit
ontarioHealth: Ontario Ministry of Health
gotoOntarioHealth: Go to Ontario Ministry of Health
downloadSignedPDF: and enter your information to display your official vaccination receipt. Press the Share Icon at the bottom, "Save As Files" to store it onto your phone.
reminderNotToRepeat: If you have completed this step before, simply proceed to Step 2.
formatChange: After the recent vaccination receipt formatting change, both doses are included in the same file. Please select which dose you want to save.
saveMultiple: To save multiple receipts, please select the first one you want to save and click the Wallet or Photo button below, then change which dose is selected here and push the button again to generate another Wallet or Photo for another dose.
pickColor: Pick a Color
pickColorDescription: Pick a background color for your pass.
colorWhite: white
colorBlack: black
colorGrey: grey
colorGreen: green
colorIndigo: indigo
colorBlue: blue
colorPurple: purple
colorTeal: teal
downloadSignedPDF: and enter your information to display your official vaccination receipt. Press the Share Icon at the bottom, "Save As Files" to store it onto your iPhone. You can also take a picture or screenshot of your QR code with your phone (please make sure the picture is good-quality, is not blurry, and captures ALL of the QR code!)
reminderNotToRepeat: If you have completed this step before, simply proceed to Step 2
addToWallet: Add to Apple Wallet
addToGooglePay: Add to Google Pay
addToWalletHeader: Add to Wallet / Save as Photo
saveAsPhoto: Save as Photo
dataPrivacyDescription: |
Press the "Add to Wallet" below to import data into Wallet.
iAcceptThe: I accept the
privacyPolicy: Privacy Policy
createdOnDevice: Your receipt is processed on your device only.
createdOnDevice: Your receipt is processed on your device only
piiNotSent: No personally-identifiable information is sent to servers
openSourceTransparent: Source code is open for re-use/contributions on GitHub.
openSourceTransparent: Source code is open for re-use/contributions on GitHub
verifierLink: QR code verifier available at
numPasses: receipts processed since Sept 2, 2021
demo: Video Demo
whatsnew: What's New
questions: Have Questions?
#hostedInEU: Hosted in the EU

View File

@ -1,36 +0,0 @@
title: Verify QR code
subtitle: Verify the vaccination record is consistent with provincial records
iosHint: On iOS, please use Safari.
errorClose: Close
selectCertificate: Select vaccination receipt (PDF)
selectCertificateDescription: |
Press "Select File", "Browse..." and select the PDF file you have saved in Step 1. [ Reminder : Only receipts downloaded from the provincial web site can be verified and converted to Apple Wallet Pass.]
#stopCamera: Stop Camera
#startCamera: Start Camera
openFile: Select File
#foundQrCode: Found QR Code!
downloadReceipt: Download official receipt from Ontario Ministry of Health
visit: Visit
ontarioHealth: Ontario Ministry of Health
gotoOntarioHealth: Go to Ontario Ministry of Health
downloadSignedPDF: and enter your information to display your official vaccination receipt. Press the Share Icon at the bottom, "Save As Files" to store it onto your iPhone.
pickColor: Pick a Color
pickColorDescription: Pick a background color for your pass.
colorWhite: white
colorBlack: black
colorGrey: grey
colorGreen: green
colorIndigo: indigo
colorBlue: blue
colorPurple: purple
colorTeal: teal
addToWallet: Add to Wallet
dataPrivacyDescription: |
Press the "Add to Wallet" below to import data into Wallet.
iAcceptThe: I accept the
privacyPolicy: Privacy Policy
createdOnDevice: No personal data is sent to the Internet.
qrCode: QR code is for verification only, with no personal info.
openSourceTransparent: Source code is free and open for re-use/contributions on GitHub.
demo: Video Demo
#hostedInEU: Hosted in the EU

13
public/shield-black.svg Normal file
View File

@ -0,0 +1,13 @@
<svg width="111.811" height="122.88" xmlns="http://www.w3.org/2000/svg">
<g>
<title>background</title>
<rect fill="none" id="canvas_background" height="514" width="514" y="-1" x="-1"/>
</g>
<g>
<title>Layer 1</title>
<g id="svg_1">
<path fill="#000000" id="svg_2" d="m55.713,0c20.848,13.215 39.682,19.467 55.846,17.989c2.823,57.098 -18.263,90.818 -55.63,104.891c-36.085,-13.172 -57.429,-45.441 -55.846,-105.757c18.975,0.993 37.591,-3.109 55.63,-17.123l0,0zm-21.929,66.775c-1.18,-1.01 -1.318,-2.786 -0.309,-3.967c1.011,-1.181 2.787,-1.318 3.967,-0.309l11.494,9.875l25.18,-27.684c1.047,-1.15 2.828,-1.234 3.979,-0.188c1.149,1.046 1.233,2.827 0.187,3.978l-27.02,29.708l-0.002,-0.002c-1.02,1.121 -2.751,1.236 -3.91,0.244l-13.566,-11.655l0,0zm21.951,-59.72c18.454,11.697 35.126,17.232 49.434,15.923c2.498,50.541 -16.166,80.39 -49.241,92.846c-31.942,-11.659 -50.837,-40.221 -49.435,-93.613c16.797,0.88 33.275,-2.751 49.242,-15.156l0,0z" clip-rule="evenodd" fill-rule="evenodd"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1007 B

View File

@ -1,10 +1,11 @@
// adapted from https://github.com/fproulx/shc-covid19-decoder/blob/main/src/shc.js
const jsQR = require("jsqr");
const zlib = require("zlib");
import {Receipt, HashTable} from "./payload";
import {SHCReceipt, SHCVaccinationRecord} from "./payload";
import {getVerifiedIssuer} from "./issuers";
import {registerPass, generateSHCRegisterPayload} from "./passphoto-common";
export function getQRFromImage(imageData) {
export function getQRFromImage(imageData: ImageData) {
return jsQR(
new Uint8ClampedArray(imageData.data.buffer),
imageData.width,
@ -16,92 +17,142 @@ export function getQRFromImage(imageData) {
// https://gist.github.com/alexdunae/49cc0ea95001da3360ad6896fa5677ec
// http://mchp-appserv.cpe.umanitoba.ca/viewConcept.php?printer=Y&conceptID=1514
// .vc.credentialSubject.fhirBundle.entry
export function decodedStringToReceipt(decoded: object) : HashTable<Receipt> {
const codeToVaccineName = {
'28581000087106': 'PFIZER',
'28951000087107': 'JANSSEN',
'28761000087108': 'ASTRAZENECA',
'28571000087109': 'MODERNA'
function getCvxVaccineCodeForResource(immunizationResource: any) : string {
for (let curCoding of immunizationResource.vaccineCode.coding) {
if (curCoding.system === 'http://hl7.org/fhir/sid/cvx') {
return curCoding.code;
}
}
console.error(`Cannot determine vaccine type - missing expected coding [http://hl7.org/fhir/sid/cvx] in SHC resource [${JSON.stringify(immunizationResource)}]`);
return null;
}
function getOrganizationForResource(immunizationResource: any) : string {
// By default, this is under performer actor display
if (immunizationResource.performer) {
// Assume there's only one performer (this is all that has been seen in data to date)
return immunizationResource.performer[0].actor.display;
}
// Quebec does something different from most.
if (immunizationResource.location) {
return immunizationResource.location.display;
}
console.error(`Cannot determine organization name for SHC resource [${JSON.stringify(immunizationResource)}]`);
return null;
}
export function decodedStringToReceipt(decoded: object) : SHCReceipt {
const cvxCodeToVaccineName = { // https://www2a.cdc.gov/vaccines/iis/iisstandards/vaccines.asp?rpt=cvx
'208': 'PFIZER',
'208': 'PFIZER', // Nominally for 16+, in practice seems to be used for 12+
// The two records below are for childrens' doses of Pfizer, they have different CVX codes.
'217': 'PFIZER', // 12+ dose size, unclear how this differs from 208
'218': 'PFIZER', // 5-11 dose size
'219': 'PFIZER', // 2-4 dose size
'212': 'JANSSEN',
'210': 'ASTRAZENECA',
'207': 'MODERNA'
'207': 'MODERNA',
'510': 'SINOPHARM-BIBP',
'511': 'CORONAVAC-SINOVAC',
// All of the vaccines below this line have not yet been approved by the WHO, but they are here because we have seen them all in our dataset
'502': 'COVAXIN',
'505': 'SPUTNIK V',
'506': 'CONVIDECIA',
'507': 'ZIFIVAX',
// All of the vaccines below are listed as CVX codes, but we haven't seen them yet in our data - adding for completeness
'501': 'QAZCOVID-IN',
'503': 'COVIVAC',
'500': 'UNKNOWN',
'213': 'UNKNOWN',
'509': 'EPIVACCORONA',
'508': 'CHOCELL',
'211': 'NOVAVAX',
'504': 'SPUTNIK LIGHT',
}
// Track whether the SHC code is validated - if it is not, we will record it so that we can
// proactively track new SHC codes we haven't seen yet and support them quickly if appropriate
let isValidatedSHC = true;
// 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';
const verifiedIssuer = getVerifiedIssuer(decoded['iss']);
if (!verifiedIssuer) {
// Bail out now - this is not a recognized issuer
console.error(`Issuer ${decoded['iss']} was not a recognized issuer! Cannot create card`);
isValidatedSHC = false;
}
// Now verify that this SHC deals with a COVID immunization
const cardData = decoded['vc'];
const isCovidCard = cardData.type.includes('https://smarthealth.cards#covid19');
if (!isCovidCard) {
// Bail out now - this is not a COVID card
console.error(`SHC QR code was not COVID-related (type [https://smarthealth.cards#covid19] not found)! Cannot create card`);
isValidatedSHC = false;
}
// If we're here, we have an SHC QR code which was issued by a recognized issuer. Start mapping values now
const shcResources = cardData.credentialSubject.fhirBundle.entry;
let name = '';
let dateOfBirth;
let receipts : HashTable<Receipt> = {};
let dateOfBirth = '';
const vaxRecords: SHCVaccinationRecord[] = [];
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;
}
}
switch(resource.resourceType) {
case 'Patient':
// Assume there is only one name on the card.
const nameObj = resource.name[0];
name = `${nameObj.given.join(' ')} ${nameObj.family}`;
dateOfBirth = resource.birthDate;
console.log('Detected Patient resource, added name and birthdate');
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;
break;
case 'Immunization':
// Start collecting data about this immunization record
const vaccineCode = getCvxVaccineCodeForResource(resource);
const vaccineName = cvxCodeToVaccineName[vaccineCode];
const organizationName = getOrganizationForResource(resource);
const vaccinationDate = resource.occurrenceDateTime;
const receipt = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, receiptNumber, organizationName);
// console.log(receipt);
receipts[receiptNumber] = receipt;
// Add this to our set of records
vaxRecords.push(new SHCVaccinationRecord(vaccineName, vaccinationDate, organizationName));
console.log(`Detected Immunization resource, added vaccination record (current count: ${vaxRecords.length})`);
break;
default:
console.warn(`Unexpected SHC resource type ${resource.resourceType}! Ignoring...`);
break;
}
}
return receipts;
if (name.length === 0 || dateOfBirth.length === 0) {
// Bail out now - we are missing basic info
console.error(`No name or birthdate was found! Cannot create card`);
isValidatedSHC = false;
}
const retReceipt = new SHCReceipt(name, dateOfBirth, verifiedIssuer.display, verifiedIssuer.iss, vaxRecords);
console.log(`Creating receipt for region [${retReceipt.cardOrigin}] with vaccination records [${JSON.stringify(retReceipt.vaccinations)}]`);
if (!isValidatedSHC) {
// Send this SHC to our registration endpoint so we can proactively track and react to unexpected SHCs
// (e.g. for jurisdictions we aren't aware of yet)
const registerPayload = generateSHCRegisterPayload(retReceipt);
registerPass(registerPayload);
// Now bail out
return null;
}
return retReceipt;
}

View File

@ -1,16 +1,22 @@
const issuers = [
export const issuers = [
{
id: "ca.qc",
display: "Québec",
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" },
{ kid: "2XlWk1UQMqavMtLt-aX35q_q9snFtGgdjH4-Y1gfH1M",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "XSxuwW_VI_s6lAw6LAlL8N7REGzQd_zXeIVDHP_j_Do",
y: "88-aI4WAEl4YmUpew40a9vq_w5OcFvsuaKMxJRLRLL0" },
]
},
{
id: "us.ca",
display: "California, USA",
iss: "https://myvaccinerecord.cdph.ca.gov/creds",
keys: [
{ kid: "7JvktUpf1_9NPwdM-70FJT3YdyTiSe2IvmVxxgDSRb0",
@ -21,6 +27,7 @@ const issuers = [
},
{
id: "us.ny",
display: "New York, USA",
iss: "https://ekeys.ny.gov/epass/doh/dvc/2021",
keys: [
{ kid: "9ENs36Gsu-GmkWIyIH9XCozU9BFhLeaXvwrT3B97Wok",
@ -31,6 +38,7 @@ const issuers = [
},
{
id: "us.la",
display: "Louisiana, USA",
iss: "https://healthcardcert.lawallet.com",
keys: [
{ kid: "UOvXbgzZj4zL-lt1uJVS_98NHQrQz48FTdqQyNEdaNE",
@ -41,16 +49,23 @@ const issuers = [
},
{
id: "ca.yt",
display: "Yukon",
iss: "https://pvc.service.yukon.ca/issuer",
keys: [
{ kid: "UnHGY-iyCIr__dzyqcxUiApMwU9lfeXnzT2i5Eo7TvE",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x5c: [
"MIICGTCCAZ6gAwIBAgIJALC8NylJvTNbMAoGCCqGSM49BAMDMDMxMTAvBgNVBAMMKEdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgQ0EwHhcNMjEwODI4MjM1NzQyWhcNMjIwODI4MjM1NzQyWjA3MTUwMwYDVQQDDCxHb3Zlcm5tZW50IG9mIFl1a29uIFNNQVJUIEhlYWx0aCBDYXJkIElzc3VlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMAnk/a3S2E6TiudjitPqEW8A8W5YyXTYg7sT7D7f9YKIgFPi1OrHtJWJGwPMvlueeHmULUKEpScgpQtoHNjX+SjgZYwgZMwCQYDVR0TBAIwADALBgNVHQ8EBAMCB4AwOQYDVR0RBDIwMIYuaHR0cHM6Ly9zcGVjLnNtYXJ0aGVhbHRoLmNhcmRzL2V4YW1wbGVzL2lzc3VlcjAdBgNVHQ4EFgQUitSA1n/iAP1N2FYJwvvM624tgaUwHwYDVR0jBBgwFoAU5wzUU8M7Lqq4yxgxB2Yfc8neS6UwCgYIKoZIzj0EAwMDaQAwZgIxAMznKWBgcaCywPLb2/XxRaG6rnrcF5Si3JXAxi9z9PLapjjFXnn01PihQ8uf6jGM1AIxAK2ySU71gTqXbriCMq0ALOcLW0zmzcaLLEAmq5kR6iunRZNFp1v4MQxLUno5qsm2Rg==",
"MIICEzCCAXWgAwIBAgIJALQZZWjfw5NnMAoGCCqGSM49BAMEMDgxNjA0BgNVBAMMLUdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgUm9vdCBDQTAeFw0yMTA4MjQyMTM0MDRaFw0yNjA4MjMyMTM0MDRaMDMxMTAvBgNVBAMMKEdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAS3TOjxd26WFX4RPaTnWcpw7/ZsfBb/+s/I7Gt+9GAsmZKjvZUDOf9Dc1ARkPh76BaE+ABzZ83LcmDqMhYdHbZXB6hOvVTmqYjtnQIovFK8irY1MsmWPfq4BW7QN6B6aKCjUDBOMAwGA1UdEwQFMAMBAf8wHQYDVR0OBBYEFOcM1FPDOy6quMsYMQdmH3PJ3kulMB8GA1UdIwQYMBaAFAbK320153UDRVQ30TnWECrILX8KMAoGCCqGSM49BAMEA4GLADCBhwJCAPvYBOO/NVZaglYiW35+z8UvhEadsCWfAknIdJBYkKdNQWQ0ktqS7+ctqBEVCUQng2IROSG4BnJrs7H7+1G4wgZnAkEQ+HuRcVUmiicTkPDlbwZHtoDEc1fv3TvNG9h+Qp5WvfLivz7BrFHt11ByuTCxEn7juv3B1JhpyPVZKTnQ+Nefpw==",
"MIICPTCCAaCgAwIBAgIJAOuKKbelzgyxMAoGCCqGSM49BAMEMDgxNjA0BgNVBAMMLUdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgUm9vdCBDQTAeFw0yMTA4MjQyMTM0MDRaFw0zMTA4MjIyMTM0MDRaMDgxNjA0BgNVBAMMLUdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgUm9vdCBDQTCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAIQ94p9+W9Iszm7Nizv/NHnLKGM624IEdsJUs9MrTjCBNUsuRJNa+F99tTjTqP5u1ocbGVxSrfRxlc8Fv1fGROi5AB2c4iA+fOT55iX1TQbguCsv0YobG2YOCwHPIwcFfUm4bxTLnLjky5i7wYWPMlwj+JFmzuUkaPxFY8pdZnyMxoaSo1AwTjAMBgNVHRMEBTADAQH/MB0GA1UdDgQWBBQGyt9tNed1A0VUN9E51hAqyC1/CjAfBgNVHSMEGDAWgBQGyt9tNed1A0VUN9E51hAqyC1/CjAKBggqhkjOPQQDBAOBigAwgYYCQVSvLDHcrHXqLntgB4rXgMr7lgZ/wVBqYlzhrD8SXNNvGM8lu9KV3sWHV7n3eyyqnOR/etyb52zoKe6NBkBG+AsbAkFaxXyuwweeZHAJ379aIlKJJ2EySEOY9Bo6j+6DYd69V+lFQtNQithatUS06xLXgcXD8TF6ZcoALiP3oOwF17xHDA=="
],
x: "wCeT9rdLYTpOK52OK0-oRbwDxbljJdNiDuxPsPt_1go",
y: "IgFPi1OrHtJWJGwPMvlueeHmULUKEpScgpQtoHNjX-Q" },
]
},
{
id: "ca.bc",
display: "British Columbia",
iss: "https://smarthealthcard.phsa.ca/v1/issuer",
keys: [
{ kid: "XCqxdhhS7SWlPqihaUXovM_FjU65WeoBFGc_ppent0Q",
@ -61,6 +76,7 @@ const issuers = [
},
{
id: "ca.sk",
display: "Saskatchewan",
iss: "https://skphr.prd.telushealthspace.com",
keys: [
{ kid: "xOqUO82bEz8APn_5wohZZvSK4Ui6pqWdSAv5BEhkes0",
@ -71,6 +87,7 @@ const issuers = [
},
{
id: "ca.ab",
display: "Alberta",
iss: "https://covidrecords.alberta.ca/smarthealth/issuer",
keys: [
{ kid: "JoO-sJHpheZboXdsUK4NtfulfvpiN1GlTdNnXN3XAnM",
@ -78,9 +95,105 @@ const issuers = [
x: "GsriV0gunQpl2X9KgrDZ4EDCtIdfOmdzhdlosWrMqKk",
y: "S99mZMCcJRsn662RaAmk_elvGiUs8IvSA7qBh04kaw0" },
]
},
{
id: "ca.ns",
display: "Nova Scotia",
iss: "https://pvc.novascotia.ca/issuer",
keys: [
{ kid: "UJrT9jU8vOCUl4xsI1RZjOPP8hFUv7n9mhVtolqH9qw",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "kIaIeOhhxpiN13sDs6RKVzCpvxxObI9adKF5YEmKngM",
y: "AZPQ7CHd3UHp0i4a4ua1FhIq8SJ__BuHgDESuK3A_zQ" },
]
},
{
id: "ca.on",
display: "Ontario",
iss: "https://prd.pkey.dhdp.ontariohealth.ca",
keys: [
{ kid: "Nlgwb6GUrU_f0agdYKc77sXM9U8en1gBu94plufPUj8",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "ibapbMkHMlkR3D-AU0VTFDsiidQ49oD9Ha7VY8Gao3s",
y: "arXU5frZGOvTZpvg045rHC7y0fqVOS3dKqJbUYhW5gw" },
]
},
{
id: "us.hi",
display: "Hawaii, USA",
iss: "https://travel.hawaii.gov",
keys: [
{ kid: "Qxzp3u4Z6iafzbz-6oNnzobPG8HUr0Jry38M3nuV5A8",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "sxIW-vGe4g7LXU0ZpMOiMmgMznaC_8qj6HW-2JhCTkI",
y: "Ytmnz6q7qn9GhnsAB3GP3MFlnk9kTW3wKk7RAue9j8U" },
]
},
{
id: "us.va",
display: "Virginia, USA",
iss: "https://apps.vdh.virginia.gov/credentials",
keys: [
{ kid: "sy5Q85VbiH4jNee-IpFkQvMxlVAhZ_poLMPLHiDF_8I",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "UDYtkThsYIdMuzC9AJi0CDNwwmSGt8Z75BBl9DbLXn0",
y: "xWNNHxwz0RtTgTlBom3X8xFP6U5e92KYGZIBI2SYImA" },
]
},
{
id: "ca.mb",
display: "Manitoba",
iss: "https://immunizationcard.manitoba.ca/api/national",
keys: [
{ kid: "YnYeVk1pCtYvnmOytVTq09igCGdu_SyJM2Wn29AV7AQ",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "E2mScyP_Iwm0gn1nAYldT0MbWFUeapIsuh9ebqCJgkQ",
y: "AePVDo-_XxQDJ_25BW4txoLPzuu7CQ65C2oLJIN4DxI" },
]
},
{
id: "ca.nt",
display: "Northwest Territories",
iss: "https://www.hss.gov.nt.ca/covax",
keys: [
{ kid: "8C-9TNgyGuOqc-3FXyNRq6m5U9S1wyhCS1TvpgjzkoU",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "C-9Lltax_iU6iYdK8DdCZzv4cQN6SFVUG7ACaCT_MKM",
y: "_qaENBMJz6iLf1qyYMx2_D6fXxbbNoHbLcfdPF9rUI0" },
]
},
{
id: "us.nj",
display: "New Jersey, USA",
iss: "https://docket.care/nj",
keys: [
{ kid: "HvlLNClY2JAEhIhsZZ_CfRaxF5jdooWgaKAbLajhv2I",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "FssCyCxGTEuKiKqo-MwLDQlxz1vdKll4YFMkQaXVOkY",
y: "A3nNMWC8IEQsZqH8Mp83qVLTA_X9eYwzr46o4-3YyRE" },
]
},
{
id: "us.ut",
display: "Utah, USA",
iss: "https://docket.care/ut",
keys: [
{ kid: "sBHR4URZTz8cq2kIV_JhTXwicbqp1tHtodItRSx0O0Q",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "uyzHUWf8EVXtlFW9nssxa1Z002rpc-GUw-YrZOZtmqo",
y: "oFofHWIlPqfqCCU9R3fJOaUoWdzVzTcSNgmtF0Qgb6w" },
]
}
];
module.exports = {
issuers,
};
// Check for updates above at https://files.ontario.ca/apps/verify/verifyRulesetON.json
export function getVerifiedIssuer(requestIssuer) {
for (let curIssuer of issuers) {
if (curIssuer.iss === requestIssuer) {
return curIssuer;
}
}
return null;
}

View File

@ -1,11 +1,9 @@
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';
import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common';
import {QrCode,PassPhotoCommon} from './passphoto-common';
import {COLORS} from "./colors";
const crypto = require('crypto')
@ -48,7 +46,7 @@ export class PassData {
const apiBaseUrl = (await configResponse.json()).apiBaseUrl
// console.log(`${apiBaseUrl}/sign`);
// console.log(JSON.stringify(signData));
console.log(JSON.stringify(signData));
const response = await fetch(`${apiBaseUrl}/sign`, {
method: 'POST',
@ -77,6 +75,10 @@ export class PassData {
const pass: PassData = new PassData(results.payload, results.qrCode);
if (!pass.expirationDate) {
delete pass['expirationDate'];
}
// Create new zip
const zip = [] as { path: string; data: Buffer | string }[];
@ -116,10 +118,12 @@ export class PassData {
// Create pass hash
const passHash = PassData.getBufferHash(Buffer.from(passJson));
const useBlackVersion = (payload.backgroundColor == COLORS.WHITE);
// Sign hash with server
const manifestSignature = await PassData.signWithRemote({
PassJsonHash: passHash,
useBlackVersion: false,
useBlackVersion: useBlackVersion,
});
// Add signature to zip
@ -139,7 +143,16 @@ export class PassData {
this.barcodes = [qrCode];
this.barcode = qrCode;
this.generic = payload.generic;
this.sharingProhibited = true;
this.expirationDate = payload.expirationDate;
// Update our pass name if this is an SHC pass
if (payload.rawData.length > 0) {
// const newPassTitle = `${Constants.NAME}, ${payload.shcReceipt.cardOrigin}`;
const newPassTitle = `${Constants.NAME}`; // hot patch for production for now... string too long to fit in pass
this.logoText = newPassTitle;
this.organizationName = newPassTitle;
this.description = newPassTitle;
}
}
}

View File

@ -1,10 +1,6 @@
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';
import {Payload, PayloadBody, SHCReceipt} from "./payload";
export enum QrFormat {
PKBarcodeFormatQR = 'PKBarcodeFormatQR',
@ -28,72 +24,115 @@ export interface PackageResult {
qrCode: QrCode;
}
var _configData: any;
async function getConfigData(): Promise<any> {
if (!_configData) {
// Only call this once
const configResponse = await fetch('/api/config');
_configData = await configResponse.json();
}
return _configData;
}
export async function registerPass(registrationPayload: any) : Promise<boolean> {
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(registrationPayload) // body data type must match "Content-Type" header
}
const configData = await getConfigData();
// console.log('registering ' + JSON.stringify(registrationPayload, null, 2));
const registrationHost = configData.registrationHost;
const functionSuffix = configData.functionSuffix?? '';
const registerUrl = `${registrationHost}/register${functionSuffix}`;
// console.log(registerUrl);
try {
const response = await fetch(registerUrl, requestOptions);
const responseJson = await response.json();
// console.log(JSON.stringify(responseJson,null,2));
const wasSuccess = (responseJson["result"] === 'OK');
if (!wasSuccess) {
console.error(responseJson);
}
return Promise.resolve(wasSuccess);
} catch (e) {
console.error(e);
Sentry.captureException(e);
// Registration was unsuccessful
return false;
}
}
export function generateSHCRegisterPayload(shcReceipt: SHCReceipt) {
const retPayload = {};
retPayload['cardOrigin'] = shcReceipt.cardOrigin;
retPayload['issuer'] = shcReceipt.issuer;
for (let i = 0; i < shcReceipt.vaccinations.length; i++) {
retPayload[`vaccination_${i}`] = shcReceipt.vaccinations[i];
}
return retPayload;
}
export class PassPhotoCommon {
static async preparePayload(payloadBody: PayloadBody, numDose: number) : Promise<PackageResult> {
static async preparePayload(payloadBody: PayloadBody, numDose: number = 0) : Promise<PackageResult> {
console.log('preparePayload');
// console.log(JSON.stringify(payloadBody, null, 2), numDose);
const configData = await getConfigData();
const payload: Payload = new Payload(payloadBody, numDose);
payload.serialNumber = uuid4();
let qrCodeMessage;
let qrCodeMessage = '';
let registrationPayload: any;
if (payloadBody.rawData.startsWith('shc:/')) {
qrCodeMessage = payloadBody.rawData;
// Register an SHC pass by adding in our pertinent data fields. We only do this so we
// can detect changes from providers and react quickly - we don't need these for any
// validation since SHCs are self-validating. This entire registration process could
// be turned off for SHCs and there would be no harm to the card creation process
registrationPayload = generateSHCRegisterPayload(payloadBody.shcReceipt);
registrationPayload.serialNumber = payload.serialNumber; // serial number is needed as it's the firestore document id
} else {
// register record
// register an old-style ON pass
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(`Error while trying to register pass!`);
}
registrationPayload = Object.assign({}, payloadBody.receipts[numDose]);
delete registrationPayload.name;
delete registrationPayload.dateOfBirth;
registrationPayload["serialNumber"] = payload.serialNumber;
registrationPayload["type"] = 'applewallet';
const verifierHost = configData.verifierHost;
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;
qrCodeMessage = `${verifierHost}/verify?${encodedUri}`;
// console.log(qrCodeUrl);
}
const wasSuccess = await registerPass(registrationPayload);
if (!wasSuccess) {
return Promise.reject(`Error while trying to register pass!`);
}
// Create QR Code Object
const qrCode: QrCode = {
message: qrCodeMessage,

View File

@ -1,17 +1,25 @@
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) {};
constructor(public name: string, public vaccinationDate: string, public vaccineName: string, public dateOfBirth: string, public numDoses: number, public organization: string) {};
}
export interface HashTable<T> {
[key: string]: T;
}
enum TextAlignment {
right = 'PKTextAlignmentRight',
center = 'PKTextAlignmentCenter'
// QR CODE NEW FORMAT:
// * Origin jurisdiction
// * Person's name
// * NOTHING ELSE ON THE CARD TO ENCOURAGE SCANNING IT TO GET DATA (this is what QC does, and what BC mostly does; other jurisdictions add more data, but that encourages bad behaviour)
export class SHCReceipt {
constructor(public name: string, public dateOfBirth: string, public cardOrigin: string, public issuer: string, public vaccinations: SHCVaccinationRecord[]) {};
}
export class SHCVaccinationRecord {
constructor(public vaccineName: string, public vaccinationDate: string, public organization: string) {};
}
interface Field {
@ -30,14 +38,15 @@ export interface PassDictionary {
}
export interface PayloadBody {
// color: COLORS;
rawData: string;
receipts: HashTable<Receipt>;
shcReceipt: SHCReceipt;
}
export class Payload {
receipts: HashTable<Receipt>;
shcReceipt: SHCReceipt;
rawData: string;
backgroundColor: string;
labelColor: string;
@ -48,44 +57,41 @@ export class Payload {
generic: PassDictionary;
expirationDate: string;
constructor(body: PayloadBody, numDose: number) {
constructor(body: PayloadBody, numDose: number = 0) {
let generic: PassDictionary = {
this.receipts = body.receipts;
this.shcReceipt = body.shcReceipt;
this.rawData = body.rawData;
this.generic = {
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;
}
}
if (body.rawData.length > 0) {
processSHCReceipt(body.shcReceipt, this.generic);
this.backgroundColor = COLORS.WHITE;
this.labelColor = COLORS.BLACK;
this.foregroundColor = COLORS.BLACK;
this.img1x = Constants.img1xBlack;
this.img2x = Constants.img2xBlack;
} else {
fullyVaccinated = processReceipt(body.receipts[numDose], generic);
const fullyVaccinated = processReceipt(body.receipts[numDose], this.generic);
if (fullyVaccinated) {
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.receipts = body.receipts;
this.rawData = body.rawData;
this.generic = generic;
if (body.rawData.length == 0) { // Ontario special handling
// These are the non-SHC ON receipts, which expire Oct 22nd
this.expirationDate = '2021-10-22T23:59:59-04:00';
generic.auxiliaryFields.push({
this.generic.auxiliaryFields.push({
key: "expiry",
label: "QR code expiry",
value: '2021-10-22'
@ -96,85 +102,124 @@ export class Payload {
function processReceipt(receipt: Receipt, generic: PassDictionary) : boolean {
console.log(`processing receipt #${receipt.numDoses}`);
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();
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('PFIZER'))
vaccineNameProper = 'Pfizer (Comirnaty)'
if (vaccineName.includes('MODERNA'))
vaccineNameProper = 'Moderna (SpikeVax)'
if (vaccineName.includes('MODERNA'))
vaccineNameProper = 'Moderna (SpikeVax)'
if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD'))
vaccineNameProper = 'AstraZeneca (Vaxzevria)'
if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD'))
vaccineNameProper = 'AstraZeneca (Vaxzevria)'
let doseVaccine = "#" + String(receipt.numDoses) + ": " + vaccineNameProper;
let fullyVaccinated = false;
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;
if (receipt.numDoses > 1 ||
vaccineName.toLowerCase().includes('janssen') ||
vaccineName.toLowerCase().includes('johnson') ||
vaccineName.toLowerCase().includes('j&j')) {
fullyVaccinated = true;
}
if (generic.primaryFields.length == 0) {
generic.primaryFields.push(
{
key: "vaccine",
label: "Vaccine",
value: doseVaccine
}
)
}
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: receipt.organization
},
{
key: "dov",
label: "Vacc. Date",
value: receipt.vaccinationDate,
}
);
if (generic.primaryFields.length == 0) {
generic.primaryFields.push(
{
key: "vaccine",
label: "Vaccine",
value: doseVaccine
}
)
}
if (generic.auxiliaryFields.length == 0) {
generic.auxiliaryFields.push(
{
key: "name",
label: "Name",
value: name
},
{
key: "dob",
label: "Date of Birth",
value: dateOfBirth
});
}
let fieldToPush = generic.secondaryFields;
if (fieldToPush.length > 0) {
return fullyVaccinated;
}
function processSHCReceipt(receipt: SHCReceipt, generic: PassDictionary) {
console.log(`processing receipt for origin ${receipt.cardOrigin}`);
if (generic.primaryFields.length == 0) {
generic.primaryFields.push(
{
key: "name",
label: "",
value: `${receipt.name} (${receipt.dateOfBirth})`
}
);
}
let fieldToPush;
for (let i = 0; i < receipt.vaccinations.length; i++) {
if (i <= 1)
fieldToPush = generic.secondaryFields;
else if (i <= 3)
fieldToPush = generic.auxiliaryFields;
else if (i <= 5)
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: receipt.organization
},
{
key: "dov",
label: "Vacc. Date",
value: receipt.vaccinationDate,
key: 'vaccine' + i,
label: receipt.vaccinations[i].vaccineName,
value: receipt.vaccinations[i].vaccinationDate
}
);
)
if (generic.auxiliaryFields.length == 0) {
generic.auxiliaryFields.push(
{
key: "name",
label: "Name",
value: name
},
{
key: "dob",
label: "Date of Birth",
value: dateOfBirth
});
}
return fullyVaccinated;
}
}

View File

@ -1,11 +1,8 @@
import {Constants} from "./constants";
import {Payload, PayloadBody} from "./payload";
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';
import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common';
import { EncodeHintType } from "@zxing/library";
import {PayloadBody} from "./payload";
import { toBlob } from 'html-to-image';
import {QrCode,PassPhotoCommon} from './passphoto-common';
import { Encoder, QRByte, QRNumeric, ErrorCorrectionLevel } from '@nuintun/qrcode';
export class Photo {
@ -28,13 +25,7 @@ export class Photo {
const payload = results.payload;
const qrCode = results.qrCode;
let receipt;
if (results.payload.rawData.length == 0) {
receipt = results.payload.receipts[numDose];
} else {
receipt = results.payload.receipts[numDose];
}
let receipt = results.payload.receipts[numDose];
const body = document.getElementById('pass-image');
body.hidden = false;
@ -75,25 +66,72 @@ export class Photo {
}
}
const codeWriter = new BrowserQRCodeSvgWriter();
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 qrcode = new Encoder();
qrcode.setEncodingHint(true);
qrcode.setErrorCorrectionLevel(ErrorCorrectionLevel.L);
const blobPromise = toBlob(body);
return blobPromise;
if (qrCode.message.includes('shc:/')) {
// Write an SHC code in 2 chunks otherwise it won't render right
qrcode.write(new QRByte('shc:/'));
qrcode.write(new QRNumeric(qrCode.message.substring(5)));
} else {
// If this isn't an SHC code, just write it out as a string
qrcode.write(qrCode.message);
}
qrcode.make();
const qrImage = new Image(220, 220);
qrImage.src = qrcode.toDataURL(2, 15);
document.getElementById('qrcode').appendChild(qrImage);
return toBlob(body);
} catch (e) {
return Promise.reject(e);
}
}
private constructor(payload: Payload, qrCode: QrCode) {
static async generateSHCPass(payloadBody: PayloadBody): Promise<Blob> {
// Create Payload
try {
console.log('generateSHCPass');
const results = await PassPhotoCommon.preparePayload(payloadBody);
const qrCode = results.qrCode;
const body = document.getElementById('shc-pass-image');
body.hidden = false;
document.getElementById('shc-card-name').innerText = results.payload.shcReceipt.name;
document.getElementById('shc-card-origin').innerText = results.payload.shcReceipt.cardOrigin;
const qrcode = new Encoder();
qrcode.setEncodingHint(true);
qrcode.setErrorCorrectionLevel(ErrorCorrectionLevel.L);
if (qrCode.message.includes('shc:/')) {
// Write an SHC code in 2 chunks otherwise it won't render right
qrcode.write(new QRByte('shc:/'));
qrcode.write(new QRNumeric(qrCode.message.substring(5)));
} else {
// If this isn't an SHC code, just write it out as a string
qrcode.write(qrCode.message);
}
qrcode.make();
const qrImage = new Image(220, 220);
qrImage.src = qrcode.toDataURL(2, 15);
document.getElementById('shc-qrcode').appendChild(qrImage);
return toBlob(body);
} catch (e) {
return Promise.reject(e);
}
}
private constructor() {
// make a png in buffer using the payload
}
}

View File

@ -1,14 +1,11 @@
import {PayloadBody, Receipt, HashTable} from "./payload";
import {PayloadBody} from "./payload";
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 {QRCode} from "jsqr";
import * as Sentry from '@sentry/react';
import * as Decode from './decode';
import {getScannedJWS, verifyJWS, decodeJWS} from "./shc";
import { PNG } from 'pngjs/browser';
import { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
import { PDFPageProxy } from 'pdfjs-dist/types/src/display/api';
// import {PNG} from 'pngjs'
// import {decodeData} from "./decode";
@ -19,242 +16,41 @@ PdfJS.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pd
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
let imageData: ImageData[];
switch (file.type) {
case 'application/pdf':
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
imageData = await getImageDataFromPdf(fileBuffer);
break;
case 'image/png':
case 'image/jpeg':
case 'image/webp':
case 'image/gif':
console.log(`image ${file.type}`);
imageData = [await getImageDataFromImage(file)];
break;
default:
throw Error('invalidFileType')
}
return {
receipts: receipts,
rawData: rawData
}
// Send back our SHC payload now
return processSHC(imageData);
}
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 docMetadata = await pdfDocument.getMetadata();
const numItems = content.items.length;
if (numItems == 0) { // QC has no text items
console.log('detected QC');
Sentry.setContext("QC-pdf-metadata", {
numPages: pdfDocument.numPages,
docMetadata: JSON.stringify(docMetadata)
});
Sentry.captureMessage('QC PDF Metadata for structure analysis');
console.log(`PDF details: numPages=${pdfDocument.numPages}`);
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');
Sentry.setContext("BC-pdf-metadata", {
numPages: pdfDocument.numPages,
docMetadata: JSON.stringify(docMetadata)
});
Sentry.captureMessage('BC PDF Metadata for structure analysis');
return Promise.resolve('SHC');
} else if (value.includes('Proof of Vaccination') || value.includes('User Information')) {
// possible missed QC PDF?
Sentry.setContext("Possible-missed-QC-pdf-metadata", {
numPages: pdfDocument.numPages,
docMetadata: JSON.stringify(docMetadata),
matchedValue: value
});
Sentry.captureMessage('Possible missed QC PDF Metadata for structure analysis');
}
}
}
return Promise.resolve('ON');
}
async function loadPDF(fileBuffer : ArrayBuffer): Promise<HashTable<Receipt>> {
try {
const certs = getCertificatesInfoFromPDF(fileBuffer);
const result = certs[0];
const refcert = '-----BEGIN CERTIFICATE-----\r\n'+
'MIIHNTCCBh2gAwIBAgIQanhJa+fBXT8GQ8QG/t9p4TANBgkqhkiG9w0BAQsFADCB\r\n'+
'ujELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsT\r\n'+
'H1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAy\r\n'+
'MDE0IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEuMCwG\r\n'+
'A1UEAxMlRW50cnVzdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEwxTTAeFw0y\r\n'+
'MTA1MjAxMzQxNTBaFw0yMjA2MTkxMzQxNDlaMIHTMQswCQYDVQQGEwJDQTEQMA4G\r\n'+
'A1UECBMHT250YXJpbzEQMA4GA1UEBxMHVG9yb250bzETMBEGCysGAQQBgjc8AgED\r\n'+
'EwJDQTEYMBYGCysGAQQBgjc8AgECEwdPbnRhcmlvMRcwFQYDVQQKEw5PbnRhcmlv\r\n'+
'IEhlYWx0aDEaMBgGA1UEDxMRR292ZXJubWVudCBFbnRpdHkxEzARBgNVBAUTCjE4\r\n'+
'LTA0LTIwMTkxJzAlBgNVBAMTHmNvdmlkMTlzaWduZXIub250YXJpb2hlYWx0aC5j\r\n'+
'YTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL2bD+Ng1RNYCNVVtEQ3\r\n'+
'zg8JKFvRWFFPIF/UTXGg3iArK1tKr1xtjx6OdFtwosHyo+3ksPRicc4KeuV6/QMF\r\n'+
'qiVJ5IOy9TSVImJsmONgFyEiak0dGYG5SeHiWwyaUvkniWd7U3wWEl4nOZuLAYu4\r\n'+
'8ZLot8p8Q/UaNvAoNsRDv6YDGjL2yGHaXxi3Bb6XTQTLcevuEQeM6g1LtKyisZfB\r\n'+
'Q8TKThBq99EojwHfXIhddxbPKLeXvWJgK1TcL17UFIwx6ig74s0LyYqEPm8Oa8qR\r\n'+
'+IesFUT9Liv7xhV+tU52wmNfDi4znmLvs5Cmh/vmcHKyhEbxhYqciWJocACth5ij\r\n'+
'E3kCAwEAAaOCAxowggMWMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFFoW3zt+jaHS\r\n'+
'pm1EV5hU4XD+mwO5MB8GA1UdIwQYMBaAFMP30LUqMK2vDZEhcDlU3byJcMc6MGgG\r\n'+
'CCsGAQUFBwEBBFwwWjAjBggrBgEFBQcwAYYXaHR0cDovL29jc3AuZW50cnVzdC5u\r\n'+
'ZXQwMwYIKwYBBQUHMAKGJ2h0dHA6Ly9haWEuZW50cnVzdC5uZXQvbDFtLWNoYWlu\r\n'+
'MjU2LmNlcjAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vY3JsLmVudHJ1c3QubmV0\r\n'+
'L2xldmVsMW0uY3JsMCkGA1UdEQQiMCCCHmNvdmlkMTlzaWduZXIub250YXJpb2hl\r\n'+
'YWx0aC5jYTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG\r\n'+
'AQUFBwMCMEsGA1UdIAREMEIwNwYKYIZIAYb6bAoBAjApMCcGCCsGAQUFBwIBFhto\r\n'+
'dHRwczovL3d3dy5lbnRydXN0Lm5ldC9ycGEwBwYFZ4EMAQEwggF+BgorBgEEAdZ5\r\n'+
'AgQCBIIBbgSCAWoBaAB3AFYUBpov18Ls0/XhvUSyPsdGdrm8mRFcwO+UmFXWidDd\r\n'+
'AAABeYoCz+MAAAQDAEgwRgIhAKGKAoZMzwkh/3sZXq6vtEYhoYHfZzsjh9jqZvfS\r\n'+
'xQVZAiEAmJu/ftbkNFBr8751Z9wA2dpI0Qt+LoeL1TJQ833Kdg4AdQDfpV6raIJP\r\n'+
'H2yt7rhfTj5a6s2iEqRqXo47EsAgRFwqcwAAAXmKAs/cAAAEAwBGMEQCICsD/Vj+\r\n'+
'ypZeHhesMyv/TkS5ftQjqyIaAFTL/02Gtem4AiBcWdPQspH3vfzZr4LO9z4u5jTg\r\n'+
'Psfm5PZr66tI7yASrAB2AEalVet1+pEgMLWiiWn0830RLEF0vv1JuIWr8vxw/m1H\r\n'+
'AAABeYoC0WkAAAQDAEcwRQIgTL5F11+7KhQ60jnODm9AkyvXRLY32Mj6tgudRAXO\r\n'+
'y7UCIQDd/dU+Ax1y15yiAA5xM+bWJ7T+Ztd99SD1lw/o8fEmOjANBgkqhkiG9w0B\r\n'+
'AQsFAAOCAQEAlpV3RoNvnhDgd2iFSF39wytf1R6/0u5FdL7eIkYNfnkqXu9Ux9cO\r\n'+
'/OeaGAFMSzaDPA8Xt9A0HqkEbh1pr7UmZVqBwDr4a7gczvt7+HFJRn//Q2fwhmaw\r\n'+
'vXTLLxcAPQF00G6ySsc9MUbsArh6AVhMf9tSXgNaTDj3X3UyYDfR+G8H9eVG/LPp\r\n'+
'34QV/8uvPUFXGj6MjdQysx6YG+K3mae0GEVpODEl4MiceEFZ7v4CPA6pFNadijRF\r\n'+
'6tdXky2psuo7VXfnE2WIlahKr56x+8R6To5pcWglKTywTqvCbnKRRVZhXXYo3Awd\r\n'+
'8h9+TbL3ACHDqA4fi5sAbZ7nMXp8RK4o5A==\r\n'+
'-----END CERTIFICATE-----';
const pdfCert = result.pemCertificate.trim();
const pdfOrg = result.issuedBy.organizationName;
const issuedpemCertificate = (pdfCert == refcert.trim());
//console.log(`pdf is signed by this cert ${result.pemCertificate.trim()}`);
//console.log(issuedpemCertificate);
//console.log(`PDF is signed by ${result.issuedBy.organizationName}, issued to ${result.issuedTo.commonName}`);
// const bypass = window.location.href.includes('grassroots2');
if (( issuedpemCertificate )) {
//console.log('getting receipt details inside PDF');
const receipt = await getPdfDetails(fileBuffer);
// console.log(JSON.stringify(receipt, null, 2));
return Promise.resolve(receipt);
} else {
// According to the Sentry docs, this can be up to 8KB in size
// https://develop.sentry.dev/sdk/data-handling/#variable-size
Sentry.setContext("certificate", {
pdfCert: pdfCert,
pdfOrg: pdfOrg,
});
Sentry.captureMessage('Certificate validation failed');
console.error('invalid certificate');
return Promise.reject(`invalid certificate + ${JSON.stringify(result)}`);
}
} catch (e) {
if (e.message.includes('Failed to locate ByteRange') ||
e.message.includes(' ASN.1') ||
e.message.includes('Failed byte range verification') ||
e.message.includes('parse DER') ||
e.message.includes('8, 16, 24, or 32 bits')) {
e.message = 'Sorry. Selected PDF file is not digitally signed. Please download official copy from Step 1 and retry. Thanks.'
} else {
if (!e.message.includes('cancelled')) {
console.error(e);
Sentry.captureException(e);
}
}
return Promise.reject(e);
}
}
async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt>> {
try {
const typedArray = new Uint8Array(fileBuffer);
let loadingTask = PdfJS.getDocument(typedArray);
const pdfDocument = await loadingTask.promise;
// Load all dose numbers
const { numPages } = pdfDocument;
const receiptObj = {};
for (let pages = 1; pages <= numPages; pages++){
const pdfPage = await pdfDocument.getPage(pages);
const content = await pdfPage.getTextContent();
const numItems = content.items.length;
let name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization;
for (let i = 0; i < numItems; i++) {
let item = content.items[i] as TextItem;
const value = item.str;
if (value.includes('Name / Nom'))
name = (content.items[i+1] as TextItem).str;
if (value.includes('Date:')) {
vaccinationDate = (content.items[i+1] as TextItem).str;
vaccinationDate = vaccinationDate.split(',')[0];
}
if (value.includes('Product name')) {
vaccineName = (content.items[i+1] as TextItem).str;
}
if (value.includes('Date of birth'))
dateOfBirth = (content.items[i+1] as TextItem).str;
if (value.includes('Authorized organization'))
organization = (content.items[i+1] as TextItem).str;
if (value.includes('You have received'))
numDoses = Number(value.split(' ')[3]);
}
receiptObj[numDoses] = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization);
//console.log(receiptObj[numDoses]);
}
return Promise.resolve(receiptObj);
} catch (e) {
if (e && e.message && !e.message.includes('cancelled')) {
Sentry.captureException(e);
}
return Promise.reject(e);
}
}
async function getImageDataFromPdf(pdfPage: PDFPageProxy): Promise<ImageData> {
async function getImageDataFromPdfPage(pdfPage: PDFPageProxy): Promise<ImageData> {
const pdfScale = 2;
const canvas = <HTMLCanvasElement>document.getElementById('canvas');
const canvasContext = canvas.getContext('2d');
const viewport = pdfPage.getViewport({scale: pdfScale})
const viewport = pdfPage.getViewport({scale: pdfScale});
// Set correct canvas width / height
canvas.width = viewport.width
canvas.height = viewport.height
canvas.width = viewport.width;
canvas.height = viewport.height;
// render PDF
const renderTask = pdfPage.render({
@ -265,54 +61,121 @@ async function getImageDataFromPdf(pdfPage: PDFPageProxy): Promise<ImageData> {
await renderTask.promise;
// Return PDF Image Data
return canvasContext.getImageData(0, 0, canvas.width, canvas.height)
return canvasContext.getImageData(0, 0, canvas.width, canvas.height);
}
async function processSHC(fileBuffer : ArrayBuffer) : Promise<any> {
function getImageDataFromImage(file: File): Promise<ImageData> {
return new Promise((resolve, reject) => {
const canvas = <HTMLCanvasElement>document.getElementById('canvas');
const canvasContext = canvas.getContext('2d');
// create Image object
const img = new Image();
img.onload = () => {
// constrain image to 2 Mpx
const maxPx = 2000000;
let width: number;
let height: number;
if (img.naturalWidth * img.naturalHeight > maxPx) {
const ratio = img.naturalHeight / img.naturalWidth;
width = Math.sqrt(maxPx / ratio);
height = Math.floor(width * ratio);
width = Math.floor(width);
} else {
width = img.naturalWidth;
height = img.naturalHeight;
}
// Set correct canvas width / height
canvas.width = width;
canvas.height = height;
// draw image into canvas
canvasContext.clearRect(0, 0, width, height);
canvasContext.drawImage(img, 0, 0, width, height);
// Obtain image data
resolve(canvasContext.getImageData(0, 0, width, height));
};
img.onerror = (e) => {
reject(e);
};
// start loading image from file
img.src = URL.createObjectURL(file);
});
}
async function getImageDataFromPdf(fileBuffer: ArrayBuffer): Promise<ImageData[]> {
const typedArray = new Uint8Array(fileBuffer);
const loadingTask = PdfJS.getDocument(typedArray);
const pdfDocument = await loadingTask.promise;
console.log('SHC PDF loaded');
const retArray = [];
// Load and return every page in our PDF
for (let i = 1; i <= pdfDocument.numPages; i++) {
console.log(`Processing PDF page ${i}`);
const pdfPage = await pdfDocument.getPage(i);
const imageData = await getImageDataFromPdfPage(pdfPage);
retArray.push(imageData);
}
return Promise.resolve(retArray);
}
async function processSHC(allImageData : ImageData[]) : Promise<PayloadBody> {
console.log('processSHC');
try {
const typedArray = new Uint8Array(fileBuffer);
const loadingTask = PdfJS.getDocument(typedArray);
if (allImageData) {
for (let i = 0; i < allImageData.length; i++) {
const pdfDocument = await loadingTask.promise;
console.log('SHC PDF loaded');
// Load all dose numbers
const pdfPage = await pdfDocument.getPage(1);
console.log('SHC PDF Page 1 loaded');
const imageData = await getImageDataFromPdf(pdfPage);
console.log('SHC PDF Page 1 image data loaded');
const code : QRCode = await Decode.getQRFromImage(imageData);
console.log('SHC code detected:');
console.log(code);
const imageData = allImageData[i];
const code : QRCode = await Decode.getQRFromImage(imageData);
//console.log(`SHC code result from page ${i}:`);
//console.log(code);
if (!code) {
return Promise.reject(new Error('No valid ON proof-of-vaccination digital signature found! Please make sure you download the PDF directly from covid19.ontariohealth.ca, Save as Files on your iPhone, and do NOT save/print it as a PDF!'));
}
if (code) {
try {
// We found a QR code of some kind - start analyzing now
const rawData = code.data;
const jws = getScannedJWS(rawData);
const decoded = await decodeJWS(jws);
const rawData = code.data;
const jws = getScannedJWS(rawData);
//console.log(decoded);
const decoded = await decodeJWS(jws);
// console.log(decoded);
const verified = verifyJWS(jws, decoded.iss);
const verified = verifyJWS(jws, decoded.iss);
if (verified) {
const receipts = Decode.decodedStringToReceipt(decoded);
//console.log(receipts);
return Promise.resolve({receipts: receipts, rawData: rawData});
} else {
return Promise.reject(`Issuer ${decoded.iss} cannot be verified.`);
if (verified) {
const shcReceipt = Decode.decodedStringToReceipt(decoded);
//console.log(shcReceipt);
return Promise.resolve({receipts: null, shcReceipt, rawData});
} else {
// If we got here, we found an SHC which was not verifiable. Consider it fatal and stop processing.
return Promise.reject(`Issuer ${decoded.iss} cannot be verified.`);
}
} catch (e) {
// We blew up during processing - log it and move on to the next page
console.log(e);
}
}
}
}
// If we got here, no SHC was detected and successfully decoded.
// The vast majority of our processed things right now are ON proof-of-vaccination PDFs, not SHC docs, so assume anything
// that blew up here was a malformed ON proof-of-vaccination and create an appropriate error message for that
return Promise.reject(new Error('No SHC QR code found! Please try taking another picture of the SHC you wish to import'));
} catch (e) {
Sentry.captureException(e);
return Promise.reject(e);
}
}
}

View File

@ -3,9 +3,8 @@ import { Integrations } from '@sentry/tracing';
export const initSentry = () => {
SentryModule.init({
release: 'grassroots_covidpass@1.10.0', // App version. Needs to be manually updated as we go unless we make the build smarter
dsn: "https://e0c718835ae148e2b3bbdd7037e17462@o997319.ingest.sentry.io/6000731",
release: 'grassroots_covidpass@2.0.5', // 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(),
],

View File

@ -28,7 +28,7 @@ function getScannedJWS(shcString) {
function verifyJWS(jws, iss) {
const issuer = issuers.find(el => el.iss === iss);
if (!issuer) {
error = new Error("Unknown issuer " + iss);
error = new Error(`Unknown issuer ${iss}`);
error.customMessage = true;
return Promise.reject(error);
}

View File

@ -159,6 +159,13 @@
"@nodelib/fs.scandir" "2.1.5"
"fastq" "^1.6.0"
"@nuintun/qrcode@^3.0.1":
"integrity" "sha512-0dY347xYgJvmBP0I75UHeVEx7JeDczahhEYaS03uh9bhCqa3FycdtLPxWulQh/r3OsX5lpi+z3mEXfAaQtAPwQ=="
"resolved" "https://registry.npmjs.org/@nuintun/qrcode/-/qrcode-3.0.1.tgz"
"version" "3.0.1"
dependencies:
"tslib" "^2.0.1"
"@sentry/browser@^6.13.2", "@sentry/browser@6.13.2":
"integrity" "sha512-bkFXK4vAp2UX/4rQY0pj2Iky55Gnwr79CtveoeeMshoLy5iDgZ8gvnLNAz7om4B9OQk1u7NzLEa4IXAmHTUyag=="
"resolved" "https://registry.npmjs.org/@sentry/browser/-/browser-6.13.2.tgz"
@ -3427,6 +3434,11 @@
"resolved" "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
"version" "1.14.1"
"tslib@^2.0.1":
"integrity" "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
"resolved" "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz"
"version" "2.3.1"
"tty-browserify@0.0.0":
"integrity" "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
"resolved" "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz"