🔐

ActiveSupport::MessageVerifierと自作Goパッケージ

2023/10/31に公開

はじめに

ActiveSupport::MessageVerifier[1]はメッセージの署名と検証を行う機能を提供するクラスです。利用されているケースとしては、Cookieの改ざんのチェックがあります。この署名付きCookieは、Devise[2]というRailsではおなじみの認証用のGemでも利用されます。筆者は最近Golangを学習しているのですが、このActiveSupport::MessageVerifierのような機能が欲しくなる場面がありました。そこでまずこのクラスの内部実装を調査した上で、自作のGoパッケージを作成することにしました。

もしかしたら既に同様のパッケージはあるかもしれません。しかし筆者にとっては車輪の再発明は毎度のことなので、気にせず先に進むことにします。ところでActiveSupport::MessageVerifierという名前はやや長いので、この記事内では便宜上「MessageVerifier」と呼ばせて頂きます。本記事の内容はどちらかと言えばこのMessageVerifierの調査が中心です。最後に自作のGoパッケージを簡単に紹介させて下さい。

syarin

最新のコンピューターや科学技術を用いてついに車輪を発明した博士のイラストです。
出典:いらすとや

仕様

まずはActiveSupport::MessageVerifierの基本的な仕様を確認してみましょう。さほど複雑な仕様ではありません。鍵を用いて、署名付きのメッセージを生成することができます。そして、そのメッセージは同じ鍵でのみ検証に成功します。

# "secret"は鍵
irb(main):001> verifier = ActiveSupport::MessageVerifier.new("secret")
# "signed message"を署名付きのメッセージにする
irb(main):002> signed_message = verifier.generate("signed message")
=> "BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU--f67d5f27c3ee0b8483cebf2103757455e947493b"
# 署名付きのメッセージを検証して、元のメッセージを得る
irb(main):003> verifier.verify(signed_message)
=> "signed message"
# 別の鍵でインスタンスを生成
irb(main):004> other_verifier = ActiveSupport::MessageVerifier.new("other_secret")
# 別の鍵で検証を行うとエラーが発生する
irb(main):005> other_verifier.verify(signed_message)
=> ActiveSupport::MessageVerifier::InvalidSignatur

署名付きのメッセージ

先程の例で生成された署名付きメッセージは下記の通りです。

"BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU--f67d5f27c3ee0b8483cebf2103757455e947493b"

この文字列は下記のように分解することができます。

メッセージ:"BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU"
セパレート:"--"
メッセージ認証コード:"f67d5f27c3ee0b8483cebf2103757455e947493b"

メッセージ認証符号の仕組みについて確認しておきましょう。
下記の図における、MESSAGEはメッセージ("BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU")、Keyは鍵("secret")、MACはメッセージ認証コード("f67d5f27c3ee0b8483cebf2103757455e947493b")に該当します。
mac
出典:Wikipedia メッセージ認証符号

メッセージは単にbase64でエンコードされた文字列にすぎません。試しにデコードしてみると理解し易いです。

irb(main):007> ("BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU")
=> "\x04\bI\"\x13signed message\x06:\x06ET"

「signed message」という元のメッセージを閲覧することができました。例えばサーバーサイドで生成した署名付きのメッセージをクライアントに渡したとします。クライアントはbase64でデコードすれば元のメッセージの値を閲覧することが可能ということです。

署名とはデータの解読を防ぐのではなく、改ざんを検出するためのものです。改ざんの検証においては、メッセージを同じ鍵とアルゴリズムを用いてハッシュ値を計算します。そのハッシュ値がメッセージ認証コードと一致していれば、改ざんされていないことが保証されます。

共通鍵と公開鍵

セキュリティの性質を理解した上で、ライブラリを利用することは重要です。
MessageVerifierは内部的にHMACというメッセージ認証符号を用いています。RFC 2104[3]にあるように、計算と検証の双方に同じ鍵を使用します。つまり、HMACは共通鍵を使用するアルゴリズムです。鍵の受け渡しが発生するようなユースケースでは、利用を避けた方が良いでしょう。

HMAC also uses a secret key for calculation and verification of the message authentication values.
出典:RFC 2104

