1. 記事一覧 >
  2. ブログ記事
category logo

新型コロナワクチン接種証明書QRコードをphpでverifyしてみた

(更新) (公開)

はじめに

新型コロナワクチン接種証明書アプリがデジタル庁よりリリースされました。(https://www.digital.go.jp/policies/posts/vaccinecert
新型コロナワクチン接種証明書(QR コード)を php で verify することに成功しましたので、verify 方法の詳細を紹介したいと思います。

phpでverify 概要図

図中のQRコードは、接種証明書のQRコードではなく、App StoreのQRコードです。

この記事中の「接種証明書」「QRコード」とは、全て"新型コロナワクチン接種証明書アプリ"の国内用の証明書、QRコードの事です。他の類似のシステムや海外用は、まったくの考慮外です。

公開鍵は、デジタル庁が公開しています。(https://vc.vrs.digital.go.jp/issuer/.well-known/jwks.json

ソースコード中のQRコードデータは、SMART Health Cardsのサンプルデータです。"新型コロナワクチン接種証明書アプリ"の接種証明のデータではないため、verifyに失敗します。実際のデータではverifyされることを確認済みです。ただし、数個程度確認しただけです。

本記事情報の誤りにより何らかの問題が生じても、一切責任を負いません。


php プログラム

まず、いきなりですが、ソースコードは以下です。

shc:/... の部分は、https://spec.smarthealth.cards/examples/example-00-f-qr-code-numeric-value-0.txtのサンプルのため、verify に失敗します。本物の QR コードを解析して文字列として抽出して verify に成功するデータに書き換えが必要です。
$key = の部分は、公開鍵で、https://vc.vrs.digital.go.jp/issuer/.well-known/jwks.jsonkeys.x5c[0]の値です。

phpプログラムは通信を一切行いませんが、QRコードやshc数字列は他人に露出したりしないようにすることを強くお勧めします。

ソースコードはGitHub Gist にも公開しています。https://gist.github.com/itc-lab/b27b87ed2157d23ef9c1a4f71c873fdd

<?php
// https://spec.smarthealth.cards/examples/example-00-f-qr-code-numeric-value-0.txt
$shc =
    "shc:/56762909524320603460292437404460312229595326546034602925407728043360287028647167452228092861333145643765314159064022030645045908564355034142454136403706366541713724123638030437562204673740753232392543344332605736010645293353123320242853503861415427430004753129732924613628592228745843637025633507624310445605276341230873773410384042310567070707557107456708120606570623663777033227585056600966652061117174674573052039356659081177056970202340093324411221033620704509340334726467084141553342105243565305117726236003330521350707280644115077311275690310252859522456650834302142030032421132356925364207640341547153233158256573404361230833427550287355370364073612617152257762554334416343592473506303250640723232755255224553615345283508034505715835543824640362752809742110292808262363330070672827105052096258577360225721657062351032403464112037593862616074377133321252633250456036590477732325236925540853264476722141625060067762414204690971254238066824724408560429331253305209115437341112115432637273272235114428647569653672234512286523392210043574710637564139651203320632115864330645577526363129506822397454221131362955556520732962772632113729236210713060617166542903672359353361406064457359396659096438076131446668670575607108610432300733774460560025267641305468504325341237666453750753127466352932402528406661571140042352723667640009716224095011767453691204746205704024595859401250577707075358002332050723250066113770014577047673320011127663106423774557605457453327093460560729346274592562703645075620044267387510507768385953326808311006586943413623095505586922122438246445563331303709345774";
// https://vc.vrs.digital.go.jp/issuer/.well-known/jwks.json
// keys.x5c[0]
$key =
    "MIIByjCCAXGgAwIBAgIJAPZFN9WW4voaMAoGCCqGSM49BAMDMCIxIDAeBgNVBAMMF3ZjLnZycy5kaWdpdGFsLmdvLmpwIENBMB4XDTIxMTEyNTEyNTUxNloXDTIyMTEyNTEyNTUxNlowJjEkMCIGA1UEAwwbdmMudnJzLmRpZ2l0YWwuZ28uanAgSXNzdWVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEViKBgZ0f3pQKv+tSz653HUtIzCS8TVSNu1Hwi0tKpSnTXXvtqkpcfYeAZ+SfvVk8SWNaTRDZ9wTNjb9c58v9l6OBizCBiDAJBgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAdBgNVHQ4EFgQUiIXKUyT93YdyqsIjE8i5I1z8w0IwHwYDVR0jBBgwFoAU0cYt0sPpuIDBt7a9PD3qs9mOu7EwLgYDVR0RBCcwJYYjaHR0cHM6Ly92Yy52cnMuZGlnaXRhbC5nby5qcC9pc3N1ZXIwCgYIKoZIzj0EAwMDRwAwRAIgEwVdLdbPqMYqEsVltnsm3bI/Z6eibgMwYaNVZiu0r2sCIFebHk1i6ghWOQn+Q0+t5F77fasgJ3Oc6NWx9I8AWjRM";
$jws = "";
for ($pos = 5; $pos < strlen($shc); $pos += 2) {
    $jws .= chr(intval(substr($shc, $pos, 2)) + 45);
}
list($headb64, $bodyb64, $cryptob64) = explode(".", $jws);
$header = json_decode(base64url_decode($headb64), true);
$payload = base64url_decode($bodyb64);
$sig = base64url_decode($cryptob64);

echo "header: ";
print_json($header);

$body = $payload;
if ($header["zip"] == "DEF") {
    $body = zlib_decode($body);
}
$body = json_decode($body, true);
echo "body: ";
print_json($body);

$public_key =
    "-----BEGIN CERTIFICATE-----\n" .
    chunk_split($key, 60, "\n") .
    "-----END CERTIFICATE-----";

list($r, $s) = str_split($sig, strlen($sig) / 2);
$r = ltrim($r, "\x00");
$s = ltrim($s, "\x00");
if (ord($r[0]) > 0x7f) {
    $r = "\x00" . $r;
}
if (ord($s[0]) > 0x7f) {
    $s = "\x00" . $s;
}

$sig = encodeDER(0x30, encodeDER(0x02, $r) . encodeDER(0x02, $s));
$verified = openssl_verify(
    "{$headb64}.{$bodyb64}",
    $sig,
    $public_key,
    "sha256"
);
if ($verified === 1) {
    echo "verified\n";
} elseif ($verified === 0) {
    echo "unverified\n";
} else {
    echo openssl_error_string() . "\n";
}

function base64url_decode($input)
{
    $remainder = (4 - (strlen($input) % 4)) % 4;
    $input .= str_repeat("=", $remainder);
    return base64_decode(strtr($input, "-_", "+/"));
}

function encodeDER($type, $value)
{
    $der = chr($type);
    $der .= chr(strlen($value));
    return $der . $value;
}

function print_json($array)
{
    echo json_encode(
        $array,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
    ) . "\n\n";
}

php decode_shc.php のように実行すると、以下のような出力が得られます。

jsonにして、echoしたものです。//の後のコメントは、後から書いています。実際の出力には有りません。また、値は一部実際の出力から変更しています。

header: {
  "zip": "DEF",//zip=DEFでzip圧縮されていることを示す
  "alg": "ES256",//署名のアルゴリズム
  "kid": "f1vhQP9oOZkityrguynQqB4aVh8u9xcf3wm4AFF4aVw"//署名のためにどの鍵が使用されたのかの情報
}

body: {
  "iss": "https://vc.vrs.digital.go.jp/issuer",//Issuer クレーム 証明書発行者
  "nbf": 1234567890.123456,//Not Before クレーム
                           //これが存在する場合、トークンは、このクレームで指定されている時刻以降にのみ有効
  "vc": {//Verifiable Credentials 様々な個人情報を、デジタルに標準化したもの
    "type": [//認証情報の種別
      "https://smarthealth.cards#health-card",
      "https://smarthealth.cards#immunization",
      "https://smarthealth.cards#covid19"
    ],
    "credentialSubject": {//FHIR形式のデータ
      "fhirVersion": "4.0.1",//FHIRのバージョン
      "fhirBundle": {
        "resourceType": "Bundle",//リソースタイプ=Bundle
        "type": "collection",//バンドルタイプ データの操作内容
                             //document | message | transaction | transaction-response | batch | batch-response | history | searchset | collection
        "entry": [
          {
            "fullUrl": "resource:0",//リソース間の関連付けを解決するURL
            "resource": {
              "resourceType": "Patient",//Patient 患者情報
              "name": [
                {
                  "use": "usual",//従来型/通常使用
                  "family": "名無",//姓
                  "given": [
                    "権兵衛"//名
                  ]
                }
              ],
              "birthDate": "1888-08-08"//生年月日
            }
          },
          {
            "fullUrl": "resource:1",//1回目接種情報
            "resource": {
              "resourceType": "Immunization",//予防接種情報
              "status": "completed",//接種済み
              "vaccineCode": {//ワクチンコード
                "coding": [
                  {
                    "system": "http://hl7.org/fhir/sid/cvx",//コードの定義システム
                    "code": "208"//CVX Code 208=ファイザーのCOVID-19ワクチン
                  }
                ]
              },
              "patient": {
                "reference": "resource:0"//患者情報 接種者=上のfullUrl=resource:0の人
              },
              "occurrenceDateTime": "2022-01-01",
              "performer": [//予防接種実施者
                {
                  "actor": {//医療従事者
                    "display": "MHLW_Gov_of_JAPAN"//日本政府(新型コロナワクチン接種証明書アプリの場合、固定文字列)
                  }
                }
              ],
              "lotNumber": "XX8888"//ワクチンのロットナンバー
            }
          },
          {
            "fullUrl": "resource:2",//2回目接種情報
            "resource": {
              "resourceType": "Immunization",
              "status": "completed",
              "vaccineCode": {
                "coding": [
                  {
                    "system": "http://hl7.org/fhir/sid/cvx",
                    "code": "208"
                  }
                ]
              },
              "patient": {
                "reference": "resource:0"
              },
              "occurrenceDateTime": "2022-01-15",
              "performer": [
                {
                  "actor": {
                    "display": "MHLW_Gov_of_JAPAN"
                  }
                }
              ],
              "lotNumber": "XX9999"
            }
          }
        ]
      }
    }
  }
}

verified

仕組み、用語把握

QR コード生成の仕組みと用語を把握していきます。

QRコード生成の仕組み 図

【 SMART Health Cards 】

SMART Health Cardsはワクチン接種履歴や検査結果などの健康情報をデジタル記録するための国際標準です。

プライバシーが大切な医療情報を扱う上での国際標準(HL7 FHIR)を採択していることなどが大きな特徴です。

【 shc:/ 】

SMART Health Card標準のデータを表します。

shcは、SMART Health Cardの略です。

フォーマットは、 shc:/数字の羅列 です。

【 HL7 FHIR 】

HL7 Internationalによって作成された医療情報交換の標準規格です。

FHIRは、Fast Healthcare Interoperability Resourcesの略です。(interoperability = 相互運用性)

厚生労働省HPで公開されている「HL7 FHIRに関する調査研究一式 最終報告書」(https://www.mhlw.go.jp/content/12600000/000708279.pdf)にて概要を把握できます。

FHIR基本仕様全て翻訳するだけで、1.8億円らしいです...。

【 JWS 】

JSON Web Signatureの略で、JSONデータに署名や暗号化を施す方法を定めたオープン標準 (RFC 7519) です。

Header、Payload、Signatureの3要素がBase64 Urlでエンコードされ、ドット(.)で連結されています。

【 x5c 】

x5c (X.509 certificate chain) パラメータです。X.509 証明書チェーンを示します。

それぞれの証明書文字列は base64 エンコードされた DER 形式の PKIX 証明書です。

先頭の証明書以外は、その一つ前の証明書を証明します。

【 PKIX 】

Public-Key Infrastructure using X.509の略です。公開鍵証明書の標準形式や証明書パス検証アルゴリズムなどを定めたものです。

【 ES256 】

署名アルゴリズムの一つです。E=ECDSA、S256=SHA-256を意味します。

【 SHA-256 】

Secure Hash Algorithm 256-bitの略で、標準化された暗号学的ハッシュ関数です。

【 ECDSA 】

暗号化アルゴリズムの一つ楕円曲線DSAです。DSAとは、Digital Signature Algorithmの略で、デジタル署名方式の一つです。

【 ASN.1 】

Abstract Syntax Notation One(ASN.1)(日本語訳:抽象構文記法1)とは、電気通信やコンピュータネットワークでのデータ構造の表現・エンコード・転送・デコードを記述する標準的かつ柔軟な記法です。

【 DER 】

DER(Distinguished Encoding Rules)は、暗号の記述形式です。ASN.1 のデータ構造を具体的なバイト配列に落とし込むための技術仕様で、バイナリ形式のデータです。PEMは、Base64でエンコードされたDERです。


ソースコード紐解き

$shc =
    "shc:/56762909524320603460292437404460312229595326546034602925407728043360287028647167452228092861333145643765314159064022030645045908564355034142454136403706366541713724123638030437562204673740753232392543344332605736010645293353123320242853503861415427430004753129732924613628592228745843637025633507624310445605276341230873773410384042310567070707557107456708120606570623663777033227585056600966652061117174674573052039356659081177056970202340093324411221033620704509340334726467084141553342105243565305117726236003330521350707280644115077311275690310252859522456650834302142030032421132356925364207640341547153233158256573404361230833427550287355370364073612617152257762554334416343592473506303250640723232755255224553615345283508034505715835543824640362752809742110292808262363330070672827105052096258577360225721657062351032403464112037593862616074377133321252633250456036590477732325236925540853264476722141625060067762414204690971254238066824724408560429331253305209115437341112115432637273272235114428647569653672234512286523392210043574710637564139651203320632115864330645577526363129506822397454221131362955556520732962772632113729236210713060617166542903672359353361406064457359396659096438076131446668670575607108610432300733774460560025267641305468504325341237666453750753127466352932402528406661571140042352723667640009716224095011767453691204746205704024595859401250577707075358002332050723250066113770014577047673320011127663106423774557605457453327093460560729346274592562703645075620044267387510507768385953326808311006586943413623095505586922122438246445563331303709345774";

QR コードを読み取った結果、このようなshc:/...という文字列が得られます。shc:/の後は全て数字です。


$key =
    "MIIByjCCAXGgAwIBAgIJAPZFN9WW4voaMAoGCCqGSM49BAMDMCIxIDAeBgNVBAMMF3ZjLnZycy5kaWdpdGFsLmdvLmpwIENBMB4XDTIxMTEyNTEyNTUxNloXDTIyMTEyNTEyNTUxNlowJjEkMCIGA1UEAwwbdmMudnJzLmRpZ2l0YWwuZ28uanAgSXNzdWVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEViKBgZ0f3pQKv+tSz653HUtIzCS8TVSNu1Hwi0tKpSnTXXvtqkpcfYeAZ+SfvVk8SWNaTRDZ9wTNjb9c58v9l6OBizCBiDAJBgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAdBgNVHQ4EFgQUiIXKUyT93YdyqsIjE8i5I1z8w0IwHwYDVR0jBBgwFoAU0cYt0sPpuIDBt7a9PD3qs9mOu7EwLgYDVR0RBCcwJYYjaHR0cHM6Ly92Yy52cnMuZGlnaXRhbC5nby5qcC9pc3N1ZXIwCgYIKoZIzj0EAwMDRwAwRAIgEwVdLdbPqMYqEsVltnsm3bI/Z6eibgMwYaNVZiu0r2sCIFebHk1i6ghWOQn+Q0+t5F77fasgJ3Oc6NWx9I8AWjRM";

https://vc.vrs.digital.go.jp/issuer/.well-known/jwks.jsonkeys.x5c[0]の値が verify 用の公開鍵です。


$jws = "";
for ($pos = 5; $pos < strlen($shc); $pos += 2) {
    $jws .= chr(intval(substr($shc, $pos, 2)) + 45);
}

56, 76, 29 ... のように2つの数字ずつ取り出して、+ 45してからそれを ASCII 文字コードとみなし、1 バイトの文字列に変換して、数列部分を文字列化していきます。
+ 45は何かというと、
https://spec.smarthealth.cards/#encoding-chunks-as-qr-codes
2. A segment encoded with numeric mode...でエンコードの説明があるからです。shc:/の後は全て数字ですので、numeric modeのデータです。文字コード-45という指示ですが、エンコードの説明のため、デコードの場合は、この逆の処理を行います。 2. A segment encoded with numeric mode...

この結果、$jws はドットを2つ含む<Base64 Url文字列>.<Base64 Url文字列>.<Base64 Url文字列>JWS データになります。


list($headb64, $bodyb64, $cryptob64) = explode(".", $jws);

以下の図のように、ドットで分割されたデータを$headb64, $bodyb64, $cryptob64に分けます。

JWS ドットで分割されたデータ


$header = json_decode(base64url_decode($headb64), true);
$payload = base64url_decode($bodyb64);
$sig = base64url_decode($cryptob64);

echo "header: ";
print_json($header);

JWS の仕様で、おのおの json が Base64 Url エンコードディングされているため、Base64 Url デコードします。 この時点で、$headerは、json 文字列として取り出されていますので、json_decodeで php の array にして、再びjson_encodeで読みやすい json 文字列に整形して、echoしています。


https://spec.smarthealth.cards/#health-cards-are-small
によると、ヘッダーの
alg: "ES256"
zip: "DEF"
は固定のようです。

ES256 DEF

alg が ES256 のため、署名の$sigは、ECDSA のバイナリデータです。


$body = $payload;
if ($header["zip"] == "DEF") {
    $body = zlib_decode($body);
}
$body = json_decode($body, true);
echo "body: ";
print_json($body);

https://spec.smarthealth.cards/#health-cards-are-small
によると、
payload is compressed with the DEFLATEとあり、ペイロード(ボディ部分)は、zip 圧縮されています。zlib_decodeで展開した結果が json 文字列になります。

ES256 DEF

ここまでで、暗号化の話は何もありません。つまり、QRコードが流出すると、上記ルールを知っている人によって個人情報が読み取れるということです。

と言うか...上記ルールを知らなくても、新型コロナワクチン接種証明書アプリで他人のQRコードを読み取って、情報を表示することができます。


$public_key =
    "-----BEGIN CERTIFICATE-----\n" .
    chunk_split($key, 60, "\n") .
    "-----END CERTIFICATE-----";

この後、JWS の署名部分($sig)を使って、openssl_verifyによって verify するのですが、openssl_verifyの3番目の引数public_keyは、string - PEM フォーマットの鍵 ("-----BEGIN PUBLIC KEY----- MIIBCgK..." など)。の仕様になっています。
そのため、公開鍵を PEM 形式の文字列に変換しています。
結果、$public_keyは、以下のようになります。

-----BEGIN CERTIFICATE-----
MIIByjCCAXGgAwIBAgIJAPZFN9WW4voaMAoGCCqGSM49BAMDMCIxIDAeBgNV
BAMMF3ZjLnZycy5kaWdpdGFsLmdvLmpwIENBMB4XDTIxMTEyNTEyNTUxNloX
DTIyMTEyNTEyNTUxNlowJjEkMCIGA1UEAwwbdmMudnJzLmRpZ2l0YWwuZ28u
anAgSXNzdWVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEViKBgZ0f3pQK
v+tSz653HUtIzCS8TVSNu1Hwi0tKpSnTXXvtqkpcfYeAZ+SfvVk8SWNaTRDZ
9wTNjb9c58v9l6OBizCBiDAJBgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAdBgNV
HQ4EFgQUiIXKUyT93YdyqsIjE8i5I1z8w0IwHwYDVR0jBBgwFoAU0cYt0sPp
uIDBt7a9PD3qs9mOu7EwLgYDVR0RBCcwJYYjaHR0cHM6Ly92Yy52cnMuZGln
aXRhbC5nby5qcC9pc3N1ZXIwCgYIKoZIzj0EAwMDRwAwRAIgEwVdLdbPqMYq
EsVltnsm3bI/Z6eibgMwYaNVZiu0r2sCIFebHk1i6ghWOQn+Q0+t5F77fasg
J3Oc6NWx9I8AWjRM
-----END CERTIFICATE-----

変換せずに渡した場合、以下のエラーになります。
PHP Warning: openssl_verify(): Supplied key param cannot be coerced into a public key
error:0909006C:PEM routines:get_name:no start line


list($r, $s) = str_split($sig, strlen($sig) / 2);
$r = ltrim($r, "\x00");
$s = ltrim($s, "\x00");
if (ord($r[0]) > 0x7f) {
    $r = "\x00" . $r;
}
if (ord($s[0]) > 0x7f) {
    $s = "\x00" . $s;
}

$sig = encodeDER(0x30, encodeDER(0x02, $r) . encodeDER(0x02, $s));
function encodeDER($type, $value)
{
    $der = chr($type);
    $der .= chr(strlen($value));
    return $der . $value;
}

openssl_verifyの2番目の引数signatureは、生のバイナリ文字列。openssl_sign() もしくはそれと同等の手段を使って生成したもの。の仕様になっています。
ただ、困ったことに、ECDSA のバイナリデータである$sigをそのまま渡すと、以下のエラーになります。
error:0D0680A8:asn1 encoding routines:asn1_check_tlen:wrong tag


これは、OpenSSL が DER 形式のデータが渡されるのを想定しているからのようです。
つまり、ECDSA 生データ → DER 形式 の変換をしないといけません。


どうやって行うか調べた結果、
https://github.com/firebase/php-jwt/blob/main/src/JWT.php
にやりたいことそのままの関数を見つけました。
function signatureToDER($sig)

ECDSA生データ → DER形式 の変換関数

これをほぼコピペして、ECDSA 生データ → DER 形式 の変換を実現しています。


コピペしたソースコードの説明になりますが、やっていることは、以下です。
① $sig(バイナリデータ)をr部とs部に2分割

r部とs部ともに先頭から0x00(NUL)を取り除く(連続してある場合も全て取り除く。)

② r部とs部ともに先頭の1バイトが0x7fより大きい場合、先頭に0x00を付ける。

③ r部とs部ともに以下の加工を行う。
[0x02→STX=テキスト開始に文字化したもの]+[$r(s)部の長さを文字コードととらえて文字化したもの]+[元のバイナリデータ]

④ [r 部の上記実行結果]+[s 部の上記実行結果]を連結

⑤ [0x30→文字で表すと、"0"]+[上記連結結果の長さを文字コードととらえて文字化したもの]+[上記連結結果データ]

ECDSA生データ → DER形式 の変換 図


$verified = openssl_verify(
    "{$headb64}.{$bodyb64}",
    $sig,
    $public_key,
    "sha256"
);

"{$headb64}.{$bodyb64}":署名を作成するときに使ったデータの文字列
$sig:署名(DER 形式)
$public_key:PEM フォーマットの鍵 ("-----BEGIN PUBLIC KEY----- MIIBCgK..." など)
"sha256":署名アルゴリズム
です。


if ($verified === 1) {
    echo "verified\n";
} elseif ($verified === 0) {
    echo "unverified\n";
} else {
    echo openssl_error_string() . "\n";
}

OKの場合、verifiedの出力が得られます。
NGの場合(適当に作った QR コードや改ざんされた QR コードの場合)、unverifiedです。

loading...