💉

ハワイ州のワクチン接種証明書SMART Health Cardを読んでみる

2021/09/21に公開

アメリカ・ハワイ州などではデルタ株によるコロナウイルス感染症の再流行に伴って、レストランなどに入るためにコロナウイルスワクチンを接種した証明か最近のウイルス検出テストが陰性だった証明を求められるようになりました。ワクチンを接種した証明としては、Hawai‘i SMART Health Cardを利用することができます。SMART Health CardはBoston Children’s Hospital Computational Health Informatics Programが策定した規格で、仕様や周辺のソフトウェアコードが公開されています。この機会に、自分のSMART Health Cardを発行してもらって読んでみました。

カードに含まれるのは下記のような情報でした。ワクチンの接種履歴の改竄を防ぎ、レストランなどが身分証明書(多くの場合は運転免許証)と併せて本人を確認するのに必要充分に思われます:

  • 接種対象者(自分)の
    • 名字と名前
    • 誕生年月日
  • 1度目の接種と2度目の接種それぞれの
    • ワクチンの種類(Pfizer-BioNTech COVID-19 Vaccineなど)
    • 接種年月日
    • ロット番号

また、証明書の検証に必要な公開鍵を事前に取得しておくことで、レストランなどではネットワーク接続なしにカードの内容の検証と本人確認をすることができそうです。

2022年3月に再度試したところ、3度目の接種についても情報の入力が可能になっていて、SMART® Health Card Verifier Appや下記のRubyコードでも3度目の接種についての情報を含めて表示・検証されました。

SMART Health Cardの取得

筆者の住んでいるハワイ州では、レストランなどへの入場制限を厳しくするのに先駆けて、Safe Travelsサイトで、ハワイ州への州外からの旅行の際のワクチン接種の記録の登録に加えて、ハワイ州居住者のワクチン接種の記録の登録ができるようになりました。アカウントを作成し、本名と誕生日を設定した後、ワクチンの接種日とロット番号を登録し、紙のワクチンカードの写真をアップロードすることで、SMART Health CardのQRコードを取得できます。

Safe Travelsサイトで取得したQRコード

レストランなどでのSMART Health Cardの検証

レストランなどがカードの検証をする際には、The Commons ProjectがAndroidやiOSのために無料で配布しているSMART® Health Card Verifier Appを利用できます。顧客から提示されたQRコードを読み取ることで、氏名・誕生日、ワクチンの種類・ロット番号・接種日が表示されます。

表示されている氏名と誕生日が、顧客が提示した身分証明書と一致していることを確かめれば、カードが本人のものであることを確認できます。

アプリケーションによるQRコードの検証

手元でのSMART Health Cardの解釈と検証

SMART Health CardのQRコードの内容を手元で確認することもできます。

携帯電話などでQRコードを読むと、shc:/という文字列に続いて0から9までの数字の羅列が得られます。2桁ずつを10進数として読み45を加えてASCIIコードとして解釈した文字列はJWTとなっていて、ピリオド.で分割することで、JWSヘッダ、JWSペイロード、電子署名をそれぞれBase64でエンコードした文字列を得ることができます。

Base64でデコードしたペイロードはdeflateされていて、inflateすることでJSONの文字列を得ることができます。これをjqコマンドで整形すると下記のようになります(証明書の生成時刻、本名、誕生日、ワクチンの接種日とロット番号は編集してあります)。

{
  "iss": "https://travel.hawaii.gov",
  "nbf": 1631672860,
  "vc": {
    "type": [
      "https://smarthealth.cards#health-card",
      "https://smarthealth.cards#immunization",
      "https://smarthealth.cards#covid19"
    ],
    "credentialSubject": {
      "fhirVersion": "4.0.1",
      "fhirBundle": {
        "resourceType": "Bundle",
        "type": "collection",
        "entry": [
          {
            "fullUrl": "resource:0",
            "resource": {
              "resourceType": "Patient",
              "name": [
                {
                  "family": "(名字)",
                  "given": [
                    "(名前)"
                  ]
                }
              ],
              "birthDate": "(誕生日yyyy-mm-dd)"
            }
          },
          {
            "fullUrl": "resource:1",
            "resource": {
              "resourceType": "Immunization",
              "status": "completed",
              "vaccineCode": {
                "coding": [
                  {
                    "system": "http://hl7.org/fhir/sid/cvx",
                    "code": "208"
                  }
                ]
              },
              "patient": {
                "reference": "resource:0"
              },
              "occurrenceDateTime": "(1度目接種日yyyy-mm-dd)",
              "lotNumber": "(ロット番号)"
            }
          },
          {
            "fullUrl": "resource:2",
            "resource": {
              "resourceType": "Immunization",
              "status": "completed",
              "vaccineCode": {
                "coding": [
                  {
                    "system": "http://hl7.org/fhir/sid/cvx",
                    "code": "208"
                  }
                ]
              },
              "patient": {
                "reference": "resource:0"
              },
              "occurrenceDateTime": "(2度目接種日yyyy-mm-dd)",
              "lotNumber": "(ロット番号)"
            }
          }
        ]
      }
    }
  }
}

