Let’s make a COVID Certificate [ENCODING]

The EU Digital COVID Certificate is just a QR code, is it really that hard to make it? My good old friend Homer Simpson doesn’t want to get the vaccine, so they don’t let him enter the bar, could we make him one? Let’s find out.

In a previous post I described in detail how to decode the EU Digital COVID Certificate, now we go the other way, to generate such a QR code. Here is a brief overview taken from that previous post. If you want to try this code, you should definitely visit the previous post and copy all the maven dependencies, because I will use them here as well.

Data → JSON

This is how I generated the JSON, again with ObjectMapper.

List<VaccinationPayload> vaccinations = new ArrayList<>();
VaccinationPayload vaccine = new VaccinationPayload();
...
vaccine.setVaccine("1119349007"); // COVID-19 mRNA vaccine
vaccine.setAuthorizationHolder("ORG-100030215"); // Biontech
vaccine.setDoseNumber(2); // Dose number: 2
vaccine.setDoseTotalNumber(2); // Expected doses: 2
...
vaccinations.add(vaccine);

PersonPayload person = new PersonPayload();
person.setFamilyName("Simpson");
person.setGivenName("Homer");
...
CertificatePayload certificate = new CertificatePayload();
certificate.setSubject(person);
certificate.setVaccinations(vaccinations);
certificate.setDateOfBirthString("1956-05-12");
...
ObjectMapper mapper  = new ObjectMapper();
mapper.setVisibility(mapper.getSerializationConfig()
	.getDefaultVisibilityChecker()
	.withFieldVisibility(Visibility.ANY)
	.withGetterVisibility(Visibility.NONE)
	.withSetterVisibility(Visibility.NONE)
	.withCreatorVisibility(Visibility.NONE));

String json = mapper.writeValueAsString(certificate);

Of course, this JSON can be generated in various ways, even manually, the most important thing is that in the end it looks like this.

{"v":[{"ci":"URN:UVCI:01:DE:187/37512422923","co":"DE","dn":2,"dt":"2021-09-24","is":"Robert Koch-Institut","ma":"ORG-100030215","mp":"EU/1/20/1528","sd":2,"tg":"840539006","vp":"1119349007"}],"dob":"1956-05-12","nam":{"fn":"Simpson","gn":"Homer","fnt":"SIMPSON","gnt":"HOMER"},"ver":"1.0.0"}

JSON → CBOR

You may have seen the country code DE, which means that Homer is now German, this is for a specific reason and will be revealed at the end. Now let’s make these CBOR bytes.

long issuedAtSec = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() / 1000L;
long expirationSec = LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() / 1000L;

CBORObject map = CBORObject.NewMap();
map.set(CBORObject.FromObject(1), CBORObject.FromObject("DE"));
map.set(CBORObject.FromObject(6), CBORObject.FromObject(issuedAtSec));
map.set(CBORObject.FromObject(4), CBORObject.FromObject(expirationSec));
CBORObject hcertVersion = CBORObject.NewMap();
CBORObject hcert = CBORObject.FromJSONString(json);
hcertVersion.set(CBORObject.FromObject(1), hcert);
map.set(CBORObject.FromObject(-260), hcertVersion);

byte[] cbor = map.EncodeToBytes();

This is what it looks like when encoded in CBOR.

A401624445041A632EFCA9061A614DC929390103A101A4617681AA626369781E55524E3A555643493A30313A44453A3138372F333735313234323239323362636F62444562646E026264746A323032312D30392D323462697374526F62657274204B6F63682D496E737469747574626D616D4F52472D313030303330323135626D706C45552F312F32302F3135323862736402627467693834303533393030366276706A3131313933343930303763646F626A313935362D30352D3132636E616DA462666E6753696D70736F6E62676E65486F6D657263666E746753494D50534F4E63676E7465484F4D45526376657265312E302E30

CBOR → COSE

Now comes the complicated part, we have to sign these bytes with a key issued by the German certification authority in charge of COVID certificates. So, let’s hack them…
Just kidding, we’ll make a new key and sign it with him.

OneKey privateKey = OneKey.generateKey(AlgorithmID.ECDSA_256);
byte[] kid = UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8);

Sign1Message msg = new Sign1Message();
msg.addAttribute(HeaderKeys.Algorithm, privateKey.get(KeyKeys.Algorithm), Attribute.PROTECTED);
msg.addAttribute(HeaderKeys.KID, CBORObject.FromObject(kid), Attribute.PROTECTED);
msg.SetContent(cbor);
msg.sign(privateKey);

byte[] cose = msg.EncodeToBytes();

This was the most complicated part, but now we have COSE bytes.