JWTトークン

話は脱線しますが、JWTトークンとの違いも気になったので整理してみることにしました。JWTトークンは一般的にRSAのような公開鍵による署名を行います。下記はJWTトークンの検証を表した図です。

jwt

シグネチャは公開鍵で生成されたものです。複合には秘密鍵を用いるのもそうですが、シグネチャを複合して元データとの突合する点においても異なります。MessageVerifierでは元データ側のハッシュ値を計算していました。

メタデータ

MessageVerifierは内部的にMessages::Metadataクラス[4]を使用して、メタデータを取り扱っています。メタデータには、有効期限、用途といった情報の格納が可能です。

有効期限

署名付きメッセージの作成時に有効期限を指定することができます。有効期限にはexpires_atではなく、expires_in、例えばexpires_in: 1.monthのような設定も可能です。

# あえて有効期限を1日前に設定
irb(main):002> signed_message = verifier.generate("signed message", expires_at: Time.zone.now - 1.hour)
=> "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJaE56YVdkdVpXUWdiV1Z6YzJGblpRWTZCa1ZVIiwiZXhwIjoiMjAyMy0xMC0zMFQxMjo0NTowNS4wMDZaIiwicHVyIjpudWxsfX0=--08c6d0c2ecd48216b0b4d999d7ac186..."
# エラーが発生する
irb(main):002> verifier.verify(signed_message)
=> ActiveSupport::MessageVerifier::InvalidSignatur

目的

目的を指定することもできます。検証時に目的が異なれば、その検証は失敗します。

irb(main):002> signed_message = verifier.generate("signed message", purpose: 'purpose')
=> "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJaE56YVdkdVpXUWdiV1Z6YzJGblpRWTZCa1ZVIiwiZXhwIjpudWxsLCJwdXIiOiJwdXJwb3NlIn19--c0287cdb6ac50544b19efa4b59f7b8a1278cce0e"
irb(main):003> verifier.verify(signed_message, purpose: "purpose")
=> "signed message"
# 異なるpurposeを指定すると、エラーが発生する
irb(main):004> verifier.verify(signed_message, purpose: "diffrent purpose")
=> ActiveSupport::MessageVerifier::InvalidSignatur

ローテーション

キーの更新は重要です。実稼働しているアプリケーションではローテーションの仕組みが必要になるでしょう。

However, periodic key refreshment is a fundamental security practice that helps against potential weaknesses of the function and keys, and limits the damage of an exposed key.
出典:RFC 2104

# "secret"を鍵に指定
irb(main):001> verifier = ActiveSupport::MessageVerifier.new("secret")
irb(main):002> signed_message = verifier.generate("signed message")
=> "BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU--f67d5f27c3ee0b8483cebf2103757455e947493b"
# new secretを鍵に指定
irb(main):003> new_verifier =  ActiveSupport::MessageVerifier.new("new secret")
# 署名時の鍵と異なるので当然エラーになる
irb(main):004> new_verifier.verify(signed_message)
=> ActiveSupport::MessageVerifier::InvalidSignatur
# ローテーションを設定
irb(main):016> new_verifier.rotate("secret")
# 検証に成功する
irb(main):017> new_verifier.verify(signed_message)
=> "signed message"

自作Goパッケージ

下記が今回作成した自作のGoパッケージです。
実を言うと少し実装が甘い箇所はあるのですが、メタデータやローテーションも実装しています。
https://github.com/kondo97/verifier/blob/main/verifier.go
リポジトリはこちら
https://github.com/kondo97/verifier

余談

冒頭でMessageVerifierはCookieの改ざんのチェックに用いられると述べました。正確にはCookiesクラスのsignedメソッドで使用されています。少し余談にはなりますが、Deviseでの活用例をご紹介できればと思います。

# 署名付きのCookieを生成する
cookies.signed[:discount] = 45

そしてこの署名付きのCookieは、Deviseのremember_meという機能で利用されます。remember_meはトークンを生成します。この仕組みは「安全なWebアプリケーションの作り方」でも紹介されている「トークンによる自動ログイン」[5]に近しいものです[6]。セッション上に認証情報がセットされていなければ、トークンの情報を用いて認証が実行されます。

