この記事はCTF Advent Calender 2024の4日目の記事です。
昨日の記事はn01e0さんのSECCON 13 Quals - Jumpでした。作問ミスって怖いですね。カツカツスケジュールになってしまうのも作問ミスも他人事ではないので、襟を正してレビュー等をしようと思います。
この記事は元々はCryptoワザップと題して「400-bit程度の合成数までならノートPCで素因数分解できますよ〜、512-bit程度の合成数ならば5000円と数時間くらいで素因数分解できますよ〜」みたいなことを書こうとしていた記事でした。しかし、その一環で楕円曲線法について調べ出したらRabbit Holeに落ちてしまったので、楕円曲線法に限定した記事として公開することにしました。
本記事では、楕円曲線法のアルゴリズムを解説した後、楕円曲線法に適用される高速化のテクニックを紹介します。実装やソフトウェアの使用方法などには触れません。
記号・用語:
楕円曲線法の概要
まずは、簡単に楕円曲線法のアルゴリズムについて紹介します。楕円曲線法はp-1法やp+1法の原理を応用したアルゴリズムです。これらのアルゴリズムはが計算量に大きな影響を与えるという特性があります。これは、計算量がに大きく影響される二次ふるい法や数体ふるい法とは対照的です。そして、楕円曲線法はこれらのアルゴリズムのなかでも最も高い性能を持つアルゴリズムです。そのため、楕円曲線法はの大きさに依存する素因数分解アルゴリズムである数体ふるい法などを適用する前処理として用いられます。他にも、数体ふるい法を適用できないような非常に大きな数を素因数分解するのに用いられたりもします。2024年現在、楕円曲線法で見つかった最大の素因数は7^337+1の素因数である274-bitの数です(ECMNET)。
p-1法やp+1法、楕円曲線法のアルゴリズムについては、以下の記事に分かりやすくまとまっています。
本記事でも、簡単にですが楕円曲線法自体について説明をします。
楕円曲線法は、の非自明な約数をランダムに発見する乱択アルゴリズムです。発見できる約数の大きさや約数を発見できる確率は、アルゴリズムに与えるパラメータによって変動します。以降では、説明の簡略化のためにアルゴリズムが成功した場合は素因数を発見するものとします*1。
以下は、高速化が行われていない教科書的な楕円曲線法の大まかな実装です。
def get_M(n, bound): m = 1 # equivalent to: for i in range(1, bound): m = LCM(m, i) for p in primes(bound+1): # primes in [0, bound] k = floor(log(bound) / log(p)) # k such that p^{k} <= bound < p^{k+1} m *= p**k return m def ecm(n, bound): E, P0 = get_random_elliptic_curve_and_point(N) m = get_M(n, bound) try: Q = m * P0 except ModInvException as e: return gcd(e.value, n) return None
楕円曲線法では、まず上の楕円曲線(のようなもの*2)をランダムに生成します。アルゴリズムが素因数の発見に成功できるとして、と同じパラメータの上の楕円曲線を考えてみます*3。ここで、、つまりになるような点を見つけられたとします。このとき、元のではどうなるでしょうか?楕円曲線の性質より、 が成立します。よって、です。一方で、楕円曲線の加法公式にはの場合にが係数として登場します。ほとんどの場合なので、このは計算することができません。よって、を満たす値は上に存在しないことが分かりました。同時に、を計算する過程で素因数を発見できていることも分かるかと思います。
ここまでの議論で、でとなるようなとを発見できれば、素因数を発見することができると分かりました。これは、加算ではなくスカラー倍でも同様の結論になります*4。つまり、を満たすような正整数とを発見することでも、素因数を発見することができるということです。は、がの位数の倍数であるときかつそのときに限り成立します。ここで、うまいことをとれば、の一定割合はを割り切ってくれるのではないかというのが楕円曲線法のアイデアです。
の定め方は、あるパラメータを用いて、とします。こうすると、が-power-smoothな場合、つまり各素因数毎の積が全て以下の場合にアルゴリズムが成功すると言えます。
はにほぼ一様に分布します。このことを用いて、求めたいの大きさ毎にパラメータをうまく定めて素因数分解を行います。
計算量の解析
を-bitの整数の乗算にかかる計算量とします。すると、楕円曲線法の具体的な計算量はとなります。本項では、これを導出します。この計算量は、先程のecm
ルーチン一度の実行にかかる計算量ではなく、以下の素因数を見逃す確率がを下回るために必要な計算量であることに注意してください。
まず、先程のecm
ルーチンに必要な計算量を見積もります。bound
をとおくと、が成立します。そのため、を計算するために必要な計算量は素数定理を用いることで
と分かります。これはecm
ルーチンで最も支配的な部分であるため、これがルーチンの計算量となります。
次に、ecm
ルーチンを実行する回数との最適な値を見積もります。
まず、関数を以下のように定義します。
以下の整数が-smooth*5である確率は、でであることが知られています(Canfield-Erdős-Pomerance theorem)。これは整数をから選んだときにも、経験的に同じことが言えます。
をにして回ルーチンを実行することを考えます。これの成功確率は、であることからでに漸近します。なので、このパラメータでと実行回数で要求する成功確率を達成できています。最後に、全体の計算量を考えます。これは、となります。この式を最適化するはであるため、これを代入してを得ることができます。
楕円曲線法の高速化
教科書的な実装を再掲します:
def get_m(n, bound): m = 1 # equivalent to: for i in range(1, bound): m = LCM(m, i) for p in primes(bound+1): # primes in [0, bound] k = floor(log(bound) / log(p)) # k such that p^{k} <= bound < p^{k+1} m *= p**k return m def ecm(n, bound): E, P0 = get_random_elliptic_curve_and_point(n) m = get_m(n, bound) try: Q = m * P0 except ModInvException as e: return gcd(e.value, n) return None
この実装を高速化していきます。
高速化手法1: 射影座標とLucas Chain、基礎算術の高速化
まず、楕円曲線上の点を射影座標で持つことで、楕円曲線上の加減算で除算を行わないようにできます。射影座標を用いることは原著論文(Lenstra 1985)の時点から指摘されていました。射影座標については、以下で詳しく解説されています。
次に、を陽に計算しないようにし、その代わりにループごとにをk度計算することとします。楕円曲線上のスカラー倍は、既知の点の加算か二倍算を繰り返して行われます。射影座標は他の座標の持ち方と比べて加算に多くのステップ数がかかるという欠点がありますが、が分かっているならば高速に計算することができます(詳細は20 years of ECMの2節を参照)。そのため、加算は差が既知の点同士のみで行いたいです。このような条件下で、最小ステップ数でを計算するための加法連鎖はLucas Chainとして知られています。Lucas Chainの長さはとなるので、加法連鎖の条件に制限をつけることでのペナルティは大きくありません。以下は、を計算するためのLucas Chainの一例です:
- 2P := 2*P
- 3P := 2P + P (差はP)
- 5P := 2P + 3P (差はP)
- 7P := 2P + 5P (差は3P)
- 9P := 2P + 7P (差は5P)
- 16P := 7P + 9P (差は2P)
- 23P := 7P + 16P (差は9P)
このようなLucas Chainは、小さいについては前計算することができます。Lucas Chainが既知でない場合も、PRACアルゴリズムというアルゴリズムを用いることで最適な演算回数に近いchainを生成することができます*6。
さらに、これらのアルゴリズム演算の根底にある多倍長演算やmod p上での乗算の高速化も行えます。これには998244353で割ったあまりを求めることを日課にしている勢力にはお馴染みの、モンゴメリ乗算やBarrett Reductionといったテクニックを適用することができます。
これらの最適化を実装したコードを以下に示します:
def ecm(n, bound): E, P0 = get_random_elliptic_curve_and_point(n) Q = P0.to_proj_coords() for p in filter(is_prime, range(bound)): # primes in [0, bound) k = floor(log(bound) / log(p)) # k such that p^{k} <= bound < p^{k+1} p_chain = get_chain(p) for i in range(k): Q = compute_mul_using_chain(Q, p_chain) if gcd(Q.Z, n) != 1: return gcd(Q.Z, n) return None
高速化手法2-1: standard continuation
次に、アルゴリズム自体に改良を加えます。新しい処理を今までのアルゴリズムに続く形で実装し、今までのアルゴリズムをstage 1、加えたアルゴリズムをstage 2と呼ぶことにします。また、stage 1で用いていたboundをbound1(あるいはB1)と言い換え、stage 2で用いる新たなboundをbound2(あるいはB2)と呼ぶことにもします。
stage 2では、stage 1で計算された点であるに様々な係数を別々で掛け合わせます。具体的には、に含まれる素数すべてについて、を都度確かめます。こうすることで、「あと一歩」だったに対しても、。これには一見すると嬉しさが殆どないように思えるかもしれませんが、このステップがあることで倍ほどの高速化がもたらされます(Brent 1985)。また、以降に述べるテクニックによってこの計算はより高速に行えるようになります。
def ecm_stage1(n, E, B1): # not optimized return get_m(n, B1) * E.random_element() def ecm_stage2(n, E, Q, B1, B2): for p in primes(B1, B2+1): # primes in [B1, B2] Q2 = p * Q if gcd(Q2.Z, n) != 1: return gcd(Q2.Z, n) return None def ecm(n, B1, B2): assert B1 <= B2 E = get_random_elliptic_curve(n) Q = ecm_stage1(n, E, bound1) if gcd(Q.Z, n) != 1: return gcd(Q.Z, n) return ecm_stage2(n, E, Q, B1, B2)
高速化手法2-2: Montgomery continuation
まず、に含まれる素数をすべてとの和で表すことができるようなを考ます。これは色々なとり方がありますが、を用いてすることにします。これはbaby-step giant-step法の考え方と近いです。これを使えばあるを用いてと表せます。なので、をすべて前計算した後に、を毎度計算してあげればよいことになります。
ここで、はを表していることを思い出します。このときであるため、Affine座標(射影座標でない、(x, y)の2つ組でとる座標)での楕円曲線の性質を思い出せば(はアフィン座標でのxをとる記法とする)であることが分かります。まとめると、(についても同様)と表記することとすれば、ならばであるということです。
また、であるという性質を考えます。すると、について調べた場合、自動的にについても調べたことになると分かります。これを用いると、アルゴリズムでカバーする集合のサイズはそのままに、片方の集合のサイズを半分に減らすことができます。
この高速化では計算量は定数倍ほどしか変化していませんが、問題を扱いやすい形に整えることができました。ここまでを実装すると、以下のとおりとなります。
def ecm_stage2(n, E, Q, B1, B2): d = floor(sqrt(B2 - B1)) S = list([s for s in range((d+1)//2) if gcd(s, d) == 1]) T = list(range(B1//d*d, B2, d)) sxs = [(s*Q).to_affine().x for s in S] # x coords of sQ txs = [(t*Q).to_affine().x for t in T] # x coords of tQ for sx in sxs: for tx in txs: if gcd(sx - tx, n) != 1: return gcd(sx - tx, n) return None
高速化手法2-3: FFT continuation
以上のMontgomery Continuationは計算量がでしたが、これを高速化します。を解として持つ多項式を考えます。このとき、のうち、どれかはを満たします。すべてのについてを求めることは、多項式の多点評価を行うことでの計算量で行うことができます。も分割統治的に掛け合わせることの計算量で行えるため、この計算量で求めることができると分かりました。
また、を解として持つ多項式を考えた上での多項式GCDを取る手法もあります。原理は理解できていないですが、多項式の多項式GCDを取るとと互いに素でない値が定数項に出てくるようです。直感的にはで同じ解を持つことが影響してきそうです。
def polyeval_method(sxs, txs, n): F = prod([(sx - X) for sx in sxs]) for Ftx in multipoint_evaluation(F, txs): # equivalent to [F(tx) for tx in txs] p = gcd(prod(Ftxs), n) if p != 1: return p return 1 def polygcd_method(sxs, txs, n): F = prod([(sx - X) for sx in sxs]) # sxs G = prod([(tx - X) for tx in txs]) # sxs return gcd(polygcd(F, G)[0], n)
高速化手法3: Brent-Suyama’s extension
今までのstage 2では、を掛け合わせていました。Brent-Suyama’s extensionは、掛け合わせる数をある多項式を用いてとする手法です。は低次のやDickson polynomialとされることが多くあります。このようにをとったはは割り切れる上に他の素因数も含みます。よって、アルゴリズムの成功確率が少し上昇します。
高速化手法4: Edwards Curve
ここからの手法は、20 years of ECMにも記載がなく、gmp-ecmに実装されていない手法です。
今までの手法では、ランダムな楕円曲線をのパラメータをランダムに決めることでとってきていました。この選択する楕円曲線を、計算するのに都合のよいものに変更することはできないのでしょうか?ECM using Edwards curvesでは、選択する楕円曲線をEdwards Curveと呼ばれる楕円曲線にしました。Edwards Curveでは、射影座標での加算のような制約を受けずにある程度高速な加算が可能な楕円曲線です*7。
また、最新の研究では複数の楕円曲線の表現間を移行しながら演算することで、複数の表現のいいところ取りをするといった手法も提案されています。
eprint.iacr.org
おわりに
時間がなくて最後のほうが雑になってしまいましたが、時間があるときを見て追記していけたらよいかなと思っています。
明日はarkさんによる、about:blankテクニックと"disconnection"の解説です。お楽しみに〜
*2:楕円曲線は体に対して定義されるものなので、「ようなもの」と表記しています。「ようなもの」なので、加法に対して閉じていないため群をなしません。
*3:循環論法になっていますが、アルゴリズムの正当性を証明することを目指したものではないので目を瞑ってください。
*4:スカラー倍は加算の繰り返しで計算されることから直ちに分かるかと思います。
*5:これがpower-smoothについての議論でなくて良い理由はあまりよく分かっていません。どこを見てもsmoothnessで議論していて謎です
*6:私の考えでは、Lucas Chainが既知でない場合はに対してPRACアルゴリズムを適用し、それに沿って計算するほうがよいように思います。しかし、gmp-ecmの実装(ecm.c#L1207-1240)ではどうやらそうはなっていません。なぜなっていないのかの理由は謎なので、分かったら教えてほしいです。