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のコードを読むのは正解だったと思います。

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) 日本語訳 を読んで仕様を一通り頭に入れた上でコードリーディングをすると、

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

Railsのcofig_forメソッドでアプリケーションのカスタム値を設定してみる

こんにちは!kossyです!





今回はcofig_forメソッドでカスタム値を設定する方法について、ブログに残してみたいと思います。





環境
Ruby 3.0.3
Rails 6.1.4




使い方

# config/environments/development.rb

Rails.application.configure do
  config.custom_value = config_for(:custom_value)
end

上記のように設定を行なった場合、config/custom_value.yml を見に行って、その定義値を Rails.application.config.custom_value で参照することができます。

# config/custom_value.yml

default: &default
  status: custom

development:
  <<: *default
test:
  <<: *default
production:
  <<: *default

ではコンソールで試してみます。

# In Console

$ Rails.application.config.custom_value.status
=> "custom"

問題なくymlの値を呼び出すことができました。


OSSでの使われ方を見てみる

Railsを用いているOSSではどのように使われている見てみました。

github.com

# frozen_string_literal: true

require 'health_cards'

Rails.application.configure do
  config.smart = config_for('well-known')
  config.metadata = config_for('metadata')
  config.operation = config_for('operation')

  config.hc_key_path = ENV['KEY_PATH']
  FileUtils.mkdir_p(File.dirname(ENV['KEY_PATH']))
  kp = HealthCards::PrivateKey.load_from_or_create_from_file(config.hc_key_path)

  config.hc_key = kp
  config.issuer = HealthCards::Issuer.new(url: ENV['HOST'], key: config.hc_key)

  config.auth_code = ENV['AUTH_CODE']
  config.client_id = ENV['CLIENT_ID']
end

どうやらシンボルでも文字列でもconfig/配下のymlファイルを探しに行くようですね。

上記のアプリケーションでは、APIのエンドポイントや外部ライブラリのバージョンや返り値のフォーマット?をymlファイルに切り出して管理しているようでした。


config_for メソッドのソースコードを見てみる

まずはソースコードの定義位置を確認してみます。

# In Console

$ Rails.application.method(:config_for).source_location
=> [".../lib/rails/application.rb", 218]

こちらでした。

github.com

def config_for(name, env: Rails.env)
  yaml = name.is_a?(Pathname) ? name : Pathname.new("#{paths["config"].existent.first}/#{name}.yml")

  if yaml.exist?
    require "erb"
    all_configs    = ActiveSupport::ConfigurationFile.parse(yaml).deep_symbolize_keys
    config, shared = all_configs[env.to_sym], all_configs[:shared]

    if shared
      config = {} if config.nil? && shared.is_a?(Hash)
      if config.is_a?(Hash) && shared.is_a?(Hash)
        config = shared.deep_merge(config)
      elsif config.nil?
        config = shared
      end
    end

    if config.is_a?(Hash)
      config = ActiveSupport::OrderedOptions.new.update(config)
    end

    config
  else
    raise "Could not load configuration. No such file - #{yaml}"
  end
end

一行ずつ見てみましょう。

yaml = name.is_a?(Pathname) ? name : Pathname.new("#{paths["config"].existent.first}/#{name}.yml")

引数のnameがPathnameクラスのインスタンスであれば、nameをそのまま返し、

なければnameと同名のymlファイルをconfigディレクトリ配下から見つけてPathnameインスタンスにして返却するようになっていました。

なので、symbol or string or Pathname でもymlファイルを見つけられるみたいですね。

# 以下3つはどの書き方でもymlファイルを読み込むことができる

config_for('custom_value')
config_for(:custom_value)
config_for(Pathname.new('config/custom_value.yml'))

もしymlファイルが見つからなければ例外が上がりますね。

if yaml.exist?
  # 省略
else
  raise "Could not load configuration. No such file - #{yaml}"
end

$ Rails.application.config_for(Pathname.new('config/custom_value.yml'))
=> RuntimeError (Could not load configuration. No such file - config/custom_value.yml)

ymlファイルが存在する場合のコードを読んでみます。

require "erb"
all_configs    = ActiveSupport::ConfigurationFile.parse(yaml).deep_symbolize_keys
config, shared = all_configs[env.to_sym], all_configs[:shared]

こちらはコンソールで試してみますか。

$ all_configs = ActiveSupport::ConfigurationFile.parse(yaml).deep_symbolize_keys
=> {:default=>
  {:status=>"custom_value"},
 :development=>
  {:status=>"custom_value"},
 :test=>
  {:status=>"custom_value"},
 :production=>
  {:status=>"custom_value"},
}

$ config, shared = all_configs[env.to_sym], all_configs[:shared]
=> [{:status=>"custom_value"}, nil]

ymlで定義した値をシンボル化して、Rails.envと合致するシンボルを持つ値をconfigとして変数に格納し、

sharedというシンボルの値があればそちらも変数にしていました。(私の環境ではsharedは設定していないので nil が返っています)

もしsharedが存在していれば、という処理も記載されていましたが、今回のコードリーディングのスコープからは除外します。

次は ActiveSupport::OrderedOptions 周りを読んでみます。

if config.is_a?(Hash)
  config = ActiveSupport::OrderedOptions.new.update(config)
end

ドキュメントを見てみます。

api.rubyonrails.org

OrderedOptions inherits from Hash and provides dynamic accessor methods.

With a Hash, key-value pairs are typically managed like this:

