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

こんにちは!kossyです!




今回はSmartHealthCardsの発行と検証を行うことができるGem 「health_cards」の JWSの生成周りのソースコードを読んでみたので、

備忘録としてブログに残してみたいと思います。




環境
Ruby 3.0.3
Rails 6.1.3.1
MacOS Catalina latest



HealthCards::Issuer#issue_jws

例によってtest_appディレクトリのファイルを基に動作を確認してみます。

github.com

$ key = HealthCards::PrivateKey.generate_key

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

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

From: health_cards/lib/health_cards/issuer.rb:35 HealthCards::Issuer#issue_jws:

    33: def issue_jws(bundle, type: Payload)
    34:   binding.pry
 => 35:   card = create_payload(bundle, type: type)
    36:   JWS.new(header: jws_header, payload: card.to_s, key: key)
    37: end

$ bundle
=> #<FHIR::Bundle:0x00007fb0c1cbfce0
 @entry=[],
 @id=nil,
 @identifier=nil,
 @implicitRules=nil,
 @language=nil,
 @link=[],
 @meta=nil,
 @signature=nil,
 @timestamp=nil,
 @total=nil,
 @type=nil>

$ type
=> HealthCards::Payload

一通り引数の中身を確認できたので、まずは HealthCards::Issuer#create_payload メソッドから見てみます。


HealthCards::Issuer#create_payload

github.com

# Create a Payload from the supplied FHIR bundle
#
# @param bundle [FHIR::Bundle, String] the FHIR bundle used as the Health Card payload
# @return [Payload::]
def create_payload(bundle, type: Payload)
  type.new(issuer: url, bundle: bundle)
end

コメントアウト部分を訳してみます。

引数で渡されたFHIRバンドルからペイロードを作成します。

bundle (FHIR::BundleまたはString) : HealthCardのPayloadとして使用されるFHIRバンドル
返り値は HealthCards::Payload インスタンス

出典: https://github.com/dvci/health_cards/blob/main/lib/health_cards/issuer.rb

FHIR::Bundle はfhir_model というGemで定義されているクラスです。

github.com

stepメソッドでcreate_payloadメソッドの処理を追ってみます。

From: health_cards/issuer.rb:24 HealthCards::Issuer#create_payload:

    23: def create_payload(bundle, type: Payload)
 => 24:   type.new(issuer: url, bundle: bundle)
    25: end

$ type.new(issuer: url, bundle: bundle)
=> #<HealthCards::Payload:0x00007fb103fa2240
 @bundle=
  #<FHIR::Bundle:0x00007fb0c1cbfce0
   @entry=[],
   @id=nil,
   @identifier=nil,
   @implicitRules=nil,
   @language=nil,
   @link=[],
   @meta=nil,
   @signature=nil,
   @timestamp=nil,
   @total=nil,
   @type=nil>,
 @issuer="http://example.com">

コメントアウトに記載があった通り、HealthCards::Payloadクラスのインスタンスが返りました。

github.com

SmartHealthCardsの仕様に則ってPayloadを管理するクラスのようですね。

spec.smarthealth.cards

本記事内では SmartHealthCards の仕様の深掘りは避けたいと思います、、、(それだけで1本のブログになりそうなので)

次はHealthCards::JWSクラスの実装を深掘ってみます。

HealthCards::JWS

github.com

   
# frozen_string_literal: true

module HealthCards
  # Create JWS from a payload
  class JWS
    class << self
      include Encoding

      # Creates a JWS from a String representation, or returns the HealthCards::JWS object
      # that was passed in
      # @param jws [String, HealthCards::JWS] the JWS string, or a JWS object
      # @param public_key [HealthCards::PublicKey] the public key associated with the JWS
      # @param key [HealthCards::PrivateKey] the private key associated with the JWS
      # @return [HealthCards::JWS] A new JWS object, or the JWS object that was passed in
      def from_jws(jws, public_key: nil, key: nil)
        return jws if jws.is_a?(HealthCards::JWS) && public_key.nil? && key.nil?

        unless jws.is_a?(HealthCards::JWS) || jws.is_a?(String)
          raise ArgumentError,
                'Expected either a HealthCards::JWS or String'
        end

        header, payload, signature = jws.to_s.split('.').map { |entry| decode(entry) }
        header = JSON.parse(header)
        JWS.new(header: header, payload: payload, signature: signature,
                public_key: public_key, key: key)
      end
    end

    attr_reader :key, :public_key, :payload
    attr_writer :signature
    attr_accessor :header

    # Create a new JWS

    def initialize(header: nil, payload: nil, signature: nil, key: nil, public_key: nil)
      # Not using accessors because they reset the signature which requires both a key and a payload
      @header = header
      @payload = payload
      @signature = signature if signature
      @key = key
      @public_key = public_key || key&.public_key
    end

    # The kid value from the JWS header, used to identify the key to use to verify
    # @return [String]
    def kid
      header['kid']
    end

    # Set the private key used for signing issued health cards
    #
    # @param key [HealthCards::PrivateKey, nil] the private key used for signing issued health cards
    def key=(key)
      PrivateKey.enforce_valid_key_type!(key, allow_nil: true)

      @key = key

      # If it's a new private key then the public key and signature should be updated
      return if @key.nil?

      reset_signature
      self.public_key = @key.public_key
    end

    # Set the public key used for signing issued health cards
    #
    # @param public_key [HealthCards::PublicKey, nil] the private key used for signing issued health cards
    def public_key=(public_key)
      PublicKey.enforce_valid_key_type!(public_key, allow_nil: true)

      @public_key = public_key
    end

    # Set the JWS payload. Setting a new payload will result in the a new signature
    # @param new_payload [Object]
    def payload=(new_payload)
      @payload = new_payload
      reset_signature
    end

    # The signature component of the card
    #
    # @return [String] the unencoded signature
    def signature
      return @signature if @signature

      raise MissingPrivateKeyError unless key

      @signature ||= key.sign(jws_signing_input)
    end

    # Export the card to a JWS String
    # @return [String] the JWS
    def to_s
      [JSON.generate(header), payload, signature].map { |entry| JWS.encode(entry) }.join('.')
    end

    # Verify the digital signature on the jws
    #
    # @return [Boolean]
    def verify
      raise MissingPublicKeyError unless public_key

      public_key.verify(jws_signing_input, signature)
    end

    private

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

    def encoded_payload
      JWS.encode(payload)
    end

    # Resets the signature
    #
    # This method is primarily used when an attribute that affects
    # the signature is changed (e.g. the private key changes, the payload changes)
    def reset_signature
      @signature = nil
      signature if key && payload
    end
  end
