SmartHealthCardsの発行と検証を行うことができるGem 「health_cards」の 公開鍵検証周りのソースコードを読んでみる

こんにちは!kossyです!





今回は SmartHealthCardsの発行と検証を行うことができるGem 「health_cards」の、

公開鍵の検証周りのソースコードを読んでみたいと思います。





環境
Ruby 3.0.3
Rails 6.1.3.1
MacOS Catalina latest




HealthCards::Verifier

APIドキュメントが用意されていたので確認してみます。

github.com

Verify health cards based on a stored public key Verifiers may contain one or more public keys (using KeySet)

保存された公開鍵に基づいてヘルスカードを検証します。Verifiersには、1つ以上の公開鍵を含めることができます(KeySetを使用)

出典: https://github.com/dvci/health_cards/blob/main/lib/API.md#healthcardsverifier

ドキュメントからわかる仕様についての情報はほとんどないですね、、、

HealthCards::Verifierクラスの検証処理を動かしながら追ってみましょう。

HealthCards::Verifier#verify

test_appが用意されていたので、HealthCards::Verifier.verifyにbinding.pryを記述してコンソールで試してみます。

github.com

$ key = HealthCards::PrivateKey.generate_key

$ issuer = HealthCards::Issuer.new(key: key, url: 'http://example.com')

$ jws = issuer.issue_jws(FHIR::Bundle.new)

$ ver = HealthCards::Verifier.new(keys: key.public_key)

$ ver.verify(jws)

From: health_cards/lib/health_cards/verifier.rb:62 HealthCards::Verifier#verify:

    60: def verify(verifiable)
    61:   binding.pry
 => 62:   verify_using_key_set(verifiable, keys, resolve_keys: resolve_keys?)
    63: end

verify_using_key_setメソッドを見てみる必要がありそう。


HealthCards::Verification.verify_using_key_set

# Verify Health Card with given KeySet
#
# @param verifiable [HealthCards::JWS, String] the health card to verify
# @param key_set [HealthCards::KeySet, nil] the KeySet from which keys should be taken or added
# @param resolve_keys [Boolean] if keys should be resolved
# @return [Boolean]
def verify_using_key_set(verifiable, key_set = nil, resolve_keys: true)
  jws = verifiable.is_a?(HealthCards::HealthCard) ? verifiable.jws : JWS.from_jws(verifiable)
  key_set ||= HealthCards::KeySet.new
  key_set.add_keys(resolve_key(jws)) if resolve_keys && key_set.find_key(jws.kid).nil?

  key = key_set.find_key(jws.kid)
  unless key
    raise MissingPublicKeyError,
          'Verifier does not contain public key that is able to verify this signature'
  end

  jws.public_key = key
  jws.verify
end

まずはコメントアウト部分を読んでみます。

# Verify Health Card with given KeySet
#
# @param verifiable [HealthCards::JWS, String] the health card to verify
# @param key_set [HealthCards::KeySet, nil] the KeySet from which keys should be taken or added
# @param resolve_keys [Boolean] if keys should be resolved
# @return [Boolean]

指定されたKeySetでヘルスカードを確認します。

verifiable (HealthCards::JWS または String) : 確認するヘルスカード
key_set(HealthCards::KeySet または nil) : キーを取得または追加する必要があるKeySet
resolve_keys(Boolean) : キーを解決する必要がある場合は true 
返り値はBoolean

一通り概要は掴めました。具体的な処理も見ていきます。

verifiable.jws
# 引数のverifiableが HealthCards::HealthCardクラスのインスタンスの時はそのインスタンスが持つjwsを返却して、
# そうでない場合はJWSのfrom_jwsメソッドでjwsを返却