h = {}
h[:boy] = 'John'
h[:girl] = 'Mary'
h[:boy]  # => 'John'
h[:girl] # => 'Mary'
h[:dog]  # => nil

Using OrderedOptions, the above code can be written as:

h = ActiveSupport::OrderedOptions.new
h.boy = 'John'
h.girl = 'Mary'
h.boy  # => 'John'
h.girl # => 'Mary'
h.dog  # => nil

出典: https://api.rubyonrails.org/classes/ActiveSupport/OrderedOptions.html

使い方は理解できました。便利ですね。

updateメソッドはどうやら定義されていないようなので、method_missingメソッドで捕捉されているものと思われます。

config = ActiveSupport::OrderedOptions.new.update(config)

の実行結果は以下です。

$ config = ActiveSupport::OrderedOptions.new.update(config)
=> { :status=>"custom_value"}

結局config_forメソッドの返り値は configが返っていました。

まとめ

settingslogicやfigaro等でアプリケーションの設定値を管理するのがメジャーかと思っていたんですが、config_forでも全然良さそうですね。

Smart Health Cardの発行と検証を行えるGem 「health_cards」をRails開発環境で動かす

こんにちは!kossyです!




今回はSmart Health Cardの発行と検証を行えるGem 「health_cards」をRails開発環境で動かす手順について、ブログに残してみたいと思います。




環境
Ruby 2.7.4
Rails 6.1.3.1
MacOS Catalina



環境構築

以下のページからソースコードをcloneします。

github.com

f:id:kossy-web-engineer:20211231125409p:plain

$ git clone https://github.com/dvci/health_cards.git

$ cd health_cards

次に、Ruby 2.7.4 がローカル環境にない場合は install してください。(私はrbenvを使いました)

$ rbenv install 2.7.4

次に、bin/setup コマンドを叩きます。

$ bin/setup

これで、bundle install から データベースのセットアップ(create/migrate)まで行ってくれます。

あとは bundle exec rails db:seed を実行して seed データを流すだけです。

$ bundle exec rails db:seed

動作確認

ローカルサーバーを立ち上げます。

$ bundle exec rails s -b 0.0.0.0

http://localhost:3000 にアクセスすると、以下のページが表示されると思います。

f:id:kossy-web-engineer:20211231125837p:plain

開発環境で動かすことができました。

RubyのOpenStrictクラスの使い方をGemのコードで理解を試みる

こんにちは!kossyです!




今回は、RubyのOpenStrictクラスの使い方を saml-idp という Gem のコード内での使い方を見て、理解を深めてみたいと思います。




ドキュメントを見る

公式のOpenStrictクラスのドキュメントを見てみます。

docs.ruby-lang.org

要素を動的に追加・削除できる手軽な構造体を提供するクラスです。
OpenStruct のインスタンスに対して未定義なメソッド x= を呼ぶと、 OpenStruct クラスの BasicObject#method_missing で捕捉され、そのインスタンスインスタンスメソッド x, x= が定義されます。
この挙動によって要素を動的に変更できる構造体として働きます。

出典: https://docs.ruby-lang.org/ja/latest/class/OpenStruct.html

「動的にメソッドを生やしたり削除したりできる」ところを抑えればよさそうですね。

かなり実装の自由度が高いように感じますが、saml-idp Gem 内でどのように使われているかを見てみます。


SamlIdp::Configurator

github.com

# encoding: utf-8
require 'ostruct'
require 'securerandom'

module SamlIdp
  class Configurator
    attr_accessor :x509_certificate
    attr_accessor :secret_key
    attr_accessor :password
    attr_accessor :algorithm
    attr_accessor :organization_name
    attr_accessor :organization_url
    attr_accessor :base_saml_location
    attr_accessor :entity_id
    attr_accessor :reference_id_generator
    attr_accessor :attribute_service_location
    attr_accessor :single_service_post_location
    attr_accessor :single_service_redirect_location
    attr_accessor :single_logout_service_post_location
    attr_accessor :single_logout_service_redirect_location
    attr_accessor :attributes
    attr_accessor :service_provider
    attr_accessor :assertion_consumer_service_hosts
    attr_accessor :session_expiry

    def initialize
      self.x509_certificate = Default::X509_CERTIFICATE
      self.secret_key = Default::SECRET_KEY
      self.algorithm = :sha1
      self.reference_id_generator = ->() { SecureRandom.uuid }
      self.service_provider = OpenStruct.new
      self.service_provider.finder = ->(_) { Default::SERVICE_PROVIDER }
      self.service_provider.metadata_persister = ->(id, settings) {  }
      self.service_provider.persisted_metadata_getter = ->(id, service_provider) {  }
      self.session_expiry = 0
      self.attributes = {}
    end

    # formats
    # getter
    def name_id
      @name_id ||= OpenStruct.new
    end

    def technical_contact
      @technical_contact ||= TechnicalContact.new
    end

    class TechnicalContact < OpenStruct
      def mail_to_string
        "mailto:#{email_address}" if email_address.to_s.length > 0
      end
    end
  end
end

initializeメソッド内で service_provider 属性に対して、OpenStrictクラスのインスタンスが代入されています。

service_providerに対して未定義のメソッドを呼び出す箇所は、ReadMeのConfigration Sectionに記載されていました。