Base64でデコードしたヘッダには電子署名に用いたアルゴリズム、鍵対の指紋、圧縮アルゴリズムの情報が含まれます。

{
  "alg": "ES256",
  "kid": "Qxzp3u4Z6iafzbz-6oNnzobPG8HUr0Jry38M3nuV5A8",
  "typ": "JWT",
  "zip": "DEF"
}

Base64でデコードした電子署名と、ペイロードのissプロパティから生成したURLで公開されている公開鍵を用いて、Base64でデコードする前のヘッダとペイロードをピリオドで結合した文字列を検証することができます。

QRコードの確認には下記のようなRubyプログラムを用いました。OpenSSL v1.xで動きます。

#!/usr/bin/ruby
#
# usage: echo shc:/0123..(decoded from a QR code) | ruby shc-payload.rb | jq
# Prints the payload from SMART Health Card on QR code
# https://spec.smarthealth.cards/
#
# Copyright 2021 by zunda <zundan at gmail.com>
#
# Permission is granted for use, copying, modification, distribution,
# and distribution of modified versions of this work as long as the
# above copyright notice is included.
#
require "base64"
require "json"
require "zlib"
require "open-uri"
require "openssl"

class SmartHealthCard
  attr_reader :header, :payload, :signature
  def initialize(encoded)
    # This only supports single-chunk QR code
    x = encoded.match(%r|\A\s*shc:/(\d+)\s*\z|)
    raise "Malformed SHC chunk or part of SHC chunks" unless x
    # Followed
    # https://github.com/dvci/health_cards/blob/main/lib/health_cards/jws.rb
    # and health_cards-0.0.2 gem
    @header_encoded, @payload_encoded, signature_encoded = x[1].scan(/\d\d/).map{|d| (d.to_i + 45).chr}.join.split(".")
    @header = JSON.parse(Base64.urlsafe_decode64(@header_encoded))
    @payload = JSON.parse(Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(Base64.urlsafe_decode64(@payload_encoded)))
    @signature = Base64.urlsafe_decode64(signature_encoded)
  end

  def pubkey
    unless @pubkey
      iss = payload["iss"]
      raise "Issuer is not specified in payload" unless iss
      @pubkey = JSON.parse(URI.open("#{iss}/.well-known/jwks.json").read)
    end
    @pubkey
  end

  def verify
    # Followed
    # https://github.com/dvci/health_cards/blob/main/lib/health_cards/key.rb
    # https://github.com/dvci/health_cards/blob/main/lib/health_cards/public_key.rb
    group = OpenSSL::PKey::EC::Group.new('prime256v1')
    ec_key = OpenSSL::PKey::EC.new(group)

    byte_size = (ec_key.group.degree + 7) / 8
    sig_bytes = signature[0..(byte_size - 1)]
    sig_char = signature[byte_size..] || ''
    sig_asn1 = OpenSSL::ASN1::Sequence.new(
      [sig_bytes, sig_char].map{|i|
        OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(i, 2))
      }
    ).to_der

    pubkey_jwk = pubkey.fetch("keys")&.find{|k| k["kid"] == header["kid"]}
    raise "Public key not found" unless pubkey_jwk
    pubkey_bn = ['04'].pack('H*') + Base64.urlsafe_decode64(pubkey_jwk["x"]) + Base64.urlsafe_decode64(pubkey_jwk["y"])
    ec_key.public_key = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(pubkey_bn, 2))

    signing_input = "#{@header_encoded}.#{@payload_encoded}"
    return ec_key.verify(OpenSSL::Digest.new('SHA256'), sig_asn1, signing_input)
  end