以下は実際に生成されたトークンです。Cookieにセットされているので、ブラウザの検証ツールからこの値は確認することができました。

"eyJfcmFpbHMiOnsibWVzc2FnZSI6Ilcxc3hYU3dpSkRKaEpERXlKRXBTTDB4MU0xZE9XSHBFU3pCRVVYTXhUWEV6VFU4aUxDSXhOams0TmpjMk5qUXdMamMwTVRFM0lsMD0iLCJleHAiOiIyMDIzLTExLTEzVDE0OjM3OjIwLjc0MVoiLCJwdXIiOiJjb29raWUucmVtZW1iZXJfdXNlcl90b2tlbiJ9fQ%3D%3D--ec86f639a1685f6c93ce52101d3325f90580823e"

前半部(--より前の文字列)はメッセージ、後半部はメッセージ認証コードです。試しにデコードしてみましょう。

irb(main):001:0> base64.decode64("eyJfcmFpbHMiOnsibWVzc...")
# message、exp(有効期限)、pur(目的)のキー名が確認できる
=> "{\"_rails\":{\"message\":\"W1sxXSwiJDJhJDEyJEpSL0x1M1dOWHpESzBEUXMxTXEzTU8iLCIxNjk4Njc2NjQwLjc0MTE3Il0=\",\"exp\":\"2023-11-13T14:37:20.741Z\",\"pur\":\"cookie.remember_user_token\"}}\r\xC3\xDC7\x9C\xF3\xA7\xFA\xDF\xD6\xB5\xEB\xCE_\xE9\xCFwq\xEEv\xD7M]\xDF}\xB9\x7F\xDD9\xF3O6\xDD"
irb(main):002:0>  Base64.decode64("W1sxXSwiJDJhJDEyJEpSL0x1M1dOWHpESzBEUXMxTXEzTU8iLCIxNjk4Njc2NjQwLjc0MTE3Il0=")
# 0番地:user_id、1番地:remember_token、2番地:有効期限
=> "[[1],\"$2a$12$JR/Lu3WNXzDK0DQs1Mq3MO\",\"1698676640.74117\"]"

Deviseの内部的な話になってしまのですが、"$2a$12$JR/Lu3WNXzDK0DQs1Mq3MO"の値は認証対象のテーブル(usersなど)にremember_tokenというカラム名で値が保存されています。データベース上に保存されているremember_tokenカラムの値とこのトークンの値を比較して合致していれば認証を成功させるという仕組みです。
※なお正確にはremember_tokenカラムが存在しない場合は、パスワードのハッシュ値を保存するencrypted_passwordカラムの前半30文字のソルト値が使用されます。

おわりに

OSSのコードはとても参考になります。しかしそれが正しいものであることは保証されていません。例えば、MessageVerifierではデフォルトのハッシュ関数にSHA-1が指定されています。SHA-1は2030年12月31日までの段階的な廃止が計画されています。[6:1]
加えて各々のコードが意味するところを正確に読み取る必要があります。MessageVerifierのdigest_matches_data?メソッドがActiveSupport::SecurityUtils.secure_compareを用いて安全な比較[7]を行うと同じように、筆者も自作パッケージではsubtle.ConstantTimeCompareを用いました。
しかし、この記事や筆者の自作パッケージにはおそらくいくつもの見落としや誤りがあるでしょう。お気づきの点があればご指摘いただければ幸いです。

参考

Wikipedia HMAC
基本から理解するJWTとJWT認証の仕組み

脚注
  1. https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html ↩︎

  2. https://github.com/heartcombo/devise ↩︎

  3. https://datatracker.ietf.org/doc/html/rfc2104 ↩︎

  4. https://www.rubydoc.info/docs/rails/7.0.4.3/ActiveSupport/Messages/Metadata ↩︎

  5. 「安全なWebアプリケーションの作り方」5.1章 ↩︎

  6. deviseでの使われ方を見ると、同一のものではないかと思います。 ↩︎ ↩︎

  7. https://news.mynavi.jp/techplus/article/20221219-2540657/ ↩︎

GitHubで編集を提案
株式会社スタメン

Discussion