RailsのActiveRecordを使ったモデルに定義されたassociation情報を全て取得する reflect_on_all_associations のソースコードを読んでみる

こんにちは!kossyです!




諸事情ありブログを書かずにおりました、、、

月8 ~ 10本ペースは守りたいと思いつつも、なかなか時間の確保が厳しいですね、、、(根性が足りないと言われればその通りなのですが)




今回は、RailsActiveRecordを使ったモデルに定義されたassociation情報を全て取得する reflect_on_all_associations のソースコードを読んでみたので、

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




環境
Ruby 2.6.9
Rails 6.1.4




reflect_on_all_associations

まずは適当な箇所にbinding.pryを仕込んで、stepメソッドで処理の内部を見てみます。

From: /usr/local/bundle/gems/activerecord-6.0.4.1/lib/active_record/reflection.rb:104 ActiveRecord::Reflection::ClassMethods#reflect_on_all_associations:

    103: def reflect_on_all_associations(macro = nil)
 => 104:   association_reflections = reflections.values
    105:   association_reflections.select! { |reflection| reflection.macro == macro } if macro
    106:   association_reflections
    107: end

[1] pry(User)>

定義元はこちらですね。

github.com

いろいろ実行する前に、コメントアウト部分を読んでみます。

Returns an array of AssociationReflection objects for all the associations in the class.

If you only want to reflect on a certain association type, pass in the symbol :has_many, :has_one, :belongs_to as the first parameter.

クラス内のすべての関連付けのAssociationReflectionオブジェクトの配列を返します。

特定の関連付けタイプのみを反映する場合は、最初のパラメーターとしてシンボル:has_many、:has_one、:belongs_toを渡します。

出典: https://github.com/rails/rails/blob/main/activerecord/lib/active_record/reflection.rb

返り値は配列になるとのこと。一旦exitで抜けて、メソッドの中に入らずに返り値を確認してみます。

$  User.reflect_on_all_associations
=> [#<ActiveRecord::Reflection::HasManyReflection:0x000055b2c512eaa8
  @active_record=
   User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime),
  @constructable=true,
  @foreign_type=nil,
  @klass=nil,
  @name=:posts,
  @options={:dependent=>:destroy},
  @plural_name="posts",
  @scope=nil,
  @type=nil>]

$ User.reflect_on_all_associations.class
=> Array

$ User.reflect_on_all_associations.size
=> 1

$ User.reflect_on_all_associations.first.class
=> ActiveRecord::Reflection::HasManyReflection

返り値は配列になっていて、ActiveRecord::Reflection::HasManyReflectionクラスのオブジェクトが配列の中身に格納されていました。


ソースコードを追ってみる

ここからはもう一度stepメソッドを使ってメソッドの中身を追ってみます。

From: /usr/local/bundle/gems/activerecord-6.0.4.1/lib/active_record/reflection.rb:104 ActiveRecord::Reflection::ClassMethods#reflect_on_all_associations:

    103: def reflect_on_all_associations(macro = nil)
 => 104:   association_reflections = reflections.values
    105:   association_reflections.select! { |reflection| reflection.macro == macro } if macro
    106:   association_reflections
    107: end

# 引数を渡して実行してないため、nilが返る
>  macro
=> nil

# hashが返り値になっている
> reflections
=> {"posts"=>
  #<ActiveRecord::Reflection::HasManyReflection:0x000055b2c512eaa8
   @active_record=
    User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime),
   @constructable=true,
   @foreign_type=nil,
   @klass=nil,
   @name=:posts,
   @options={:dependent=>:destroy},
   @plural_name="posts",
   @scope=nil,
   @type=nil>}

# posts keyの中身が配列で返っている
> reflections.values
=> [#<ActiveRecord::Reflection::HasManyReflection:0x000055b2c512eaa8
  @active_record=
   User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime),
  @constructable=true,
  @foreign_type=nil,
  @klass=nil,
  @name=:posts,
  @options={:dependent=>:destroy},
  @plural_name="posts",
  @scope=nil,
  @type=nil>]

一通り処理の内容は理解できました。reflectionsオブジェクトの中身をよしなにいじって、モデルに定義されたassociation情報を返している感じですね。


応用的な使い方

以下のように書くと、そのモデルに定義されているassociation名の配列を得ることができます。

$ User.reflect_on_all_associations.map(&:name)
=> [:posts]

例えば、has_manyなレコードが存在する場合に何かしらの処理を加えたい、という場合に以下のように書くことができます。