end

c = SmartHealthCard.new(ARGF.read)
puts c.payload.to_json
$stderr.puts "#{c.verify ? "Verified" : "Not verified"} by #{c.payload["iss"]}"

2022年3月16日に取得したハワイ州の公開鍵は下記のようなものでした。

$ curl -s https://travel.hawaii.gov/.well-known/jwks.json | jq
{
    "keys": [
        {
            "kty": "EC",
            "use": "sig",
            "alg": "ES256",
            "kid": "Qxzp3u4Z6iafzbz-6oNnzobPG8HUr0Jry38M3nuV5A8",
            "crv": "P-256",
            "x": "sxIW-vGe4g7LXU0ZpMOiMmgMznaC_8qj6HW-2JhCTkI",
            "y": "Ytmnz6q7qn9GhnsAB3GP3MFlnk9kTW3wKk7RAue9j8U"
        }
    ]
}

異なるプロバイダによるSMART Health Cardの解釈と検証

2023年になって5回目の接種を受けました。これまではCDCのウェブサイトから予約して接種を受けていましたが、今回は近所の薬局(CVS)のウェブサイトで予約して接種を受けました。接種後に取得したQRコードのペイロードは下記のようなもので、CVSの公開鍵で検証できましたが、SMART® Health Card Verifier Appでの検証には失敗しました。このペイロードには1回分のみの接種情報しか含まれていないからでしょう。

{
  "vc": {
    "type": [
      "https://smarthealth.cards#covid19",
      "https://smarthealth.cards#health-card",
      "https://smarthealth.cards#immunization"
    ],
    "credentialSubject": {
      "fhirVersion": "4.0.1",
      "fhirBundle": {
        "resourceType": "Bundle",
        "type": "collection",
        "entry": [
          {
            "fullUrl": "resource:0",
            "resource": {
              "resourceType": "Patient",
              "name": [
                {
                  "family": "(名字)",
                  "given": [
                    "(名前)"
                  ]
                }
              ],
              "birthDate": "(誕生日yyyy-mm-dd)"
            }
          },
          {
            "fullUrl": "resource:1",
            "resource": {
              "resourceType": "Immunization",
              "status": "completed",
              "vaccineCode": {
                "coding": [
                  {
                    "system": "http://hl7.org/fhir/sid/cvx",
                    "code": "229"
                  }
                ]
              },
              "patient": {
                "reference": "resource:0"
              },
              "performer": [
                {
                  "actor": {
                    "display": "CVSPharmacy"
                  }
                }
              ],
              "occurrenceDateTime": "(接種日yyyy-mm-dd)",
              "lotNumber": "(ロット番号)"
            }
          }
        ]
      }
    }
  },
  "iat": (UNIX時刻によるタイムスタンプ),
  "nbf": (UNIX時刻によるタイムスタンプ),
  "iss": "https://api.cvshealth.com/public"
}

2023年1月29日に取得したCVSの公開鍵は下記のようなものでした。