github.com

  # `identifier` is the entity_id or issuer of the Service Provider,
  # settings is an IncomingMetadata object which has a to_h method that needs to be persisted
  config.service_provider.metadata_persister = ->(identifier, settings) {
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    File.open Rails.root.join("cache/saml/metadata/#{fname}"), "r+b" do |f|
      Marshal.dump settings.to_h, f
    end
  }

  # `identifier` is the entity_id or issuer of the Service Provider,
  # `service_provider` is a ServiceProvider object. Based on the `identifier` or the
  # `service_provider` you should return the settings.to_h from above
  config.service_provider.persisted_metadata_getter = ->(identifier, service_provider){
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    full_filename = Rails.root.join("cache/saml/metadata/#{fname}")
    if File.file?(full_filename)
      File.open full_filename, "rb" do |f|
        Marshal.load f
      end
    end
  }

  # Find ServiceProvider metadata_url and fingerprint based on our settings
  config.service_provider.finder = ->(issuer_or_entity_id) do
    service_providers[issuer_or_entity_id]
  end

metadata_persister と persisted_metadata_getter と finder メソッドが動的に定義されていました。

name_idメソッドも実体はOpenStrictインスタンスなので、使用例を見てみましょう。

  # Principal (e.g. User) is passed in when you `encode_response`
  #
  # config.name_id.formats =
  #   {                         # All 2.0
  #     email_address: -> (principal) { principal.email_address },
  #     transient: -> (principal) { principal.id },
  #     persistent: -> (p) { p.id },
  #   }
  #   OR
  #
  #   {
  #     "1.1" => {
  #       email_address: -> (principal) { principal.email_address },
  #     },
  #     "2.0" => {
  #       transient: -> (principal) { principal.email_address },
  #       persistent: -> (p) { p.id },
  #     },
  #   }

TechnicalContactクラスもOpenStrictクラスを継承したクラスとして定義されていて、こちらも動的に定義している箇所がありました。

  ## EXAMPLE ##
  # config.attributes = {
  #   GivenName: {
  #     getter: :first_name,
  #   },
  #   SurName: {
  #     getter: :last_name,
  #   },
  # }
  ## EXAMPLE ##

  # config.technical_contact.company = "Example"
  # config.technical_contact.given_name = "Jonny"
  # config.technical_contact.sur_name = "Support"
  # config.technical_contact.telephone = "55555555555"
  # config.technical_contact.email_address = "example@example.com"

こちらはユーザーが自由に定義できるユーザー情報を入れることを期待しているようですね。


service_provider属性にOpenStrictクラスを使う理由

これは個人的な推測ですが、 SAML認証のServiceProviderとして設定できる値は十数以上あり、

「その全てをServiceProviderクラスとして定義してしまうよりは、ユーザー側に必要な値だけ定義させれば良い」

という考えでservice_provider属性にOpenStrictクラスを使ったのかと思われます。

リンク先はruby-saml Gem で service_providerに設定できる値です。

github.com


まとめ

OpenStrictクラスを使ったコードを読んでみましたが、

「クラスとしてガチガチに定義するほどでもない時」
「使う側にメソッドを動的に定義させたい時」
「具体的にクラスに持たせたい属性が決まってない時」

などで使えるのではないかと思いました。

sendgridでメールの一斉配信をする際に宛先ごとに異なる文字列を仕込む方法

こんにちは!kossyです!




さて、今回はsendgridでメールの一斉配信をする際に宛先ごとに異なる文字列を仕込む方法について、ブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.0.4




X-SMTPAPIの Substitution Tags を使う

以下のドキュメントによると、

sendgrid.kke.co.jp

宛先ごとに「ようこそ!◯◯様」などといった形で宛名やその他様々な属性値をメール本文などに埋め込みたい場合、Substitution Tagsが利用できます。
次のように、「sub」パラメータで置換キー(-name-)と宛先毎の置換文字列([“Alice”,”Bob”])を配列で指定します。
配列の順序は「to」パラメータで指定した宛先配列の順序と対応づいています。

