├── .gitignore ├── .npmrc ├── lib ├── defs.js ├── generator.js └── extractor.js ├── package.json ├── README.md ├── index.js └── DGC.ValueSets.schema.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.png -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /lib/defs.js: -------------------------------------------------------------------------------- 1 | // https://github.com/ehn-dcc-development/hcert-spec/blob/main/hcert_spec.md 2 | // 1 -> country of establishment? 3 | // 4 -> last vaccination date 4 | // 6 -> QR code generation date 5 | // -260 -> version, name, birth date, certif signature 6 | module.exports = { 7 | CLAIM_KEY_CERT: -260, 8 | CLAIM_KEY_V1EU: 1, 9 | CLAIM_ISSUER: 1, 10 | CLAIM_LAST_VAX_DATE: 4, 11 | CLAIM_QR_GEN_DATE: 6, 12 | CLAIM_HEADER_SIGN_ALGO: 1, 13 | CLAIM_HEADER_KEY_IDENTIFIER: 4, 14 | HEALTH_CERTIFICATE_PREFIX: 'HC1:', 15 | CBOR_TAG_VERSION: 18 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eu-covid-qr-parser-demo", 3 | "version": "1.0.0", 4 | "description": "Quick Node.js PoC to parse images with european vaccination certificate QR code", 5 | "main": "index.js", 6 | "engines" : { 7 | "node" : ">=12" 8 | }, 9 | "scripts": { 10 | "start": "node index.js", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/rascafr/eu-covid-qr-parser-demo.git" 16 | }, 17 | "author": "Francois Leparoux", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/rascafr/eu-covid-qr-parser-demo/issues" 21 | }, 22 | "homepage": "https://github.com/rascafr/eu-covid-qr-parser-demo#readme", 23 | "dependencies": { 24 | "base45": "^3.0.0", 25 | "cbor": "^8.0.0", 26 | "jimp": "^0.16.1", 27 | "jsqr": "^1.4.0", 28 | "qrcode": "^1.4.4", 29 | "zlib": "^1.0.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/generator.js: -------------------------------------------------------------------------------- 1 | const Generator = module.exports; 2 | const { encodeOne, Tagged } = require('cbor'); 3 | const { deflate } = require('zlib'); 4 | const { toFile } = require('qrcode'); 5 | const { encode } = require('base45'); 6 | 7 | const { 8 | CLAIM_KEY_CERT, 9 | CLAIM_KEY_V1EU, 10 | CLAIM_ISSUER, 11 | CLAIM_LAST_VAX_DATE, 12 | CLAIM_QR_GEN_DATE, 13 | CLAIM_HEADER_SIGN_ALGO, 14 | CLAIM_HEADER_KEY_IDENTIFIER, 15 | HEALTH_CERTIFICATE_PREFIX, 16 | CBOR_TAG_VERSION 17 | } = require('./defs'); 18 | 19 | Generator.pass2qr = ( 20 | algorithmSignature, keyIdentifier, hcertSignature, certIssuer, 21 | vaccinationDate, qrCodeGeneratedDate /* opt */, payload, pathToQRimage 22 | ) => new Promise((resolve, reject) => { 23 | 24 | const qrCodeGenerationDate = qrCodeGeneratedDate || new Date().getTime(); 25 | 26 | const headerMap = new Map(); 27 | headerMap.set(CLAIM_HEADER_SIGN_ALGO, algorithmSignature); 28 | headerMap.set(CLAIM_HEADER_KEY_IDENTIFIER, keyIdentifier); 29 | 30 | const euPayloadMap = new Map(); 31 | euPayloadMap.set(CLAIM_KEY_V1EU, payload); 32 | 33 | const masterMap = new Map(); 34 | masterMap.set(CLAIM_ISSUER, certIssuer); // eg: CNAM 35 | masterMap.set(CLAIM_LAST_VAX_DATE, vaccinationDate); 36 | masterMap.set(CLAIM_QR_GEN_DATE, qrCodeGenerationDate); 37 | masterMap.set(CLAIM_KEY_CERT, euPayloadMap); 38 | 39 | const taggedObject = new Tagged( 40 | CBOR_TAG_VERSION, 41 | [ 42 | encodeOne(headerMap), 43 | {}, // always empty? 44 | encodeOne(masterMap), 45 | hcertSignature 46 | ], 47 | null 48 | ); 49 | 50 | const transportCWTdata = encodeOne(taggedObject); 51 | deflate(transportCWTdata, async (err, deflCWTdata) => { 52 | if (err) return reject(err); 53 | 54 | const b45CWTdata = encode(deflCWTdata); 55 | const qrReadyCWTstr = HEALTH_CERTIFICATE_PREFIX + b45CWTdata; 56 | 57 | await toFile(pathToQRimage, qrReadyCWTstr); 58 | resolve(qrReadyCWTstr); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /lib/extractor.js: -------------------------------------------------------------------------------- 1 | const Jimp = require("jimp"); 2 | const jsQR = require("jsqr"); 3 | const fs = require('fs'); 4 | const cbor = require('cbor'); 5 | const { inflate } = require('zlib'); 6 | const { decode } = require("base45"); 7 | const { decodeFirst } = require("cbor"); 8 | const { 9 | HEALTH_CERTIFICATE_PREFIX, 10 | CLAIM_KEY_CERT, 11 | CLAIM_KEY_V1EU 12 | } = require("./defs"); 13 | 14 | const Extractor = module.exports; 15 | 16 | Extractor.qr2pass = (imagePath) => new Promise(async (resolve, reject) => { 17 | 18 | // read the image as color buffer 19 | const image = await Jimp.read(fs.readFileSync(imagePath)); 20 | 21 | // convert to unsigned 8bit values so we can use jsQR 22 | const qrCodeImageArray = new Uint8ClampedArray(image.bitmap.data.buffer); 23 | 24 | // decode the QR image data 25 | const code = jsQR( 26 | qrCodeImageArray, 27 | image.bitmap.width, 28 | image.bitmap.height 29 | ); 30 | 31 | // check prefix health certificate 32 | if (!code.data || !code.data.startsWith(HEALTH_CERTIFICATE_PREFIX)) { 33 | return reject(`QR code payload prefix ${HEALTH_CERTIFICATE_PREFIX} not found, skipping`); 34 | } 35 | // remove the signature from the payload (HC1:...) 36 | const targetPayload = code.data.slice(HEALTH_CERTIFICATE_PREFIX.length); 37 | 38 | // decode the data in the base45 39 | // helpful source -> https://ehealth.vyncke.org/ 40 | const b45decoded = decode(targetPayload); 41 | 42 | // inflate the data since it has been passed into zlib before (0x78) 43 | inflate(b45decoded, async (err, deflated) => { 44 | if (err) return reject(err); 45 | 46 | // decode whole payload (COSE/CBOR), returned as a js Map 47 | const objBase = await cbor.decodeFirst(deflated); 48 | const partUser = objBase.toJSON().value[2]; 49 | 50 | // decode certificate / user payload, returned as a js Map 51 | const objUser = await decodeFirst(partUser); 52 | const certData = objUser.get(CLAIM_KEY_CERT); 53 | const userData = certData.get(CLAIM_KEY_V1EU); 54 | 55 | return resolve(userData); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EU Covid QR extractor & generator 2 | 3 | Quick Node.js PoC to parse and generate european vaccination certificate QR codes 4 | 5 | > 🛑 **DISCLAIMER PLEASE READ** 🛑 6 | > 7 | > **I'm receiving a lot of messages from people asking if it's possible to create a valid EU Covid Vaccination QRcode so I'll make it clear for you here: NO YOU CAN'T**. 8 | > 9 | > The QRcode is signed with a private key to certify its authenticity, so except if you found a way to get it (which is nearly impossible), yes, your QRcode will be decoded with your personal details BUT marked as invalid. This project allows you to play with the QRcodes, not to do forgery, or counterfeiting. 10 | > 11 | > If it's your main goal, please: educate yourself, and get vaccinated. 12 | 13 | ## How it works 14 | 15 | ``` 16 | 1) Read image 17 | 2) Find & decode QRcode 18 | 3) Remove HC1 (health certificate) prefix 19 | 4) Base45 decode 20 | 5) zlib inflate (decompress) 21 | 6) CBOR decode required fields 22 | ``` 23 | 24 | Same thing for the QRcode creation... reverse order. 25 | 26 | ## Prerequisites 27 | 28 | **Requires Node.js 12 at least**, otherwise you'll get the `ReferenceError: TextDecoder is not defined` error. 29 | 30 | ```bash 31 | nvm use 12 32 | ``` 33 | 34 | ## Install 35 | 36 | ```bash 37 | git clone https://github.com/rascafr/eu-covid-qr-parser-demo.git 38 | cd eu-covid-qr-parser-demo 39 | npm i 40 | ``` 41 | 42 | ## Usage 43 | 44 | ```bash 45 | npm start 46 | 47 | # example return 48 | Opening eu_digital_att.png ... 49 | Decoded in 499 ms: { 50 | v: [ 51 | { 52 | ci: 'urn:uvci:01:FR:AZERTY123456#7', 53 | co: 'FR', 54 | dn: 2, 55 | dt: '2021-06-17', 56 | is: 'CNAM', 57 | ma: 'ORG-PFIZER', 58 | mp: 'EU/BIONTECH', 59 | sd: 2, 60 | tg: '1234567', 61 | vp: 'XXAA000' 62 | } 63 | ], 64 | dob: '1993-12-12', 65 | nam: { fn: 'LEPAROUX', gn: 'FRANCOIS', fnt: 'LEPAROUX', gnt: 'FRANCOIS' }, 66 | ver: '1.3.0' 67 | } 68 | ``` 69 | 70 | ## Helpful sources 71 | 72 | - https://ehealth.vyncke.org/ 73 | - https://github.com/ehn-dcc-development/hcert-spec/blob/main/hcert_spec.md 74 | - https://ec.europa.eu/health/sites/default/files/ehealth/docs/covid-certificate_json_specification_en.pdf 75 | 76 | ## Used libraries 77 | 78 | - `jsQR` 79 | - `jimp` 80 | - `base45` 81 | - `cbor` 82 | - `zlib` 83 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { pass2qr } = require("./lib/generator"); 2 | const { qr2pass } = require("./lib/extractor"); 3 | 4 | const IMG_PATH = process.argv[2]; 5 | if (!IMG_PATH) { 6 | console.error('Usage: npm start '); 7 | return process.exit(-1); 8 | } 9 | 10 | (async () => { 11 | 12 | // Create a dummy QR code (check function code) 13 | await createOne(); 14 | 15 | // Try to parse an existing one 16 | const ts = new Date(); 17 | console.log('Opening', IMG_PATH, '...'); 18 | const extrInfo = await qr2pass(IMG_PATH); 19 | console.log('Decoded in', (new Date() - ts), 'ms:', extrInfo); 20 | 21 | })(); 22 | 23 | 24 | async function createOne() { 25 | 26 | // https://github.com/ehn-dcc-development/hcert-spec/blob/main/hcert_spec.md 27 | const AGLO_SIGN = -123 // Protected Header Signature Algorithm (may-be public); 28 | const SIGN_KEY_ID = Buffer.from([0x7c, /* ... 8 bytes sequence for the used algorithm signature (may-be public) */]); 29 | const HCERT_SIGN = Buffer.from([/* ... 64 bytes sequence health certificate signature */]); 30 | 31 | const obj = { 32 | v: [ 33 | { 34 | // Unique certificate identifier 35 | ci: 'urn:uvci:01:FR:WOWSUCHVACCINE#42', 36 | // Member State or third country in which the vaccine was administered 37 | co: 'FR', 38 | // Doses count 39 | dn: 2, 40 | // Date of vaccination 41 | dt: '2021-01-01', 42 | // Vaccine issuer 43 | is: 'CNAM', 44 | // Vaccine manufacturer, e.g., "ORG-100030215" (Biontech Manufacturing GmbH) 45 | ma: 'ORG-100030215', 46 | // Vaccine product, e.g., "EU/1/20/1528" (Comirnaty) 47 | mp: 'EU/1/20/1528', 48 | // Overall doses count 49 | sd: 2, 50 | // Targeted agent / disease 51 | tg: '840539006', 52 | // Type of vaccine used 53 | vp: 'BLEACH' 54 | } 55 | ], 56 | // Date of birth 57 | dob: '1993-01-01', 58 | // Firstnames an lastnames, according to ICAO 9303 transliteration 59 | nam: { fn: 'DARC', gn: 'JEANNE', fnt: 'DARC', gnt: 'JEANNE' }, 60 | // JSON schema / certificate semantic version 61 | ver: '1.3.0' 62 | }; 63 | 64 | const strQR = await pass2qr( 65 | AGLO_SIGN, SIGN_KEY_ID, HCERT_SIGN, 66 | 'CNAM', // Vaccine issuer, french one here 67 | 1234567890 /* timestamp of vaccination */, 68 | 1234567890 /* timestamp for qrcode generation date */, 69 | obj, 70 | './new_generated.png' // path to output 71 | ); 72 | console.log('Generated!', strQR); 73 | 74 | } 75 | -------------------------------------------------------------------------------- /DGC.ValueSets.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft-07/schema", 3 | "$id": "https://ec.europa.eu/dgc/DGC.ValueSets.schema.json", 4 | "title": "EU DGC", 5 | "description": "EU Digital Green Certificate Value Set Data Types", 6 | "$defs": { 7 | "disease-agent-targeted": { 8 | "description": "EU eHealthNetwork: Value Sets for Digital Green Certificates. version 1.0, 2021 - 04 - 16, section 2.1 ", 9 | "type": "object", 10 | "required": ["code", "system"], 11 | "oneOf": [{ 12 | "code": { 13 | "const": "840539006" 14 | }, 15 | "system": { 16 | "const": "2.16.840.1.113883.6.96 " 17 | }, 18 | "version ": { 19 | "const ": "2021 - 01 - 31 " 20 | } 21 | }] 22 | }, 23 | "vaccine-prophylaxis": { 24 | "description": "EU eHealthNetwork: Value Sets for Digital Green Certificates. version 1.0, 2021 - 04 - 16, section 2.2 ", 25 | "type": "object", 26 | "required": ["code"], 27 | "oneOf": [{ 28 | "code": { 29 | "const": "1119305005" 30 | } 31 | }, 32 | { 33 | "code": { 34 | "const": "1119349007" 35 | } 36 | }, 37 | { 38 | "code": { 39 | "const": "J07BX03" 40 | } 41 | } 42 | ] 43 | }, 44 | "vaccine-medicinal-product": { 45 | "description": "EU eHealthNetwork: Value Sets for Digital Green Certificates. version 1.0, 2021 - 04 - 16, section 2.3 ", 46 | "type": "object", 47 | "required": ["code"], 48 | "oneOf": [{ 49 | "code": { 50 | "const": "EU/1/20/1528" 51 | } 52 | }, 53 | { 54 | "code": { 55 | "const": "EU/1/20/1507" 56 | } 57 | }, 58 | { 59 | "code": { 60 | "const": "EU/1/21/1529" 61 | } 62 | }, 63 | { 64 | "code": { 65 | "const": "EU/1/20/1525" 66 | } 67 | }, 68 | { 69 | "code": { 70 | "const": "CVnCoV" 71 | } 72 | }, 73 | { 74 | "code": { 75 | "const": "NVX-CoV2373" 76 | } 77 | }, 78 | { 79 | "code": { 80 | "const": "Sputnik-V" 81 | } 82 | }, 83 | { 84 | "code": { 85 | "const": "Convidecia" 86 | } 87 | }, 88 | { 89 | "code": { 90 | "const": "EpiVacCorona" 91 | } 92 | }, 93 | { 94 | "code": { 95 | "const": "BBIBP-CorV" 96 | } 97 | }, 98 | { 99 | "code": { 100 | "const": "Inactivated-SARS-CoV-2-Vero-Cell" 101 | } 102 | }, 103 | { 104 | "code": { 105 | "const": "CoronaVac" 106 | } 107 | }, 108 | { 109 | "code": { 110 | "const": "Covaxin" 111 | } 112 | } 113 | ] 114 | }, 115 | "vaccine-mah-manf": { 116 | "description": "EU eHealthNetwork: Value Sets for Digital Green Certificates. version 1.0, 2021 - 04 - 16, section 2.4 ", 117 | "type": "object", 118 | "required": ["code"], 119 | "oneOf": [{ 120 | "code": { 121 | "const": "ORG-100001699" 122 | } 123 | }, 124 | { 125 | "code": { 126 | "const": "ORG-100030215" 127 | } 128 | }, 129 | { 130 | "code": { 131 | "const": "ORG-100001417" 132 | } 133 | }, 134 | { 135 | "code": { 136 | "const": "ORG-100031184" 137 | } 138 | }, 139 | { 140 | "code": { 141 | "const": "ORG-100006270" 142 | } 143 | }, 144 | { 145 | "code": { 146 | "const": "ORG-100013793" 147 | } 148 | }, 149 | { 150 | "code": { 151 | "const": "ORG-100020693" 152 | } 153 | }, 154 | { 155 | "code": { 156 | "const": "ORG-100020693" 157 | } 158 | }, 159 | { 160 | "code": { 161 | "const": "ORG-100010771" 162 | } 163 | }, 164 | { 165 | "code": { 166 | "const": "ORG-100024420" 167 | } 168 | }, 169 | { 170 | "code": { 171 | "const": "ORG-100032020" 172 | } 173 | }, 174 | { 175 | "code": { 176 | "const": "Gamaleya-Research-Institute" 177 | } 178 | }, 179 | { 180 | "code": { 181 | "const": "Vector-Institute" 182 | } 183 | }, 184 | { 185 | "code": { 186 | "const": "Sinovac-Biotech" 187 | } 188 | }, 189 | { 190 | "code": { 191 | "const": "Bharat-Biotech" 192 | } 193 | } 194 | ] 195 | }, 196 | "test-manf": { 197 | "description": "EU eHealthNetwork: Value Sets for Digital Green Certificates. version 1.0, 2021 - 04 - 16, section 2.8 ", 198 | "type": "object", 199 | "required": ["code"], 200 | "oneOf": [{ 201 | "code": { 202 | "const": "1232" 203 | } 204 | }, 205 | { 206 | "code": { 207 | "const": "1304" 208 | } 209 | }, 210 | { 211 | "code": { 212 | "const": "1065" 213 | } 214 | }, 215 | { 216 | "code": { 217 | "const": "1331" 218 | } 219 | }, 220 | { 221 | "code": { 222 | "const": "1484" 223 | } 224 | }, 225 | { 226 | "code": { 227 | "const": "1242" 228 | } 229 | }, 230 | { 231 | "code": { 232 | "const": "1223" 233 | } 234 | }, 235 | { 236 | "code": { 237 | "const": "1173" 238 | } 239 | }, 240 | { 241 | "code": { 242 | "const": "1244" 243 | } 244 | }, 245 | { 246 | "code": { 247 | "const": "1360" 248 | } 249 | }, 250 | { 251 | "code": { 252 | "const": "1363" 253 | } 254 | }, 255 | { 256 | "code": { 257 | "const": "1767" 258 | } 259 | }, 260 | { 261 | "code": { 262 | "const": "1333" 263 | } 264 | }, 265 | { 266 | "code": { 267 | "const": "1268" 268 | } 269 | }, 270 | { 271 | "code": { 272 | "const": "1180" 273 | } 274 | }, 275 | { 276 | "code": { 277 | "const": "1481" 278 | } 279 | }, 280 | { 281 | "code": { 282 | "const": "1162" 283 | } 284 | }, 285 | { 286 | "code": { 287 | "const": "1271" 288 | } 289 | }, 290 | { 291 | "code": { 292 | "const": "1341" 293 | } 294 | }, 295 | { 296 | "code": { 297 | "const": "1097" 298 | } 299 | }, 300 | { 301 | "code": { 302 | "const": "1489" 303 | } 304 | }, 305 | { 306 | "code": { 307 | "const": "344" 308 | } 309 | }, 310 | { 311 | "code": { 312 | "const": "345" 313 | } 314 | }, 315 | { 316 | "code": { 317 | "const": "1218" 318 | } 319 | }, 320 | { 321 | "code": { 322 | "const": "1278" 323 | } 324 | }, 325 | { 326 | "code": { 327 | "const": "1343" 328 | } 329 | } 330 | ] 331 | }, 332 | "test-result": { 333 | "description": "EU eHealthNetwork: Value Sets for Digital Green Certificates. version 1.0, 2021 - 04 - 16, section 2.9 ", 334 | "type": "object", 335 | "required": ["code"], 336 | "oneOf": [{ 337 | "code": { 338 | "const": "260415000" 339 | } 340 | }, 341 | { 342 | "code": { 343 | "const": "260373001" 344 | } 345 | } 346 | ] 347 | } 348 | } 349 | } --------------------------------------------------------------------------------