end

まずは initializeメソッドから見てみます。

HealthCards::JWS#initialize
# Create a new JWS
# 新しいJWSを作成します。

def initialize(header: nil, payload: nil, signature: nil, key: nil, public_key: nil)
  # Not using accessors because they reset the signature which requires both a key and a payload
  @header = header
  @payload = payload
  @signature = signature if signature
  @key = key
  @public_key = public_key || key&.public_key
end

stepメソッドで処理を追ってみます。

From: health_cards/lib/health_cards/issuer.rb:36 HealthCards::Issuer#issue_jws:

    33: def issue_jws(bundle, type: Payload)
    34:   binding.pry
    35:   card = create_payload(bundle, type: type)
 => 36:   JWS.new(header: jws_header, payload: card.to_s, key: key)
    37: end

$ step

From: health_cards/lib/health_cards/issuer.rb:63 HealthCards::Issuer#jws_header:

    62: def jws_header
 => 63:   { 'zip' => 'DEF', 'alg' => 'ES256', 'kid' => key.public_key.kid }
    64: end

$ key.public_key.kid
=> "v_HwwS_bad-bOABD3Uf9DpmMNMpKD_OxKtYluML1Mxs"

zipやalg、kidとはなんぞや?についてはrfc7516に記載があります。

datatracker.ietf.org

alg

4.1.1. "alg" (Algorithm) Header Parameter

This parameter has the same meaning, syntax, and processing rules as the "alg" Header Parameter defined in Section 4.1.1 of [JWS], except that the Header Parameter identifies the cryptographic algorithm used to encrypt or determine the value of the CEK.
The encrypted content is not usable if the "alg" value does not represent a supported algorithm, or if the recipient does not have a key that can be used with that algorithm.

A list of defined "alg" values for this use can be found in the IANA "JSON Web Signature and Encryption Algorithms" registry established by [JWA]; the initial contents of this registry are the values defined in Section 4.1 of [JWA].

このパラメーターは、[JWS]のセクション4.1.1で定義されている「alg」ヘッダーパラメーターと同じ意味、構文、および処理ルールを持ちます。
ただし、ヘッダーパラメーターは、CEKの値を暗号化または決定するために使用される暗号化アルゴリズムを識別します。

「alg」値がサポートされているアルゴリズムを表していない場合、または受信者がそのアルゴリズムで使用できるキーを持っていない場合、暗号化されたコンテンツは使用できません。

この使用のために定義された「alg」値のリストは、[JWA]によって確立されたIANA「JSONWeb署名および暗号化アルゴリズムレジストリにあります。
このレジストリの初期の内容は、[JWA]のセクション4.1で定義されている値です。

出典: https://datatracker.ietf.org/doc/html/rfc7516#section-4

RFC7518によると、ES256 アルゴリズムが recommended として記載されていました。

そのため、health_cards GemでもデフォルトのalgパラメータにES256を指定しているものと思われます。

datatracker.ietf.org

zip

The "zip" (compression algorithm) applied to the plaintext before encryption, if any. The "zip" value defined by this specification

is:

o "DEF" - Compression with the DEFLATE [RFC1951] algorithm

Other values MAY be used. Compression algorithm values can be registered in the IANA "JSON Web Encryption Compression Algorithms" registry established by [JWA].
The "zip" value is a case-sensitive string.
If no "zip" parameter is present, no compression is applied to the plaintext before encryption.
When used, this Header Parameter MUST be integrity protected; therefore, it MUST occur only within the JWE Protected Header.
Use of this Header Parameter is OPTIONAL. This Header Parameter MUST be understood and processed by implementations.

