Go 1.22でセキュアな乱数生成機能を強化
(go.dev)- Go 1.22は、
math/randとmath/rand/v2のデフォルト乱数ソースを暗号学的に強い生成器に変更し、crypto/randを使うべき場面で誤った場合の被害を大きく減らす - 従来のGo 1生成器は、607個の
uint64状態を使う線形フィードバックシフトレジスタで、607個の出力を観測するだけで過去・未来の値を復元できる math/rand/v2のPCG-DXSMは統計的な乱数品質と状態サイズを改善したが、秘密値に必要な予測不能性までは保証しない- 新しいChaCha8Randは32バイトのシード、16ブロックごとの再キー化、コアごとに300バイトの状態を使い、
math/rand/v2、一部のmath/rand、mapのハッシュシードに適用される - 性能コストは限定的。ChaCha8RandはGo 1生成器より遅いが最大でも2倍以内で、一般的なサーバーでは差が3nsを超えず、ほとんどのプログラムでセキュリティ上の利益のほうが大きい
Go 1.22が変えた乱数のデフォルト
- Go 1.22は、
math/randとmath/rand/v2で暗号学的に強い疑似乱数生成器を使うようデフォルトを変更 - 目的は、
crypto/randが必要な場所で開発者が誤ってmath/randを使った場合の被害を減らすことにある - Goの乱数APIは伝統的に2つのカテゴリに分かれる
math/rand: シミュレーション、サンプリング、数値解析、非暗号学的なランダムアルゴリズム、ファジング、シャッフル、指数バックオフなどに使う統計的乱数crypto/rand: キーやトークンのように予測不能性が必要な暗号学的乱数
統計的乱数では十分でない理由
- 統計的乱数生成器は、基本的な統計テストに合格すれば多くの非暗号学的用途には十分な場合がある
- しかし、アルゴリズムを知っている観測者が十分な出力を見ると、その後のシーケンスを予測できることが多い
- Unix V3の
srandとrandは、その後のCや多くの言語の乱数APIに影響を与えた初期の形態- 単一の整数シードで状態を設定する
- 線形合同生成器(LCG)方式で次の値を計算する
- 内部状態が単純なため、出力1つだけでも将来の値を容易に計算できる
- LCGは、可能な出力値を繰り返し前に一度ずつ出すよう定数を選べるが、下位ビットが短い周期で繰り返される弱点がある
Go 1生成器の構造と脆弱性
- Go 1の
math/rand生成器は線形フィードバックシフトレジスタ系列 - 内部状態は607個の
uint64からなるvecスライスvec[606]は「tap」vec[334]は「feed」- 次の値を作るときにtapとfeedを足して
xを作り、xをfeed位置に保存してから返す
- 実際の実装ではスライス全体を移動せず、tapとfeedの位置だけを後ろに動かしてコストを下げている
- 次の値の生成には、2回の減算、2回の条件付き加算、2回のロード、1回の加算、1回のストアが必要
- 返り値が内部状態ベクトルの1要素なので、607個の出力を読むと状態全体が露出する
- 同じ
vecを埋めてアルゴリズムを実行すれば将来の値を予測できる - アルゴリズムを逆に実行すれば過去の値も復元できる
- 同じ
- Go 1生成器はセキュリティ目的ではなく、生成された数の品質も初期
vec設定に依存する
PCGが改善した点と残る限界
math/rand/v2は、より現代的な統計的乱数生成器としてMelissa O’NeillのPCGを採用- GoのPCGは128ビットLCGをベースにし、
scramble関数で128ビット状態を64ビット出力に縮小する - Goは提案の議論中、O’Neillの提案を基に乗算ベースの
scrambleを使用- この形式はPCG-DXSMと呼ばれる
- NumpyもこのPCG形式を使用している
- PCGはGo 1生成器より状態がはるかに小さい
- Go 1生成器: 607個の
uint64 - PCG: 2個の
uint64
- Go 1生成器: 607個の
- PCGは初期状態値への感度が低く、複数の統計テストに合格するが、予測不能性は保証しない
- PCG-XSL-RRは逆算できる
- PCG-DXSMも逆算できても驚くことではないと見られている
- 秘密値の生成にはPCGではなく別の生成器が必要
暗号学的乱数とオペレーティングシステムの役割
- 暗号学的乱数は、生成方式を知り、過去の出力値を大量に見た観測者に対しても、実質的に予測不能でなければならない
- 暗号プロトコル、秘密鍵、現代の商取引、オンラインプライバシーなどは暗号学的乱数に依存している
- 実際の乱数供給はオペレーティングシステムが担う
- マウス、キーボード、ディスク、ネットワークのタイミングなど物理デバイスからランダム性を収集する
- 近年はCPUが直接測定した電気的ノイズも活用される
- オペレーティングシステムが十分な乱数、たとえば少なくとも256ビットを集めると、ハッシュや暗号化アルゴリズムで長い乱数シーケンスを作る
- 以前は
/dev/randomのようなデバイスファイルが主に使われていたが、現在のオペレーティングシステムは直接システムコールを提供している - Goの
crypto/randはオペレーティングシステムごとの差異を隠し、同じインターフェイスであるrand.Readを提供する
ChaCha8Randの設計
- Go 1.22の新しい生成器ChaCha8Randは、Daniel J. BernsteinのChaChaストリーム暗号を軽く修正したもの
- ChaChaはTLSやSSHでも使われるChaCha20形式が広く利用されている
- Jean-Philippe AumassonのToo Much Cryptoは8ラウンド形式であるChaCha8も安全だと見ており、ChaCha8はおよそ2.5倍速い
- ChaCha8RandはChaCha8を
rand.Sourceとして使うため、生成ブロックを入力とXORせず、直接乱数ストリームとして使用する- これは全て0のデータを暗号化または復号するのと同じ
ChaCha8Randの変更点
- ChaCha8Randは32バイトシードをChaCha8キーとして使用する
- ChaCha8は64バイトブロックを生成し、計算ではブロックを16個の
uint32として扱う - 一般的な実装はSIMD命令で4ブロックを同時に計算できるが、XOR入力に使うにはインターリーブされたブロックを再びほどく必要がある
- ChaCha8Randはこのインターリーブされたブロック自体を乱数ストリームとして定義し、unshuffleのコストをなくす
- ChaCha8のブロック終端処理では、特定の値を各
uint32に加える- 半分はキー素材で、半分は既知の定数
- ChaCha8Randは既知の定数を再び加えないことで、最終加算の半分を取り除く
- 16個目の生成ブロックごとに、最後の32バイトを次の16ブロックのキーとして使用する
- この再キー化は一種の前方安全性を提供する
- 生成器のメモリ状態全体が露出しても、最後の再キー化以降の値だけを復元可能で、過去の値にはアクセスできない
- GoはChaCha8Rand C2SP仕様とテストケースを公開し、同じシードに対して他の実装もGo実装と再現性を共有できるようにしている
標準ライブラリでの適用箇所
- Goランタイムは、オペレーティングシステムが供給した暗号学的乱数でシードされたコアごとのChaCha8Rand状態を維持する
- 各コアあたりの状態サイズは300バイト
- 16コアシステムでは、単一共有のGo 1生成器状態4,872バイトと同程度
- コアごとの状態により、ロック競合なしに高速に乱数を作れる
math/rand/v2のパッケージ関数は常にChaCha8Randを使う- 例:
rand.N、rand.Float64
- 例:
math/randのパッケージ関数は、rand.Seedが呼ばれていない場合にChaCha8Randを使う- 例:
rand.Intn、rand.Float64 rand.Seedが呼ばれると、互換性のためGo 1生成器に戻る必要がある
- 例:
- ランタイムは新しいmapのハッシュシードを、従来のwyrandベース生成器ではなくChaCha8Randで選ぶ
- 攻撃者がmap実装の特定のハッシュ関数を知っていれば、入力を用意してmapを二次時間の動作に追い込める
- グローバルシード1つではなくmapごとのシードを使えば、他の退化動作も避けられる
- mapシードに暗号学的乱数が必須かは明確ではないが、切り替えは簡単で慎重な選択だった
- 別個のChaCha8Randインスタンスが必要なコードは、
rand.ChaCha8を直接作れる
セキュリティ上のミスによる被害の縮小
- Goは、セキュリティ問題のあるよくあるミスを減らす、またはなくすことで、デフォルトで安全なコードを書けるようにすることを目指している
- Go 1.20で
math/randのReadがdeprecatedになると、一部の開発者はキー素材の生成のようにcrypto/randが必要な場所でmath/randを使っていたことに気づいた - Go 1.20では、このようなミスは深刻なセキュリティ問題だった
- キーがどこで使われたか
- キーがどのように露出したか
- 他の乱数出力が攻撃者にキー推測の手がかりを与えたかを調査しなければならなかった
- Go 1.22では、同じミスは依然としてミスだが、セキュリティ上の惨事につながる可能性は下がる
- それでも秘密値には
crypto/randを使うほうがよい- オペレーティングシステムのカーネルは乱数値をより適切に保護できる
- カーネルは生成器に新しいエントロピーを継続的に追加する
- カーネル実装はより多くのレビューを受けている
暗号らしく見えない事例
- ランダムUUIDの生成は、UUIDが秘密値ではないため
math/randで十分に見えることがある - しかし
math/randを現在時刻でシードすると、異なるコンピューターが同じ瞬間に実行されたとき同じ値を作る可能性がある- 現在時刻がミリ秒精度しか提供されないシステムでは、この可能性がさらに高まる
- Go 1.20のOSエントロピーに基づく自動シードがあっても、Go 1生成器のシードは63ビット整数にすぎない
- 起動時にUUIDを作るプログラムでは、可能な最初のUUIDが2⁶³個に制限される
- 約2³¹個のUUID後に衝突の可能性が生じる
- Go 1.22のChaCha8Randは256ビットのエントロピーでシードされる
- 可能な最初のUUIDは2²⁵⁶個
- 衝突を心配する必要はない
- フロントエンドサーバーがリクエストをバックエンドサーバーにランダムに割り当てるロードバランシングでも、予測不能な乱数が必要な場合がある
- 攻撃者が割り当てを観測し、予測可能なアルゴリズムを知っていれば、高コストなリクエストを特定のバックエンドに集中させられる
- Go 1生成器ではまれだが起こり得る問題
- Go 1.22では問題にならない
性能特性
- ChaCha8Randのセキュリティ上の利点には小さなコストがあるが、性能はGo 1生成器やPCGと同じ範囲にある
- 比較対象の演算は2つ
Uint64: 乱数ストリームから次のuint64を返すN(1000):[0, 1000)範囲の乱数を返す
- 64ビットx86チップで
GOARCH=386としてビルドし32ビットモードで実行すると、PCGの128ビット乗算のためPCGはChaCha8Randより遅い- ChaCha8Randは32ビットSIMD演算を使う
- 一部のシステムでは
Go 1: Uint64がPCG: Uint64より速いが、Go 1: N(1000)はPCG: N(1000)より遅い- Go 1の
N(1000)は範囲縮小に64ビット整数除算を2回使う - PCGとChaCha8の
N(1000)は、ほとんどの除算を避けるより高速なmath/rand/v2アルゴリズムを使う
- Go 1の
- 全体としてChaCha8RandはGo 1生成器より遅いが、2倍以上遅くなることはない
- 一般的なサーバーでは差は3nsを超えず、この差がボトルネックになるプログラムは非常に少ない
結論
- Go 1.22はコード変更なしにプログラムのセキュリティを高める
- 中核となる方法は、
crypto/randの代わりにmath/randを誤って使うよくある問題を減らすため、math/rand自体を強化したこと - npmの
keypairパッケージのように、Web Crypto APIがないときJavaScriptのMath.randomでRSA鍵ペア生成を試みる事例もある - システムセキュリティは、開発者がミスをしないという前提に依存できない
- Go 1.22のChaCha8Randは、「数学的」乱数にも暗号学的に強い疑似乱数生成器を使うアプローチが、他の生成器と競争可能な性能を出せることを示している
1件のコメント
Hacker News のコメント
記事に出ているように、rclone でまさにこのミスを犯しました。
crypto/randのReadを使っていたコードをリファクタリングしているうちに import が自動で変わり、おそらくmath/randを使うコードと混ざったことで、goimportsがmath/randに置き換えたようです。その結果、セキュアな乱数生成器ではなく、rclone が時刻でシードした決定的生成器を使うことになり、diff では気づけませんでした :-(
https://www.cvedetails.com/cve/CVE-2020-28924/
なので今回の変更には大いに賛成です。
goimportsはcrypto/randを優先するよう変更されたので、リファクタリング中に何が起きたのかは確かではありません。おそらく同じファイルに
math/rand専用 API を使うコードが入ったのかもしれません。https://go-review.googlesource.com/24847
いずれにせよ、こうした部分を整理できてよかったです。
math/randが使われていると誤解されて脆弱性を報告されたことがあります。実際にはそうではなく、複数のファイルを取り違えただけだったので大事には至りませんでしたが、この全体がどれほど紛らわしいかを示しています。text/templateとhtml/templateも似ています。振り返ってみると、こうしたパッケージ名のシャドーイングは悪いアイデアでした。"secure password generation golang"で検索すると、ほとんどすべての例がmath/randを使っているのも見ました。さらに悪いことに、どれもパスワードを作る直前に現在時刻でシードを初期化しています。
自分たちのコードで誰かが
math/randを使っているのを見つけたあと、どこからコピーしたのか気になって探しているうちに分かりました。goimportsはほぼ最初期からmath/rand.Readとcrypto/rand.Readを特別扱いしていました。ただし 2016年の https://github.com/golang/tools/commit/0835c735343e0d8e375f0... を見ると、
"rand.Read"が"math/rand"と解釈され得た時期について触れられています。おそらくその時期に当たっていたのかもしれません。
"PredictableRand"のような名前の API 呼び出しを用意するのは、それほど難しくなさそうです。先週も spacey が https://news.ycombinator.com/item?id=40237491 に投稿していましたが、その投稿は https://news.ycombinator.com/item?id=40224864 の重複として誤って埋もれてしまったようです。
2本の go.dev ブログ記事は同じシリーズの2本ですが、かなり異なります。今回の記事は効率的なセキュア乱数生成アルゴリズムに関するもので、以前の記事はGo API 設計についての記事です。
Russell Cox は一貫して優れた技術ブログ、提案書、そして成果物を出し続けています。
文章力と思考の明晰さを高めたいなら、Russell Cox から見るのが良い出発点です。
当時は Russ Cox が誰なのかも知りませんでしたが、その連載は本当に素晴らしいものでした。
正規表現の実装について無料で読める資料としては、おそらく最高品質で、その次に来るのはコンパイラ寄りの各種書籍ですが、無料ではなく、Web で簡単に検索できるものでもありません。
crypto/randが絶対に必要なところでmath/randを使ってしまったことがあります。その結果、dnscrypt-proxy2 の初期バージョンで静的キーが使われました。
原因は import を自動で追加する VSCode 拡張機能でした。セキュアな乱数が必要なすべてのソースファイルでは
crypto/randを自分で慎重に import していましたが、1つのファイルでそれを忘れており、すべてがコンパイルされ正常に動作し、その特定のファイルに拡張機能がひそかにmath/randの import を追加したことに気づきませんでした。それ以降は、誤った
randが自動 import されるのを避けるため、crypto/randをcryptorandという別名で import しています。ちなみに Zig も ChaCha8 ベースの乱数生成器を使っており、暗号演算ではユーザーが自分の生成器を供給することはできず、常に安全な生成器が使われます。テスト用には、一部の関数が明示的なシードを受け取ります。
制約のある環境向けに、標準ライブラリには Ascon 順列と Reverie 構成に基づく、より小さな生成器も含まれています。
2016年に
goimportsはmath/randよりcrypto/randを優先するよう変更されており(https://go-review.googlesource.com/24847)、その頃は Go 向けの VSCode サポートが登場する前でした。2020年代になっても、いくつものプログラミング言語のデフォルトの乱数実装が、なぜ LFSR や MT のような高速な乱数生成器を使っているのか、よく考えていた
人々が必要としているのが擬似乱数生成器なのか、暗号学的に安全な擬似乱数生成器なのか分かっていない、と保守的に見積もって、デフォルトを後者に変え、前者が必要な人だけ明示的に選ぶようにするほうがよさそうに見える
開発者が使用する乱数エンジンを明示的に選ばなければ、暗号学的に安全な生成器が使われる
今や難しいのは、人々を新 API に移行するよう説得すること。さらに言えば、グローバルな
Mt19937インスタンスを使うmt_rand()から、PHP 7.0 以降すでに提供されている CSPRNG ベースのrandom_int()へ移行することさえ簡単ではない[1] https://www.php.net/releases/8.2/en.php#random_extension
私のユースケースでは構成要素が数万個あり、プロファイリングしてみると、データ構造の初期化時間のかなりの部分が
crypto/randのRead()に費やされていて、私の MacBook ではシステムコールを実行していたライブラリにパッチを当てて
math/randのRead()を使うようにしたところ、性能が大きく向上したmath/randのほうが速いことに加えて、特に理由もなくシステムのエントロピープールを枯渇させてしまうのではないかとも心配だった。この場合、ID がランダムである必要がある唯一の理由は、データ構造をシリアライズ/デシリアライズしたあとで、後からさらに構成要素を追加することだけだったが、私はそうするつもりはなかったこのブログで述べられている変更の時期が、私の経験と正確にどう噛み合うのかは分からない。おそらく私が古いバージョンのライブラリを使っていて、今では
crypto/randが実質的にmath/randと区別がつかないほどなら、それはそれでよいと思う :-)状態サイズは依然として比較的大きい(64 バイト対 16 バイト)ものの、
mt19937や以前の Go の PRNG などよりははるかにましCSPRNG がはるかに遅いなら、縮小ラウンドの ChaCha 変種ではない通常の CSPRNG では概してそうであるように、デフォルトとしての魅力は下がる
シードが不要でも、人々が PRNG 側に押される小さな要因がもう一つある。CSPRNG API には、システムコール失敗やエントロピー不足に備えて常に処理しなければならないエラーが含まれる
crypto/randの読み取りは実際どれくらいの頻度で失敗するのだろうか。現代のシステムでエントロピーを枯渇させるには、どれほど多く読み取る必要があるのか。数十億リクエストでも失敗を見たことはないし、ddでも問題なく動くほとんどのユースケースでは、
Must/panicスタイルの API がデフォルトとして適しているのかもしれないとも思うちなみに Python の
secretsパッケージ(https://docs.python.org/3/library/secrets.html)を見たが、例外を投げる可能性についての言及はまったくない。実務では単に起こらないことなのだろうか?性能を少し失う代わりに、間違った乱数生成器を使って大惨事を起こさないという、はるかに強い保証を得るということ
ほとんどすべての言語で、開発者がいまだにこの鋭い角を気にしなければならないのは残念だ
知らない人のために補足すると、
gosecとその拡張であるgolangci-lintはmath/randの使用を警告してくれるhttps://github.com/securego/gosec/blob/d3b2359ae29fe344f4df5...
math/rand/v2でいちばん気に入っている点の一つは、会社でnolintディレクティブとその後の PR 議論なしに使えることセキュリティと新しい v2 オプションに関する推奨事項を、まだ解釈しているところ
ブログ記事は「秘密値には別のものが必要だ」といった文を書き、そのうえ暗号学的な乱数性、ChaCha8、システム乱数でシードされる方式まで詳しく扱っていて、とても「安全」だという印象を与える
ところがパッケージのドキュメントにはこうある
... but it should not be used for security-sensitive work ... This package's outputs might be easily predictable regardless of how it's seeded. For random numbers suitable for security-sensitive work, see the crypto/rand package.それなら、なぜブログ記事では
math/rand/v2を「秘密値」に使うかのような示唆をしているのだろうか?短く言えば、機微なものは今でもすべて
crypto/randを使うべきで、ここで説明されている改善は、誰かがmath/rand/v2を不適切に使ったときのセーフティネットという意味なのだろうか?math/rand/v2が最適というわけではないが、もはやcrypto/randを使うべき場所でうっかり使ったからといって、即座に致命的なセキュリティ欠陥になるわけではない記事にもこう書かれている
crypto/randを使うほうが今でもより良い。OS カーネルはさまざまな覗き見攻撃から乱数値を秘密に保つことにより長けており、カーネルは生成器に新しいエントロピーを継続的に追加し、より多くのレビューを受けてきたからだ。しかし、誤ってmath/randを使うことが、もはやセキュリティ上の大惨事ではない最悪のベンチマークでも、新しい戦略は安全でない乱数生成器よりおよそ半分ほど遅いだけで、ほとんどのベンチマークでは差はずっと小さかった
Go は標準ライブラリと、その上に作られるアプリのために 安全性と性能のバランスをうまく取っている。他のエコシステムも追随してくれるとよい
アプリケーションで高速かつ安全でない乱数が必要なら、内部生成器を自分で実装する必要がある
手の届きやすい場所に安全でない乱数を置いておくのは、片付けられる 足を撃つ道具である
人々に
"random"というプリミティブ機能が暗号学的に安全だと仮定するよう促すのは、悪い慣行を助長するものだmath/rand/v2を暗号学的に安全にすることで一つの問題は解決できるかもしれないが、今度はセキュリティを約束しているようには見えないものが「大丈夫」な状態になる一般に
math/rand関数には、暗号学的に安全だという慣例はない。それを変えて、悪いコードが偶然正しく動作するようにしてしまうと、私たちがこうした明白な間違いをしているなら、ほかにどんな間違いをしているのかが見えにくくなる可能性があるGo 1 の
math/randは、より正確には 加算型ラグ付きフィボナッチ生成器と呼ぶのが適切である最初の発表は Green、Smith、Klem の論文である
[1] https://doi.org/10.1145/320998.321006
https://www.leviathansecurity.com/blog/attacking-gos-lagged-... も知っており、ここでもラグ付きフィボナッチ生成器と呼んでいる
Rob Pike と私は数か月前、Go 1 生成器の元の C 版を書いた Don Mitchell とメールをやり取りし、彼がこのアルゴリズムをどう説明するか尋ねたところ、彼は「記憶では、Jim と私は Marsaglia の LFSR に似た生成器を実装した」と答えた
2つの説明、つまりラグ付きフィボナッチと LFSR 類似生成器は、異なる観点からどちらも正しいと思う。どちらでもよいが、記事では原作者の説明を使うことにした
小さな瑕疵を一つ挙げるなら、ここでは 統計的ランダム性と 擬似乱数生成器が混同されているように思う
Wikipedia の統計的ランダム性の定義は「数列に認識できるパターンや規則性がないとき、統計的にランダムであるという」だ
この定義は真性乱数生成器(TRNG)にも適用されるだろうか。適用されることを望むべきだ。少なくとも長期的、あるいは極限ではそうでなければならない。そうでなければ TRNG ではない
TRNG は長期的に「認識できるパターンや規則性のない数列」を生成しなければならない
したがって統計的ランダム性が PRNG を意味するわけではないが、TRNG にも適用できると言える
問題は、PRNG が限定された形の統計的ランダム性を持つかどうかを検証するための統計的ランダム性テストが数多くあることに由来するようだ
そのため PRNG を識別するには、「統計的ランダム性」よりも「擬似乱数生成器」という表現のほうが適切だったと思う。それでもごく小さな瑕疵である