$ jws = verifiable.is_a?(HealthCards::HealthCard) ? verifiable.jws : JWS.from_jws(verifiable)
=> #<HealthCards::JWS:0x00007fcb5c8a26e8
 @header={"zip"=>"DEF", "alg"=>"ES256", "kid"=>"kp4-CyUBQV4x5nBpAzITzdkJJPVrRekiiHs2JrMqpPw"},
 @key=
  #<HealthCards::PrivateKey:0x00007fcb7f389710
   @key=#<OpenSSL::PKey::EC:0x00007fcb7f389760>,
   @public_key=
    #<HealthCards::PublicKey:0x00007fcb5e6bb210
     @coordinates=
      {:x=>"BePU7QxXPU9lSSKJ7w_7uYg-67qAic347XKfNUhs_lQ",
       :y=>"6QVRjH1arEJcFYsBw1wd_CbvpW-iT5HV7LcSzsH3v_0",
       :kty=>"EC",
       :crv=>"P-256"},
     @key=#<OpenSSL::PKey::EC:0x00007fcb5e6bb288>>>,
 @payload=
  "-\x8DM\x0E\x820\x10\x85\xEF2n\xB1@D\xA2]z\x05\x8D\e\xE3\xA2\x94!\xADi\v\x99\x0EFC\xBC\xBB-:\xBB7\xEF\xE7[\xC0\xC6\b\x12\f\xF3$\xCB\x12_\xCAO\x0E\x85\x1E=\x14\x10\xBA\x01d\xDD6uu\xDC\x1D\xF6m\x01O\rr\x01~O\b\xF2\xB6vb*E\xAF\x88\r*\xC7FhE}\xDC\xFC\xC46\v\xB8\x17\xA0\t{\fl\x95;\xCF\xDD\x035\xE7\x95\xC1X\xBA\"E;\x86\xC4oD%\xEA\x84\xCC\xDF\xD3\x1Cz\x879C\x18\xC7\x994^V\"\xFC\x8DO\xBA/",
 @public_key=
  #<HealthCards::PublicKey:0x00007fcb5e6bb210
   @coordinates=
    {:x=>"BePU7QxXPU9lSSKJ7w_7uYg-67qAic347XKfNUhs_lQ",
     :y=>"6QVRjH1arEJcFYsBw1wd_CbvpW-iT5HV7LcSzsH3v_0",
     :kty=>"EC",
     :crv=>"P-256"},
   @key=#<OpenSSL::PKey::EC:0x00007fcb5e6bb288>>>

HealthCardクラスの処理を見たところ、initializeメソッドでjwsが定義されているようでした。

github.com

# frozen_string_literal: true

module HealthCards
  # Represents a signed SMART Health Card
  class HealthCard
    extend Forwardable

    attr_reader :jws

    def_delegator :@qr_codes, :code_by_ordinal
    def_delegators :@payload, :bundle, :issuer

    # Create a HealthCard from a JWS
    # @param jws [JWS, String] A JWS object or JWS string
    def initialize(jws)
      @jws = JWS.from_jws(jws)
      @payload = Payload.from_payload(@jws.payload)
      @qr_codes = QRCodes.from_jws(@jws)
    end

  # 省略
end


次に key_set.find_key の処理を見てみます。

HealthCards::KeySet#find_key

github.com

# Retrieves a key from the keyset with a kid
# that matches the parameter
# @param kid [String] a Base64 encoded kid from a JWS or Key
# @return [Payload::Key] a key with a matching kid or nil if not found

# パラメータに一致する子を持つキーセットからキーを取得します。
# kid(String) : JWSまたはキーからのBase64でエンコードされたkid
# 返り値は Payload :: Key (一致する子を持つキー、または見つからない場合はnil)

def find_key(kid)
  @key_map[kid]
end

KeySetインスタンス生成時に定義される@key_map変数の中の値を引数のkidで走査して、該当するkidを持つ値があればそれを返却するメソッドでした。

取得したkeyをjwsのpublic_keyとして代入し、その後 HealthCards::JWS#verifyを呼び出しているようなのでコードを読んでみます。


HealthCards::JWS#verify

github.com

def verify
  raise MissingPublicKeyError unless public_key

  public_key.verify(jws_signing_input, signature)
end

public_key属性が nil または falseの場合は例外をraiseさせていました。

検証する処理はHealthCard::PublicKeyのインスタンスが担っているっぽいですね。

その前にpublic_key.verifyに渡している引数の中身を見てみましょう。

From: health_cards/lib/health_cards/jws.rb:105 HealthCards::JWS#verify:

    102: def verify
    103:   raise MissingPublicKeyError unless public_key
    104:
 => 105:   public_key.verify(jws_signing_input, signature)
    106: end

