昨日、QRコードが印字されたワクチン接種証明書の話を書いた。
国際標準というICAO VDS-NCはQRコードを読み取ってみると、半分ぐらいまでは普通に読めるデータが入っている。
途中からBase64でエンコードされた文字列が並んでいるが、これは電子署名ですね。
一方で国内用・海外用の両方に記載されたSMART Healthcare Cards(SHC)は読み取ると、shc:/56762909… という数字の羅列が続いている。
実はこの数字、Base64でエンコードされた文字列の文字コードを10進数表記したものである。
ハワイ州のワクチン接種証明書SMART Health Cardを読んでみる (Zenn)
しかもデータ本体はDeflate(ZIPなどで使われるアルゴリズム)で圧縮されているのだtろいう。
そもそもBase64というと、E-mailで8bitの文字コードやバイナリデータを扱うために使われていたものである。
これはかつてはSMTPプロトコルでは7bitの文字コードしか扱えなかった。
そのため8bitのデータ3byteを7bitのASCIIコード4文字に変換する処理を行い、これをBase64と呼んでいる。
日本では7bitのISO-2022-JPという文字コードを使うことが一般的だったが。
現在はSMTPでも8bit取り扱えるので、8bitのままUTF-8で送ることもあるけど。
文字コードはさておき、添付ファイルは現在もBase64に変換されて送られることが通常である。
現在はBase64はE-mailに限らず多目的に活用されている。
しかし、その原点は8bitのデータを7bitのASCIIコードに変換することにある。
一方のQRコードだがデータの格納方式には4方式がある。
数字モード、英数字モード、バイナリモード、漢字モードである。
数字モードは数字を3桁ずつに分けて10bitデータに変換する。
英数字モードは数字・大文字アルファベットなど45種類の文字を2文字ずつ分けて11bitデータに変換する。
バイナリモードは8bitのデータをそのまま8bitで格納する。
漢字モードはShift_JISの1字を13bitで格納する。
実際には漢字でもUTF-8などでバイナリで格納している場合が多いのではないか。
で、Base64の文字列は大文字小文字混在なので、英数字モードは使えない。
このためQRコードではバイナリモードを選択し、3文字→24bit となる。
一方で文字コードから45引いた数値を10進数にして2桁表記すると、
3文字は数字6桁になり、QRコードでは数字モードを選択すると、数字6桁は20bit。
というわけで、数字表記にする方がコンパクトなQRコードが作れるんですね。
もちろんバイナリデータをバイナリデータとして格納すればよりコンパクトだが、
JWT形式という既存のデータ形式が流用できるメリットも考慮したのではないか。
しかし、実は接種証明書アプリで表示されるQRコードはこのメリットが生きていない。
接種証明書アプリで国内向け証明書のQRコードのサイズはVersion30である。
誤り訂正レベルはLだったので、13880bit格納できるサイズである。
一方で格納されていたデータは1675文字、最初の5文字以外は数字である。
この場合、最初の5文字(小文字含む)はバイナリモード、残りの1670文字は数字モードと分ければ、
ヘッダー部分を合わせて5618bitで済むはずで、これはVersion18に相当する。
1675文字をバイナリデータで格納すると13408bit、Version30にピタリ一致する。
だから本来はだいぶ小さなQRコードで済むのである。
ここで18と30を見比べてみればわかりますが。
というわけで、せっかくの圧縮も水の泡という感じはあるが、これはこれで正当なQRコードではある。
shc:/に続く数字を2桁ずつ10進数で解釈して文字列に変換する。
すると “eyJ6……J9.5Z……QQ.Ae……NQ” というようなピリオドで3節に区切られた長い文字列が出てきた。
これがJSON Web Tokens(JWT)形式というもので、Webサイトでの認証データのやりとりに使われているらしい。
特徴としては電子署名とデータの暗号化ができることで、特に電子署名ですよね。
で最初のeyJ6……J9の部分をBase64から戻すとこんなデータは入っている。
{"zip": "DEF", "alg": "ES256", "kid": "f1vhQP9oOZkityrguynQqB4aVh8u9xcf3wm4AFF4aVw"}
ここは特に秘密にするデータはないと思うので貼ってしまうが。
algとkidが電子署名のアルゴリズムと鍵の識別ID(フィンガースタンプ)である。
ここら辺は一般的だがzipという情報があるのは珍しく、これがデータ本体がDeflateで圧縮されていることを表しているのだという。
2つ目の5Z……QQの部分を展開して表示するのは、Pythonで書くとこんな感じ。
databin=zlib.decompress(base64.b64decode(data64,"-_"),-15)
すると、おー。なんか接種証明書っぽいデータが出てきた。
1198byteのデータがBase64で635文字(476byte相当)だからそこそこ圧縮効果あるね。
3つ目の電子署名は “eyJ6……J9.5Z……QQ” に対する電子署名が格納されているはず。
これを逆向きにすれば接種証明書のQRコードを作れる。
名前が”\\u4e09\\u5341\\u4e03”(「三十七」に相当、サンプルのQRコードから引っ張って来た)のようにUnicodeの文字コードで格納されている。
無駄な表記っぽいなぁと思うけど、圧縮するならあまり問題はないかと思いながら書き換える。
そして、データを圧縮して、最初と最後の節はそのままくっつけてQRコードを作成する。
Pythonのqrcodeライブラリは自動的に小さくなるようにモード切替ながら作ってくれるんだよね。ちゃんとVersion18で生成された。
そして接種証明書アプリで読むと、ちゃんと読めた。
氏名は変更されていたが、電子署名が一致しないので改ざんを検出して「有効性が確認できませんでした」と表示された。というわけで完璧ですね。
というわけで、実はSHCのデータ形式はよく考えられていたという話ですね。
問題は接種証明書アプリではその特徴をうまく生かせていないことだが。
でも読取りはできたから、他の発行者が意図通りコンパクトに作ったQRコードでも受入可能なのはとりあえずよかった。
今後、この巨大QRコードも改良されてちょっと小さくなるかも知れない。
しかしQRコードに数字だけ、英数字だけをコンパクトに格納できるのは知らなかったな。
一番小さなVersion1で誤り訂正レベルMだと、数字は34桁、英数字は20字、バイナリは14byte、漢字8字となる。
英数字モードの対象が数字・大文字アルファベットなのは、工業用バーコードとして今でも広く使われているCODE39に合わせたのではないか。
品名など格納する用途ではこれでいいわけですからね。
CODE39は誤読が少ない分、巨大になりがちで、これがQRコードで大幅にコンパクトになるのは確かにメリットが大きいわけである。
ただ、多目的に使われるようになると小文字が入るだけでバイナリモードになることが気になってしまう。
まして日本ローカルのShift_JISを比較的高密度に格納できる漢字モードなんて……
そういう事情を総合的に勘案した結果が、JWT形式のデータを1文字ずつ10進数2桁に置き換えたコードをSHCのデータとするという方法なんだろう。
しかしヘッダーの “shc:/” 部分はバイナリモード前提なので、あまり考えないと全体をバイナリモードにして余計に巨大なコードになってしまうと。
全体が英数字モードになるのがシンプルで良いけど、そのためにBase32(一応そういうものもある)を使うのも汎用性に欠けるだろう。
実際、45byteがBase32では英数72字→QRコードで396bit相当であるのに対し、
Base64→10進数だと数字120桁→400bit相当なので、そこまで差はない。
それならば従来のJWTの処理をできるだけ流用できる方がお得というのはなるほどと思った。