こんにちは!kossyです!
今回は SmartHealthCardsの発行と検証を行うことができるGem 「health_cards」の、
公開鍵の検証周りのソースコードを読んでみたいと思います。
環境
Ruby 3.0.3
Rails 6.1.3.1
MacOS Catalina latest
HealthCards::Verifier
APIドキュメントが用意されていたので確認してみます。
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を記述してコンソールで試してみます。
$ 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が定義されているようでした。
# 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
# 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
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のコードはこちら。
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のコードはこちら。
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してくれるようです。
encoded_payloadメソッドも内部でJWS.encodeメソッドを呼んでいました。
さて、ようやく HealthCard::PublicKey#verifyメソッドにたどり着きました、、、
HealthCard::PublicKey#verify
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
# 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)の定義に用いられる。
概要は掴めました。(この記事内での深掘りは避けます、、、)
次に、More info のリンク先を確認してみます。
全てアクセスして確認したところ、重要そうなのは以下のリンクでした。
以下、引用と翻訳の大意です。
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は検証できません。
このissueの指摘事項を修正したPRが以下でした。
修正内容を見ると、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
公式ドキュメントがありました。
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) 日本語訳 を読んで仕様を一通り頭に入れた上でコードリーディングをすると、
より理解が深まると思います。