$ curl -s https://api.cvshealth.com/public/.well-known/jwks.json | jq
{
  "keys": [
    {
      "kty": "EC",
      "kid": "afXT8j9iwJJ7IRP24ZUKPhbkga79MfqPreO2DlK0sLA",
      "use": "sig",
      "alg": "ES256",
      "crv": "P-256",
      "x": "6lylD6yXNiue2_-kfe1kAxX2wbhY-d_lWrEV8ttO0K0",
      "y": "XDeVwjljswv3KHmZo752sY4OZ7SVJQLjaKH4gIpXDd4"
    },
    {
      "e": "AQAB",
      "kty": "RSA",
      "n": "xpz3K354PZpq5v4Te08V9sDM_VDXk8DXvpuo195vN7pKfnpnNw0-hDyV9G4auWQGzEMyW-HEehuWq-Gi8Rn020FWKPiU-1dbjXiEbs_5KfJx-6Wq1uANP6o50mSm2glAlfAYlpJvYOFLFd3viF4xIG5Z6I-0_dZNpwKWfWokfP0l8-MrTfxcIYCTGSFOpyQcteIfGd96nFOEsdrDC7Da4TcW2suZCbtDPs2qXOR0O_STjBvqHMtS47CaoAc5OX7sO4iDLsgZDq46tOfV2NE4PvrcJ4Ywsu3eLCCL4xx2aJBGbRMAiXhBrWgZpplawLt6OeSGq27LhNjCNA1RJCYlNSBOgtamMSstk7APKpYtnV65rIkFitiZq1K_kH1c3MsqWFjw5wim-H3OdP9SxkJZf8qGLwc7D6fF20x9ydyTRz3_0KWMonc1y9SwjC-8P9xnSGzf8NjDlSVnW-_mqwbyLgCJXz7OtlHm4_esE2QwbEc6HZJl0cO_PM-exY8jJklF",
      "kid": "e46ada0a-94df-4d6b-908a-13ee5dba900d"
    },
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "4a560ef3-49d3-4463-bd28-70efba817c1e",
      "n": "tMpG5nOFhBlPi0UXo6PdNux7JNsVc0O66wCHuo3vMU__az3KVzbhx9CzVqfXHJW0PRI63-KjIuwZIAhKXTJo1CRV4xt5-GbDV5QYStWY9yH1QJQzvE6JcxBLCjGv95L2kWcLwAMjBRogcs5roA31KFvIIE7pMsZxyi3f8r7ncbjIs2vYmDeZXfdvo1PGsF-EmPYKApOCRgh-mG35d8bUOSPzyH_LqUVMQd5SHIIKbAcE_IoiqnGYetvIiUC1VHUQ3cenid0cyznmV2GApq5-wMVpPo2VtQ-zNsalnRdBIGUJ9uBh3whiTV3cRl0lJWESC2raRkoqK4gtPO3Map8vaFSw4_HEld0MTG5rIhHOks_NDI4-3KzVOELJfMA3i5RMgXl82-ZHsU_VCNdDgNsfrXwuioYzlel1RsygDbfodA46z525aLcf-RacvC1WoO3j_sn2ZmqMr45MZYmE67dNW8IC331w0mmRNZNvXNQZXVMIKxzYPnY_6wrPmLyrM_YYi0p-kIyuaOJNbrSgQptDHbYTRqxZFAi42gcr5eplU8ibU45K2_aoSpy6TdZhb4hI_yQbTbyH8FSPAEO2cMFcepooUfhha9FA1iwXOXFU8veRPGyU7yk05kFFNUpublM2h2Yn6tFjKgihcc_iP_4XVcp10GLrD96uHIQnS-kGx4E"
    },
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "b207c3df-c707-4dff-8001-f3a70f12e0cb",
      "n": "hH-LR9PbzBTZs0t6hjbpZWa6U9x_HLoKNnElIS6RrZiZ35x2CwC7QUJ4lWr0GYajxmG88s0ko9-9g-v6uyrbF8Jbcma48KTbyvffQ3qvOujV6NP7NHgFkyqqxfNywwYLSTOc3bqX4m-siNEjefZlrk1mfUm7L8scxBfDVxJA1dJJ1AK-H_SDav6NZDl-I87y4Cg4_D5hoW8b5BjkFeSN15Qr_iryKfoL_u7eofRfQwF28CgYSxNKG4IjV1SM-YpSFbU86lWatL_pYucB63RVaV4OR8ZkIj01G-o9dEhY2TTEIyfxET3XQKd5Zj3__ruhGBZEjPsd5PJgmjt48UehDQ"
    },
    {
      "crv": "P-256",
      "x": "yXG6HMBHEgT-df4-mvTSyyV2vdHJLaf4pUV_fHR79Ng",
      "y": "7qknv5rwm0cBcETPS6W7v_M5g7zNScZlsJFL39VXg3E",
      "kty": "EC",
      "use": "sig",
      "alg": "ES256",
      "kid": "Kt6Xmv-9dpM2mbpbzxTM0P3YGbAW-WIJD0EE3_ddH00"
    },
    {
      "crv": "P-256",
      "x": "TXWmbGcaaK-VCByK8_ziepSXGcwjjRWOZx0vAPUcErQ",
      "y": "ID8SUpjFnwOV-H-eGLIv4xCZzw72nCGeXzLbSUXKDQg",
      "kty": "EC",
      "kid": "h0MD1WZcbX37spRMaNkLGt4uzyOqzgU8DtXVLw1YmpI"
    }
  ]
}

Discussion