暗号化する前にプレーンテキストに適用される「zip」(圧縮アルゴリズム)。この仕様で定義されている「zip」値は次のとおりです。

o "DEF"-DEFLATE [RFC1951]アルゴリズムによる圧縮

他の値が使用される場合があります。圧縮アルゴリズムの値は、[JWA]によって確立されたIANA "JSON Web Encryption Compression Algorithms"レジストリに登録できます。
「zip」値は、大文字と小文字が区別される文字列です。 「zip」パラメータが存在しない場合、暗号化の前にプレーンテキストに圧縮は適用されません。
使用する場合、このヘッダーパラメーターは整合性を保護する必要があります。したがって、JWE保護ヘッダー内でのみ発生する必要があります。
このヘッダーパラメータの使用はオプションです。このヘッダーパラメータは、実装によって理解および処理される必要があります。

出典: https://datatracker.ietf.org/doc/html/rfc7516#section-4

平たく言うと、コンテンツを暗号化する前に、zip headerで指定されているアルゴリズム(今回はDEFLATE)でプレーンテキストを圧縮できるということかと思われます。

kid

This parameter has the same meaning, syntax, and processing rules as the "kid" Header Parameter defined in Section 4.1.4 of [JWS],
except that the key hint references the public key to which the JWE was encrypted;
this can be used to determine the private key needed to decrypt the JWE.
This parameter allows originators to explicitly signal a change of key to JWE recipients.



このパラメーターは、[JWS]のセクション4.1.4で定義されている「kid」ヘッダーパラメーターと同じ意味、構文、および処理ルールを持ちます。
ただし、キーヒントはJWEが暗号化された公開キーを参照します。 これは、JWEを復号化するために必要な秘密鍵を決定するために使用できます。
このパラメーターを使用すると、発信者はキーの変更をJWE受信者に明示的に通知できます。

出典: https://datatracker.ietf.org/doc/html/rfc7516#section-4

RFC7515の4.1.4を見てみます。

datatracker.ietf.org

The "kid" (key ID) Header Parameter is a hint indicating which key was used to secure the JWS.
This parameter allows originators to explicitly signal a change of key to recipients.
The structure of the "kid" value is unspecified.
Its value MUST be a case-sensitive string. Use of this Header Parameter is OPTIONAL.

When used with a JWK, the "kid" value is used to match a JWK "kid" parameter value.


「kid」(キーID)ヘッダーパラメータは、JWSを保護するために使用されたキーを示すヒントです。
このパラメーターを使用すると、発信者は受信者にキーの変更を明示的に通知できます。
「kid」値の構造は指定されていません。
その値は大文字と小文字を区別する文字列でなければなりません。 このヘッダーパラメータの使用はオプションです。

JWKとともに使用する場合、「kid」値はJWK「kid」パラメーター値と一致するために使用されます。

「kid」値の構造は指定されていません。が、「その値は大文字と小文字を区別する文字列でなければなりません。」とのこと。

「発信者は受信者にキーの変更を明示的に通知できます。」も抑えておけば一旦はよさそうです。

寄り道がだいぶ長くなりました。実際に JWS.new で生成される値は以下の通りです。

$ jws = JWS.new(header: jws_header, payload: card.to_s, key: key)
=> #<HealthCards::JWS:0x00007fb0c0058200
 @header={"zip"=>"DEF", "alg"=>"ES256", "kid"=>"Nx1LIgqlyubgocWo7EgptlZ4Cg0iuOuUPw1a5U7ATk8"},
 @key=
  #<HealthCards::PrivateKey:0x00007fb0e3f71ef8
   @key=#<OpenSSL::PKey::EC:0x00007fb0e3f71f20>,
   @public_key=
    #<HealthCards::PublicKey:0x00007fb10760b700
     @coordinates=
      {:x=>"5cIFHiDkh35xAmGnAj5cxIBk99KQWvwo9r6cqpon6Kk",
       :y=>"wyc5x3azGxi0CaFt8yVUJHdaXblJhjh7hTnL2WqI9ek",
       :kty=>"EC",
       :crv=>"P-256"},
     @key=#<OpenSSL::PKey::EC:0x00007fb0a54a4568>>>,
 @payload="",
 @public_key=
  #<HealthCards::PublicKey:0x00007fb10760b700
   @coordinates=
    {:x=>"5cIFHiDkh35xAmGnAj5cxIBk99KQWvwo9r6cqpon6Kk", :y=>"wyc5x3azGxi0CaFt8yVUJHdaXblJhjh7hTnL2WqI9ek", :kty=>"EC", :crv=>"P-256"},
   @key=#<OpenSSL::PKey::EC:0x00007fb0a54a4568>>>

まとめ

HealthCard::JWSのソースコードを読むためには RFCの7515 ~ 7518 あたりの知識が総出で必要になりますね、、、

ただ、RFCだけ読んでも、「実際にどうやって書くの?」までは想像しづらかったりするので、

プレーンなRubyコードでJWS周りのコードを実装している health_cardsのコードを読むのは正解だったと思います。