Merge pull request #9 from KhaosT/main
Update pkpass signing mechanism to only transmit the hashed manifest
This commit is contained in:
commit
15f0977358
|
@ -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 (
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
|
@ -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'
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue