Develop with pleasure!

福岡でCloudとかBlockchainとか。

SMART Health CardのVerifiable Credential

SMART Health Cardは、COVID-19のワクチン接種や検査結果を検証可能な方法で提示できることを直近の目標としたヘルスカードのフレームワーク。長期的には予防接種やその他の健康情報なんかもサポートする計画らしい。これらの証明について、組織や国を超えて機能できるよう国際的な標準の作成と分散型のインフラの設計を目標にしている。

↓がこのフレームワークのコンセプトモデルで、

https://i.imgur.com/T8RHjlJ.png

以下のロールで構成される:

  • 接種証明や検査結果をVerifiable Credentialとして発行するIssuer
  • 発行されたVCのHolder
  • HolderからVCを受け取り正しく署名されているか検証するVerifier

↑の証明スキームにVCが利用されている。そしてデジタル庁が公開した新型コロナワクチン接種証明書アプリもこのSMART Health Cardのスキームに則ってVCを発行しているみたい↓

idmlab.eidentity.jp

ちょうど、こないだ3回めのワクチン接種をしたので、そこで発行されたVCを確認しながらSMART Health Cardの仕組みを見てみる。

SMART Health Cardの内容を確認

新型コロナワクチン接種証明書アプリで接種証明のQRコードを読み取ると、shc:/から始まり数値が続くデータになっている。shcというのは、このデータがSMART Health Cardのデータであることを示しており、スマホにこのスキームに対応するアプリが入っていたらおそらく反応するんだろう。

QRコードのデータフォーマット

shc:/以降の数値のデータがヘルスカードの実体で、これはJSON Web Signature (JWS)のデータになる。ただ、QRコードに効率的にJWSのデータを載せるため、数値モードでエンコードされており、まずはこれをデコードする必要がある。

SMART Health Card仕様では、JWSのデータを以下の手順でQRコードエンコードしている:

  1. チャンク化:1つのQRコードに記述可能なJWSは最大1195文字まで。これを超えるデータをエンコードする場合、長さ1191以下のチャンクに分割する。(チャンク分割がある場合)チャンクの総数をN、現在のチャンクのインデックスをCとした場合、shc:/の後に以下のデータを付与する。
    1. Cの10進表現
    2. セパレーター/
    3. Nの10進表現
    4. セパレーター/
  2. JWSの各文字を0〜9で構成される数値にエンコードする。JWSの各文字は、Ord(文字) - 45を計算して数値にエンコードする。45という数値は、JWSの有効な文字の内、文字コードが最小の文字-文字コードで、これを引くとデータの取り得る値が00〜99の範囲になることから設定されている定数になる。

この結果、

  • 単一のチャンクから生成したQRコードは、shc:/56762909524320603460292437404460...
  • 長いJWSで、3つのチャンクのセットの内2つめのチャンクで生成したQRコードは、`shc:/2/3/56762909524320603460292437404460...

のようになる。

ワクチン接種証明のQRコードをデコード

今回、ワクチン接種証明アプリからQRコードを表示できるので、そのデータをデコードしてみる。shc:/以降の数値のデータは、上記のエンコード方法と逆に、2桁の数値毎に+45した文字コードをの文字を算出すればいい。Rubyだと以下のように書けばデコードできる(JWSは、Header、Payload、Signatureの3つのデータで構成され、各パーツはドット(.)で区切られている。)。

qr_content = '<shc:/以降の数値>'

header, payload, sig = qr_content.scan(/.{1,2}/).map do |chrs|
  (chrs.to_i + 45).chr
end.join.split('.')

データはBase64URLエンコードされてるので、Headerをデコードしてみると↓

require 'base64'

Base64.urlsafe_decode64(header)
=> {"zip": "DEF", "alg": "ES256", "kid": "f1vhQP9oOZkityrguynQqB4aVh8u9xcf3wm4AFF4aVw"}

↑から、

ことを示している。ただ、zipはJWSの仕様で定義されているパラメータではないので、SMART Health Cardのカスタム仕様っぽい

なので、Payloadを確認するには、Inflateする必要がある。

require 'zlib'

zstream = Zlib::Inflate.new(-Zlib::MAX_WBITS)
uncompressed = zstream.inflate(Base64.urlsafe_decode64(payload))

※ RAW Inflateモードを有効にするためにウィンドウサイズを-Zlib::MAX_WBITS(-15)に設定してる。通常のZlib::Inflate.inflateを使うとincorrect header checkエラーが発生する。

uncompressedを整形すると、以下のVCが確認できる

{
  "iss": "https://vc.vrs.digital.go.jp/issuer",
  "nbf": 1646822383.591016,
  "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": [{"use": "usual", "family": "\u5b89\u571f", "given": ["\u8302\u4ea8"]}],
              "birthDate": "1980-08-23"
            }
          },
          {
            "fullUrl": "resource:1",
            "resource": {
              "resourceType": "Immunization",
              "status": "completed",
              "vaccineCode": {
                "coding": [{"system": "http://hl7.org/fhir/sid/cvx", "code": "207"}]
              },
              "patient": {"reference": "resource:0"},
              "occurrenceDateTime": "2021-07-09",
              "performer": [{"actor": {"display": "MHLW_Gov_of_JAPAN"}}],
              "lotNumber": "3002618"
            }
          },
          {
            "fullUrl": "resource:2",
            "resource": {
              "resourceType": "Immunization",
              "status": "completed",
              "vaccineCode": {"coding": [{"system": "http://hl7.org/fhir/sid/cvx", "code": "207"}]},
              "patient": {"reference": "resource:0"},
              "occurrenceDateTime": "2021-08-06",
              "performer": [{"actor": {"display": "MHLW_Gov_of_JAPAN"}}],
              "lotNumber": "3004232"
            }
          },
          {
            "fullUrl": "resource:3",
            "resource": {
              "resourceType": "Immunization",
              "status": "completed",
              "vaccineCode": {"coding": [{"system": "http://hl7.org/fhir/sid/cvx", "code": "207"}]},
              "patient": {"reference": "resource:0"},
              "occurrenceDateTime": "2022-03-09",
              "performer": [{"actor": {"display": "MHLW_Gov_of_JAPAN"}}],
              "lotNumber": "000126A"
            }
          }
        ]
      }
    }
  }
}

↑から、

  • 発行者はhttps://vc.vrs.digital.go.jp/issuerとなっており、デジタル庁。
  • nbf(Not Before Time)は発行日を表すUNIXタイムスタンプ
  • vccredentialSubjectには、 接種対象者の情報、接種情報がそれぞれ記録されている。↑だと3回分(回数増えてデータが増えると、QRコードもチャンク化されるんだろう)。Credentialのタイプは以下の3つ:
    • https://smarthealth.cards#health-card:ヘルスカードの伝達用に設計されたVC
    • https://smarthealth.cards#immunization:免疫に関する詳細のVC
    • https://smarthealth.cards#covid19:COVID-19の詳細のVC

が分かる。基本的にFHIR(Fast Healthcare Interoperability Resources)でモデル化された臨床データを提示するVCっぽい。

署名鍵

SMART Health Cardの仕様では、発行者は署名鍵の公開鍵をJSON Web Keyのセットとして/.well-known/jwks.jsonで公開することになっている。そのため↑では、https://vc.vrs.digital.go.jp/issuer/.well-known/jwks.jsonで公開鍵が公開されている↓

{
  "keys": [
    {
      "kty": "EC",
      "kid": "f1vhQP9oOZkityrguynQqB4aVh8u9xcf3wm4AFF4aVw",
      "use": "sig",
      "alg": "ES256",
      "x5c": [
        "MIIByjCCAXGgAwIBAgIJAPZFN9WW4voaMAoGCCqGSM49BAMDMCIxIDAeBgNVBAMMF3ZjLnZycy5kaWdpdGFsLmdvLmpwIENBMB4XDTIxMTEyNTEyNTUxNloXDTIyMTEyNTEyNTUxNlowJjEkMCIGA1UEAwwbdmMudnJzLmRpZ2l0YWwuZ28uanAgSXNzdWVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEViKBgZ0f3pQKv+tSz653HUtIzCS8TVSNu1Hwi0tKpSnTXXvtqkpcfYeAZ+SfvVk8SWNaTRDZ9wTNjb9c58v9l6OBizCBiDAJBgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAdBgNVHQ4EFgQUiIXKUyT93YdyqsIjE8i5I1z8w0IwHwYDVR0jBBgwFoAU0cYt0sPpuIDBt7a9PD3qs9mOu7EwLgYDVR0RBCcwJYYjaHR0cHM6Ly92Yy52cnMuZGlnaXRhbC5nby5qcC9pc3N1ZXIwCgYIKoZIzj0EAwMDRwAwRAIgEwVdLdbPqMYqEsVltnsm3bI/Z6eibgMwYaNVZiu0r2sCIFebHk1i6ghWOQn+Q0+t5F77fasgJ3Oc6NWx9I8AWjRM",
        "MIIBkDCCATagAwIBAgIJAOECTZDa4MA7MAoGCCqGSM49BAMEMCcxJTAjBgNVBAMMHHZjLnZycy5kaWdpdGFsLmdvLmpwIFJvb3QgQ0EwHhcNMjExMTI1MTI1NTEzWhcNMjYxMTI0MTI1NTEzWjAiMSAwHgYDVQQDDBd2Yy52cnMuZGlnaXRhbC5nby5qcCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEL3S0yNIJ8EuxgiaHEvsjGWd60P0BBKUfVUVSxpVyGsnXwuzkS7OPGG/DT60m5XTvKT125MRuZoS/sajPBcg2KjUDBOMAwGA1UdEwQFMAMBAf8wHQYDVR0OBBYEFNHGLdLD6biAwbe2vTw96rPZjruxMB8GA1UdIwQYMBaAFPKN8VogQyX0IuxEi7jBB5gUnFinMAoGCCqGSM49BAMEA0gAMEUCIQCcq3H/pRMRkUmpWUDsggQXJAjLB/AutlHQigEBsVx0sgIgfVyc0L1cbRaDmdCQ3CGd994rRuwlQI0/cJCIv5LeI3g=",
        "MIIBlTCCATugAwIBAgIJANt2MZrWChe2MAoGCCqGSM49BAMEMCcxJTAjBgNVBAMMHHZjLnZycy5kaWdpdGFsLmdvLmpwIFJvb3QgQ0EwHhcNMjExMTI1MTI1NDUzWhcNMzExMTIzMTI1NDUzWjAnMSUwIwYDVQQDDBx2Yy52cnMuZGlnaXRhbC5nby5qcCBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEilfgw+JIG8TOliOLe7jufm2m0+HqL4t5nvBdQj3UMgh8jjl6VoVKKwcj3T1DWFinm6sCTWYUrPSXWcvOq64GbKNQME4wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQU8o3xWiBDJfQi7ESLuMEHmBScWKcwHwYDVR0jBBgwFoAU8o3xWiBDJfQi7ESLuMEHmBScWKcwCgYIKoZIzj0EAwQDSAAwRQIgQWnKyVhaKpu1WcXP49s9inaa5mnWgV/pCW31h/NIJnwCIQDSIHvGUuPwK+ofYqLJGo99hhwhfkIBWhvSo0vr5IGesg=="
      ],
      "crv": "P-256",
      "x": "ViKBgZ0f3pQKv-tSz653HUtIzCS8TVSNu1Hwi0tKpSk",
      "y": "01177apKXH2HgGfkn71ZPEljWk0Q2fcEzY2_XOfL_Zc"
    }
  ]
}

楕円曲線P-256の公開鍵のデータになっている。x5cはこの公開鍵のX.509証明書(もしくは証明書チェーン)のデータ。

そして、JWSのHeaderにあったkidはこの公開鍵を指すJSON Web Key Thumbprintになっている。これは、json-jwt gemを使って以下のように計算できる。

require 'json/jwt'

params = {
  kty: :EC,
  crv: 'P-256',
  x: 'ViKBgZ0f3pQKv-tSz653HUtIzCS8TVSNu1Hwi0tKpSk',
  y: '01177apKXH2HgGfkn71ZPEljWk0Q2fcEzY2_XOfL_Zc'
}

jwk = JSON::JWK.new(params)
jwk.thumbprint('sha256')
=> f1vhQP9oOZkityrguynQqB4aVh8u9xcf3wm4AFF4aVw

今回は鍵は1つだったけど、/.well-known/jwks.jsonには複数の署名鍵の情報が設定されることもあるので、kidでそのうちのどの鍵を使って署名されているのか確認する。

署名検証

署名鍵が特定できたので、最後にJWSの署名を検証する。ecdsa gemを使って検証すると↓

require 'ecdsa'

x = Base64.urlsafe_decode64('ViKBgZ0f3pQKv-tSz653HUtIzCS8TVSNu1Hwi0tKpSk').unpack1('H*').to_i(16)
y = Base64.urlsafe_decode64('01177apKXH2HgGfkn71ZPEljWk0Q2fcEzY2_XOfL_Zc').unpack1('H*').to_i(16)
public_key = ECDSA::Point.new(ECDSA::Group::Secp256r1, x, y)

digest = Digest::SHA256.digest("#{header}.#{payload}")

signature = Base64.urlsafe_decode64(sig)
r = signature[0...32].unpack1('H*').to_i(16)
s = signature[32..-1].unpack1('H*').to_i(16)
signature = ECDSA::Signature.new(r, s)

ECDSA.valid_signature?(public_key, digest, signature)
=> true

と、署名の正しさを検証できる。Secp256r1NIST P-256の別名(ちなみにOpenSSLでは、prime256v1)。

気になった点

  • 接種証明をVerifiable Credentialで提供しているけど、IssuerやHolderに関してDIDで識別されている訳ではなさそう。
  • ↑のVCの提示で接種証明はできるけど、このQRコードのコピーを持っていたら誰でも証明できてしまいそう。DIDなんかでHolder側にも鍵があって、(VCのIssuerの署名に加えて)Verifiable PresentationなどでHolderの署名を提供するような仕組みが欲しい。