has_many_association_names = User.reflect_on_all_associations(:has_many).map(&:name)

if has_many_association_names.any? { |name| user.public_send(name).exists? }
  # 何かしらの処理
else
  # 何かしらの処理
end

こう書くことで、新たに関連を追加した時に、わざわざ定数にsymbolを追加しなくてもよくなり、コードの変更箇所を減らすことができます。




勉強になりました。

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を使ったアプリケーションが何万とある中で、後方互換性を維持したまま変更を加えるのが大変なんでしょう、、、

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

RailsでPostgreSQLを使う際にgen_random_uuid関数を有効化したい

こんにちは!kossyです!




今回はRailsPostgreSQLを使う際にgen_random_uuid関数を有効化する方法について、

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




環境

Ruby 2.6.9
Rails 6.0.4
PostgreSQL 13系



マイグレーションファイルの作成

gen_random_uuid関数を有効化するために、マイグレーションファイルを作成し、以下のように変更します。

class EnableUuidExtension < ActiveRecord::Migration[6.0]
  def change
    enable_extension 'pgcrypto'
  end
end

その後migrateを実行。

$ rails db:migrate

== 20211216142135 EnableUuidExtension: migrating ==============================
-- enable_extension("pgcrypto")
   -> 0.0554s
== 20211216142135 EnableUuidExtension: migrated (0.0556s) =====================

これでマイグレーションファイル内でgen_random_uuid関数が使えるようになります。

テーブルの作成

試しにテーブルを作成して、uuid型のデフォルト値にgen_random_uuid関数を指定してみます。

class CreateTenants < ActiveRecord::Migration[6.0]
  def change
    create_table :tenants do |t|
      t.string :name, null: false
      t.uuid :uuid, null: false, default: 'gen_random_uuid()'

      t.timestamps
    end
  end
end

migrateコマンドを実行した後、schema.rbを確認してみます。

  create_table "tenants", force: :cascade do |t|
    t.string "name", null: false
    t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

default値にgen_random_uuid関数が指定できているようです。


動作確認

試しにtenantsテーブルにレコードを新規作成してみます。

$ Tenant.create!(name: 'その辺にいるWebエンジニア株式会社')
   (0.5ms)  BEGIN
  Tenant Exists? (4.5ms)  SELECT 1 AS one FROM "tenants" WHERE "tenants"."id" = $1 LIMIT $2  [["id", nil], ["LIMIT", 1]]
  Tenant Create (3.7ms)  INSERT INTO "tenants" ("name", "uuid", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "その辺にいるWebエンジニア株式会社"], ["uuid", "b5188d11-d9a3-423b-be17-dae29800977e"], ["created_at", "2021-12-16 13:16:51.489288"], ["updated_at", "2021-12-16 13:16:51.489288"]]
   (3.8ms)  COMMIT

uuidにデフォルト値が挿入されていることがわかります。

念のためきちんとランダムな値になっているかどうかも確認してみます。

$ Tenant.create!(name: 'ランダム株式会社')
   (0.8ms)  BEGIN
  Tenant Exists? (7.4ms)  SELECT 1 AS one FROM "tenants" WHERE "tenants"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Tenant Exists? (4.4ms)  SELECT 1 AS one FROM "tenants" WHERE "tenants"."id" = $1 LIMIT $2  [["id", nil], ["LIMIT", 1]]
  Tenant Create (0.9ms)  INSERT INTO "tenants" ("name", "uuid", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "ランダム株式会社"], ["uuid", "a46244c4-8a3a-4b48-8236-1e76b475b4da"], ["created_at", "2021-12-16 13:19:26.898640"], ["updated_at", "2021-12-16 13:19:26.898640"]]
   (1.4ms)  COMMIT

先ほどと異なるuuidが生成されているので、ランダムな値になっているようです。


uuid_generate_v4じゃダメなの?

uuid_generate_v4という関数もあるんですが、PostgreSQLのドキュメントを見たところ、

注意: ランダムに生成された(バージョン4)UUIDのみが必要な場合には、代わりにpgcryptoモジュールのgen_random_uuid()を利用すること検討してください。

出典: www.postgresql.jp

という記述があったため、ランダムに生成された(バージョン4)UUIDのみが必要な場合にはpgcryptoモジュールを有効化するのがデファクトスタンダートのようです。


まとめ

Railsだとfriendly_id Gemを用いる際にuuidを設定することがあると思いますが、その場合にはpgcryptoモジュールを有効化するのを忘れないようにしましょう。(私は10分ハマりました)