From c632d2c2e897f1d2c8ed2519ed262269b99a7954 Mon Sep 17 00:00:00 2001 From: Marvin Sextro Date: Fri, 9 Jul 2021 03:05:57 +0200 Subject: [PATCH 1/4] Implement test and recovery certificates --- docker-compose.yml | 62 -------- next-i18next.config.js | 2 +- public/locales/de-AT | 1 + public/locales/de-CH | 1 + public/locales/de-LI | 1 + public/locales/de-LU | 1 + public/locales/de/errors.yml | 5 +- public/locales/en/errors.yml | 5 +- src/pass.ts | 89 +---------- src/payload.ts | 297 ++++++++++++++++++++++++++++++----- src/value_sets.ts | 41 ++++- 11 files changed, 307 insertions(+), 198 deletions(-) delete mode 100644 docker-compose.yml create mode 120000 public/locales/de-AT create mode 120000 public/locales/de-CH create mode 120000 public/locales/de-LI create mode 120000 public/locales/de-LU diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index e3df3fe..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,62 +0,0 @@ -version: "3.3" - -services: - - traefik: - image: "traefik:v2.4" - command: - #- "--log.level=DEBUG" - - "--api.insecure=false" - - "--providers.docker=true" - - "--providers.docker.exposedbydefault=false" - - "--entrypoints.web.address=:80" - - "--entrypoints.websecure.address=:443" - - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" - #- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - - "--certificatesresolvers.myresolver.acme.email=marvin.sextro@gmail.com" - - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - - "--entrypoints.web.http.redirections.entryPoint.to=websecure" - - "--entrypoints.web.http.redirections.entryPoint.scheme=https" - - "--entrypoints.web.http.redirections.entrypoint.permanent=true" - ports: - - "80:80" - - "443:443" - - "8080:8080" - volumes: - - "./letsencrypt:/letsencrypt" - - "/var/run/docker.sock:/var/run/docker.sock:ro" - - covidpass-api: - image: "marvinsxtr/covidpass-api:latest" - restart: "unless-stopped" - environment: - - NODE_ENV=production - ports: - - "8000:8000" - labels: - - "traefik.enable=true" - - "traefik.http.routers.covidpass-api.rule=Host(`api.covidpass.marvinsextro.de`)" - - "traefik.http.routers.covidpass-api.entrypoints=websecure" - - "traefik.http.routers.covidpass-api.tls.certresolver=myresolver" - secrets: - - env - - covidpass: - image: "marvinsxtr/covidpass:latest" - restart: "unless-stopped" - environment: - - NODE_ENV=production - ports: - - "3000:3000" - labels: - - "traefik.enable=true" - - "traefik.http.routers.covidpass.rule=Host(`covidpass.marvinsextro.de`)" - - "traefik.http.routers.covidpass.entrypoints=websecure" - - "traefik.http.routers.covidpass.tls.certresolver=myresolver" - depends_on: - - covidpass-api - -secrets: - env: - file: ./.env diff --git a/next-i18next.config.js b/next-i18next.config.js index a902ed3..7684a83 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -1,7 +1,7 @@ module.exports = { i18n: { defaultLocale: 'en', - locales: ['en', 'de', 'de-DE'], + locales: ['en', 'de', 'de-DE', 'de-AT', 'de-LI', 'de-LU', 'de-CH'], localeExtension: 'yml', }, }; \ No newline at end of file diff --git a/public/locales/de-AT b/public/locales/de-AT new file mode 120000 index 0000000..c42e816 --- /dev/null +++ b/public/locales/de-AT @@ -0,0 +1 @@ +de \ No newline at end of file diff --git a/public/locales/de-CH b/public/locales/de-CH new file mode 120000 index 0000000..c42e816 --- /dev/null +++ b/public/locales/de-CH @@ -0,0 +1 @@ +de \ No newline at end of file diff --git a/public/locales/de-LI b/public/locales/de-LI new file mode 120000 index 0000000..c42e816 --- /dev/null +++ b/public/locales/de-LI @@ -0,0 +1 @@ +de \ No newline at end of file diff --git a/public/locales/de-LU b/public/locales/de-LU new file mode 120000 index 0000000..c42e816 --- /dev/null +++ b/public/locales/de-LU @@ -0,0 +1 @@ +de \ No newline at end of file diff --git a/public/locales/de/errors.yml b/public/locales/de/errors.yml index c39b54f..671a679 100644 --- a/public/locales/de/errors.yml +++ b/public/locales/de/errors.yml @@ -2,7 +2,7 @@ noFileOrQrCode: Bitte scanne einen QR-Code oder wähle eine Datei aus signatureFailed: Fehler beim Signieren der Karte auf dem Server decodingFailed: Dekodierung der QR-Code-Daten fehlgeschlagen invalidColor: Ungültige Farbe -vaccinationInfo: Impfinformationen konnten nicht gelesen werden +certificateData: Zertifikats-Daten konnten nicht gelesen werden nameMissing: Name konnte nicht gelesen werden dobMissing: Geburtsdatum konnte nicht gelesen werden invalidMedicalProduct: Ungültiges Medizinprodukt @@ -12,3 +12,6 @@ invalidFileType: Ungültiger Dateityp couldNotDecode: Dekodierung aus QR-Code fehlgeschlagen couldNotFindQrCode: QR-Code konnte in der ausgewählten Datei nicht gefunden werden invalidQrCode: Ungültiger QR-Code +certificateType: Kein gültiger Zertifikatstyp gefunden +invalidTestResult: Ungültiges Testergebnis +invalidTestType: Ungültiger Testtyp \ No newline at end of file diff --git a/public/locales/en/errors.yml b/public/locales/en/errors.yml index b7659a1..828ec38 100644 --- a/public/locales/en/errors.yml +++ b/public/locales/en/errors.yml @@ -2,7 +2,7 @@ noFileOrQrCode: Please scan a QR Code, or select a file signatureFailed: Error while signing pass on server decodingFailed: Failed to decode QR code payload invalidColor: Invalid color -vaccinationInfo: Failed to read vaccination information +certificateData: Failed to read certificate data nameMissing: Failed to read name dobMissing: Failed to read date of birth invalidMedicalProduct: Invalid medical product @@ -12,3 +12,6 @@ invalidFileType: Invalid file type couldNotDecode: Could not decode QR code from file couldNotFindQrCode: Could not find QR Code in provided file invalidQrCode: Invalid QR code +certificateType: No valid certificate type found +invalidTestResult: Invalid test result +invalidTestType: Invalid test type \ No newline at end of file diff --git a/src/pass.ts b/src/pass.ts index 92e1463..3c56d2f 100644 --- a/src/pass.ts +++ b/src/pass.ts @@ -2,7 +2,7 @@ import {toBuffer as createZip} from 'do-not-zip'; import {v4 as uuid4} from 'uuid'; import {Constants} from "./constants"; -import {Payload, PayloadBody} from "./payload"; +import {Payload, PayloadBody, PassDictionary} from "./payload"; import {ValueSets} from "./value_sets"; const crypto = require('crypto') @@ -21,21 +21,6 @@ interface QrCode { messageEncoding: Encoding; } -interface Field { - key: string; - label: string; - value: string; - textAlignment?: string; -} - -interface PassStructureDictionary { - headerFields: Array; - primaryFields: Array; - secondaryFields: Array; - auxiliaryFields: Array; - backFields: Array; -} - interface SignData { PassJsonHash: string; useBlackVersion: boolean; @@ -56,7 +41,7 @@ export class PassData { serialNumber: string; barcodes: Array; barcode: QrCode; - generic: PassStructureDictionary; + generic: PassDictionary; // Generates a sha1 hash from a given buffer private static getBufferHash(buffer: Buffer | string): string { @@ -157,74 +142,6 @@ export class PassData { this.serialNumber = uuid4(); // Generate random UUID v4 this.barcodes = [qrCode]; this.barcode = qrCode; - this.generic = { - headerFields: [ - { - key: "type", - label: "Certificate Type", - value: payload.certificateType - } - ], - primaryFields: [ - { - key: "name", - label: "Name", - value: payload.name - } - ], - secondaryFields: [ - { - key: "dose", - label: "Dose", - value: payload.dose - }, - { - key: "dov", - label: "Date of Vaccination", - value: payload.dateOfVaccination, - textAlignment: "PKTextAlignmentRight" - } - ], - auxiliaryFields: [ - { - key: "vaccine", - label: "Vaccine", - value: payload.vaccineName - }, - { - key: "dob", - label: "Date of Birth", - value: payload.dateOfBirth, - textAlignment: "PKTextAlignmentRight" - } - ], - backFields: [ - { - key: "uvci", - label: "Unique Certificate Identifier (UVCI)", - value: payload.uvci - }, - { - key: "issuer", - label: "Certificate Issuer", - value: payload.certificateIssuer - }, - { - key: "country", - label: "Country of Vaccination", - value: payload.countryOfVaccination - }, - { - key: "manufacturer", - label: "Manufacturer", - value: payload.manufacturer - }, - { - key: "disclaimer", - label: "Disclaimer", - value: "This certificate is only valid in combination with the ID card of the certificate holder and expires one year + 14 days after the last dose. The validity of this certificate was not checked by CovidPass." - } - ] - }; + this.generic = payload.generic; } } diff --git a/src/payload.ts b/src/payload.ts index 7bdd0aa..e45d882 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -1,6 +1,31 @@ import {ValueSets} from "./value_sets"; import {Constants} from "./constants"; +enum CertificateType { + Vaccination = 'Vaccination', + Test = 'Test', + Recovery = 'Recovery', +} + +enum TextAlignment { + right = 'PKTextAlignmentRight', +} + +interface Field { + key: string; + label: string; + value: string; + textAlignment?: string; +} + +export interface PassDictionary { + headerFields: Array; + primaryFields: Array; + secondaryFields: Array; + auxiliaryFields: Array; + backFields: Array; +} + export interface PayloadBody { color: string; rawData: string; @@ -8,7 +33,7 @@ export interface PayloadBody { } export class Payload { - certificateType: string = 'Vaccination'; + certificateType: CertificateType; rawData: string; @@ -19,17 +44,7 @@ export class Payload { img2x: Buffer; dark: boolean; - name: string; - dose: string; - dateOfVaccination: string; - dateOfBirth: string; - uvci: string; - certificateIssuer: string; - medicalProductKey: string; - - countryOfVaccination: string; - vaccineName: string; - manufacturer: string; + generic: PassDictionary; constructor(body: PayloadBody, valueSets: ValueSets) { @@ -41,39 +56,99 @@ export class Payload { const dark = body.color != 'white' + const healthCertificate = body.decodedData['-260']; + const covidCertificate = healthCertificate['1']; // Version number subject to change - // Get Vaccine, Name and Date of Birth information - const vaccinationInformation = body.decodedData['-260']['1']['v'][0]; - const nameInformation = body.decodedData['-260']['1']['nam']; - const dateOfBirthInformation = body.decodedData['-260']['1']['dob']; - - if (vaccinationInformation == undefined) { - throw new Error('vaccinationInfo'); + if (covidCertificate == undefined) { + throw new Error('certificateData'); } + // Get name and date of birth information + const nameInformation = covidCertificate['nam']; + const dateOfBirthInformation = covidCertificate['dob']; + if (nameInformation == undefined) { throw new Error('nameMissing'); } - if (dateOfBirthInformation == undefined) { throw new Error('dobMissing'); } - // Get Medical, country and manufacturer information - const medialProductKey = vaccinationInformation['mp']; - const countryCode = vaccinationInformation['co']; - const manufacturerKey = vaccinationInformation['ma']; + const name = `${nameInformation['fn']}, ${nameInformation['gn']}`; + const dateOfBirth = dateOfBirthInformation; - if (!(medialProductKey in valueSets.medicalProducts)) { - throw new Error('invalidMedicalProduct'); + let properties: object; + + // Set certificate type and properties + if (covidCertificate['v'] !== undefined) { + this.certificateType = CertificateType.Vaccination; + properties = covidCertificate['v'][0]; } + if (covidCertificate['t'] !== undefined) { + this.certificateType = CertificateType.Test; + properties = covidCertificate['t'][0]; + } + if (covidCertificate['r'] !== undefined) { + this.certificateType = CertificateType.Recovery; + properties = covidCertificate['r'][0]; + } + if (this.certificateType == undefined) { + throw new Error('certificateType') + } + + // Get country code, identifier and issuer + const countryCode = properties['co']; + const uvci = properties['ci']; + const certificateIssuer = properties['is']; + if (!(countryCode in valueSets.countryCodes)) { - throw new Error('invalidCountryCode') - } - if (!(manufacturerKey in valueSets.manufacturers)) { - throw new Error('invalidManufacturer') + throw new Error('invalidCountryCode'); } + const country = valueSets.countryCodes[countryCode].display; + + const generic: PassDictionary = { + headerFields: [ + { + key: "type", + label: "Certificate Type", + value: this.certificateType + } + ], + primaryFields: [ + { + key: "name", + label: "Name", + value: name + } + ], + secondaryFields: [], + auxiliaryFields: [ + { + key: "dob", + label: "Date of Birth", + value: dateOfBirth, + textAlignment: TextAlignment.right + } + ], + backFields: [ + { + key: "uvci", + label: "Unique Certificate Identifier (UVCI)", + value: uvci + }, + { + key: "issuer", + label: "Certificate Issuer", + value: certificateIssuer + }, + { + key: "country", + label: "Country", + value: country + } + ] + } // Set Values this.rawData = body.rawData; @@ -85,16 +160,158 @@ export class Payload { this.img2x = dark ? Constants.img2xWhite : Constants.img2xBlack this.dark = dark; - this.name = `${nameInformation['fn']}, ${nameInformation['gn']}`; - this.dose = `${vaccinationInformation['dn']}/${vaccinationInformation['sd']}`; - this.dateOfVaccination = vaccinationInformation['dt']; - this.dateOfBirth = dateOfBirthInformation; - this.uvci = vaccinationInformation['ci']; - this.certificateIssuer = vaccinationInformation['is']; - - this.countryOfVaccination = valueSets.countryCodes[countryCode].display; - this.vaccineName = valueSets.medicalProducts[medialProductKey].display; - this.manufacturer = valueSets.manufacturers[manufacturerKey].display; + this.generic = Payload.fillPassData(this.certificateType, generic, properties, valueSets); } + static fillPassData(type: CertificateType, data: PassDictionary, properties: Object, valueSets: ValueSets): PassDictionary { + switch (type) { + case CertificateType.Vaccination: + const dose = `${properties['dn']}/${properties['sd']}`; + const dateOfVaccination = properties['dt']; + const medialProductKey = properties['mp']; + const manufacturerKey = properties['ma']; + + if (!(medialProductKey in valueSets.medicalProducts)) { + throw new Error('invalidMedicalProduct'); + } + if (!(manufacturerKey in valueSets.manufacturers)) { + throw new Error('invalidManufacturer') + } + + const vaccineName = valueSets.medicalProducts[medialProductKey].display; + const manufacturer = valueSets.manufacturers[manufacturerKey].display; + + data.secondaryFields.push(...[ + { + key: "dose", + label: "Dose", + value: dose + }, + { + key: "dov", + label: "Date of Vaccination", + value: dateOfVaccination, + textAlignment: TextAlignment.right + } + ]); + data.auxiliaryFields.splice(0, 0, { + key: "vaccine", + label: "Vaccine", + value: vaccineName + }); + data.backFields.push(...[ + { + key: "manufacturer", + label: "Manufacturer", + value: manufacturer + }, + { + key: "disclaimer", + label: "Disclaimer", + value: "This certificate is only valid in combination with the ID card of the certificate holder and expires one year + 14 days after the last dose. The validity of this certificate was not checked by CovidPass." + } + ]); + break; + case CertificateType.Test: + const testTypeKey = properties['tt']; + const testDateTimeString = properties['sc']; + const testResultKey = properties['tr']; + const testingCentre = properties['tc']; + + if (!(testResultKey in valueSets.testResults)) { + throw new Error('invalidTestResult'); + } + if (!(testTypeKey in valueSets.testTypes)) { + throw new Error('invalidTestType') + } + + const testResult = valueSets.testResults[testResultKey].display; + const testType = valueSets.testTypes[testTypeKey].display; + + const testTime = testDateTimeString.replace(/.*T/, '').replace('Z', ' ') + 'UTC'; + const testDate = testDateTimeString.replace(/T.*/,''); + + data.secondaryFields.push(...[ + { + key: "result", + label: "Result", + value: testResult + }, + { + key: "dot", + label: "Date of Test", + value: testDate, + textAlignment: TextAlignment.right + } + ]); + data.auxiliaryFields.pop(); + data.auxiliaryFields.push(...[ + { + key: "test", + label: "Test Type", + value: testType + }, + { + key: "time", + label: "Time of Test", + value: testTime, + textAlignment: TextAlignment.right + }, + ]); + if (testingCentre !== undefined) + data.backFields.push({ + key: "centre", + label: "Testing Centre", + value: testingCentre + }); + data.backFields.push({ + key: "disclaimer", + label: "Disclaimer", + value: "This certificate is only valid in combination with the ID card of the certificate holder and may expire 24h after the test. The validity of this certificate was not checked by CovidPass." + }); + break; + case CertificateType.Recovery: + const firstPositiveTestDate = properties['fr']; + const validFrom = properties['df']; + const validUntil = properties['du']; + + data.secondaryFields.push(...[ + { + key: "result", + label: "Test Result", + value: "Detected" + }, + { + key: "from", + label: "Valid From", + value: validFrom, + textAlignment: TextAlignment.right + } + ]); + data.auxiliaryFields.pop(); + data.auxiliaryFields.push(...[ + { + key: "testdate", + label: "Test Date", + value: firstPositiveTestDate + }, + { + key: "until", + label: "Valid Until", + value: validUntil, + textAlignment: TextAlignment.right + }, + ]); + data.backFields.push({ + key: "disclaimer", + label: "Disclaimer", + value: "This certificate is only valid in combination with the ID card of the certificate holder. The validity of this certificate was not checked by CovidPass." + }); + break; + default: + throw new Error('certificateType'); + } + + return data; + } } \ No newline at end of file diff --git a/src/value_sets.ts b/src/value_sets.ts index d1a3ac0..652e1c5 100644 --- a/src/value_sets.ts +++ b/src/value_sets.ts @@ -2,6 +2,8 @@ interface ValueTypes { medicalProducts: string; countryCodes: string; manufacturers: string; + testResults: string; + testTypes: string; } export class ValueSets { @@ -10,25 +12,50 @@ export class ValueSets { medicalProducts: 'vaccine-medicinal-product.json', countryCodes: 'country-2-codes.json', manufacturers: 'vaccine-mah-manf.json', + testResults: 'test-result.json', + testTypes: 'test-type.json', } medicalProducts: object; countryCodes: object; manufacturers: object; + testResults: object; + testTypes: object; - - private constructor(medicalProducts: object, countryCodes: object, manufacturers: object) { + private constructor( + medicalProducts: object, + countryCodes: object, + manufacturers: object, + testResults: object, + testTypes: object + ) { this.medicalProducts = medicalProducts; this.countryCodes = countryCodes; this.manufacturers = manufacturers; + this.testResults = testResults; + this.testTypes = testTypes; + } + + private static async fetchValueSet(file: string): Promise { + return await (await fetch(ValueSets.VALUE_SET_BASE_URL + file)).json(); } public static async loadValueSets(): Promise { // Load all Value Sets from GitHub - let medicalProducts = await (await fetch(ValueSets.VALUE_SET_BASE_URL + ValueSets.VALUE_TYPES.medicalProducts)).json(); - let countryCodes = await (await fetch(ValueSets.VALUE_SET_BASE_URL + ValueSets.VALUE_TYPES.countryCodes)).json(); - let manufacturers = await (await fetch(ValueSets.VALUE_SET_BASE_URL + ValueSets.VALUE_TYPES.manufacturers)).json(); + let [medicalProducts, countryCodes, manufacturers, testResults, testTypes] = await Promise.all([ + ValueSets.fetchValueSet(ValueSets.VALUE_TYPES.medicalProducts), + ValueSets.fetchValueSet(ValueSets.VALUE_TYPES.countryCodes), + ValueSets.fetchValueSet(ValueSets.VALUE_TYPES.manufacturers), + ValueSets.fetchValueSet(ValueSets.VALUE_TYPES.testResults), + ValueSets.fetchValueSet(ValueSets.VALUE_TYPES.testTypes) + ]); - return new ValueSets(medicalProducts['valueSetValues'], countryCodes['valueSetValues'], manufacturers['valueSetValues']); + return new ValueSets( + medicalProducts['valueSetValues'], + countryCodes['valueSetValues'], + manufacturers['valueSetValues'], + testResults['valueSetValues'], + testTypes['valueSetValues'] + ); } -} \ No newline at end of file +} From 9600296243936fcc7d9401da38dc0d191782cd10 Mon Sep 17 00:00:00 2001 From: Marvin Sextro Date: Fri, 9 Jul 2021 03:15:53 +0200 Subject: [PATCH 2/4] Generalize description --- public/locales/de/common.yml | 2 +- public/locales/en/common.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/de/common.yml b/public/locales/de/common.yml index 986d934..6d7fe92 100644 --- a/public/locales/de/common.yml +++ b/public/locales/de/common.yml @@ -1,5 +1,5 @@ title: CovidPass -subtitle: Übertrage Deine digitalen EU COVID-Impfzertifikate in Deine Wallet-Apps. +subtitle: Übertrage Deine digitalen EU COVID-Zertifikate in Deine Wallet-Apps. privacyPolicy: Datenschutz donate: Unterstützen gitHub: GitHub diff --git a/public/locales/en/common.yml b/public/locales/en/common.yml index 770c203..9d18fae 100644 --- a/public/locales/en/common.yml +++ b/public/locales/en/common.yml @@ -1,5 +1,5 @@ title: CovidPass -subtitle: Add your EU Digital Covid Vaccination Certificates to your favorite wallet apps. +subtitle: Add your EU Digital COVID Certificates to your favorite wallet apps. privacyPolicy: Privacy Policy donate: Sponsor gitHub: GitHub From deec26e7c57328061b2d006d986b4fed74be7c93 Mon Sep 17 00:00:00 2001 From: Marvin Sextro Date: Fri, 9 Jul 2021 03:19:04 +0200 Subject: [PATCH 3/4] Adjust SEO description --- pages/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index e2fb9bd..229587b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -8,22 +8,23 @@ import Page from '../components/Page'; function Index(): JSX.Element { const { t } = useTranslation(['common', 'index', 'errors']); + const description = "Add your EU Digital COVID Certificates to your favorite wallet app."; return ( <> Date: Fri, 9 Jul 2021 03:22:14 +0200 Subject: [PATCH 4/4] Fix title --- pages/index.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index 229587b..efcf0d6 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -8,16 +8,18 @@ import Page from '../components/Page'; function Index(): JSX.Element { const { t } = useTranslation(['common', 'index', 'errors']); - const description = "Add your EU Digital COVID Certificates to your favorite wallet app."; + + const title = 'CovidPass'; + const description = 'Add your EU Digital COVID Certificates to your favorite wallet app.'; return ( <>