ML-DSAは2024/8/13にFIPS 204としてNIST標準化されたPQCデジタル署名アルゴリズムである。FIPS文書はよくまとめられており、読んだだけでなんとなく実装できる感じになっているが「なんでそうなっているのか」みたいな深いところまでは書いてない。一部の証明などは標準化前の文書CRYSTALS-DILITHIUMを参照したほうがいい。
ML-DSAは構成としては(モジュール)格子版のシュノア署名である。シュノア署名は3-wayのシグマプロトコルをFiat-Shamir変換で非対話にしたものであるとみなせるため、シュノア署名の説明およびML-DSAの説明でもこの3-way(コミット・チャレンジ・レスポンス)の用語が使われる。
というわけでML-DSAを格子版のシュノア署名として理解すればある程度簡潔なのだが、効率化するために様々な圧縮テクニックやそれを保証するためのルーチン(Fiat-Shamir with Abort)が使われているためにごちゃついたアルゴリズムになっている。
シュノア署名
パブリックパラメータ
- 位数qの群G上の生成元g
- ハッシュ関数H: \{0,1\}^* \rightarrow Z_q
鍵生成
- 乱数s \in Z_qを秘密鍵とする。
-
p=g^{s}を公開鍵とする。
署名生成
- 秘密の乱数r \in Z_qを生成
-
e=H(m;g^r)をチャレンジとする。(g^rはコミットに対応する)
-
y=r-seをレスポンスとする。
-
\sigma=(y,e)が署名
検証
-
e'=H(m;g^yp^e)とし、e=e'なら検証成功。
- Correctness: 署名が正当な時g^yp^e=g^{r-se}g^{se}=g^rなので検証に成功する。
- Soundness: よくわからんけどランダムオラクルで示せる
ML-DSA(simplified)
シュノア署名との対応をわかりやすくするため、簡潔にしたバージョンをまず導入する。
鍵生成
-
A \in R_q^{k \times \ell}および非常に短い多項式s_1 \in R_q^\ell, s_2 \in R_q^kを一様ランダムに生成
-
(A, t=As_1+s_2)を公開鍵、(s_1,s_2)を秘密鍵とする
署名生成
-
y \in R_q^\ellを一様ランダムに生成
-
w=Ayをコミットとする。
- メッセージmに対してc=H(m;w)をチャレンジとする。
-
z=y+cs_1をレスポンスとする。
-
\sigma=(z,c)が署名
検証
-
w'=Az-ctを計算し、c'=H(m;w')がcと等しければ検証成功
- Correctness: 真正な署名ならAz-ct=A(y+cs_1)-c(As_1+s_2)=Ay+cs_2となるがs_2は小さなベクトルなのでうまく取り除くことができ、w=w'となる。
このようなSimplified ML-DSAは厳密にはcorrectnessを示せないが、Schnorr署名と対応させるとなんとなく各変数の役割がわかるだろう。実際のML-DSAはcorrectnessを満たすためもしくは署名長・鍵長を短くするために値の下位ビットを捨てる処理が入っており、かつNTT表現が仕様に入っているので複雑になる。
ML-DSA
続いてFIPS 204に基づくML-DSAアルゴリズムを紹介する。
ちなみに鍵・署名サイズは以下である。PQCの中では頑張っている方だが、ECDSAが32バイト〜、RSAでさえ数百バイトであることを考えると確かにでかい。

鍵生成
以下のような32バイト乱数\xiを入力として秘密鍵と公開鍵を出力する関数である。処理的にはLWEインスタンスを生成するだけなんだけれど、公開鍵tを圧縮して上位ビットt_1だけ使うようになっている。

ExpandA
公開シード\rhoから一様ランダムな多項式行列A \in R_q^{k\times \ell}を生成する。ML-KEMの時と同じようにSHAKEから得たビット列から棄却サンプルしてZ_qの値を作って多項式の係数に入れていくだけ。法qが23ビットなので3バイトの一様乱数が係数内に入る確率は1/2くらい。
ExpandS
要素数の異なる二つの秘密多項式ベクトルs_1 \in R_q^\ell, s_2 \in R_q^kを一様ランダムにサンプルする関数である。これもExpandAと同じくSHAKEから棄却サンプルするだけだが、多項式係数は[-\eta,\eta] (\eta \in {2,4})の小さな範囲からサンプルする。一様乱数なのでML-KEMのCBDよりはシンプル?1バイトから2係数ずつ棄却サンプリングする。

NTT and NTT^{-1}
数論変換だが、ML-KEMのところで説明したので省略する。ML-KEMのNTTはいわゆるincomplete NTTと呼ばれるちょっと特殊なやつだったがML-DSAはcomplete NTTなのでより理解しやすいかと思う。
Power2Round
23ビットのZ_qを上位10ビットと下位23ビットに分ける関数である。下位桁の値を[-4095,4096]のレンジにするため、単純にビット分解するだけではない。

pkEncode
バイト列\rhoと、多項式列t_1をバイト列pkにエンコードする。t_1をバイト列に直すためにSimpleBitPack関数を使う。単に係数を詰められるだけバイト列に詰め込んだだけである。
図にすると複雑っぽいが、実はzの係数の並びを次数の大きいほうからにすると、あとはリトルエンディアンでバイト列に変換するだけである。


skEncode
pkEncodeとほぼ同じ。SimpleBitPackではなくBitPack(負の値も許す)関数を使う。BitPackは[-a,b]範囲の値をバイト列にする関数。負の値を正にしてからパックするため、bから値を引く。例えば\eta=2の場合BitPackの範囲は[-\eta,\eta]=\{-2,-1,0,1,2\}となる。これをbから引いて\{4,3,2,1,0\}がパックする値になる。5個値があるので1係数は3ビットにパックされる。pkEncodeと同じく255次の係数から並べた整数にしてリトルエンディアンバイト列にすると楽。
t_0のパックは複雑っぽいけど、これも係数の範囲を正に戻してビット数(13ビット)分ずつバイト列に詰め込むだけである。13bit$\times256係数でt_0$は416バイトになる。

署名生成
ctxは通常empty-stringだが、アプリケーションによっては使う。署名生成はheadged (randomized) veriantとdeterministic variantが存在し、deterministicの場合は5行目の乱数生成が32バイトの0で固定される。hedged variantはサイドチャネル攻撃を緩和するため、SCAを考慮する環境ではdeterministic variantは使うべきではないとしている。
internal関数内のハッシュを先にやっておくHashML-DSA variantもある。


簡略版と異なるのは、コミットwの上位ビットw_1のみを使うようになっている点である。またw_1の情報だけだと正確な署名検証ができないため、wに小さな値を足した時に繰り上がるかどうか?を示すヒントビットhが署名に追加される。
さらにz=y+cs_1はs_1のバイアスがある(単純にいうと定数0中心乱数yとcの平均が0になるのでz/cの平均値がs_1になる)ので、これを消すためにzのノルム判定ロジックが追加されている。
skDecode
skEncodeの逆をやる。ただし入力となるskが不正な値である可能性があることに注意。
Encode時のBitPackなどである範囲の値をエンコードしたが、改ざんがあった場合デコード結果はこの範囲を外れる可能性がある。これに対しては、「信頼できるソースからの入力に対してのみ実行されるべきである」とのこと。特に対策が入っているわけではない。
ExpandMask
これも単に一様な多項式ベクトルを生成するだけだが、ExpandAやExpandSと係数のレンジが異なり、[-\gamma_1,\gamma_1]からサンプルする。\gamma_1 \in \{2^{17}, 2^{19}\}なのでZ_q全体に一様分布するわけではない。また2ベキに設定されているので棄却サンプルが不要である。
SampleInBall
\{-1,0,1\}係数の多項式で、非ゼロ要素が\tau個になるようなチャレンジcをサンプリングする。アルゴリズムはFisher-Yatesシャッフルに基づく。Fisher-Yatesはランダム置換を得るためのアルゴリズムであり、SampleInBallではまずすべての係数が0の多項式cを作り、次数が高いほうの係数\tau個に1または-1を格納する。このときの符号は64ビットの乱数h(hは\tauビットで足りるはずだが\tauの上限値64になっている)から決定する。この\tau個の非ゼロ係数をFisher-Yatesでランダム置換するイメージである。
なおcのドメインB_\tauの大きさ|B_\tau|=_{256}C_\tau \cdot 2^\tauは十分なエントロピーとなるように\tauは設定されている(192,225,257)。
HighBits, LowBits, and Decompose
DecomposeはPower2Roundのように値を二つに分割する関数であり、HighBits/LowBitsはその上位/下位桁のみを出力するものである。処理的にはPower2Roundとほとんど同じであるが、r^+-r_0=q-1のケースで特殊な分岐を入れているのが大きな違いである。

Power2Roundはr=r_1 \cdot 2^{13} + r_0になるようにrを分解するが、このとき下図のようにr_1=1023になる範囲が狭くなることが問題になる。ML-DSAはr_1部分だけを保持して、下位ビットの誤差は1ビットのヒントで補正するという構造になっている。下図のようにr_1 \ne 0 or 1023の場合は隣の値との距離が離れているため、小さなノイズ\le 8192が加わった場合でも1ビットのヒントで補正できる。しかしながらr_1 = 0 or 1023は隣の値との距離が近いため小さなノイズ\le 8192の補正に2ビット必要になってしまう。
これを避けるためにやや力技であるが、Decomposeではr_1=1023のケースを排除し、r_1=0のケースを拡大することで小さなノイズ\le 8192の補正を1ビットでやってのけるのである。

MakeHint and UseHint
前述したようにML-DSAはDecomposeにより分解した値の上位ビットのみを使って計算を行う。当然この時下位ビットの情報がなければ正確な計算ができないわけだが、下位ビットによる影響を1ビットのヒントに押し込めることでcorrectnessを達成している。
MakeHintはある値rの上位ビットと、それに小さな値zを足した時の上位ビットに差があるかどうかを1ビットのヒントとして記録するものである。このときzの範囲が制限できれば、decomposeの図で説明したように上位ビットへの影響を高々\pm 1にできる。したがってヒントは実質的に上位ビットへの繰り上がり(キャリー)である。

UseHintは、このヒントを使ってHighBitsを補正する関数である。

Validity check
Fiat-Shamir with AbortのAbort部分のチェックであり、レスポンスzが所定のレンジにないときに処理をabortし、署名生成を乱数生成からやり直す。
23行目でzとr_0の二つをチェックしているが前者はsecurityのために必要であり後者はsecurityおよびcorrectnessのために必要である。
まずzのノルムが\gamma_1-\beta以上かどうかをチェックしているが、これはzによってs_1の範囲が絞り込まれないようにしている。ここで\gamma_1はyの係数の最大値であり\betaはcs_1の係数の最大値である。極端な話だともしzに\gamma_1+\betaとなる係数があったとすればcs_1=\betaが確定するためs_1が特定可能である。
r_0のチェックはcorrectnessの意味合いが強いがこれは後述する。
securityの観点に関しては、攻撃者がw'=Az-ct=Ay-cs_2を計算できることを前提にしていると思われる。かつもし攻撃者がwを計算できたとするとw-w'=cs_2であるから秘密情報が漏れそうなのはなんとなくわかる。実際wは秘密であるが、その上位ビットw_1は検証で明らかになるためwの範囲が絞られることになり従ってcs_2についての不等式が得られる形になる。
FIPS 204ではt_0は秘密鍵の一部とされたため攻撃者は入手できないが、ML-DSAのsecurity proofではt自体がpublicとして扱われているようである。
CRYPTO2025において大量の署名を集めるとt_0が復元できることも示されているようである。
署名検証

簡略版ではw'=Az-ctを計算し、c'=H(m;w')がcと等しければ(つまりw'=wなら)検証成功としていたが、Az-ct=A(y+cs_1)-c(As_1+s_2)=Ay+cs_2であるためw' \ne wだった。
cs_2は小さな多項式ベクトルであるためw \approx w'は成り立っており、気持ち的には上位ビットだけで比較すればよいということでw_1=Highbits(w)をチャレンジ生成に使っている。合わせて公開鍵も上位のt_1だけ使うことで鍵長を圧縮している。
w'_{Approx}
は署名生成時のw=Ayの近似である。
w'_{Approx}=Az-ct_1\cdot 2^d = A(y+cs_1) -c(t-t_0)= Ay+Acs_1 -c(As_1+s_2 - t_0) = Ay - cs_2 +ct_0
なので、
wから
-cs_2 +ct_0分の差があるわけだが、
-cs_2 +ct_0が十分小さいので大体あっているということでApproxになっている。とはいえ署名検証を通過するには近似値ではだめで、署名生成と検証時でチャレンジおよびコミットを一致させる必要がある。そこでML-DSAでは
wの上位ビット
w_1=HighBits(w)をチャレンジ生成のためのコミットとして用いている。
検証側では
w_{Approx}'の上位ビットを使うわけだが、
HighBits(w_{Approx})'と
HighBits(w)のずれが高々
\pm1におさまるように
-cs_2 +ct_0の範囲を決定している。このずれを補正するのに使うのがヒント
hであり、
Under construction
Discussion