{
  "to" : [
    "alice@test.com",
    "bob@test.com"
  ],
  "sub": {
    "-name-": [
      "Alice",
      "Bob"
   ]
}

一方、メール本文には置換したい箇所に置換キーを埋め込みます。

ようこそ!-name-様

こうすることで、このリクエストはSendGridで処理され、宛先ごとに分解された上で、置換キーが宛先ごとに対応する文字列で置き換えられます。
また、Section Tagsを利用することでより高度な置換処理を指定することもできます。

出典: https://sendgrid.kke.co.jp/blog/?p=4232

との記載がありますので、この機能を使えば宛先ごとに異なる文字列をメール本文に挿入することができそうです。

Railsの場合ですと、以下のようなコードを記述すればOKです。

  def send_mail
    users = User.all

    emails = users.pluck(:email)
    names = users.pluck(&:full_name)

    xsmtp_api_params = { to: emails, sub: { "-name-": names } }
    headers['X-SMTPAPI'] = JSON.generate(xsmtp_api_params)

    mail(to: emails, subject: subject)
  end

置換コードである「-name-」はerbテンプレートに仕込んでおきます。

<p>ようこそ! -name- 様</p>

これで宛先ごとに異なる文字列をメール本文内に挿入することができます。


まとめ

メールの一斉配信の際に宛先ごとに異なる文字列を仕込めるのは便利っちゃ便利なんですが、パラメータの指定を間違えたりすると重大なインシデントにつながってしまうので、

誤りの入り込まないパラメータ構築を行う必要がありそうですね。。。

ruby-samlでX509証明書の有効期限を取得したい

こんにちは!kossyです!



今回は ruby-saml でX509証明書の有効期限を取得する方法について、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.0.4
ruby-saml 1.13.0




not_afterメソッド

結論から言うと、OpenSSL::X509::Certificate#not_afterメソッドを呼び出せばOKです。

docs.ruby-lang.org

OneLogin::RubySaml::Settings#get_idp_certで取得した OpenSSL::X509::Certificate インスタンスに対して、not_afterメソッドを実行すればOKです。

github.com

$ settings = Account.get_saml_settings('http://localhost:3000')

$ certificate = settings.get_idp_cert
=> #<OpenSSL::X509::Certificate
 subject=#<OpenSSL::X509::Name CN=IDPNAME,L=SEONGNAM,C=JP>,
 issuer=#<OpenSSL::X509::Name CN=IDPNAME,L=SEONGNAM,C=JP>,
 serial=#<OpenSSL::BN 12343304055179801234>,
 not_before=2021-09-30 04:45:51 UTC,
 not_after=2026-09-29 04:45:51 UTC>

$ certificate.not_after
=> 2026-09-29 04:45:51 UTC

ちなみに、ruby-samlには有効期限が切れているかどうかをチェックするメソッドが実装されています。

module OneLogin
  module RubySaml

    # SAML2 Auxiliary class
    #
    class Utils
      @@uuid_generator = UUID.new if RUBY_VERSION < '1.9'

      DSIG      = "http://www.w3.org/2000/09/xmldsig#"
      XENC      = "http://www.w3.org/2001/04/xmlenc#"
      DURATION_FORMAT = %r(^(-?)P(?:(?:(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?)|(?:(\d+)W))$)

      # Checks if the x509 cert provided is expired
      #
      # @param cert [Certificate] The x509 certificate
      #
      def self.is_cert_expired(cert)
        if cert.is_a?(String)
          cert = OpenSSL::X509::Certificate.new(cert)
        end

        return cert.not_after < Time.now
      end

# 省略

なお、ruby-samlでは証明書の有効期限チェックはデフォルトでfalseになっているので、もし有効期限チェックをGem側で行いたい場合は

明示的に true にしておく必要があります。

github.com


まとめ

not_before属性とnot_after属性を使用すれば簡単に証明証の有効期限のバリデーションがかけられそうです。

deviseのencrypted_passwordに値が保存される仕組みを調べてみた

こんにちは!kossyです!




今回はdeviseで認証機能を利用する際に必要になるカラムである、「encrypted_password」カラム に値が保存される仕組みを調べてみたので、

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




環境
Ruby 3.0.3
Rails 6.0.4
devise 4.8.1





なお、既にdeviseを利用しているUserモデルが定義されていて、デバッグ用のGem2種類(pry-rails & pry-byebug)がinstallされていることとします。


ユーザー作成時の動作を見てみる

まずはコンソールでユーザーを作成する際の挙動を見てみます。

# Userクラスのソースコード
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
end

# コンソール
$ user = User.create(email: 'sample-user@example.or.jp', password: 'test1234')
   (0.5ms)  BEGIN
  User Exists? (11.4ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "sample-user@example.or.jp"], ["LIMIT", 1]]
  User Create (9.0ms)  INSERT INTO "users" ("email", "encrypted_password", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["email", "sample-user@example.or.jp"], ["encrypted_password", "$2a$12$OlpguVbwkOb6BuZ4dxXV8.XP/Byqb9TSu6ot23cwpHssOGSrIS6Vu"], ["created_at", "2021-12-26 07:35:11.543708"], ["updated_at", "2021-12-26 07:35:11.543708"]]
   (3.8ms)  COMMIT

Userクラスに何か特別なメソッドを生やしたわけではありませんが、レコードのcreate時にeccrypted_passwordカラム に値が入っていることがわかります。

なので、deviseのmoduleのいずれかのソースコードを確認すればよさそうです。

database_authenticatableでencrypted_passwordの挿入を行なっていそうなので、見に行ってみます。

database_authenticatable

github.com

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

Authenticatable Module, responsible for hashing the password and
validating the authenticity of a user while signing in.

This module defines a `password=` method. This method will hash the argument
and store it in the `encrypted_password` column, bypassing any pre-existing
`password` column if it exists.

Authenticatable Moduleは、パスワードのハッシュ化と、サインイン中のユーザーの信頼性の検証を担当します。

このモジュールは、 `password =`メソッドを定義します。 このメソッドは、引数をハッシュ化して `encrypted_password`列に格納し、
既存の` password`列が存在する場合はそれをバイパスします。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/database_authenticatable.rb

password=メソッドが呼ばれた際にencrypted_passwordに値が入るので、ここでこの記事を終わりにしてもいいんですが、それでは味気ないので、

もっと深くコードを追ってみましょう。


password=

github.com

# Generates a hashed password based on the given value.
# For legacy reasons, we use `encrypted_password` to store
# the hashed password.

指定された値に基づいてハッシュ化されたパスワードを生成します。
歴史的な理由から、ハッシュ化されたパスワードを保存するために `encrypted_password`を使用します。

def password=(new_password)
  @password = new_password
  self.encrypted_password = password_digest(@password) if @password.present?
end

コメントアウトの「For legacy reasons」が気になりますね。

git blameでcommitメッセージを見てみましょう。

Change encryption for hashing in the documentation.
Throughout the documentations, we are using 'encrypt' incorrectly.
Encrypt means that someone will eventually decrypt the message,
which is obviously not the case for Devise.

I'm changing the docs to use 'hashing' instead.

However, I left the database field as `encrypted_password` for now.
I'll update the db field in an upcoming PR.

ドキュメントのハッシュの暗号化を変更します。
ドキュメント全体を通して、「encrypt」を誤って使用しています。
暗号化とは、誰かが最終的にメッセージを復号化することを意味します。
これは明らかにDeviseには当てはまりません。

代わりに「ハッシュ」を使用するようにドキュメントを変更しています。

ただし、今のところ、データベースフィールドは `encrypted_password`のままにしておきます。
今後のPRでdbフィールドを更新します。

出典: Change encryption for hashing in the documentation. · heartcombo/devise@c4b4411 · GitHub

なるほど、encryptの意味を誤用しているから、本当はhashed_passwordにカラム名を変更したいけど、

このcommitでは一旦encrypted_passwordにしておいたとのことです。

コメントアウトとcommitメッセージの意図が汲み取れたところで、ソースコードを見てみます。

def password=(new_password)
  @password = new_password
  self.encrypted_password = password_digest(@password) if @password.present?
end

引数のnew_passwordをインスタンス変数とし、@passwordがpresentであれば、password_digestをencrypted_passwordの値としているようです。

password_digestメソッドを見る必要がありそう。


password_digest

github.com

# Hashes the password using bcrypt. Custom hash functions should override
# this method to apply their own algorithm.
#
# See https://github.com/heartcombo/devise-encryptable for examples
# of other hashing engines.
def password_digest(password)
  Devise::Encryptor.digest(self.class, password)
end

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

Hashes the password using bcrypt. Custom hash functions should override this method to apply their own algorithm.

bcryptを使用してパスワードをハッシュ化します。 カスタムハッシュ関数は、独自のアルゴリズムを適用するためにこのメソッドをオーバーライドする必要があります。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/database_authenticatable.rb

パスワードハッシュアルゴリズムであるbcryptを使用して引数のpasswordをハッシュ化してくれるメソッドのようです。

コンソールで試してみます。

> user = User.first

> password = 'test1234'

> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$N3Vh/XXNu6rHPMLxBd1LMeSzH.c3rQWl6EndVZQotaQRIRU3gmrDy"

> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$A4G7CAQb50llKJ.oyJauEu6J6PoK7UZrNtN7In9AicIvyPIf5fZ5y"

# 何度実行しても同じ値は返らない
> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$klOb/T0Z/ryo9U.Z2SFbPO8f7zOYs47Om/Ea9GGncb53OHve2E6Ya"

passwordは一緒でも同じ値は返らないことが確認できますね。


regisrations_controller.rbのcreateアクションの動作を追ってみる

https://github.com/heartcombo/devise/blob/main/app/controllers/devise/regisrations_controller.rbgithub.com

どんな処理を経て最終的にユーザーが作成されるのかもついでに見てみます。

  # POST /resource
  def create
    build_resource(sign_up_params)

    resource.save
    yield resource if block_given?
    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message! :notice, :signed_up
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}"
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