$ jws_signing_input
=> "eyJ6aXAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtwNC1DeVVCUVY0eDVuQnBBeklUemRrSkpQVnJSZWtpaUhzMkpyTXFwUHcifQ.LY1NDoIwEIXvMm6xQESiXXoFjRvjopQhrWkLmQ5GQ7y7LTq7N-_nW8DGCBIM8yTLEl_KTw6FHj0UELoBZN02dXXcHfZtAU8NcgF-TwjytnZiKkWviA0qx0ZoRX3c_MQ2C7gXoAl7DGyVO8_dAzXnlcFYuiJFO4bEb0Ql6oTM39Mceoc5QxjHmTReViL8jU-6Lw"

$ signature
=>"\x11\xB7C\xF3!z\x18\xA0\xDC\x1F\xFE\xB7\x9A\xCD\xDCo[\xCAM\xAF:\xE8\x15\xFE\x00\x10\f8\xF0\xB5H\x87\xD8\x7FU\x92\xE5\xC3\x06\xC7\xE6|\x84\xB3\xBB{\xDB>\x8F\xDF]\xA2\xAE?\x88\xB4]\xD9\x8AC\xD1t_\x0F"

$ method(:jws_signing_input).source_location
=> ["health_cards/lib/health_cards/jws.rb", 110]

jws_signing_inputのコードはこちら。

github.com

def jws_signing_input
  "#{JWS.encode(@header.to_json)}.#{encoded_payload}"
end

stepメソッドで処理を追います。

From: health_cards/lib/health_cards/jws.rb:111 HealthCards::JWS#jws_signing_input:

    110: def jws_signing_input
 => 111:   "#{JWS.encode(@header.to_json)}.#{encoded_payload}"
    112: end

$ @header.to_json
=> "{\"zip\":\"DEF\",\"alg\":\"ES256\",\"kid\":\"kp4-CyUBQV4x5nBpAzITzdkJJPVrRekiiHs2JrMqpPw\"}"

$ JWS.encode(@header.to_json)
=> "eyJ6aXAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtwNC1DeVVCUVY0eDVuQnBBeklUemRrSkpQVnJSZWtpaUhzMkpyTXFwUHcifQ"

HealthCard::JWS.encodeのコードはこちら。

github.com

require 'base64'

module HealthCards
  # Encoding utilities for producing JWS
  #
  # @see https://datatracker.ietf.org/doc/html/rfc7515#appendix-A.3.1
  module Encoding
    # Encodes the provided data using url safe base64 without padding
    # @param data [String] the data to be encoded
    # @return [String] the encoded data
    def encode(data)
      Base64.urlsafe_encode64(data, padding: false).gsub("\n", '')
    end

    # Decodes the provided data using url safe base 64
    # @param data [String] the data to be decoded
    # @return [String] the decoded data
    def decode(data)
      Base64.urlsafe_decode64(data)
    end
  end
end

Base64モジュールのurlsafe_encode64を padiing: false で呼び出して、改行文字を消したdataを返すメソッドになっていました。

padding: falseにして呼び出すと、==を消した上でencodeしてくれるようです。

docs.ruby-lang.org

encoded_payloadメソッドも内部でJWS.encodeメソッドを呼んでいました。

さて、ようやく HealthCard::PublicKey#verifyメソッドにたどり着きました、、、


HealthCard::PublicKey#verify

github.com

def verify(payload, signature)
  @key.verify(OpenSSL::Digest.new('SHA256'), raw_to_asn1(signature, self), payload)
end

コンソールで試します。

From: health_cards/lib/health_cards/public_key.rb:11 HealthCards::PublicKey#verify:

    10: def verify(payload, signature)
 => 11:   @key.verify(OpenSSL::Digest.new('SHA256'), raw_to_asn1(signature, self), payload)
    12: end

$ @key
=> #<OpenSSL::PKey::EC:0x00007fcb5e6bb288>

$ payload
=> "eyJ6aXAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtwNC1DeVVCUVY0eDVuQnBBeklUemRrSkpQVnJSZWtpaUhzMkpyTXFwUHcifQ.LY1NDoIwEIXvMm6xQESiXXoFjRvjopQhrWkLmQ5GQ7y7LTq7N-_nW8DGCBIM8yTLEl_KTw6FHj0UELoBZN02dXXcHfZtAU8NcgF-TwjytnZiKkWviA0qx0ZoRX3c_MQ2C7gXoAl7DGyVO8_dAzXnlcFYuiJFO4bEb0Ql6oTM39Mceoc5QxjHmTReViL8jU-6Lw"

$ signature
=> "\x11\xB7C\xF3!z\x18\xA0\xDC\x1F\xFE\xB7\x9A\xCD\xDCo[\xCAM\xAF:\xE8\x15\xFE\x00\x10\f8\xF0\xB5H\x87\xD8\x7FU\x92\xE5\xC3\x06\xC7\xE6|\x84\xB3\xBB{\xDB>\x8F\xDF]\xA2\xAE?\x88\xB4]\xD9\x8AC\xD1t_\x0F"

$  OpenSSL::Digest.new('SHA256')
=> #<OpenSSL::Digest: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855>

$ raw_to_asn1(signature, self)
=> "0E\x02 \x11\xB7C\xF3!z\x18\xA0\xDC\x1F\xFE\xB7\x9A\xCD\xDCo[\xCAM\xAF:\xE8\x15\xFE\x00\x10\f8\xF0\xB5H\x87\x02!\x00\xD8\x7FU\x92\xE5\xC3\x06\xC7\xE6|\x84\xB3\xBB{\xDB>\x8F\xDF]\xA2\xAE?\x88\xB4]\xD9\x8AC\xD1t_\x0F"

$ method(:raw_to_asn1).source_location
=> ["health_cards/lib/health_cards/public_key.rb", 22]

raw_to_asn1メソッドを読んでみますか。

HealthCards::PublicKey.raw_to_asn1

github.com

# Convert the raw signature into the ASN.1 Representation
#
# Adapted from ruby-jwt and json-jwt gems. More info here:
# https://github.com/nov/json-jwt/issues/21
# https://github.com/jwt/ruby-jwt/pull/87
# https://github.com/jwt/ruby-jwt/issues/84
def raw_to_asn1(signature, key)
  byte_size = (key.group.degree + 7) / 8
  sig_bytes = signature[0..(byte_size - 1)]
  sig_char = signature[byte_size..] || ''
  OpenSSL::ASN1::Sequence.new([sig_bytes, sig_char].map do |int|
    OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(int, 2))
  end).to_der
end

ANS1がよくわからなかったので簡単に調べてみました。

ASN.1 【Abstract Syntax Notation One】

ASN.1とは、データ構造の表現形式を定義するための標準的な記法の一つ。主に通信プロトコル(通信規約)で扱われるデータの送受信単位(PDU:Protocol Data Unit)の定義に用いられる。

出典: https://e-words.jp/w/ASN.1.html

概要は掴めました。(この記事内での深掘りは避けます、、、)

次に、More info のリンク先を確認してみます。

全てアクセスして確認したところ、重要そうなのは以下のリンクでした。

github.com

以下、引用と翻訳の大意です。

ruby-jwt fails with a Signature verification failed (JWT::VerificationError) error when decoding a valid JWT signed with ES512.
I believe this is due to an issue with improper ECDSA signature serialization format where the signature parameters are not concatenated in the proper order.
The improper serialization results in an implementation being able to successfully verify its own JWTs but not those generated by other conforming libraries.

We encountered the same issue and fixed it on PyJWT a little while ago but some users are now reporting issues with ruby-jwt validating ECDSA-signed tokens and I wanted to pass along the information.
PyJWT now has tests validating it against RFC 7520 test vectors to ensure all algorithms serialize properly. It may be a good idea to do a similar thing here.

ES512署名アルゴリズムで署名された有効なJWTをデコードすると、ruby-jwtがエラーで失敗します。Signature verification failed (JWT::VerificationError)
これは、署名パラメータが適切な順序で連結されていない、不適切なECDSA署名シリアル化形式の問題が原因であると考えられます。
不適切なシリアル化により、実装は自身のJWTを正常に検証できますが、他の準拠ライブラリによって生成されたJWTは検証できません。

出典: https://github.com/jwt/ruby-jwt/issues/84

このissueの指摘事項を修正したPRが以下でした。

github.com

修正内容を見ると、ASN.1 DERフォーマットを考慮した実装に修正したようです。

なぜASN.1 DERフォーマットを考慮する必要があるのでしょうか。

参考になりそうな記事を見つけたので引用します。

SHA-256 with ECDSAによる電子署名は、各32オクテットのRとSの値で表現されます。

JWTのシグネチャに格納する際は、RFC 7518に記載されているように、RとSを単純に連結して64オクテットのバイナリデータとしてシグネチャを生成し、これをURLセーフBase64エンコードします。

WebCrypto APIでcrypto.subtle.sign()を実行してSHA-256 with ECDSAによって署名する場合は、このRとSの単純連結した64オクテットのUint8Arrayを実行結果として取得できます。
よって、これをURLセーフBase64エンコードして、ペイロードと.を挟んで連結することで、JWTを生成することができます。

一方、Java 8等では、ECDSAによる電子署名はASN.1 DERフォーマットで扱われるため、JWTの作成や検証の際はRとSの単純連結との相互変換を行う必要があります。
なお、WebCrypto APIでは、このASN.1 DERフォーマットには対応していません。

出典: https://qiita.com/tomoyukilabs/items/b346a71a920eb7a93501

なるほど、ASN.1DERフォーマットを扱うパターンもあるために考慮の必要があったようです。

かなり寄り道しましたが、最後に OpenSSL::PKey::PKey#verify を見てみましょう。


OpenSSL::PKey::PKey#verify

公式ドキュメントがありました。

docs.ruby-lang.org

verify(digest, sign, data) -> bool
data を秘密鍵で署名したその署名文字列が sign であることを公開鍵を使って検証し、検証に成功すれば true を返します。

digest は利用するハッシュ関数の名前を "sha256" や "md5" といった文字列で指定します。

DSA で検証をする場合はハッシュ関数には "dss1" を指定してください。

検証に失敗した、つまり署名時と異なるハッシュ関数を使った、 sign が正しい署名でなかった場合などは false を返します。

[PARAM] digest:
利用するハッシュ関数の名前
[PARAM] sign:
検証に利用する署名文字列
[PARAM] data:
検証対象の文字列
[EXCEPTION] OpenSSL::PKey::PKeyError:
検証時にエラーが起きた場合に発生します。正しい署名でなかった場合など、検証に失敗した場合はこの例外は発生しないことに注意してください

出典: https://docs.ruby-lang.org/ja/latest/method/OpenSSL=3a=3aPKey=3a=3aPKey/i/verify.html

こちらもコンソールで確認してみましょう。

From: health_cards/lib/health_cards/public_key.rb:11 HealthCards::PublicKey#verify:

    10: def verify(payload, signature)
 => 11:   @key.verify(OpenSSL::Digest.new('SHA256'), raw_to_asn1(signature, self), payload)
    12: end

$ @key.verify(OpenSSL::Digest.new('SHA256'), raw_to_asn1(signature, self), payload)
=> true

# digestにSHA512を指定
# 署名時と異なるハッシュ関数を使ったため false が返っている
$ @key.verify(OpenSSL::Digest.new('SHA512'), raw_to_asn1(signature, self), payload)
=> false

# signatureを改変してみる
$ _signature = _signature.gsub("\x11", "\x12")

# 正しい署名ではないため false が返っている
$ @key.verify(OpenSSL::Digest.new('SHA256'), raw_to_asn1(_signature, self), payload)
=> false

# payloadを改変してみる
$ _payload = payload.gsub("e", "f")

# JWSの検証に失敗したため false が返っている
$ @key.verify(OpenSSL::Digest.new('SHA256'), raw_to_asn1(signature, self), _payload)
=> false

署名時と異なるハッシュ関数を使ったり、signatureが間違っていた場合は検証に失敗することが確認できました。


まとめ

JWSを使ったverificationの仕組みをRubyで実現する良い参考実装になるのではないかと思いました。

RFC 7515 - JSON Web Signature (JWS) 日本語訳 を読んで仕様を一通り頭に入れた上でコードリーディングをすると、

より理解が深まると思います。