Merge pull request #9 from KhaosT/main

Update pkpass signing mechanism to only transmit the hashed manifest
This commit is contained in:
Marvin Sextro 2021-06-28 14:43:43 +02:00 committed by GitHub
commit 15f0977358
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 442 additions and 7476 deletions

View File

@ -5,6 +5,7 @@ import jsQR from "jsqr"
import { saveAs } from 'file-saver'
import { decodeData } from "../src/decode"
import { createPass } from "../src/pass"
import Card from "../components/Card"
import Alert from "../components/Alert"
@ -101,31 +102,27 @@ function Form() {
}
const color = document.getElementById('color').value
fetch(event.target.action, {
method: 'POST',
headers: {
'Accept': 'application/vnd.apple.pkpass',
'Content-Type': 'application/json'
},
body: JSON.stringify({
try {
const pass = await createPass(
{
decoded: result.decoded,
raw: result.raw,
color: color
})
}).then( async (resp) => {
if (!resp.ok) {
error('Error:', await resp.text())
return
}
)
if (!pass) {
error('Error:', "Something went wrong.")
} else {
const passBlob = new Blob([pass], {type: "application/vnd.apple.pkpass"});
saveAs(passBlob, 'covid.pkpass')
}
const pass = await resp.blob()
saveAs(pass, 'covid.pkpass')
}).catch((error) => {
} catch (e) {
error('Error:', error.message)
}).finally(() => {
} finally {
document.getElementById('spin').style.display = 'none'
})
}
}
return (

7544
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,13 +9,14 @@
"start": "NODE_ENV=production node server.js"
},
"dependencies": {
"@walletpass/pass-js": "^6.9.1",
"base45-js": "^1.0.1",
"cbor-js": "^0.1.0",
"do-not-zip": "^1.0.0",
"file-saver": "^2.0.5",
"jsqr": "^1.4.0",
"next": "latest",
"next-seo": "^4.26.0",
"node-fetch": "^2.6.1",
"pdfjs-dist": "^2.5.207",
"react": "^17.0.2",
"react-dom": "^17.0.2",

18
src/constants.js Normal file
View File

@ -0,0 +1,18 @@
exports.BASE_URL = 'https://raw.githubusercontent.com/ehn-dcc-development/ehn-dcc-valuesets/main/'
exports.API_BASE_URL = 'https://api.covidpass.marvinsextro.de/'
exports.VALUE_TYPES = {
medicalProducts: 'vaccine-medicinal-product.json',
countryCodes: 'country-2-codes.json',
manufacturers: 'vaccine-mah-manf.json',
}
exports.COLORS = {
white: 'rgb(255, 255, 255)',
black: 'rgb(0, 0, 0)',
grey: 'rgb(33, 33, 33)',
green: 'rgb(27, 94, 32)',
indigo: 'rgb(26, 35, 126)',
blue: 'rgb(1, 87, 155)',
purple: 'rgb(74, 20, 140)',
teal: 'rgb(0, 77, 64)',
}
exports.NAME = 'CovidPass'

View File

@ -33,11 +33,11 @@ export function decodeData(data) {
if (data.startsWith('HC1')) {
data = data.substring(3)
if (data.startsWith(':')) {
data = data.substring(1)
} else {
console.log("Warning: unsafe HC1: header - update to v0.0.4")
}
if (data.startsWith(':')) {
data = data.substring(1)
} else {
console.log("Warning: unsafe HC1: header - update to v0.0.4")
}
} else {
console.log("Warning: no HC1: header - update to v0.0.4")
}

6
src/img.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
img1xblack: new Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAABU0lEQVR4AWIYaWAUiAExoB06gGggDOMwripJthAgGwBoCGYDBIDIgAFBwDAABoJjhBgEAEChBBBaAA0JyeKAqR0hmnWx3p5o8MHdvfd9Z7SHH8Dr723iCpdoYBOZtoJ9XOALYghxjj0sw1k7OEEAiekVxyjBShto4h6SUg8N5KGqhCHEshdsI3FdiCM3SNwnxA1uKxKXZm3QfJCPQ3RmYVAfW5j2YH+QfkweQ1uDviEmdNHBR8SYddxCDOC2ojeI4RlL+K2Kd8UYcFvRE8TQxyKmVdFLOAbcVnQNMeEUCzCKPQbcVnQEiRilGQNuK9qFRI1SjAG3Fa0iiDh8hgPcQWIKwG1dHsQyD+qKCCGWhCgiVZ7T7yhagw9JyQe37FTGCKI0QhlWq2GiGDNBDU6qYwyJaYw6nFbBABJhgAoyKYc2QoghRBs5ZF4BLTz+aaGAef+nHwt5/579e2c2AAAAAElFTkSuQmCC', 'base64'),
img2xblack: new Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAACZ0lEQVR4Ae3TA8xcURRF4dq2ozqqbRtxUjeq7Zi127C2bXvSsLZtu/PP6ardjs577765K/li7mQnizGbzWaz2Wx50BXjMRYdkR0JXRq0xVq8g/ziDZaiGVIiYSqLybgPCdMtjEZpP1+oP45CYnQYPZDd7xeKnPMX1L+QAoULmnUhX12wVrwupHjBKnC8tFgEMcRcpIFjrYQYZhkcqQXEUM2h3haIoTZDvRsQQ92AeiGIoUJQTwxnB7ID2YHsQHYgO5B7zmMlztiBfhbCCKQAJUuOYbADIYRe+FP7TB1IfxyiUaYOpD8O0TJzB9IfpyqCZg6kP05ZPIBESL0gJAIBVENONMRJF8cJQr1nkDBdRWb8WBYEHB8HeAb1bkPCNB5E/xlJfRzgNtQ7CQnTWNB/R9IfBzgJ9TZCwnQJGcMYSX8cYCPUmw6JwCqkwt9K5cg4wHSo1x0SoZX/GUJ/HKA71KsAURhJdxygAtRLg1cKI2mP8wpp4EibIQoj6YwDbIZj9YIojKQzDtALjlUESZAYrEN2fK2u4jhJKAJH2wmJ0UOsRQBJECU74XjtIYZoD8dLi1sQj7uFtHClIRCPGwLXyox7EI+6h8xwtR4Qj+oB10uFExCPOYFU8ERVEIR4RBBV4KlGQTxiFDxXWgQgLgsgLTxZQdyBuOQOCsLTVcELiMNeoAqMqBHeQhzyFo1gVC3wCqLsFVrAyGrgMUTJY9SA0RXDMYVxjqEYfFEGzITEyUxkgO9qhEuQKF1CI/i69BiCB5AwPcAQpEfClBUDcR7yF+cxEFmR0NXDVFz5YirqwWaz2Ww2W9R9AE/cBAw+cEeMAAAAAElFTkSuQmCC', 'base64'),
img1xwhite: new Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAQAAABLCVATAAABUUlEQVRIx+XWP0tCURjH8cf+IBLaELSEvoIcWkRHX0AQbm4tbQ7uTi4XhIbAxfdQQ9HqkK05RINIgZtUgoOQ6A3026Sc9D73jzblb73P/XAOnPM8R2RTwyFF7rmjwMGqxC5n3PLNLDY3nLITDDnhih5O+eCSpB9inyLPeKVJgZgbk+QTv3nnWIcaBMmDDo0DQWMdCpj/A3W4oLo+9MqRiAgv60EzJmaeNB2aGr82qPK1wOzxaFRMdag/L3pjW4QMA5WBvg61ja1siYiQoakw0NahulFWI2R8WWagrkPlX4VzypGBsg5lF0prhFQGsjoUXmpn15zz5Mj0CLt1JMv3/bDcO2QC2xdjk/BqttYfrEdEhAgdT6ZDxM8ASDF0ZYak/I6jHBOVmZALMtnyjByZEfmgkzZNd4npkl5laEepGIfBpkJ09UdEnBItWpSIb+xL6gcK3j+JspcAUAAAAABJRU5ErkJggg==', 'base64'),
img2xwhite: new Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAQAAAD/5HvMAAACZklEQVRo3u3YsWsTYRiA8VcNCa3UIoJLoVsXt0KHKEWwYAfN4l4c3Bq6SCGIf0GkdJDSsYOLYCFChVKXLi7B0dKAEAgVSlpqpVBKsJLmcajFXHKXu/e7+3IZ7llzvPmF9458iUhSUtLAx11esMwSz7kdNyXNMzb4w1W/+cATbsSDmeQtP3Grzhvu9XdFL/mGX1/JW19h14r8srnCHivyK+oVBlxRf1bIQ9WKgqwwa47J8B4bvSNtBiphq3UTTg6bPdWDPlsFbelB+1ZB+3pQyyqopQdZLgEloAQU+h2rlPg+KKAWr7kuwjVeDQKoxULbnC9xgxwcEYrxgjo4IqzHCerm3KcZH6ibM8lxdDe1xyejzAPu8JhKGA5NPejUddAPRv69fouyMQdO9aAD10HLbVf8J2k5cKAHVVwHLTmuuSTpOVDRgzZdB9W42UXSc2BTD1r1GPWRlOO6lAEHVvWgec9hpU6EmgPzetBUj3EepMAcmNKD0jR0JAWnYfRjmq2eQztICo7Jz0QRERZ8xraRVBw6n8ugoHEufAZ/uvzHh0cqzgXjYhbbvsN/sUHZF+5sW0xjDhvNmYMy1CPn1MmIeRQiBxUkTIxwFCnn6Or4Yk7KRwrKS9hIsRsZZ9f7W1BDynoeZ3U1Q/wl3EEqRgIqSlSRcZyfzSqHety7SGMchuIcMibRRpYzY85ZZHePgzTLuRHnnFmxE7mehzb3GuTEXkxzouKcMC12Y4KdwJwdJsR+DLMWiLPGsPQrZqn1xNSs3ciepCEKHgfXYwoMSRwxyiJVB6bKIqMSb8ywwh57rDAjSUlJA99fMZuE+02zWzkAAAAASUVORK5CYII=', 'base64'),
}

187
src/pass.js Normal file
View File

@ -0,0 +1,187 @@
'use strict';
const consts = require('./constants')
const utils = require('./utils')
const img = require('./img')
const { Payload } = require('./payload')
const { toBuffer } = require('do-not-zip')
const crypto = require('crypto')
exports.createPass = async function(data) {
async function getJSONfromURL(url) {
return await (await fetch(url)).json()
}
function getBufferHash(buffer) {
// creating hash
const sha = crypto.createHash('sha1');
sha.update(buffer);
return sha.digest('hex');
}
async function signPassWithRemote(pass, payload) {
// From pass-js
// https://github.com/walletpass/pass-js/blob/2b6475749582ca3ea742a91466303cb0eb01a13a/src/pass.ts
// Creating new Zip file
const zip = []
// Adding required files
// Create pass.json
zip.push({ path: 'pass.json', data: Buffer.from(JSON.stringify(pass)) })
zip.push({ path: 'icon.png', data: payload.img1x })
zip.push({ path: 'icon@2x.png', data: payload.img2x })
zip.push({ path: 'logo.png', data: payload.img1x })
zip.push({ path: 'logo@2x.png', data: payload.img2x })
// adding manifest
// Construct manifest here
const manifestJson = JSON.stringify(
zip.reduce(
(res, { path, data }) => {
res[path] = getBufferHash(data);
return res;
},
{},
),
);
zip.push({ path: 'manifest.json', data: manifestJson });
const response = await fetch(consts.API_BASE_URL + 'sign_manifest', {
method: 'POST',
headers: {
'Accept': 'application/octet-stream',
'Content-Type': 'application/json'
},
body: JSON.stringify({
manifest: manifestJson
})
})
if (response.status != 200) {
return undefined
}
const manifestSignature = await response.arrayBuffer()
zip.push({ path: 'signature', data: Buffer.from(manifestSignature) });
// finished!
return toBuffer(zip);
}
let valueSets
try {
valueSets = await utils.getValueSets()
} catch {
return undefined
}
let payload
try {
payload = new Payload(data, valueSets)
} catch (e) {
return undefined
}
let signingIdentity = await getJSONfromURL(consts.API_BASE_URL + 'signing_identity')
const qrCode = {
message: payload.raw,
format: "PKBarcodeFormatQR",
messageEncoding: "utf-8"
}
const pass = {
passTypeIdentifier: signingIdentity['pass_identifier'],
teamIdentifier: signingIdentity['pass_team_id'],
sharingProhibited: true,
voided: false,
formatVersion: 1,
logoText: consts.NAME,
organizationName: consts.NAME,
description: consts.NAME,
labelColor: payload.labelColor,
foregroundColor: payload.foregroundColor,
backgroundColor: payload.backgroundColor,
serialNumber: payload.uvci,
barcodes: [qrCode],
barcode: qrCode,
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."
}
]
}
};
let buf = await signPassWithRemote(pass, payload)
return buf
}