D284582AA2012604582462646663326264612D366337342D343065632D613533302D386430343034356533336132A058F6A401624445041A632EFCA9061A614DC929390103A101A4617681AA626369781E55524E3A555643493A30313A44453A3138372F333735313234323239323362636F62444562646E026264746A323032312D30392D323462697374526F62657274204B6F63682D496E737469747574626D616D4F52472D313030303330323135626D706C45552F312F32302F3135323862736402627467693834303533393030366276706A3131313933343930303763646F626A313935362D30352D3132636E616DA462666E6753696D70736F6E62676E65486F6D657263666E746753494D50534F4E63676E7465484F4D45526376657265312E302E305840FAA429AA9920AF8FDA1126A9AE3CD146CACBE30DEF0F96C7B0832CB17CF27510C02CACF787062F5B358A842BC88C06954A00343B155032CB725D7E95AE5E366C

Do not be surprised if you got different COSE bytes, in this process a new key is generated and it is almost impossible for these keys to be the same/to generate the same signature.

COSE → COMPRESSED

Compression and the rest are much simpler.

ByteArrayOutputStream stream = new ByteArrayOutputStream();
try (CompressorOutputStream deflateOut = new CompressorStreamFactory()
	.createCompressorOutputStream(CompressorStreamFactory.DEFLATE,
	stream)) {
		deflateOut.write(cose);
	}
byte[] zip = stream.toByteArray();

All zipped up and we get some bytes again that I won’t write down, because they don’t matter, they will look different every time this code is executed.

COMPRESSED → BASE45

Base45 encoding goes like this.

String base45 = Base45.getEncoder().encodeToString(zip);

BASE45 → PREFIX

Final touch, adding the prefix.

String hc1 = "HC1:" + base45;

And this is what the certificate looks like, for now.

HC1:6BFLX1VX7YUO 53TGEHQQF+Q7.O.R3$IS5 6G*B*SR6F71LB$J9*ZMMF1FEI:OI2R2J$ISP5KG4CKG$MG0NGMG9DZ8ULV00EFY9VSNZ50.*0YGE3WSL2G:ANEUSBEFYQQWP3RSLA93A*4WJPM.41VD.U9VI0+9B5EGLRBUZ9RMBW/DC8EU1RBN503G4MSOP66S6ZZGH3Q.W1U3O2JHI2SVO5W6T0-G/GHWE2TJED-ILIHZ3LVU0PXJJUGL55R6TD3JINH3F5J9CS9G533Y9NPIE3RR6/QD03  A%6V2QP5WTW 7/YPP:KKS0//N7V0U-IETA*RK9K0AF1VFO :QUQPWI2HXGG40FDM4A5CG9H*JTK58*LPYP522CE0/5A +F%PD5LPQUUV3B$T08J7KW7DAF+ ANZFY-SGIJ2XF2-B%4O-4DCHJM/922UU 9:329VSU3EWKUT%KZ8TN-N2LEM$7IK7:/VFOQ8MR4-V/TNRMEPPVO5S.08Y2VR03V2JCAWHKUDF8VAD *GK5VREMQVT37RJ0VUYD:/3Q07R2

PREFIX → QR Code Image 

All that remains is to make a QR code out of this.

QRCodeWriter qrCodeWriter = new QRCodeWriter();
int width = 300, height = 300; //px
BitMatrix bitMatrix = qrCodeWriter.encode(hc1,
	BarcodeFormat.QR_CODE, width, height);
// Write to file
MatrixToImageWriter.writeToPath(bitMatrix, "PNG",
	new File(elHomo.png).toPath());

And here it is.

Conclusion

The only thing left is to scan the code with the CovPas app. And… D’oh! It doesn’t work. That app was made by IBM and they are not so naive.

But there is hope, and it all lies in the fact that we made Homer a German. To uncover the secret, the Corona-Warn-App, before version 2.7, did not perform a digital signature check at all. This means that it will accept our certificate without any problems. You don’t believe me, download the Corona-Warn-App 2.6.1 and scan this previously generated QR code.

Guess who’s now staying in the bar until dawn, let’s hope no one will actually check his certificate.

4 thoughts on “Let’s make a COVID Certificate [ENCODING]

  1. Daniel Harmann says:

    Hi,
    Excellent Article
    Small question at the JSON – CBOR step.
    You have converted the JSON data to CBOR before the COSE signing stage.
    Do you also need to convert the protected/unprotected headers to CBOR BEFORE the COSE signing stage?
    Thanks.

    Reply
  2. Daniel Harmann says:

    Hi, me again.
    Thanks for your reply to the last comment.
    At the CBOR-COSE step, I have found that decoding a QR is not possible if the cbor.tag “18” has not been encoded at the COSE stage.
    The COSE step would look like this {18: [[headers],[payload],[signature]]} and not just {[[headers],[payload],[signature]]}
    Do you have any idea how you would encode the cbor.tag of 18 into the COSE step.

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *