Spomky-Labs / cbor-php

CBOR Encoder/Decoder for PHP
MIT License
43 stars 11 forks source link

decode german covid impfpass #32

Closed sebmeg closed 2 years ago

sebmeg commented 3 years ago
Q A
Bug report? no
Feature request? yes
BC Break report? no
RFC? / Specification no

Is it possible to decode the german covid impfpass? I tried it with python like here: https://hackernoon.com/how-to-decode-your-own-eu-vaccination-green-pass-with-a-few-lines-of-python-9v2c37s1

Works without any problems. With PHP i cant get it to work with this class. Maybe i`m to stupid.

sebmeg commented 3 years ago

For Better understanding: https://pastebin.com/Ev6wx6cj

First i decode Base45, then decompress using zlib and put it in this class.

Spomky commented 3 years ago

Hi @sebmeg,

Thank you for opening this issue. I was not aware of the use of the CBOR format for such case. I've just tested with the data from the blog post you mentioned and I can load the expected data.

The missing part is that the tag for COSE signature is not supported by this library (this is something I definitively want to do!). But this is not an issue at all, we can create one for the example.

Also, the data cannot be verified as I don't have the public key. Hereafter a working example with comments. Let me know if you need help, I am really curious!

<?php

use CBOR\ByteStringObject;
use CBOR\CBORObject;
use CBOR\Decoder;
use CBOR\ListObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\StringStream;
use CBOR\Tag\TagObjectManager;
use CBOR\TagObject as Base;
use Mhauri\Base45;

require_once 'vendor/autoload.php';

$data = 'HC1:6BFNX1:HM*I0PS3TLU.NGMU5AG8JKM:SF9VN1RFBIKJ:3AXL1RR+ 8::N$OAG+RC4NKT1:P4.33GH40HD*98UIHJIDB 4N*2R7C*MCV+1AY3:YP*YVNUHC.G-NFPIR6UBRRQL9K5%L4.Q*4986NBHP95R*QFLNUDTQH-GYRN2FMGO73ZG6ZTJZC:$0$MTZUF2A81R9NEBTU2Y437XCI9DU 4S3N%JRP:HPE3$ 435QJ+UJVGYLJIMPI%2+YSUXHB42VE5M44%IJLX0SYI7BU+EGCSHG:AQ+58CEN RAXI:D53H8EA0+WAI9M8JC0D0S%8PO00DJAPE3 GZZB:X85Y8345MOLUZ3+HT0TRS76MW2O.0CGL EQ5AI.XM5 01LCWBA.RE.-SUYH+S7SBE0%B-KT+YSMFCLTQQQ6LEHG.P46UNL6DA2C$AF-SQ00A58HYO5:M8 7S$ULGC-IP49MZCSU8ST3HDRJNPV3UJADJ9BVV:7K13B4WQ+DCTEG4V8OT09797FZMQ3/A7DU0.3D148IDZ%UDR9CYF';

$base45Decoder = new Base45;
$decoded = $base45Decoder->decode(mb_substr($data, 4)); // We remove the HC1: prefix

$decompressed = zlib_decode($decoded); // We unzip the data
$stream = new StringStream($decompressed);

final class CoseSign1Tag extends Base // Specific tag for the example
{
    public static function getTagId(): int
    {
        return 18;
    }

    public static function createFromLoadedData(int $additionalInformation, ?string $data, CBORObject $object): Base
    {
        return new self($additionalInformation, $data, $object);
    }

    public function getNormalizedData(bool $ignoreTags = false)
    {
        return $this->getValue()->getNormalizedData($ignoreTags);
    }
}

$tagObjectManager = new TagObjectManager();
$tagObjectManager->add(CoseSign1Tag::class);
$cborDecoder = new Decoder($tagObjectManager, new OtherObjectManager());

$cbor = $cborDecoder->decode($stream); //We decode the data
if (!$cbor instanceof CoseSign1Tag) {
    throw new InvalidArgumentException('Not a valid certificate. Not a CoseSign1 type.');
}

$list = $cbor->getValue();
if (!$list instanceof ListObject) {
    throw new InvalidArgumentException('Not a valid certificate. No list.');
}
if ($list->count() !==4) {
    throw new InvalidArgumentException('Not a valid certificate. The list size is not correct.');
}

$firstItem = $list->get(0); // The first item corresponds to the protected header
$headerStream = new StringStream($firstItem->getValue()); // The first item is also a CBOR encoded byte string
dump('Protected header', $cborDecoder->decode($headerStream)->getNormalizedData()); // The array [1 => "-7"] = ["alg" => "ES256"]

$secondItem = $list->get(1); // The second item corresponds to unprotected header
dump('Unprotected header', $secondItem->getNormalizedData()); // The index 4 refers to the 'kid' (key ID) parameter (see https://www.iana.org/assignments/cose/cose.xhtml)

$thirdItem = $list->get(2); // The third item corresponds to the data we want to load
if (!$thirdItem instanceof ByteStringObject) {
    throw new InvalidArgumentException('Not a valid certificate. The payload is not a byte string.');
}
$infoStream = new StringStream($thirdItem->getValue()); // The third item is a CBOR encoded byte string
dump('The payload', $cborDecoder->decode($infoStream)->getNormalizedData()); // The data we are looking for

$fourthItem = $list->get(3); // The fourth item is the signature.
// It can be verified using the protected header (first item) and the data (third item)
// And the public key
if (!$fourthItem instanceof ByteStringObject) {
    throw new InvalidArgumentException('Not a valid certificate. The signature is not a byte string.');
}
dump('Digital signature', $fourthItem->getNormalizedData()); // The digital signature
nonsintetic commented 3 years ago

Hi, I tested the above code with a vaccination certificate issued by the Romanian Health Ministry and it works fine with the caveats mentioned. Thank you for the example!

I think the data required for checking the signature can be found here: https://raw.githubusercontent.com/Digitaler-Impfnachweis/covpass-ios/main/Certificates/PROD_RKI/CA/dsc.json

The "kid" referred in the file above is different for each county and is the data from key 4 in the protected header, base64 encoded.

Spomky commented 3 years ago

Hi all,

Thank you @nonsintetic for the information. I tried to verify the digital signature with the appropriate key and it works fine!!!

Note that the keyset you shared is outdated and the key ID is missing from this set. I've found it in the v1.9.0 branch of the same project: https://raw.githubusercontent.com/Digitaler-Impfnachweis/covpass-ios/v1.9.0/Certificates/PROD/CA/dsc.json

Hereafter a full example where loading and data verification pass.

<?php

declare(strict_types=1);

use CBOR\ByteStringObject;
use CBOR\CBORObject;
use CBOR\Decoder;
use CBOR\ListObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\StringStream;
use CBOR\Tag\TagObjectManager;
use CBOR\TagObject as Base;
use CBOR\TextStringObject;
use Mhauri\Base45;

require_once 'vendor/autoload.php';

$data = 'HC1:6BFNX1:HM*I0PS3TLU.NGMU5AG8JKM:SF9VN1RFBIKJ:3AXL1RR+ 8::N$OAG+RC4NKT1:P4.33GH40HD*98UIHJIDB 4N*2R7C*MCV+1AY3:YP*YVNUHC.G-NFPIR6UBRRQL9K5%L4.Q*4986NBHP95R*QFLNUDTQH-GYRN2FMGO73ZG6ZTJZC:$0$MTZUF2A81R9NEBTU2Y437XCI9DU 4S3N%JRP:HPE3$ 435QJ+UJVGYLJIMPI%2+YSUXHB42VE5M44%IJLX0SYI7BU+EGCSHG:AQ+58CEN RAXI:D53H8EA0+WAI9M8JC0D0S%8PO00DJAPE3 GZZB:X85Y8345MOLUZ3+HT0TRS76MW2O.0CGL EQ5AI.XM5 01LCWBA.RE.-SUYH+S7SBE0%B-KT+YSMFCLTQQQ6LEHG.P46UNL6DA2C$AF-SQ00A58HYO5:M8 7S$ULGC-IP49MZCSU8ST3HDRJNPV3UJADJ9BVV:7K13B4WQ+DCTEG4V8OT09797FZMQ3/A7DU0.3D148IDZ%UDR9CYF';

$base45Processor = new Base45();
$decoded = $base45Processor->decode(mb_substr($data, 4)); // We remove the HC1: prefix

$decompressed = zlib_decode($decoded); // We unzip the data
$stream = new StringStream($decompressed);

final class CoseSign1Tag extends Base // Specific tag for the example
{
    public static function getTagId(): int
    {
        return 18;
    }

    public static function createFromLoadedData(int $additionalInformation, ?string $data, CBORObject $object): Base
    {
        return new self($additionalInformation, $data, $object);
    }

    public function getNormalizedData(bool $ignoreTags = false)
    {
        return $this->getValue()->getNormalizedData($ignoreTags);
    }
}

$tagObjectManager = new TagObjectManager();
$tagObjectManager->add(CoseSign1Tag::class);
$cborDecoder = new Decoder($tagObjectManager, new OtherObjectManager());

$cbor = $cborDecoder->decode($stream); //We decode the data
if (!$cbor instanceof CoseSign1Tag) {
    throw new InvalidArgumentException('Not a valid certificate. Not a CoseSign1 type.');
}

$list = $cbor->getValue();
if (!$list instanceof ListObject) {
    throw new InvalidArgumentException('Not a valid certificate. No list.');
}
if (4 !== $list->count()) {
    throw new InvalidArgumentException('Not a valid certificate. The list size is not correct.');
}
dump($list);

$firstItem = $list->get(0); // The first item corresponds to the protected header
$headerStream = new StringStream($firstItem->getValue()); // The first item is also a CBOR encoded byte string
$protectedHeader = $cborDecoder->decode($headerStream);
dump('Protected header', $protectedHeader->getNormalizedData()); // The array [1 => "-7"] = ["alg" => "ES256"]

$secondItem = $list->get(1); // The second item corresponds to unprotected header
$unprotectedHeader = $secondItem;
dump('Unprotected header', $unprotectedHeader->getNormalizedData()); // The index 4 refers to the 'kid' (key ID) parameter (see https://www.iana.org/assignments/cose/cose.xhtml)
$keyId = base64_encode($unprotectedHeader->getNormalizedData()[4]);

$thirdItem = $list->get(2); // The third item corresponds to the data we want to load
if (!$thirdItem instanceof ByteStringObject) {
    throw new InvalidArgumentException('Not a valid certificate. The payload is not a byte string.');
}
$infoStream = new StringStream($thirdItem->getValue()); // The third item is a CBOR encoded byte string
$payload = $cborDecoder->decode($infoStream);
dump('The payload', $payload->getNormalizedData()); // The data we are looking for

$fourthItem = $list->get(3); // The fourth item is the signature.
// It can be verified using the protected header (first item) and the data (third item)
// And the public key
if (!$fourthItem instanceof ByteStringObject) {
    throw new InvalidArgumentException('Not a valid certificate. The signature is not a byte string.');
}
$signature = $fourthItem;
dump('Digital signature', $signature->getNormalizedData()); // The digital signature

//We retrieve the public keys
$ch = curl_init('https://raw.githubusercontent.com/Digitaler-Impfnachweis/covpass-ios/v1.9.0/Certificates/PROD/CA/dsc.json');
$fp = fopen('dsc.json', 'wb');

curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_HEADER, 0);

curl_exec($ch);
if (curl_error($ch)) {
    fwrite($fp, curl_error($ch));
}
curl_close($ch);
fclose($fp);

//We decode the JSON object we received
$certificates = json_decode(file_get_contents('dsc.json'), true, 512, JSON_THROW_ON_ERROR);

//We filter the keyset using the country and the key ID from the data
$country = $payload->getNormalizedData()[1];
$countryCertificates = array_filter($certificates['certificates'], static function (array $data) use ($country, $keyId): bool {
    return $data['country'] === $country && $data['kid'] === $keyId;
});

//If no public key is found, we cannot continue
if (1 !== count($countryCertificates)) {
    throw new \InvalidArgumentException('Public key not found');
}

//We convert the raw data into a PEM encoded certificate
$signingCertificate = current($countryCertificates);
$pem = chunk_split($signingCertificate['rawData'], 64, PHP_EOL);
$pem = '-----BEGIN CERTIFICATE-----'.PHP_EOL.$pem.'-----END CERTIFICATE-----'.PHP_EOL;

//Needed to convert the digital signature into an ASN.1 signature (this format is required by OpenSSL)
final class ECSignature
{
    private const ASN1_SEQUENCE = '30';
    private const ASN1_INTEGER = '02';
    private const ASN1_MAX_SINGLE_BYTE = 128;
    private const ASN1_LENGTH_2BYTES = '81';
    private const ASN1_BIG_INTEGER_LIMIT = '7f';
    private const ASN1_NEGATIVE_INTEGER = '00';
    private const BYTE_SIZE = 2;

    /**
     * @throws InvalidArgumentException if the length of the signature is invalid
     */
    public static function toAsn1(string $signature, int $length): string
    {
        $signature = bin2hex($signature);

        if (self::octetLength($signature) !== $length) {
            throw new InvalidArgumentException('Invalid signature length.');
        }

        $pointR = self::preparePositiveInteger(mb_substr($signature, 0, $length, '8bit'));
        $pointS = self::preparePositiveInteger(mb_substr($signature, $length, null, '8bit'));

        $lengthR = self::octetLength($pointR);
        $lengthS = self::octetLength($pointS);

        $totalLength = $lengthR + $lengthS + self::BYTE_SIZE + self::BYTE_SIZE;
        $lengthPrefix = $totalLength > self::ASN1_MAX_SINGLE_BYTE ? self::ASN1_LENGTH_2BYTES : '';

        $bin = hex2bin(
            self::ASN1_SEQUENCE
            .$lengthPrefix.dechex($totalLength)
            .self::ASN1_INTEGER.dechex($lengthR).$pointR
            .self::ASN1_INTEGER.dechex($lengthS).$pointS
        );
        if (!is_string($bin)) {
            throw new InvalidArgumentException('Unable to parse the data');
        }

        return $bin;
    }

    private static function octetLength(string $data): int
    {
        return (int) (mb_strlen($data, '8bit') / self::BYTE_SIZE);
    }

    private static function preparePositiveInteger(string $data): string
    {
        if (mb_substr($data, 0, self::BYTE_SIZE, '8bit') > self::ASN1_BIG_INTEGER_LIMIT) {
            return self::ASN1_NEGATIVE_INTEGER.$data;
        }

        while (0 === mb_strpos($data, self::ASN1_NEGATIVE_INTEGER, 0, '8bit')
            && mb_substr($data, 2, self::BYTE_SIZE, '8bit') <= self::ASN1_BIG_INTEGER_LIMIT) {
            $data = mb_substr($data, 2, null, '8bit');
        }

        return $data;
    }
}

//The object is the data that should have been signed
$structure = new ListObject();
$structure->add(new TextStringObject('Signature1'));
$structure->add($firstItem);
$structure->add(new ByteStringObject(''));
$structure->add($thirdItem);

//COnverted signature
$derSignature = ECSignature::toAsn1($signature->getNormalizedData(), 64);

//We verify the signature with the data structure and the PEM encoded key
// If valid, the result is 1 
$isValid = 1 === openssl_verify((string) $structure, $derSignature, $pem, 'sha256');
if (!$isValid) {
    while ($m = openssl_error_string()) {
        dump($m);
    }
    throw new \InvalidArgumentException('The signature is NOT valid');
}

// At this point, we have the data and the signature is verified.
nonsintetic commented 3 years ago

Lengendary! It all works!

Spomky commented 3 years ago

Excellent. I'm happy to read that it also work for you. Next step for me is to work on the COSE/CWT RFC implementations to simlify all of that spaghetti code.

timoschwarzer commented 3 years ago

Hi all! :wave:

This thread really helped me putting together a library for reading and validating EU eHealth certificates: https://github.com/stw-on/covpasscheck-php

Maybe it's useful for some of you.

Thank you for your work! :)

herald-si commented 3 years ago

This is a valid CH certificate but the index 4 that refers to the 'kid' (key ID) parameter is missing.

HC1:NCFK60DG0/3WUWGSLKH47GO0:S4KQDITFAUO9CK-500XK0JCV496F3JBS33S3F3MU394SY50.FK6ZK7:EDOLOPCO8F6%E3.DA%EOPC1G72A6YM86G77460A6TL6IL6G*8J*8:Q6E46VM8K:6 47FN8UPC0JCZ69FVCPD0LVC6JD846Y96E463W5.A6+EDG8F3I80/D6$CBECSUER:C2$NS346$C2%E9VC- CSUE145GB8JA5B$D% D3IA4W5646646-96:96.JCP9EJY8L/5M/5546.96SF63KC.SC4KCD3DX47B46IL6646H*6Z/ER2DD46JH8946JPCT3E5JDLA7$Q69464W51S6..DX%DZJC2/DYOA$$E5$C JC3/D9Z95LEZED1ECW.C8WE2OA3ZAGY8MPCG/DU2DRB8MTA8+9$PC5$CUZC$$5Y$5FBBC30.9V$*J7TUO*9T$MJ5FT9U:HMD$EUJD:IG/64QL452M4KJH8S7$9N:655W*:OY6M+M3GH0N4F9YN-W91UHUO8BBJH64H8N/5LN%05P0-KG87ONE58+M%N8FJJT9S+K65WRNN2D AM/H*UQ6SEBG71Q1R5H68KTL2R*FDP5DBGWEM18J45MS$5O.296HOMB9 CTTOZN8:MB95ITP045OUQ41AAB1K19W5L84%G%9H5Z57LRP$IH$K0PLLFCCNE8QG%8LRWN$1P$IA+3I:.3$JB:DUK%DQYIO5GRJF*$G*EMHV3RMLHISWW8FJIHGQ*:BV7N+CA:VVXOILXHDUJQL9LTNQ1THAB$EAGYU07V 89NNLA$NS7F8ENV:COAAJ+F-2NK+P-3

Also the Italian ones have the same issue: HC1:6BFOXN%TS3DH0YOJ58S S-W5HDC *M0II5XHC9B5G2+$N IOP-IA%NFQGRJPC%OQHIZC4.OI1RM8ZA.A5:S9MKN4NN3F85QNCY0O%0VZ001HOC9JU0D0HT0HB2PL/IB*09B9LW4T*8+DCMH0LDK2%K:XFE70*LP$V25$0Q:J:4MO1P0%0L0HD+9E/HY+4J6TH48S%4K.GJ2PT3QY:GQ3TE2I+-CPHN6D7LLK*2HG%89UV-0LZ 2ZJJ524-LH/CJTK96L6SR9MU9DHGZ%P WUQRENS431T1XCNCF+47AY0-IFO0500TGPN8F5G.41Q2E4T8ALW.INSV$ 07UV5SR+BNQHNML7 /KD3TU 4V*CAT3ZGLQMI/XI%ZJNSBBXK2:UG%UJMI:TU+MMPZ5$/PMX19UE:-PSR3/$NU44CBE6DQ3D7B0FBOFX0DV2DGMB$YPF62I$60/F$Z2I6IFX21XNI-LM%3/DF/U6Z9FEOJVRLVW6K$UG+BKK57:1+D10%4K83F+1VWD1NE

What we can do to validate those signatures?

Thanks in advance

Spomky commented 3 years ago

Hi @herald-si,

For these inputs, the key ID is not in the unprotected header, but the protected one. The Italian certificate can be verified with the code I wrote with the following modification

//Instead of 
$keyId = base64_encode($unprotectedHeader->getNormalizedData()[4]);

//Write this
$keyId = base64_encode(($unprotectedHeader->getNormalizedData() + $protectedHeader->getNormalizedData())[4]);

Same for the Swiss one. The problem with that input is that the signature algorithm is -37 i.e. PS256 which is not supported in my example.

Spomky commented 3 years ago

Hi @clariion,

Yes you can convert back the $cbor variable into a valid certificate, unless you change one bit or more. The initial post contains a useful link to that encoding process.

Spomky commented 3 years ago

No it won't. The data is digitally signed and the signature verification will fail.

herald-si commented 3 years ago

Hi @herald-si,

For these inputs, the key ID is not in the unprotected header, but the protected one. The Italian certificate can be verified with the code I wrote with the following modification

//Instead of 
$keyId = base64_encode($unprotectedHeader->getNormalizedData()[4]);

//Write this
$keyId = base64_encode(($unprotectedHeader->getNormalizedData() + $protectedHeader->getNormalizedData())[4]);

Same for the Swiss one. The problem with that input is that the signature algorithm is -37 i.e. PS256 which is not supported in my example.

Hi @Spomky !

with this change everything works correctly. Thank you so much

duskohu commented 2 years ago

Hi when I use thus example: 1.json: DGC with vaccination entry (1 dose) https://github.com/eu-digital-green-certificates/dgc-testdata/tree/main/SK https://github.com/eu-digital-green-certificates/dgc-testdata/blob/main/SK/2DCode/raw/1.json

"PREFIX": "HC1:NCFOXNEG2NBJ5*H:QO-.O9B3QZ8Y*M9WL7LG4/8+W4VGAXOE4+4J59BZ6%-OD 4YQFPF6R:5SVBWVBDKBYLDR4DF4D$ZJ*DJWP42W5J3U4OG7.R7%NC.UPTUD*Q9RK7RMEN4CD1B+K8AV2PTO*N--T0SFXZQ H9RQGX-FO2WYZQ2J95J02O8..V$T7%$D4J8$T7T$7YNGHM4PRAAUICO1DV59UE6Q1M650 LHZA0D9E2LBHHGKLO-K%FGLIA5D8MJKQJK JMDJL9GG.IA.C8KRDL4O54O4IGUJKJGI.IAHLCV5GVWNZIKXGG JMLII7EDTG91PC3DE0OARH9W/IO6AHCRTWA4EQN95N14Z+HP+POC1.AO5PIZ.VTZOSV0I+QWZJHN1ZBQR*MTNK EM5MGPI5A-M8F7AJOZNV9JHKIJYE9*FJ+UVAZ8-.A2*CEHJ5$0O/A%4SL/IG%8R.9Z6TG0MW%8N*48-930J7*4E%2L+9N2LY2Q%%2G0M172ZUJYBW897MJM5DB0J4XETW8PX+KN4K.-V:3WROR$04.7E93Q6VUE$TO%R$:3HUCQZ6D8OG2B:%A6-I8PJ8%VKYOU1Q96E01MRKUU2G730F%2H2", I have error: Argument 1 passed to CBOR\StringStream::__construct() must be of the type string, boolean given, called in Do you know why $decompressed = zlib_decode($decoded); return false?

herald-si commented 2 years ago

I tried the HC1 string (without "PREFIX" ) with the library and works correctly but throws a 'Public key not found'.

Which version of the code did you use?

duskohu commented 2 years ago

PREFIX this one: https://github.com/Spomky-Labs/cbor-php/issues/32#issuecomment-929561510

herald-si commented 2 years ago

yes, it works, try this code:

<?php

declare(strict_types=1);

use CBOR\ByteStringObject;
use CBOR\CBORObject;
use CBOR\Decoder;
use CBOR\ListObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\StringStream;
use CBOR\Tag\TagObjectManager;
use CBOR\TagObject as Base;
use CBOR\TextStringObject;
use Mhauri\Base45;

require_once 'vendor/autoload.php';

$data = 'HC1:NCFOXNEG2NBJ5*H:QO-.O9B3QZ8Y*M9WL7LG4/8+W4VGAXOE4+4J59BZ6%-OD 4YQFPF6R:5SVBWVBDKBYLDR4DF4D$ZJ*DJWP42W5J3U4OG7.R7%NC.UPTUD*Q9RK7RMEN4CD1B+K8AV2PTO*N--T0SFXZQ H9RQGX-FO2WYZQ2J95J02O8..V$T7%$D4J8$T7T$7YNGHM4PRAAUICO1DV59UE6Q1M650 LHZA0D9E2LBHHGKLO-K%FGLIA5D8MJKQJK JMDJL9GG.IA.C8KRDL4O54O4IGUJKJGI.IAHLCV5GVWNZIKXGG JMLII7EDTG91PC3DE0OARH9W/IO6AHCRTWA4EQN95N14Z+HP+POC1.AO5PIZ.VTZOSV0I+QWZJHN1ZBQR*MTNK EM5MGPI5A-M8F7AJOZNV9JHKIJYE9*FJ+UVAZ8-.A2*CEHJ5$0O/A%4SL/IG%8R.9Z6TG0MW%8N*48-930J7*4E%2L+9N2LY2Q%%2G0M172ZUJYBW897MJM5DB0J4XETW8PX+KN4K.-V:3WROR$04.7E93Q6VUE$TO%R$:3HUCQZ6D8OG2B:%A6-I8PJ8%VKYOU1Q96E01MRKUU2G730F%2H2';

$base45Processor = new Base45();
$decoded = $base45Processor->decode(mb_substr($data, 4)); // We remove the HC1: prefix

$decompressed = zlib_decode($decoded); // We unzip the data
$stream = new StringStream($decompressed);

final class CoseSign1Tag extends Base // Specific tag for the example
{
    public static function getTagId(): int
    {
        return 18;
    }

    public static function createFromLoadedData(int $additionalInformation, ?string $data, CBORObject $object): Base
    {
        return new self($additionalInformation, $data, $object);
    }

    public function getNormalizedData(bool $ignoreTags = false)
    {
        return $this->getValue()->getNormalizedData($ignoreTags);
    }
}

$tagObjectManager = new TagObjectManager();
$tagObjectManager->add(CoseSign1Tag::class);
$cborDecoder = new Decoder($tagObjectManager, new OtherObjectManager());

$cbor = $cborDecoder->decode($stream); //We decode the data
if (!$cbor instanceof CoseSign1Tag) {
    throw new InvalidArgumentException('Not a valid certificate. Not a CoseSign1 type.');
}

$list = $cbor->getValue();
if (!$list instanceof ListObject) {
    throw new InvalidArgumentException('Not a valid certificate. No list.');
}
if (4 !== $list->count()) {
    throw new InvalidArgumentException('Not a valid certificate. The list size is not correct.');
}
dump("list", $list);

$firstItem = $list->get(0); // The first item corresponds to the protected header
$headerStream = new StringStream($firstItem->getValue()); // The first item is also a CBOR encoded byte string
$protectedHeader = $cborDecoder->decode($headerStream);
dump('Protected header', $protectedHeader->getNormalizedData()); // The array [1 => "-7"] = ["alg" => "ES256"]

$secondItem = $list->get(1); // The second item corresponds to unprotected header
$unprotectedHeader = $secondItem;
dump('Unprotected header', $unprotectedHeader->getNormalizedData()); // The index 4 refers to the 'kid' (key ID) parameter (see https://www.iana.org/assignments/cose/cose.xhtml)

//Write this
$keyId = base64_encode(($unprotectedHeader->getNormalizedData() + $protectedHeader->getNormalizedData())[4]);

$thirdItem = $list->get(2); // The third item corresponds to the data we want to load
if (!$thirdItem instanceof ByteStringObject) {
    throw new InvalidArgumentException('Not a valid certificate. The payload is not a byte string.');
}
$infoStream = new StringStream($thirdItem->getValue()); // The third item is a CBOR encoded byte string
$payload = $cborDecoder->decode($infoStream);
dump('The payload', $payload->getNormalizedData()); // The data we are looking for

$fourthItem = $list->get(3); // The fourth item is the signature.
// It can be verified using the protected header (first item) and the data (third item)
// And the public key
if (!$fourthItem instanceof ByteStringObject) {
    throw new InvalidArgumentException('Not a valid certificate. The signature is not a byte string.');
}
$signature = $fourthItem;
dump('Digital signature', $signature->getNormalizedData()); // The digital signature

//We retrieve the public keys
$ch = curl_init('https://raw.githubusercontent.com/Digitaler-Impfnachweis/covpass-ios/v1.9.0/Certificates/PROD/CA/dsc.json');
$fp = fopen('dsc.json', 'wb');

curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_HEADER, 0);

curl_exec($ch);
if (curl_error($ch)) {
    fwrite($fp, curl_error($ch));
}
curl_close($ch);
fclose($fp);

//We decode the JSON object we received
$certificates = json_decode(file_get_contents('dsc.json'), true, 512, JSON_THROW_ON_ERROR);

//We filter the keyset using the country and the key ID from the data
$country = $payload->getNormalizedData()[1];
$countryCertificates = array_filter($certificates['certificates'], static function (array $data) use ($country, $keyId): bool {
    return $data['country'] === $country && $data['kid'] === $keyId;
});

//If no public key is found, we cannot continue
if (1 !== count($countryCertificates)) {
    throw new \InvalidArgumentException('Public key not found');
}

//We convert the raw data into a PEM encoded certificate
$signingCertificate = current($countryCertificates);
$pem = chunk_split($signingCertificate['rawData'], 64, PHP_EOL);
$pem = '-----BEGIN CERTIFICATE-----'.PHP_EOL.$pem.'-----END CERTIFICATE-----'.PHP_EOL;

//Needed to convert the digital signature into an ASN.1 signature (this format is required by OpenSSL)
final class ECSignature
{
    private const ASN1_SEQUENCE = '30';
    private const ASN1_INTEGER = '02';
    private const ASN1_MAX_SINGLE_BYTE = 128;
    private const ASN1_LENGTH_2BYTES = '81';
    private const ASN1_BIG_INTEGER_LIMIT = '7f';
    private const ASN1_NEGATIVE_INTEGER = '00';
    private const BYTE_SIZE = 2;

    /**
     * @throws InvalidArgumentException if the length of the signature is invalid
     */
    public static function toAsn1(string $signature, int $length): string
    {
        $signature = bin2hex($signature);

        if (self::octetLength($signature) !== $length) {
            throw new InvalidArgumentException('Invalid signature length.');
        }

        $pointR = self::preparePositiveInteger(mb_substr($signature, 0, $length, '8bit'));
        $pointS = self::preparePositiveInteger(mb_substr($signature, $length, null, '8bit'));

        $lengthR = self::octetLength($pointR);
        $lengthS = self::octetLength($pointS);

        $totalLength = $lengthR + $lengthS + self::BYTE_SIZE + self::BYTE_SIZE;
        $lengthPrefix = $totalLength > self::ASN1_MAX_SINGLE_BYTE ? self::ASN1_LENGTH_2BYTES : '';

        $bin = hex2bin(
            self::ASN1_SEQUENCE
            .$lengthPrefix.dechex($totalLength)
            .self::ASN1_INTEGER.dechex($lengthR).$pointR
            .self::ASN1_INTEGER.dechex($lengthS).$pointS
        );
        if (!is_string($bin)) {
            throw new InvalidArgumentException('Unable to parse the data');
        }

        return $bin;
    }

    private static function octetLength(string $data): int
    {
        return (int) (mb_strlen($data, '8bit') / self::BYTE_SIZE);
    }

    private static function preparePositiveInteger(string $data): string
    {
        if (mb_substr($data, 0, self::BYTE_SIZE, '8bit') > self::ASN1_BIG_INTEGER_LIMIT) {
            return self::ASN1_NEGATIVE_INTEGER.$data;
        }

        while (0 === mb_strpos($data, self::ASN1_NEGATIVE_INTEGER, 0, '8bit')
            && mb_substr($data, 2, self::BYTE_SIZE, '8bit') <= self::ASN1_BIG_INTEGER_LIMIT) {
            $data = mb_substr($data, 2, null, '8bit');
        }

        return $data;
    }
}

//The object is the data that should have been signed
$structure = new ListObject();
$structure->add(new TextStringObject('Signature1'));
$structure->add($firstItem);
$structure->add(new ByteStringObject(''));
$structure->add($thirdItem);

//COnverted signature
$derSignature = ECSignature::toAsn1($signature->getNormalizedData(), 64);

//We verify the signature with the data structure and the PEM encoded key
// If valid, the result is 1 
$isValid = 1 === openssl_verify((string) $structure, $derSignature, $pem, 'sha256');
if (!$isValid) {
    while ($m = openssl_error_string()) {
        dump("openssl",$m);
    }
    throw new \InvalidArgumentException('The signature is NOT valid');
}

// At this point, we have the data and the signature is verified.

function dump($title, $list)
{
    echo "<h1>$title</h1><pre>" . print_r($list, true) . "</pre>";
}
?>
duskohu commented 2 years ago

Thx, It work And https://raw.githubusercontent.com/Digitaler-Impfnachweis/covpass-ios/v1.9.0/Certificates/PROD/CA/dsc.json this is always actual certificates? or it is only for example? what about this sources? https://pkg.go.dev/github.com/stapelberg/coronaqr/trustlist/trustlistmirror https://eudcc.tibordp.workers.dev/trust-list/prod or ... _``

herald-si commented 2 years ago

This is just one example. Each country has its own list with a different format: DE: https://de.dscg.ubirch.com/trustList/DSC/ IT: https://get.dgc.gov.it/v1/dgc/signercertificate/status - https://get.dgc.gov.it/v1/dgc/signercertificate/update SE: https://dgcg.covidbevis.se/tp/ NL: https://verifier-api.coronacheck.nl/v4/verifier/public_keys ..... they synchronize certificates with other countries on a daily basis. Moreover, each nation has different business rules for validating the data contained in the dgc.

Here: https://github.com/herald-si/verificac19-sdk-php there is a working example of dcg verification according to italian rules. It is based on the cbor library, the code https://github.com/Spomky-Labs/cbor-php/issues/32#issuecomment-929561510. once the signature has been validated, it extracts the data in a dedicated php object

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

luggesexe commented 1 year ago

Hello @Spomky,

since 2021 there were several updates. I tried replicating the code posted. I think because of the changes in the project structure I cannot use the code. The TagObjectManager does not exist anymore. I tried changing it to the existing TagManager which throws further issues with the list items and the ->getValue() functions and connected with this the ->getNormalizedData-function.

As I could not find the changed parts in the documentation or in the code I would like to ask for further information on significant changes in the CBOR-Code structure regarding the reading of the CBOR-data.

Kindly

github-actions[bot] commented 9 months ago

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.