Covid Zertifikate validieren for Fun and .. Sicherheit?

German Perl/Raku Workshop 2022 Cyberspace 2022-03-31
Thomas Klausner http://domm.plix.at domm AT plix.at

Permalink for this talk:
http://domm.plix.at/talks/2022_gpw_qrona

Corona

You might have noticed this small virus in the last two years

The main reason you're watching me on a screen...

After the second wave, when vaccinations where beginning to be available, the European Union started to think about a way to implement digital vaccination certificates

In May 2021 the European Parliament and the Council agreed on the EU Digital COVID Certificate

Which surprisingly doesn't suck!

EU Digital COVID Certificate

https://ec.europa.eu/info/live-work-travel-eu/coronavirus-response/safe-covid-19-vaccines-europeans/eu-digital-covid-certificate_en

"The EU Digital COVID Certificate contains a QR code with a digital signature to protect it against falsification."

"When the certificate is checked, the QR code is scanned and the signature verified."

"Each issuing body (e.g. a hospital, a test centre, a health authority) has its own digital signature key. All of these are stored in a secure database in each country."

"The European Commission has built a gateway through which all certificate signatures can be verified across the EU. The personal data of the certificate holder does not pass through the gateway, as this is not necessary to verify the digital signature."

decentral validation

no DB-lookup, no tracking

implemented using existing open standards

lots of documentation

EU++

Decoding COVID certificates for fun and .. security?

A few months ago (between some waves...) I wanted to do a birthday party (which in the end did not happen for other reasons)

For that party, I wanted people to adhere to "2G+":

(recovered or fully vaccinated) and PCR-tested

But: The various "Green Pass" Apps provided by state actors where ugly and/or did not work on my laptop (only on mobile phones)

So I set out to write my own

But I rembered a rather long chat on #Austria.pm started by Maroš:

  2021-11-02 09:57:39     maros   hat wer erfahrungen mit cbor/cose/cwt? versuche grad so ein digitales covid cert in perl
                                  zu validieren und scheitere (mangels cpan cwt library) mit meiner naiven cwt interpretation
                                  am überprüfen der signatur

And (being lazy) asked:

  2022-02-18 15:20:17     domm    maros: Hast du den Corona-Zertifikat-QR-Scanner/Validator jemals fertig bekommen?
  2022-02-18 15:39:43     maros   yup. ist bei gh auch "produktiv" im einsatz

(See the following talk by Maroš himself on "Werda - der Geizhals Anwesenheitsmonitor")

Maroš was so kind to share his code with me, which I used to build my own Corona QR Code Scanner & Verifyer

Decode & Validate

QR-Code

QR-Code is just a fancy way to represent a piece of text

 ~$ zbarimg testcert_gurgel.png
 
 
 
 ~$ zbarimg testcert_gurgel.png
  QR-Code:HC1:NCFOXN%TS3DHDWKJ/8 1K10K.0ICID:D4 W2%CMRY4/7OMOO**IJ04Z9HPJPC%OQHIZC4.OI:OIC*I80P2W4V
  Z0G.8O%0VON0-CI 0 +AE3PCY0JCAEV4R83/70K%4E+4 $4LHF2B5:0IG94/Y4WW25-74GD$B9ZHHQDFGNN4:H1D7.+O8OTW$
  ...
 ~$ zbarimg testcert_gurgel.png
  QR-Code:HC1:NCFOXN%TS3DHDWKJ/8 1K10K.0ICID:D4 W2%CMRY4/7OMOO**IJ04Z9HPJPC%OQHIZC4.OI:OIC*I80P2W4V
  Z0G.8O%0VON0-CI 0 +AE3PCY0JCAEV4R83/70K%4E+4 $4LHF2B5:0IG94/Y4WW25-74GD$B9ZHHQDFGNN4:H1D7.+O8OTW$
  ...

Added by zbarimg, please ignore

 ~$ zbarimg testcert_gurgel.png
  QR-Code:HC1:NCFOXN%TS3DHDWKJ/8 1K10K.0ICID:D4 W2%CMRY4/7OMOO**IJ04Z9HPJPC%OQHIZC4.OI:OIC*I80P2W4V
  Z0G.8O%0VON0-CI 0 +AE3PCY0JCAEV4R83/70K%4E+4 $4LHF2B5:0IG94/Y4WW25-74GD$B9ZHHQDFGNN4:H1D7.+O8OTW$
  ...

Some sort of QR-Code header, also irrelevant

  NCFOXN%TS3DHDWKJ/8 1K10K.0ICID:D4 W2%CMRY4/7OMOO**IJ04Z9HPJPC%OQHIZC4.OI:OIC*I80P2W4V
  Z0G.8O%0VON0-CI 0 +AE3PCY0JCAEV4R83/70K%4E+4 $4LHF2B5:0IG94/Y4WW25-74GD$B9ZHHQDFGNN4:
  ...

Nice, but that's not telling me lot...

So let's pass this string on to Maroš' script

Disclaimer: I removed handling of some corner cases and error handling from Maroš code for this talk!

  use QRCode::Base45 qw(decode_base45);
  
  my $cert = $ARGV[0];
  $cert = decode_base45($cert);
  use QRCode::Base45 qw(decode_base45);
  
  my $cert = $ARGV[0];
  $cert = decode_base45($cert);
  use QRCode::Base45 qw(decode_base45);
  
  my $cert = $ARGV[0];
  $cert = decode_base45($cert);
  xڻ�⻈ţ�Fꞅ�5��j          "��H%��>�&�d��1�1Ē�y

Some binary data...

that might be compressed, so we uncompress it

  use Compress::Zlib qw(uncompress);
  
  $cert = uncompress($cert) if ord( bytes::substr $cert, 0, 1 ) == 120;
  use Compress::Zlib qw(uncompress);
  
  $cert = uncompress($cert) if ord( bytes::substr $cert, 0, 1 ) == 120;
  use Compress::Zlib qw(uncompress);
  
  $cert = uncompress($cert) if ord( bytes::substr $cert, 0, 1 ) == 120;
  M�H��e��I|A&�Y �▒bG��▒b?^�bAT9��at��bsct2022-03inistry of H

Still binary, but now we can start to make out human readable text

  M�H��e��I|A&�Y �▒bG��▒b?^�bAT9��at��bsct2022-03inistry of H

It's a CBOR!

CBOR

"The Concise Binary Object Representation (CBOR) is a data format whose design goals include the possibility of extremely small code size, fairly small message size, and extensibility without the need for version negotiation."

https://cbor.io

https://www.rfc-editor.org/rfc/rfc8949.html

JSON Web Tokens (JWT) on steroids

A very complex way to encode some data structure

  use CBOR::XS;

  my $cbor = CBOR::XS->new;
  my $decoded = $cbor->decode($cert);
  use CBOR::XS;

  my $cbor = CBOR::XS->new;
  my $decoded = $cbor->decode($cert);
  use CBOR::XS;

  my $cbor = CBOR::XS->new;
  my $decoded = $cbor->decode($cert);

$decoded now looks like this:

  bless([
    18,
    [
      '�H��e��I|A&',
      {},
      '�▒bG��▒b?^�bAT9��at��bsct2022-03inistry of Health, A...',
      'G��^fI�G�����G��J����M4���(�yZ&���\'��5�q...'
    ]
  ]), 'CBOR::XS::Tagged'
  bless([
    18,
    [
      '�H��e��I|A&',
      {},
      '�▒bG��▒b?^�bAT9��at��bsct2022-03inistry of Health, A...',
      'G��^fI�G�����G��J����M4���(�yZ&���\'��5�q...'
    ]
  ]), 'CBOR::XS::Tagged'
  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  my $payload = $cbor->decode($payload_cbor);
  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  my $payload = $cbor->decode($payload_cbor);
  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  my $payload = $cbor->decode($payload_cbor);
  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  my $payload = $cbor->decode($payload_cbor);
  {
    '-260' => {
      '1' => {
        'dob' => '1976-13-32',
        'nam' => {
          'fn' => 'Klausner',
          'fnt' => 'KLAUSNER',
          'gn' => 'Thomas',
          'gnt' => 'THOMAS'
        },
        't' => [
          {
            'ci' => 'URN:UVCI:01:AT:BD9DWLTL4451CCIF6JJ700CKB#/',
            'co' => 'AT',
            'is' => 'Ministry of Health, Austria',
            'nm' => 'NAAT',
            'sc' => '2022-03-26T06:23:00Z',
            'tc' => 'Lifebrain - Alles gurgelt',
            'tg' => '840539006',
            'tr' => '260415000',
            'tt' => 'LP6464-4'
          }
        ],
        'ver' => '1.3.0'
      }
    },
    '1' => 'AT',
    '4' => 1648880580,
    '6' => 1648320162
  }

Now we're getting at some usable data

in some weird data structure

  {
    '-260' => {
      '1' => {
        'nam' => {
          'fn' => 'Klausner',
          'gn' => 'Thomas',
        },

COMMISSION IMPLEMENTING DECISION (EU) 2021/1073

ANNEX I 3.2.1. CWT Structure Overview

https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32021D1073&from=EN

A spec that's also a law :-)

Again thanks to Maroš for digging through all those specs...

  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  my $payload = $cbor->decode($payload_cbor);

  my $data    = $payload->{-260}{1};

$data contains some obvious data like dob, nam and ver

And, depending on the type of the certificate

  r   .. recovered
  t   .. test
  v   .. vaccination

In this example, we're looking at a test:

  't' => [
    {
      'ci' => 'URN:UVCI:01:AT:BD9DWLTL4451CCIF6JJ700CKB#/',
      'co' => 'AT',
      'is' => 'Ministry of Health, Austria',
      'nm' => 'NAAT',
      'sc' => '2022-03-26T06:23:00Z',
      'tc' => 'Lifebrain - Alles gurgelt',
      'tg' => '840539006',
      'tr' => '260415000',
      'tt' => 'LP6464-4'
    }
  ],
  't' => [
    {
      'ci' => 'URN:UVCI:01:AT:BD9DWLTL4451CCIF6JJ700CKB#/',
      'co' => 'AT',
      'is' => 'Ministry of Health, Austria',
      'nm' => 'NAAT',
      'sc' => '2022-03-26T06:23:00Z',
      'tc' => 'Lifebrain - Alles gurgelt',
      'tg' => '840539006',
      'tr' => '260415000',
      'tt' => 'LP6464-4'
    }
  ],
  't' => [
    {
      'ci' => 'URN:UVCI:01:AT:BD9DWLTL4451CCIF6JJ700CKB#/',
      'co' => 'AT',
      'is' => 'Ministry of Health, Austria',
      'nm' => 'NAAT',
      'sc' => '2022-03-26T06:23:00Z',
      'tc' => 'Lifebrain - Alles gurgelt',
      'tg' => '840539006',
      'tr' => '260415000',
      'tt' => 'LP6464-4'
    }
  ],
  't' => [
    {
      'ci' => 'URN:UVCI:01:AT:BD9DWLTL4451CCIF6JJ700CKB#/',
      'co' => 'AT',
      'is' => 'Ministry of Health, Austria',
      'nm' => 'NAAT',
      'sc' => '2022-03-26T06:23:00Z',
      'tc' => 'Lifebrain - Alles gurgelt',
      'tg' => '840539006',
      'tr' => '260415000',
      'tt' => 'LP6464-4'
    }
  ],
  't' => [
    {
      'ci' => 'URN:UVCI:01:AT:BD9DWLTL4451CCIF6JJ700CKB#/',
      'co' => 'AT',
      'is' => 'Ministry of Health, Austria',
      'nm' => 'NAAT',
      'sc' => '2022-03-26T06:23:00Z',
      'tc' => 'Lifebrain - Alles gurgelt',
      'tg' => '840539006',
      'tr' => '260415000', # test result
      'tt' => 'LP6464-4'
    }
  ],
  't' => [
    {
      'ci' => 'URN:UVCI:01:AT:BD9DWLTL4451CCIF6JJ700CKB#/',
      'co' => 'AT',
      'is' => 'Ministry of Health, Austria',
      'nm' => 'NAAT',
      'sc' => '2022-03-26T06:23:00Z',
      'tc' => 'Lifebrain - Alles gurgelt',
      'tg' => '840539006',  COVID-19
      'tr' => '260415000',
      'tt' => 'LP6464-4'
    }
  ],

Future proof!

  use List::Util qw(first);
  use DateTime;

  my ($valid_days,$valid_from);
  foreach my $type (qw(v r t)) { # vaccinated recovered tested
      next unless defined $data->{$type};
      my $detail = first { $_->{tg} eq '840539006' } $data->{$type}->@*;
      if (defined $detail) {
        ...
      }
  }
  use List::Util qw(first);
  use DateTime;

  my ($valid_days,$valid_from);
  foreach my $type (qw(v r t)) { # vaccinated recovered tested
      next unless defined $data->{$type};
      my $detail = first { $_->{tg} eq '840539006' } $data->{$type}->@*;
      if (defined $detail) {
        ...
      }
  }

go through all the possible types

  use List::Util qw(first);
  use DateTime;

  my ($valid_days,$valid_from);
  foreach my $type (qw(v r t)) { # vaccinated recovered tested
      next unless defined $data->{$type};
      my $detail = first { $_->{tg} eq '840539006' } $data->{$type}->@*;
      if (defined $detail) {
        ...
      }
  }

find the first one where tg eq 840539006 (i.e. target is COVID-19)

  if ($type eq 't') {
      # tt: The type of test
      # nm: Test name (PCR only)
      # ma: Test device identifier (Antigen only)
      # tc: Name of the actor that conducted the test
      # sc: The date and time when the test sample was collected
      
      $valid_from = parse_date($detail->{sc});
      
      if ($detail->{tr} ne '260415000') {
          fail('Positive test certificate! Go home!');
      }
      
      if ($detail->{tt} eq 'LP6464-4') { # PCR
          $valid_days = 2;
      } elsif ($detail->{tt} eq 'LP217198-3') { # Antigen
          $valid_days = 1;
      }
  }

If it's a test

  if ($type eq 't') {
      # tt: The type of test
      # nm: Test name (PCR only)
      # ma: Test device identifier (Antigen only)
      # tc: Name of the actor that conducted the test
      # sc: The date and time when the test sample was collected
      
      $valid_from = parse_date($detail->{sc});
      
      if ($detail->{tr} ne '260415000') {
          fail('Positive test certificate! Go home!');
      }
      
      if ($detail->{tt} eq 'LP6464-4') { # PCR
          $valid_days = 2;
      } elsif ($detail->{tt} eq 'LP217198-3') { # Antigen
          $valid_days = 1;
      }
  }

Get the valid_from date from sc ("sample collected")

  if ($type eq 't') {
      # tt: The type of test
      # nm: Test name (PCR only)
      # ma: Test device identifier (Antigen only)
      # tc: Name of the actor that conducted the test
      # sc: The date and time when the test sample was collected
      
      $valid_from = parse_date($detail->{sc});
      
      if ($detail->{tr} ne '260415000') {
          fail('Positive test certificate! Go home!');
      }
      
      if ($detail->{tt} eq 'LP6464-4') { # PCR
          $valid_days = 2;
      } elsif ($detail->{tt} eq 'LP217198-3') { # Antigen
          $valid_days = 1;
      }
  }

If the test result (tr) is positiv (obviously encoded as "260415000"), fail.

  if ($type eq 't') {
      # tt: The type of test
      # nm: Test name (PCR only)
      # ma: Test device identifier (Antigen only)
      # tc: Name of the actor that conducted the test
      # sc: The date and time when the test sample was collected
      
      $valid_from = parse_date($detail->{sc});
      
      if ($detail->{tr} ne '260415000') {
          fail('Positive test certificate! Go home!');
      }
      
      if ($detail->{tt} eq 'LP6464-4') { # PCR
          $valid_days = 2;
      } elsif ($detail->{tt} eq 'LP217198-3') { # Antigen
          $valid_days = 1;
      }
  }

Calc expiry date based on type of test and some hardcoded values (which should be adapted to the current rules)

  } elsif ($type eq 'v') {
      # vp = Type of the vaccine or prophylaxis used.
      # mp = Medicinal product used for this specific dose of vaccination.
      # v = Marketing authorisation holder or manufacturer
      # dn = Sequence number of the dose given during this vaccination event.
      # sd = The overall number of doses in the series
      # dt = Date of vaccination

      $valid_days = $detail->{dn} >= 3 ? 270:180;
      $valid_from = parse_date($detail->{dt});

      # Jansen
      if ($detail->{mp} eq 'EU/1/20/1525') {
          $valid_from = $valid_from->add( days => 22 );
      }

      # Skip invalid - single Jansen jab isn't valid anymore
      if ($detail->{dn} != $detail->{sd} || $detail->{dn} == 1) {
          $valid_from = undef;
      }
  }

If it's a vaccination

  } elsif ($type eq 'v') {
      # vp = Type of the vaccine or prophylaxis used.
      # mp = Medicinal product used for this specific dose of vaccination.
      # v = Marketing authorisation holder or manufacturer
      # dn = Sequence number of the dose given during this vaccination event.
      # sd = The overall number of doses in the series
      # dt = Date of vaccination

      $valid_days = $detail->{dn} >= 3 ? 270:180;
      $valid_from = parse_date($detail->{dt});

      # Jansen
      if ($detail->{mp} eq 'EU/1/20/1525') {
          $valid_from = $valid_from->add( days => 22 );
      }

      # Skip invalid - single Jansen jab isn't valid anymore
      if ($detail->{dn} != $detail->{sd} || $detail->{dn} == 1) {
          $valid_from = undef;
      }
  }

Again get valid_from and valid_until from the data, considering the different "medical products"

  } elsif ($type eq 'r') {
      # fr: The date when a sample for the NAAT test producing a positive result was collected
      # df: The first date on which the certificate is considered to be valid
      # du: The last date on which the certificate is considered to be valid, assigned by the certificate issuer.

      $valid_from = parse_date($detail->{df});
      $valid_days = 180;
  }

And if it's a recovery, do some even simpler date math.

  # Check expiry
  fail('Certificate is not yet valid')
      if ! $valid_from || $valid_from->epoch > time;
  
  fail('Not a valid COVID-19 certificate')
      if !$valid_days;
  
  fail('Certificate is epxired')
      if $valid_from->add( days => $valid_days )->epoch < time;

Now we can define some fail cases...

or declare the certificate valid

  return "VALID, have fun!";

um, but we have a problem here

(yes, returning a string is stupid (and not in the original code), but that's not the problem)

We now only decoded the certificate, but have not validated it!

If you reverse these steps, you could easily generate a fake certificate

COSE

Like JOSE for JWTs, there is also COSE for CBORs

CBOR Object Signing and Encryption

Concise Binary Object Representation Object Signing and Encryption

  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  
  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  
  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  
  my ($pheader_cbor, $uheader, $payload_cbor, $signature) = $decoded->value->@*;
  my $pheader = $cbor->decode($pheader_cbor);

We get the header and decode it

  {
    '1' => -7,
    '4' => '��e��I|A'
  }

$pheader->{1} defines the signing & digest algorithm

$pheader->{4} defines the key id

  my %ALGS = (
    '-7'  => { class => 'Crypt::PK::ECC', digest => 'SHA256' },
    '-35' => { class => 'Crypt::PK::ECC', digest => 'SHA384' },
    '-36' => { class => 'Crypt::PK::ECC', digest => 'SHA512' },
    '-37' => { class => 'Crypt::PK::RSA', digest => 'SHA256' },
    '-38' => { class => 'Crypt::PK::RSA', digest => 'SHA384' },
    '-39' => { class => 'Crypt::PK::RSA', digest => 'SHA512' },
  );
  my $alg = $ALGS{$pheader->{1}};
  my %ALGS = (
    '-7'  => { class => 'Crypt::PK::ECC', digest => 'SHA256' },
    '-35' => { class => 'Crypt::PK::ECC', digest => 'SHA384' },
    '-36' => { class => 'Crypt::PK::ECC', digest => 'SHA512' },
    '-37' => { class => 'Crypt::PK::RSA', digest => 'SHA256' },
    '-38' => { class => 'Crypt::PK::RSA', digest => 'SHA384' },
    '-39' => { class => 'Crypt::PK::RSA', digest => 'SHA512' },
  );
  my $alg = $ALGS{$pheader->{1}};

Could be RSA or ECC

  my %ALGS = (
    '-7'  => { class => 'Crypt::PK::ECC', digest => 'SHA256' },
    '-35' => { class => 'Crypt::PK::ECC', digest => 'SHA384' },
    '-36' => { class => 'Crypt::PK::ECC', digest => 'SHA512' },
    '-37' => { class => 'Crypt::PK::RSA', digest => 'SHA256' },
    '-38' => { class => 'Crypt::PK::RSA', digest => 'SHA384' },
    '-39' => { class => 'Crypt::PK::RSA', digest => 'SHA512' },
  );
  my $alg = $ALGS{$pheader->{1}};

Could be various digests

  my %ALGS = (
    '-7'  => { class => 'Crypt::PK::ECC', digest => 'SHA256' },
    '-35' => { class => 'Crypt::PK::ECC', digest => 'SHA384' },
    '-36' => { class => 'Crypt::PK::ECC', digest => 'SHA512' },
    '-37' => { class => 'Crypt::PK::RSA', digest => 'SHA256' },
    '-38' => { class => 'Crypt::PK::RSA', digest => 'SHA384' },
    '-39' => { class => 'Crypt::PK::RSA', digest => 'SHA512' },
  );
  my $alg = $ALGS{$pheader->{1}};

Find the one used in this cert

  my %ALGS = (
    '-7'  => { class => 'Crypt::PK::ECC', digest => 'SHA256' },
    '-35' => { class => 'Crypt::PK::ECC', digest => 'SHA384' },
    '-36' => { class => 'Crypt::PK::ECC', digest => 'SHA512' },
    '-37' => { class => 'Crypt::PK::RSA', digest => 'SHA256' },
    '-38' => { class => 'Crypt::PK::RSA', digest => 'SHA384' },
    '-39' => { class => 'Crypt::PK::RSA', digest => 'SHA512' },
  );
  my $alg = $ALGS{$pheader->{1}};

As $pheader->{1} contains -7, we're dealing with some elliptic curves keys using SHA256 for hashing.

That was the easy part..

Now we need the public key of the signer

You can get them here via a nice API https://dgcg.covidbevis.se/tp/trust-list

that returns a signed JWT

So to decode and verify that, we need their public key:

  ~$ curl https://dgcg.covidbevis.se/tp/cert -o signer.crt
  ~$ cat signer.crt
  -----BEGIN CERTIFICATE-----
  MIIB6jCCAY+gAwIBAgIUZVUDZ9xmLgGkjoqXhMizIaqko4AwCgYIKoZIzj0EAwIw
  TTELMAkGA1UEBhMCU0UxHzAdBgNVBAoMFlN3ZWRpc2ggZUhlYWx0aCBBZ2VuY3kx
  ...
  b3JAZWhhbHNvbXluZGlnaGV0ZW4uc2UwCgYIKoZIzj0EAwIDSQAwRgIhAL02Iy2x
  if3oTSivwKg+fZDwquoTgTMJnCUJzyltYExjAiEAsT3U7icfdH3FtrZ/NZ2LlC5r
  sSSXWtDUTXhmclnJFnU=
  -----END CERTIFICATE-----
  ~$ curl https://dgcg.covidbevis.se/tp/trust-list
  eyJ4NWMiOlsiTUlJQjZqQ0NBWStnQXdJQkFnSVVaVlVEWjl4bUxnR2tqb3FYaE1pe
  klhcWtvNEF3Q2dZSUtvWkl6ajBFQXdJd1RURUxNQWtHQTFVRUJoTUNVMFV4SHpBZE
  JnTlZCQW9NRmxOM1pXUnBjMmdnWlVobFlXeDBhQ0JCWjJWdVkza3hIVEFiQmdOVkJ
  ..
  ~$ curl https://dgcg.covidbevis.se/tp/trust-list |       
     perl -MCrypt::JWT=decode_jwt -MCpanel::JSON::XS -n -E 
     'say encode_json( decode_jwt(                         
        token => $_,                                       
        key => Crypt::PK::ECC->new("signer.crt")))'        
     > trust_list.json