build_resource

github.com

# Build a devise resource passing in the session. Useful to move
# temporary session data to the newly created user.

セッションで渡すデバイスリソースを構築します。
 一時的なセッションデータを新しく作成したユーザーに移行するのに便利です。

def build_resource(hash = {})
  self.resource = resource_class.new_with_session(hash, session)
end

resource_classはサインアップしようとしているモデルのクラスが返ります。

次にnew_with_sessionメソッドを確認してみます。

new_with_session

github.com

# A convenience method that receives both parameters and session to
# initialize a user. This can be used by OAuth, for example, to send
# in the user token and be stored on initialization.
#
# By default discards all information sent by the session by calling
# new with params.
def new_with_session(params, session)
  new(params)
end

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

A convenience method that receives both parameters and session to initialize a user.
This can be used by OAuth, for example, to send in the user token and be stored on initialization.
By default discards all information sent by the session by calling new with params.

ユーザーを初期化するためにパラメーターとセッションの両方を受け取る便利なメソッド。
これは、たとえば、ユーザートークンを送信し、初期化時に保存するためにOAuthで使用できます。
デフォルトでは、paramsを使用してnewを呼び出すことにより、セッションによって送信されたすべての情報を破棄します。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/registerable.rb

メソッド名の通り、パラメーターとセッションの両方を受け取って処理を行うメソッドですね。

REPLで確認してみます。

From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/registerable.rb:22 Devise::Models::Registerable::ClassMethods#new_with_session:

    21: def new_with_session(params, session)
 => 22:   new(params)
    23: end

[1] pry(User)> params
=> {"email"=>"test-user-1@example.or.jp", "password"=>"test1234", "password_confirmation"=>"141b3ccef657"}
[2] pry(User)> session
=> #<ActionDispatch::Request::Session:0x00007f300842df80 ...>
[3] pry(User)> session.to_h
=> {"session_id"=>"b71549ad67aa9d67e27aa04fea2c0b37", "_csrf_token"=>"KbKDaYOjWm7jMLPfqctmdhG3ImpMGoQ4y9JX89plxLc="}

paramsはユーザー登録時に入力した値が格納されていて、sessionはActionDispatch::Request::Sessionクラスのインスタンスでした。

newメソッドは ActiveRecord::Inheritance::ClassMethods#new が実行されるようです。


resource=

nextメソッドで処理を進めるとresource=メソッドがcallされました。

From: /usr/local/bundle/gems/devise-4.8.0/app/controllers/devise_controller.rb:95 DeviseController#resource=:

    94: def resource=(new_resource)
 => 95:   instance_variable_set(:"@#{resource_name}", new_resource)
    96: end

このメソッドが実行されると、以降は resource でユーザーを取得することができます。

ここでbuild_resourceの処理は終わりでした。


まとめ

deviseのencrypted_passwordに値が保存される仕組みを一言で言うと、

「password属性に値が入るタイミングでecnrypted_passwordカラムにBCryptアルゴリズムによってハッシュ化された文字列が代入される」

でしたね。

また、「encryptの意味が異なるからencrypted_passwordをやめたい」という課題があることは知りませんでした。

上記の変更が2014年に加わっていて、その後encrypted_passwordという名前が変わっていないことを考えると、

既にdeviseを使ったアプリケーションが何万とある中で、後方互換性を維持したまま変更を加えるのが大変なんでしょう、、、

命名には細心の注意を払うようにしましょう、、、

deviseのdestroyアクションを実行すると何が起こるか調べてみた

こんにちは!kossyです!




今回は、deviseのdestroyアクションを実行すると何が起こるか調べてみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 3.0.3
Rails 6.0.4
devise 4.8.1



前準備

pry-railsとpry-byebugをGemfileに記載してbundleした後、

sessions_controller.rbのdestroyアクションにbinding.pryを仕込みます。

    25: def destroy
    26:   binding.pry
    27:   signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
    28:   set_flash_message! :notice, :signed_out if signed_out
    29:   yield if block_given?
    30:   respond_to_on_destroy
    31: end

その後、適当なアカウントでログインして、ログアウトボタンを押してREPLを起動できたらOKです。

Devise.sign_out_all_scopes

From: /app/app/controllers/devise/sessions_controller.rb:27 Devise::SessionsController#destroy:

    25: def destroy
    26:   binding.pry
 => 27:   signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
    28:   set_flash_message! :notice, :signed_out if signed_out
    29:   yield if block_given?
    30:   respond_to_on_destroy
    31: end

> Devise.sign_out_all_scopes
=> true

Devise.sign_out_all_scopesはconfigファイルで設定できる値かと思われるので、見に行ってみます。

# Set this configuration to false if you want /users/sign_out to sign out
# only the current scope. By default, Devise signs out all scopes.
# config.sign_out_all_scopes = true

もし users/sign_out でサインアウトさせたい場合は false にする必要があるようです。

デフォルト値は true のようです。

「Deviseはすべてのスコープをサインアウトします。」と記載があるため、

例えば管理者権限と一般ユーザー権限の2つのモデルを作成し、両方の権限でログインしてからログアウトすると、全ての権限でログアウトになると思われます。


sign_out

github.com

def sign_out(resource_or_scope = nil)
  return sign_out_all_scopes unless resource_or_scope
  scope = Devise::Mapping.find_scope!(resource_or_scope)
  user = warden.user(scope: scope, run_callbacks: false) # If there is no user

  warden.logout(scope)
  warden.clear_strategies_cache!(scope: scope)
  instance_variable_set(:"@current_#{scope}", nil)

  !!user
end

こちらはREPLで試しながら動作を確認してみます。

$ resource_or_scope
=> nil

このまま1行ずつ実行すると sign_out_all_scopes が実行されてしまうので、resource_or_scopeに値を入れた上で試してみます。

$ resource_or_scope = User.first

$ scope = Devise::Mapping.find_scope!(resource_or_scope)
=> :user

$ user = warden.user(scope: scope, run_callbacks: false)
=> #<User id: 1, ...>

$ warden.logout(scope)
=> nil

$ warden.clear_strategies_cache!(scope: scope)
=> {}

$  instance_variable_set(:"@current_#{scope}", nil)
=> nil

$ !!user
=> true

実際にsessionを削除するような処理はwardenの中で行っているようなので、wardenの処理にstepメソッドで入ってみたいと思います。

From: /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:267 Warden::Proxy#logout:

    266: def logout(*scopes)
 => 267:   if scopes.empty?
    268:     scopes = @users.keys
    269:     reset_session = true
    270:   end
    271:
    272:   scopes.each do |scope|
    273:     user = @users.delete(scope)
    274:     manager._run_callbacks(:before_logout, user, self, :scope => scope)
    275:
    276:     raw_session.delete("warden.user.#{scope}.session") unless raw_session.nil?
    277:     session_serializer.delete(scope, user)
    278:   end
    279:
    280:   reset_session! if reset_session
    281: end

> scopes.empty?
=> false

> user = @users.delete(scope)
=> #<User id: 1, ...>

> raw_session
=> #<ActionDispatch::Request::Session:0x00007fb208019c00 ...>

raw_sessionおよびsession_serializerからsessionをdeleteして、

Railsのreset_session!メソッドを呼び出しているようです。

railsdoc.com

と思いきや、warden_compat.rbのメソッドらしいです。

> method(:reset_session!).source_location
=> ["/usr/local/bundle/gems/devise-4.8.0/lib/devise/rails/warden_compat.rb", 8]
module Warden::Mixins::Common
  def request
    @request ||= ActionDispatch::Request.new(env)
  end

  def reset_session!
    request.reset_session
  end

  def cookies
    request.cookie_jar
  end
end

実際には ActionDispatch::Request クラスのreset_sessionメソッドが呼ばれているようです。