99
src/payload.js Normal file
View File

@ -0,0 +1,99 @@
const img = require('./img')
const consts = require('./constants')
exports.Payload = class {
constructor(body, valueSets) {
const color = body["color"]
const rawData = body["raw"]
const decoded = body["decoded"]
if (!(color in consts.COLORS)) {
throw new Error('Invalid color')
}
const dark = (color !== 'white')
let backgroundColor = dark ? consts.COLORS[color] : consts.COLORS.white
let labelColor = dark ? consts.COLORS.white : consts.COLORS.grey
let foregroundColor = dark ? consts.COLORS.white : consts.COLORS.black
let img1x = dark ? img.img1xwhite : img.img1xblack
let img2x = dark ? img.img2xwhite : img.img2xblack
if (typeof rawData === 'undefined') {
throw new Error('No raw payload')
}
if (typeof decoded === 'undefined') {
throw new Error('No decoded payload')
}
const v = decoded["-260"]["1"]["v"][0]
if (typeof v === 'undefined') {
throw new Error('Failed to read vaccination information')
}
const nam = decoded["-260"]["1"]["nam"]
if (typeof nam === 'undefined') {
throw new Error('Failed to read name')
}
const dob = decoded["-260"]["1"]["dob"]
if (typeof dob === 'undefined') {
throw new Error('Failed to read date of birth')
}
const firstName = nam["gn"]
const lastName = nam["fn"]
const name = lastName + ', ' + firstName
const doseIndex = v["dn"]
const totalDoses = v["sd"]
const dose = doseIndex + '/' + totalDoses
const dateOfVaccination = v["dt"]
const uvci = v["ci"]
const certificateIssuer = v["is"]
const medicalProducts = valueSets.medicalProducts["valueSetValues"]
const countryCodes = valueSets.countryCodes["valueSetValues"]
const manufacturers = valueSets.manufacturers["valueSetValues"]
const medicalProductKey = v["mp"]
if(!(medicalProductKey in medicalProducts)) {
throw new Error('Invalid medical product key')
}
const countryCode = v["co"]
if(!(countryCode in countryCodes)) {
throw new Error('Invalid country code')
}
const manufacturerKey = v["ma"]
if(!(manufacturerKey in manufacturers)) {
throw new Error('Invalid manufacturer')
}
this.certificateType = 'Vaccination'
this.backgroundColor = backgroundColor
this.labelColor = labelColor
this.foregroundColor = foregroundColor
this.img1x = img1x
this.img2x = img2x
this.raw = rawData
this.name = name
this.dose = dose
this.dateOfVaccination = dateOfVaccination
this.dateOfBirth = dob
this.uvci = uvci
this.certificateIssuer = certificateIssuer
this.medicalProductKey = medicalProductKey
this.countryOfVaccination = countryCodes[countryCode].display
this.vaccineName = medicalProducts[medicalProductKey].display
this.manufacturer = manufacturers[manufacturerKey].display
}
}

16
src/utils.js Normal file
View File

@ -0,0 +1,16 @@
const { VALUE_TYPES, BASE_URL } = require("./constants")
const fetch = require("node-fetch")
exports.getValueSets = async function() {
async function getJSONfromURL(url) {
return await (await fetch(url)).json()
}
var valueSets = {}
for (const [key, value] of Object.entries(VALUE_TYPES)) {
valueSets[key] = await getJSONfromURL(BASE_URL + value)
}
return valueSets
}