Some old-school Perl commandline power

  ~$ curl https://dgcg.covidbevis.se/tp/trust-list |       
     perl -MCrypt::JWT=decode_jwt -MCpanel::JSON::XS -n -E 
     'say encode_json( decode_jwt(                         
        token => $_,                                       
        key => Crypt::PK::ECC->new("signer.crt")))'        
     > trust_list.json

Load the modules we need to decode the JWT and produce a nice result

  ~$ curl https://dgcg.covidbevis.se/tp/trust-list |       
     perl -MCrypt::JWT=decode_jwt -MCpanel::JSON::XS -n -E 
     'say encode_json( decode_jwt(                         
        token => $_,                                       
        key => Crypt::PK::ECC->new("signer.crt")))'        
     > trust_list.json

Get the content we got from the API ($_, our token) and the signature we downloaded earlier, and use that to decode_jwt

  ~$ curl https://dgcg.covidbevis.se/tp/trust-list |       
     perl -MCrypt::JWT=decode_jwt -MCpanel::JSON::XS -n -E 
     'say encode_json( decode_jwt(                         
        token => $_,                                       
        key => Crypt::PK::ECC->new("signer.crt")))'        
     > trust_list.json

Encode the data as json and write it into a file, trust_list.json

  ~$ json_pretty < trust_list.json  | head -n 50
  {
     "aud" : null,
     "dsc_trust_list" : {
        "AD" : {
           "eku" : {
              "mqWkXpNR0Rk=" : [
                 "1.3.6.1.4.1.1847.2021.1.1",
                 "1.3.6.1.4.1.1847.2021.1.2",
                 "1.3.6.1.4.1.1847.2021.1.3"
              ]
           },
           "keys" : [
              {
                 "crv" : "P-256",

Remember $pheader->{4}, which contains the key id?

  {
    '1' => -7,
    '4' => '��e��I|A'
  }

We need to base64 encode that

  use MIME::Base64 qw(encode_base64);

  my $key_id = encode_base64($pheader->{4});
  use MIME::Base64 qw(encode_base64);

  my $key_id = encode_base64($pheader->{4});

which yields

  gNhlvKFJfEE=

Which we can now (hopefully) find in the trust-list

  "AT" : {
     "eku" : {},
     "keys" : [
        {
           "crv" : "P-256",
           "kid" : "Is2JtrOJhik=",
           "kty" : "EC",
           "x" : "YE24qIKmdcfRWUh2TqklkfZ6nyNBpX4VHeLMxfFl8rk",
           "x5c" : [
              "MIIB7z..9AGw=="
           ],
           "x5t#S256" : "Is2JtrOJhinpnQsaO73CXL3yZEx1jbytAn55PJ52JfU",
           "y" : "EPGZLtG3Jx-TmV3JJErfrSrPhRmfbSidVbTQ5nnZS-s"
        },
        {
           "crv" : "P-256",
           "kid" : "gNhlvKFJfEE=",
           "kty" : "EC",
           "x" : "RFDliGDvd_2OKXevNNyURLSR-0qv7Ajz8C6yTLXJ1QY",
           "x5c" : [
              "MIIB8TC..ASkBs7UJ1Jc"
           ],
           "x5t#S256" : "gNhlvKFJfEGPpoJbsNE6dP5rqaKblm6KqLtSRSluq-M",
           "y" : "yQqj60DlauAl4sioorffH1i6LU3pTKaMOM9ZtPWLOls"
        }
     ]
  },
  "AT" : {
     "eku" : {},
     "keys" : [
        {
           "crv" : "P-256",
           "kid" : "Is2JtrOJhik=",
           "kty" : "EC",
           "x" : "YE24qIKmdcfRWUh2TqklkfZ6nyNBpX4VHeLMxfFl8rk",
           "x5c" : [
              "MIIB7z..9AGw=="
           ],
           "x5t#S256" : "Is2JtrOJhinpnQsaO73CXL3yZEx1jbytAn55PJ52JfU",
           "y" : "EPGZLtG3Jx-TmV3JJErfrSrPhRmfbSidVbTQ5nnZS-s"
        },
        {
           "crv" : "P-256",
           "kid" : "gNhlvKFJfEE=",
           "kty" : "EC",
           "x" : "RFDliGDvd_2OKXevNNyURLSR-0qv7Ajz8C6yTLXJ1QY",
           "x5c" : [
              "MIIB8TC..ASkBs7UJ1Jc"
           ],
           "x5t#S256" : "gNhlvKFJfEGPpoJbsNE6dP5rqaKblm6KqLtSRSluq-M",
           "y" : "yQqj60DlauAl4sioorffH1i6LU3pTKaMOM9ZtPWLOls"
        }
     ]
  },

x5c contains the signature for our certificate!

  use Cpanel::JSON::XS qw(decode_json encode_json);
  use Path::Tiny qw(path);
  use Crypt::PK::ECC;
  use Crypt::PK::RSA;

  my $trust_list = path('trust_list.json');
  my $certs      = decode_json($trust_list->slurp);
  use Cpanel::JSON::XS qw(decode_json encode_json);
  use Path::Tiny qw(path);
  use Crypt::PK::ECC;
  use Crypt::PK::RSA;

  my $trust_list = path('trust_list.json');
  my $certs      = decode_json($trust_list->slurp);
  use Cpanel::JSON::XS qw(decode_json encode_json);
  use Path::Tiny qw(path);
  use Crypt::PK::ECC;
  use Crypt::PK::RSA;

  my $trust_list = path('trust_list.json');
  my $certs      = decode_json($trust_list->slurp);
  my $signing_cert = first { $_->{kid} eq $key_id }
    $certs->{dsc_trust_list}{$payload->{1}}{keys}->@*;

  my $x509cert = $signing_cert->{x5c}[0];

  my $public_key = $alg->{class}->new(\qq[-----BEGIN CERTIFICATE-----
  ${x509cert}
  -----END CERTIFICATE-----]);

$payload->{1} contains the country code ('AT'), so we get a list of all the certs for this country

  my $signing_cert = first { $_->{kid} eq $key_id }
    $certs->{dsc_trust_list}{$payload->{1}}{keys}->@*;

  my $x509cert = $signing_cert->{x5c}[0];

  my $public_key = $alg->{class}->new(\qq[-----BEGIN CERTIFICATE-----
  ${x509cert}
  -----END CERTIFICATE-----]);

And get the first one where the key-id matches

  my $signing_cert = first { $_->{kid} eq $key_id }
    $certs->{dsc_trust_list}{$payload->{1}}{keys}->@*;

  my $x509cert = $signing_cert->{x5c}[0];

  my $public_key = $alg->{class}->new(\qq[-----BEGIN CERTIFICATE-----
  ${x509cert}
  -----END CERTIFICATE-----]);

Get the public key (from x5c)

  my $signing_cert = first { $_->{kid} eq $key_id }
    $certs->{dsc_trust_list}{$payload->{1}}{keys}->@*;

  my $x509cert = $signing_cert->{x5c}[0];

  my $public_key = $alg->{class}->new(\qq[-----BEGIN CERTIFICATE-----
  ${x509cert}
  -----END CERTIFICATE-----]);

Use the crypto algorithm (Crypt::PK::ECC) to read the public key

  my %ALGS = (
    '-7'  => { class => 'Crypt::PK::ECC', digest => 'SHA256' },
  )

remember that?

  my $check_signature = $cbor->encode([
      CBOR::XS::as_text('Signature1'),
      $pheader_cbor,
      CBOR::XS::as_bytes(''),
      $payload_cbor,
  ]);

Build a COSE signature

  my $check_signature = $cbor->encode([
      CBOR::XS::as_text('Signature1'),
      $pheader_cbor,
      CBOR::XS::as_bytes(''),
      $payload_cbor,
  ]);

using some magic params I was not motivated enough to understand :-)

  if ( $public_key->verify_message_rfc7518(
    $signature,        # from QR-code
    $check_signature,  # calculated by us
    $alg->{digest}
  ) {
     # OK!
  }
  else {
    fail('Could not verify signature')
  }

We can now verify the signature

  if ( $public_key->verify_message_rfc7518(
    $signature,        # from QR-code
    $check_signature,  # calculated by us
    $alg->{digest}
  ) {
     # OK!
  }
  else {
    fail('Could not verify signature')
  }

comparing the signature we got from the QR-Code with the one we generated ourself, using the correct digest (as specified in the QR-Code)

  if ( $public_key->verify_message_rfc7518(
    $signature,        # from QR-code
    $check_signature,  # calculated by us
    $alg->{digest}
  ) {
     # OK!
  }
  else {
    fail('Could not verify signature')
  }

If the signatures match, we now have proof that the payload has not been tampered with

  if ( $public_key->verify_message_rfc7518(
    $signature,        # from QR-code
    $check_signature,  # calculated by us
    $alg->{digest}
  ) {
     # OK!
  }
  else {
    fail('Could not verify signature')
  }

If the don't match, we fail!

covidvalid.pl

  ~$ perl covidvalid.pl 'NCFOXN%TS3DHDWKJ/8 1K10K.0IC...WQSI008:PG5'
  
  ~$ perl covidvalid.pl 'NCFOXN%TS3DHDWKJ/8 1K10K.0IC...WQSI008:PG5'
  {"type":"success","fn":"Klausner","gn":"Thomas"}

Object::Pad-ify

  file: lib/CheckCovidCert.pm
  use 5.034;
  use Object::Pad;
  
  class CheckCovidCert {

  }
  file: lib/CheckCovidCert.pm
  use 5.034;
  use Object::Pad;
  
  class CheckCovidCert {

  }

define a new class

  use QRCode::Base45 qw(decode_base45);
  use Compress::Zlib qw(uncompress);
  use CBOR::XS ();
  use MIME::Base64 qw(encode_base64);
  use Cpanel::JSON::XS qw(decode_json encode_json);
  use Path::Tiny qw(path);
  use List::Util qw(first);
  use FindBin qw($Bin);
  use DateTime;
  use Crypt::PK::ECC;
  use Crypt::PK::RSA;

load all the CPAN modules we need

  has $cert :reader :param;
  has $ignore_expired :reader :param = 0;

Define some attributes

  has $cert :reader :param;
  has $ignore_expired :reader :param = 0;

$cert will get a read-only accessor (:reader) and can be initiated on object creation (:param)

  has $cert :reader :param;
  has $ignore_expired :reader :param = 0;

$ignore_expired will also default to 0

As $cert has no default, but is a :param, it is required

  my $c3 = CheckCovidCert->new(
      cert => $the_cert
  
  )

Initiate an object with a cert

  my $c3 = CheckCovidCert->new(
      cert => $the_cert,
      ignore_expired => 1
  )
  method decode {
      my $cbor = CBOR::XS->new;

      $cert =~ s/^HC1://;
      chomp($cert);
      ...
  }

A nice keyword to define a method

No need for my $self = shit

(c) Ingy

  method decode {
      my $cbor = CBOR::XS->new;

      $cert =~ s/^HC1://;
      chomp($cert);
      ...
  }

No need for an accessor inside the class, just use $cert.

  # Success
  return {
      status        => 'valid',
      given_name    => $data->{nam}{gn},
      family_name   => $data->{nam}{fn},
      date_of_birth => $data->{dob},
      reason        => $reason,
      more_reason   => $more_reason,
  };

I changed the return value to include a bit more data

  method fail {
      my $reason = shift;
      return {
          status => 'invalid',
          reason => $reason,
      };
  }

Another simple method

A small wrapper script

  file: check.pl
  #!/usr/bin/perl
  use 5.034;
  use CheckCovidCert;
  use Data::Dumper;

  my $c3 = CheckCovidCert->new(
      cert => $ARGV[0]
  );
  say Dumper $c3->decode;
  file: check.pl
  #!/usr/bin/perl
  use 5.034;
  use CheckCovidCert;
  use Data::Dumper;

  my $c3 = CheckCovidCert->new(
      cert => $ARGV[0]
  );
  say Dumper $c3->decode;

Load the module

  file: check.pl
  #!/usr/bin/perl
  use 5.034;
  use CheckCovidCert;
  use Data::Dumper;

  my $c3 = CheckCovidCert->new(
      cert => $ARGV[0]
  );
  say Dumper $c3->decode;

Initiate an object, passing the first command line arg as the cert

  file: check.pl
  #!/usr/bin/perl
  use 5.034;
  use CheckCovidCert;
  use Data::Dumper;

  my $c3 = CheckCovidCert->new(
      cert => $ARGV[0]
  );
  say Dumper $c3->decode;

Output the result

  ~# perl check.pl 'NCFOXN%TS3DH...6SH+H3OFVAWDSFUQG'
  
  
  
  
  ~# perl check.pl 'NCFOXN%TS3DH...6SH+H3OFVAWDSFUQG'
  $VAR1 = {
          'status' => 'invalid',
          'reason' => 'Certificate is expired'
        };

Plack API

  file: lib/QRona.pm
  package QRona;
  
  use 5.034;
  use Plack::Builder;
  use Plack::App::File;
  use Plack::Request;
  use CheckCovidCert;
  use Cpanel::JSON::XS qw(decode_json encode_json);
  file: lib/QRona.pm
  package QRona;
  
  use 5.034;
  use Plack::Builder;
  use Plack::App::File;
  use Plack::Request;
  use CheckCovidCert;
  use Cpanel::JSON::XS qw(decode_json encode_json);

Load some basic Plack modules

  file: lib/QRona.pm
  package QRona;
  
  use 5.034;
  use Plack::Builder;
  use Plack::App::File;
  use Plack::Request;
  use CheckCovidCert;
  use Cpanel::JSON::XS qw(decode_json encode_json);

and our new Cert-Checker

  sub run_psgi {
      my $self = shift;
  
      my $api_app = sub {
          my $env = shift;
          # TODO .. proper app
          return [200, [], 'ok'];
      };

      return builder {
          mount '/'           => $api_app;
      };
  }

Reminder: A simple Plack App is just a sub that returns an array containing Status Code, Header and Body

  sub run_psgi {
      my $self = shift;
  
      my $api_app = sub {
          my $env = shift;
          # TODO .. proper app
          return [200, [], 'ok'];
      };

      return builder {
          mount '/'           => $api_app;
      };
  }

Here's the sub

  sub run_psgi {
      my $self = shift;
  
      my $api_app = sub {
          my $env = shift;
          # TODO .. proper app
          return [200, [], 'ok'];
      };

      return builder {
          mount '/'           => $api_app;
      };
  }

Get the environment (i.e. request parameters etc)

  sub run_psgi {
      my $self = shift;
  
      my $api_app = sub {
          my $env = shift;
          # TODO .. proper app
          return [200, [], 'ok'];
      };

      return builder {
          mount '/'           => $api_app;
      };
  }

Return the HTTP Response

  sub run_psgi {
      my $self = shift;
  
      my $api_app = sub {
          my $env = shift;
          # TODO .. proper app
          return [200, [], 'ok'];
      };

      return builder {
          mount '/'           => $api_app;
      };
  }

Use Plack::Builder to mount the app at /

  file: qrona.psgi
  #!/usr/bin/env perl
  use 5.034;
  use QRona;
  
  QRona->run_psgi;
  ~$ plackup -p 5123 -r qrona.psgi
  
 
 
 
  ~$ plackup -p 5123 -r qrona.psgi
  HTTP::Server::PSGI: Accepting connections at http://0:5123/
 
 
 
  ~$ plackup -p 5123 -r qrona.psgi
  HTTP::Server::PSGI: Accepting connections at http://0:5123/
 
  ~$ curl http://localhost:5123/
  
  ~$ plackup -p 5123 -r qrona.psgi
  HTTP::Server::PSGI: Accepting connections at http://0:5123/
 
  ~$ curl http://localhost:5123/
  ok
  my $api_app = sub {
      my $env = shift;

      my $req = Plack::Request->new($env);
      if (my $raw = $req->content) {
          my $payload = decode_json($raw);
          my $c3 = CheckCovidCert->new(
              cert           => $payload->{qr},
              ignore_expired => $payload->{ignore_expired},
          );
          my $res = $c3->decode;
          return [200, ['Content-Type'=>'application/json'],[ encode_json($res) ]];
      }
      else {
          return [ 400, [], ['bad request'] ];
      }
  };

Generate a Plack::Request because it has a nice API then dealing with the raw $env

  my $api_app = sub {
      my $env = shift;

      my $req = Plack::Request->new($env);
      if (my $raw = $req->content) {
          my $payload = decode_json($raw);
          my $c3 = CheckCovidCert->new(
              cert           => $payload->{qr},
              ignore_expired => $payload->{ignore_expired},
          );
          my $res = $c3->decode;
          return [200, ['Content-Type'=>'application/json'],[ encode_json($res) ]];
      }
      else {
          return [ 400, [], ['bad request'] ];
      }
  };

Get the Request Content and decode the JSON payload

  my $api_app = sub {
      my $env = shift;

      my $req = Plack::Request->new($env);
      if (my $raw = $req->content) {
          my $payload = decode_json($raw);
          my $c3 = CheckCovidCert->new(
              cert           => $payload->{qr},
              ignore_expired => $payload->{ignore_expired},
          );
          my $res = $c3->decode;
          return [200, ['Content-Type'=>'application/json'],[ encode_json($res) ]];
      }
      else {
          return [ 400, [], ['bad request'] ];
      }
  };

Init our CheckCovidCert object with the data from the request (which will hopefully contain the QR code in $payload->{$qr}

  my $api_app = sub {
      my $env = shift;

      my $req = Plack::Request->new($env);
      if (my $raw = $req->content) {
          my $payload = decode_json($raw);
          my $c3 = CheckCovidCert->new(
              cert           => $payload->{qr},
              ignore_expired => $payload->{ignore_expired},
          );
          my $res = $c3->decode;
          return [200, ['Content-Type'=>'application/json'],[ encode_json($res) ]];
      }
      else {
          return [ 400, [], ['bad request'] ];
      }
  };

Now run the whole decode process

  my $api_app = sub {
      my $env = shift;

      my $req = Plack::Request->new($env);
      if (my $raw = $req->content) {
          my $payload = decode_json($raw);
          my $c3 = CheckCovidCert->new(
              cert           => $payload->{qr},
              ignore_expired => $payload->{ignore_expired},
          );
          my $res = $c3->decode;
          return [200, ['Content-Type'=>'application/json'],[ encode_json($res) ]];
      }
      else {
          return [ 400, [], ['bad request'] ];
      }
  };

And send the result back as JSON

  ~$ curl 'http://localhost:5123/api/qr' -X POST -H 'Content-Type: application/json' --data-raw '{"qr":"HC1:NOFOXN%TS3DHDWK....VAWDSFUQG"}'
  
  
  
  
  
  
  
  
  ~$ curl 'http://localhost:5123/api/qr' -X POST -H 'Content-Type: application/json' --data-raw '{"qr":"HC1:NOFOXN%TS3DHDWK....VAWDSFUQG"}'
  {"reason":"Certificate is expired","status":"invalid"}
  
  
  
  
  
  
  
  ~$ curl 'http://localhost:5123/api/qr' -X POST -H 'Content-Type: application/json' --data-raw '{"qr":"HC1:YESOXN%TS3DHDWK....VAWDSFUQG"}'
  
  
  
  
  
  
  
  
  ~$ curl 'http://localhost:5123/api/qr' -X POST -H 'Content-Type: application/json' --data-raw '{"qr":"HC1:YESOXN%TS3DHDWK....VAWDSFUQG"}'
  {
     "date_of_birth" : "1976-13-32",
     "family_name" : "Klausner",
     "given_name" : "Thomas",
     "more_reason" : "J07BX03, EU/1/20/1528, 3",
     "reason" : "Vaccination",
     "status" : "valid"
  }
    my $static_app = Plack::App::File->new( root => './dist' )->to_app;

    return builder {
        enable 'CrossOrigin' => (
            origins => '*',
            headers => [
                qw(Cache-Control Depth If-Modified-Since User-Agent X-File-Name X-File-Size X-Requested-With X-Prototype-Version Cookie Content-Type Accept)
            ]
        );
        return builder {
            mount '/'           => $static_app;
            mount '/api'        => $api_app;
        };
    };

another Plack app, serving static files (the frontend) from dist/ using Plack::App::File

    my $static_app = Plack::App::File->new( root => './dist' )->to_app;

    return builder {
        enable 'CrossOrigin' => (
            origins => '*',
            headers => [
                qw(Cache-Control Depth If-Modified-Since User-Agent X-File-Name X-File-Size X-Requested-With X-Prototype-Version Cookie Content-Type Accept)
            ]
        );
        return builder {
            mount '/'           => $static_app;
            mount '/api'        => $api_app;
        };
    };

CORS, OMG

    my $static_app = Plack::App::File->new( root => './dist' )->to_app;

    return builder {
        enable 'CrossOrigin' => (
            origins => '*',
            headers => [
                qw(Cache-Control Depth If-Modified-Since User-Agent X-File-Name X-File-Size X-Requested-With X-Prototype-Version Cookie Content-Type Accept)
            ]
        );
        return builder {
            mount '/'           => $static_app;
            mount '/api'        => $api_app;
        };
    };

Mount the static app (the frontend) at /

And the API and /api

Web UI

vue.js Frontend

  ~$ npm install -g @vue/cli
  
    
   
  ~$ npm install -g @vue/cli
  ~$ vue create qrona
    
   
  ~$ npm install -g @vue/cli
  ~$ vue create qrona
  ... manual cleanup and fiddline  
   
  ~$ npm install -g @vue/cli
  ~$ vue create qrona
  ... manual cleanup and fiddline  
  ~$ yarn run serve
  App running at:
  - Local:   http://localhost:8080/ 
  - Network: http://192.168.1.6:8080/

  Note that the development build is not optimized.
  To create a production build, run yarn build.

Single File Component

  file: src/App.js
  <template>, <script>, <css>

<css>

  ... 
  div#app {
    height: 100%;
    display: flex;
    flex-direction: column;
    align-content: flex-start;
  }
  
  main {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-content: flex-start;
    flex: 1 0 auto;
  }
  ...

Just some plain old CSS

  ... 
  div#app {
    height: 100%;
    display: flex;
    flex-direction: column;
    align-content: flex-start;
  }
  
  main {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-content: flex-start;
    flex: 1 0 auto;
  }
  ...

The flex stuff might be interesting, but I think this talk already has enough content...

<template>

  <template>
    <div id="app">
      <main>
        <div id="head">
          <h1>QRona</h1>
          Scan and validate your Corona QR Certificate
        </div>
        <div id="camera">
          <qrcode-stream :camera="camera" @decode="onDecode" />
        </div>
        <div id="result" :class="status">
          <div v-if="result">
            <h1>{{ result.reason }}</h1>
            <h3>{{ result.given_name }} {{result.family_name}}</h3>
            <p>{{ result.date_of_birth }}</p>
            <p v-if="result.more_reason">{{ result.more_reason }}</p>
            <button @click="reset">Scan another code!</button>
          </div>
          <h1 v-else>Please scan your QR Code!</h1>
        </div>
      </main>
      <footer>
        QRona - a not very serious Corona Certificate Validator.<br />
        Made by <a href="https://domm.plix.at">domm</a> for <a href="http://act.yapc.eu/gpw2022/talk/7791">this talk at German Perl Workshop</a>.<br />
        Original validator code by Maroš.
      </footer>
    </div>
  </template>
  <div id="head">
    <h1>QRona</h1>
    Scan and validate your Corona QR Certificate
  </div>

Just some plain old HTML

  <div id="camera">
    <qrcode-stream :camera="camera" @decode="onDecode" />
  </div>

And some weird HTML

Which isn't HTML, but (in this case) calling a vue component (think module)

This implements the QR code scanner

https://gruhn.github.io/vue-qrcode-reader/

  ~$ yarn add vue-qrcode-reader

More on that later

  <div id="result" :class="status">
    <div v-if="result">
      <h1>{{ result.reason }}</h1>
      <h3>{{ result.given_name }} {{result.family_name}}</h3>
      <p>{{ result.date_of_birth }}</p>
      <p v-if="result.more_reason">{{ result.more_reason }}</p>
      <button @click="reset">Scan another code!</button>
    </div>
    <h1 v-else>Please scan your QR Code!</h1>
  </div>

Some more weird HTML

  <div id="result" :class="status">
    <div v-if="result">
      <h1>{{ result.reason }}</h1>
      <h3>{{ result.given_name }} {{result.family_name}}</h3>
      <p>{{ result.date_of_birth }}</p>
      <p v-if="result.more_reason">{{ result.more_reason }}</p>
      <button @click="reset">Scan another code!</button>
    </div>
    <h1 v-else>Please scan your QR Code!</h1>
  </div>

v-if, v-else etc are very ugly but effective control structures

if result is defined, render the child node(s)

else just render "Please scan your QR Code!"

  <div id="result" :class="status">
    <div v-if="result">
      <h1>{{ result.reason }}</h1>
      <h3>{{ result.given_name }} {{result.family_name}}</h3>
      <p>{{ result.date_of_birth }}</p>
      <p v-if="result.more_reason">{{ result.more_reason }}</p>
      <button @click="reset">Scan another code!</button>
    </div>
    <h1 v-else>Please scan your QR Code!</h1>
  </div>

{{ and }} define template strings (think [% %] in Template::Toolkit.

Which we can use to access and render data provided by our app.

  <div id="result" :class="status">
    <div v-if="result">
      <h1>{{ result.reason }}</h1>
      <h3>{{ result.given_name }} {{result.family_name}}</h3>
      <p>{{ result.date_of_birth }}</p>
      <p v-if="result.more_reason">{{ result.more_reason }}</p>
      <button @click="reset">Scan another code!</button>
    </div>
    <h1 v-else>Please scan your QR Code!</h1>
  </div>

@click tells vue to listen on click events on the button

And call the reset method defined in our app when the event happens

<script>

  import { QrcodeStream } from 'vue-qrcode-reader';
  import { beep } from "@/components/beep/beep.js";
  
  export default {
    name:       'QRona',
    components: {
      QrcodeStream,
    },
    data () {
      return {
        camera: 'auto',
        result: null,
        showScanConfirmation: false,
        status: 'waiting',
      }
    },
    methods: {
      async onDecode(content) {
        fetch("/api/qr", {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ qr: content })
        })
        .then(response => response.json())
        .then(json => {
          this.result = json;
          this.status = json.status;
          if (json.status == 'valid') {
            beep(json.reason);
          }
          else {
            beep("error");
          }
          this.camera = 'off';
          setTimeout(this.reset, 10 * 1000);
        })
        .catch(err => alert('Request Failed: ' + err));
      },
      reset() {
        this.result = null;
        this.status = 'waiting';
        this.camera = 'auto';
      }
    }
  }
  import { QrcodeStream } from 'vue-qrcode-reader';
  import { beep } from "@/components/beep/beep.js";

  export default {
    name:       'QRona',
    components: { ... },
    data()      { ... },
    methods:    { ... }
  }

A Vue app usually consists of some other components, data and methods

  import { QrcodeStream } from 'vue-qrcode-reader';
  import { beep } from "@/components/beep/beep.js";

  export default {
    name:       'QRona',
    components: {
      QrcodeStream,
    },

Import the Component and register it, so we can use it in template:

 <qrcode-stream :camera="camera" @decode="onDecode" />
    data () {
      return {
        camera: 'auto',
        result: null,
        showScanConfirmation: false,
        status: 'waiting',
      }
    },

a bit like attributes of a class

    data () {
      return {
        camera: 'auto',
        result: null,
        showScanConfirmation: false,
        status: 'waiting',
      }
    },

we can access data in the template:

    <div id="result" :class="status">

renders as

    <div id="result" class="waiting">

:class="status" is short for v-bind:class="status" and binds the value of the attribute class to the value of status

As soon as data.status changes, the template will automatically refresh and render the new data!

This is main selling point for SPAs and frontend applications!

And it is fact nice!

    methods: {
      async onDecode(content) {
        fetch("/api/qr", {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ qr: content })
        })
        .then(response => response.json())
        .then(json => {
          this.result = json;
          this.status = json.status;
          if (json.status == 'valid') {
            beep(json.reason);
          }
          else {
            beep("error");
          }
          this.camera = 'off';
          setTimeout(this.reset, 10 * 1000);
        })
        .catch(err => alert('Request Failed: ' + err));
      },
    methods: {
      async onDecode(content) {
        fetch("/api/qr", {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ qr: content })
        })

this is called when the QRcode Reader parsed a new QR code:

 <qrcode-stream :camera="camera" @decode="onDecode" />

qrcode-stream emits an event of type decode, and we route this event to our method onDecode

    methods: {
      async onDecode(content) {
        fetch("/api/qr", {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ qr: content })
        })

Use fetch to send a POST request to /api/qr

    methods: {
      async onDecode(content) {
        fetch("/api/qr", {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ qr: content })
        })

Pass the content (the decoded QR code) in the request body

        .then(response => response.json())
        .then(json => {
          this.result = json;
          this.status = json.status;

fetch returns a Promise, which we handle via then

        .then(response => response.json())
        .then(json => {
          this.result = json;
          this.status = json.status;

We can now copy the returned data from the request into our data

    <div id="result" :class="status">

which will trigger updates in the UI.

          if (json.status == 'valid') {
            beep(json.reason);
          }
          else {
            beep("error");
          }
          this.camera = 'off';
          setTimeout(this.reset, 10 * 1000);
        })
        .catch(err => alert('Request Failed: ' + err));
      },

If we got a valid response, play a short audio snippet based on the type of the certificate (test, vaccination, recovered)

          if (json.status == 'valid') {
            beep(json.reason);
          }
          else {
            beep("error");
          }
          this.camera = 'off';
          setTimeout(this.reset, 10 * 1000);
        })
        .catch(err => alert('Request Failed: ' + err));
      },

Or play an error sound if the certicificate is invalid

          if (json.status == 'valid') {
            beep(json.reason);
          }
          else {
            beep("error");
          }
          this.camera = 'off';
          setTimeout(this.reset, 10 * 1000);
        })
        .catch(err => alert('Request Failed: ' + err));
      },

Reset the camera, so we can scan another code

          if (json.status == 'valid') {
            beep(json.reason);
          }
          else {
            beep("error");
          }
          this.camera = 'off';
          setTimeout(this.reset, 10 * 1000);
        })
        .catch(err => alert('Request Failed: ' + err));
      },

And finally set a timeout of 10 seconds to reset the app to its initial state.

Very simple, eh?

Not too much code for a nice small app...

Live Demo!

https://qrona.plix.at

https://github.com/domm/QRona

Questions / Discussion