reset_session (ActionController::Base) - APIdock

raw_sessionについての知見が皆無なので調べてみます。

raw_session

先ほどstepで入った処理の中で色々実行してみました。

> raw_session.class
=> ActionDispatch::Request::Session

> raw_session.methods
=> [:loaded?, :to_hash, :delete, :clear, :exists?, :to_h, ...]

> raw_session.to_h
=>  {"session_id"=>"b71549ad67aa9d67e27aa04fea2c0b37",
 "warden.user.user.key"=>[[1], "$2a$12$oLbqXNXAJ3P17TM.7bfaiu"],
 "warden.user.user.session"=>{"unique_session_id"=>"jj2nrehY8TNM-f_nrryY"},
 "_csrf_token"=>"KbKDaYOjWm7jMLPfqctmdhG3ImpMGoQ4y9JX89plxLc="}

> raw_session.method(:to_h).source_location
=> ["/usr/local/bundle/gems/actionpack-6.0.4.1/lib/action_dispatch/request/session.rb", 143]

action_dispatch/request/session.rb を見に行けばよさそう。

github.com

読んでもよくわからなかったので公式ドキュメントを見てみます、、、

Class: ActionDispatch::Request::Session — Documentation for rails (6.0.2.1)

Session is responsible for lazily loading the session from store.

セッションは、ストアからセッションを遅延ロードする責任があります。

出典: https://www.rubydoc.info/docs/rails/ActionDispatch/Request/Session

raw_sessionの役割についてはわかりましたが、定義元がよくわからないので調べてみます。

> method(:raw_session).source_location
=> ["/usr/local/bundle/gems/warden-1.2.9/lib/warden/mixins/common.rb", 9]

github.com

# Convenience method to access the session
# :api: public
def session
  env['rack.session']
end # session

# Alias :session to :raw_session since the former will be user API for storing scoped data.
alias :raw_session :session

env['rack.session']の返り値だったみたいです。

rack.sessionに値が書き込まれるタイミングはいつなのか?が気になりますが、返り値も定義元も役割もわかったので一旦ここまでにします。

session_serializerもいまいちわからんので調べます。

session_serializer

> session_serializer
=> #<Warden::SessionSerializer:0x00007f99501d5790 ...

> method(:session_serializer).source_location
=> ["/usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb", 50]

github.com

def session_serializer
  @session_serializer ||= Warden::SessionSerializer.new(@env)
end

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

Points to a SessionSerializer instance responsible for handling everything related with storing, fetching and removing the user session.

ユーザーセッションの保存、フェッチ、および削除に関連するすべての処理を担当するSessionSerializerインスタンスを指します。

出典: https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L46

CRUD全てを担当するクラスっぽいですね。定義元を見に行ってみます。

github.com

deleteメソッドの中身を見てみます。

def delete(scope, user=nil)
  session.delete(key_for(scope))
end

# We can't cache this result because the session can be lazy loaded(セッションを遅延ロードするためにキャッシュを行いません)
def session
  env["rack.session"] || {}
end

def key_for(scope)
  "warden.user.#{scope}.key"
end

deleteメソッドはsessionから warden.user.user.key のようなkeyをdeleteするメソッドでした。

先ほど、「rack.sessionに値が書き込まれるタイミングはいつなのか?」と疑問を書いていましたが、storeメソッドが書き込んでいそうなので処理を追ってみます。

def store(user, scope)
  return unless user
  method_name = "#{scope}_serialize"
  specialized = respond_to?(method_name)
  session[key_for(scope)] = specialized ? send(method_name, user) : serialize(user)
end

storeメソッドの呼び出し元がこちらでした。

github.com

def set_user(user, opts = {})
  scope = (opts[:scope] ||= @config.default_scope)

  # Get the default options from the master configuration for the given scope
  opts = (@config[:scope_defaults][scope] || {}).merge(opts)
  opts[:event] ||= :set_user
  @users[scope] = user

  if opts[:store] != false && opts[:event] != :fetch
    options = env[ENV_SESSION_OPTIONS]
    if options
      if options.frozen?
        env[ENV_SESSION_OPTIONS] = options.merge(:renew => true).freeze
      else
        options[:renew] = true
      end
    end
    session_serializer.store(user, scope)
  end

  run_callbacks = opts.fetch(:run_callbacks, true)
  manager._run_callbacks(:after_set_user, user, self, opts) if run_callbacks

  @users[scope]
end

set_userは_perform_authenticationというprivateメソッドで呼ばれています。

github.com

なので、「rack.sessionに値が書き込まれるタイミングはいつなのか?」という問いは、「_perform_authenticationメソッドでset_userが呼ばれた時」が一つの解かと思います。

ログアウトのことを調査していたのにログイン時の挙動の調査をしてしまいました、、、寄り道はここまでにします。

まとめ

destroyアクションが呼ばれると、

「Devise.sign_out_all_scopesが true なら、

全てのdeviseを使っている認証中のモデルのwardernのsessionを削除し、

falseなら引数に与えられたresourceのモデルのwardenのsessionを削除」

していました。

「wardenのsessionを削除」は、Warden::Proxy#logout メソッドが呼ばれていて、

内部では request.env['rack.session'].clear を呼んでsessionの中身をnilにすることでログアウトを実現してました。

今までログアウトについて、cookie初期化してsession削除してるんだろうな、程度の曖昧な理解に留まっていましたが、実際に処理を追ってみることで、

ログアウトの詳細な動作とwardenの仕組みの一端を理解することができました。

認証周りのコードを読めば読むほど、自分で認証周りを自作してはいけない気持ちになりますね。

devise-securityのparanoid_verificationのソースコードを追ってみた

こんにちは!kossyです!




今回はdevise-securityのparanoid_verificationのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 3.0.3
Rails 6.0.4
devise-security 0.16.0



paranoid_verificationってなに?

paranoid_verification モジュールは、「アプリケーションの管理者がいつでも、ユーザーが自分自身を確認するように強制できる」機能です。

以下のPRが paranoid_verification モジュールが実装された時のものです。

github.com

devise_security gem は メンテの止まった devise_security_extension Gemから公式にforkされたGemのため、blameしてもPRまで遡るのができないコードがあります。

paranoid_verification はform前の実装だったので、devise-security-extension gemの過去のPRを見たところ、以下の記載がありました。

Basically I got requirement for one application that "reset password" links should be additional verified after user set his Password.
He should call application support team and they will give him verification code. (hardcore security)

But another usage of this feature is that at any point admin of application can enforce that user should verify himself.

so the feature: Generate (paranoid) verification code and enforce user to fill in verification code.
Until then user wont be able to use the application (similar functionality of expired password)


基本的に、ユーザーがパスワードを設定した後、「パスワードのリセット」リンクを追加で確認する必要があるという1つのアプリケーションの要件がありました。
ユーザーはアプリケーションサポートチームに電話する必要があり、サポートチームはユーザーに確認コードを与えます。 (ハードコアセキュリティ)

ただし、この機能のもう1つの使用法は、アプリケーションの管理者がいつでも、ユーザーが自分自身を確認するように強制できることです。

そのため、機能: paranoid verification code を生成し、ユーザーに検証コードの入力を強制します。
それまでは、ユーザーはアプリケーションを使用できません(期限切れのパスワードと同様の機能)

出典: https://github.com/phatworx/devise_security_extension/pull/117

「アプリケーションの管理者がいつでも、ユーザーが自分自身を確認するように強制したい」という要件がある場合は、このモジュールを有効活用できそうですね。



コードリーディング

上記の機能をどのように実現しているのでしょうか。実際にコードを読んでコンソールで実行しつつ仕様の理解を進めてみます。


need_paranoid_verification?

github.com

def need_paranoid_verification?
  !!paranoid_verification_code
end

paranoid_verificationを使う際にテーブルに追加する必要のあるカラムである paranoid_verification_code の値の有無をBooleanで返却するメソッドでした。

用途としてはメソッド命名的に検証コードを実行する必要があるかどうか?を判定するために用いるためかと。


generate_paranoid_code

github.com

def generate_paranoid_code
  update_without_password paranoid_verification_code: Devise.verification_code_generator.call(),
                          paranoid_verification_attempt: 0
end

名前の通りparanoid_codeをgenerateし、レシーバーに保存するメソッドのようです。(updateするなら!をメソッド名につけるのが慣習的にいいと思うが)

Devise.verification_code_generator.call()の返り値は以下のように、5文字のランダムな文字列が返るようです。

$ Devise.verification_code_generator.call()
=> "c8e93"
$ Devise.verification_code_generator.call()
=> "98102"

定義元は以下でした。

github.com

# captcha integration for confirmation form
mattr_accessor :verification_code_generator
@@verification_code_generator = -> { SecureRandom.hex[0..4] }

captcha向けに5文字の文字列を返しているんですね。なぜ5文字なんだろう、普通一時的な認証コードって6文字が一般的では?と思ったんですが、captcha向けなら納得です。


paranoid_attempts_remaining

github.com

def paranoid_attempts_remaining
  Devise.paranoid_code_regenerate_after_attempt - paranoid_verification_attempt
end

あと何回検証コードの実行ができるかを返却するメソッドのように見えますが、コンソールで試してみます。

$ user = User.first

$ user.paranoid_verification_attempt
=> 1

$ user.paranoid_attempts_remaining
=> 9

# config/devise-security.rbで設定できる値です(デフォルトは10)
$ Devise.paranoid_code_regenerate_after_attempt
=> 10

verify_code

github.com

def verify_code(code)
  attempt = paranoid_verification_attempt

  if (attempt += 1) >= Devise.paranoid_code_regenerate_after_attempt
    generate_paranoid_code
  elsif code == paranoid_verification_code
    attempt = 0
    update_without_password paranoid_verification_code: nil,
                            paranoid_verified_at: Time.now,
                            paranoid_verification_attempt: attempt
  else
    update_without_password paranoid_verification_attempt: attempt
  end
end

Devise.paranoid_code_regenerate_after_attemptで設定した値を上回っていない場合は generate_paranoid_code を実行して、

引数のcodeとparanoid_verification_codeが一致した場合は、

paranoid_verification_codeをnilで更新
paranoid_verified_atに現在時刻で更新
paranoid_verification_attemptに0で更新

をパスワード抜きで行っていました。

wikiを見てみる

github.com

wikiを見ると、locakbleで提供されているメソッドをオーバーライドして使うことを推奨しているようです。

lock after reset password
One example of usage could be that after a user resets their password they need to contact support for the verification code. Just add to your authentication resource code similar to this:

class User < ActiveRecord::Base
  # ...
  def unlock_access!
    generate_paranoid_code
    super
  end
end

他にも管理者アカウントでロックする方法や検証コードの認証試行回数を表示する方法などが記載されていました。




勉強になりました。