初学者向け!RSA 暗号の基礎とシミュレーションの実装

RSA 暗号の数学的理論や構成要素、アルゴリズムをまとめます。

また、C/C++ 言語により、秘密通信の簡易的なシミュレーションを実装します。

予備知識

暗号とは

一般的に、暗号とは情報を当事者間以外では理解できないように変換すること、もしくは変換後の記号列を指します。

コンピュータにおいて、文字列をはじめとする通信情報は、全て数値データとして扱われます。

数学的理論に基づく処理により、元のデータを第三者による理解が困難なデータに変換し、変換後のデータは、特定の関係者のみが元のデータに変換することができます。

専門用語

暗号技術の分野において、頻繁に利用される専門用語は下記の通りです。

用語 意味
平文 元の情報
暗号文 第三者に理解できないように変換された情報
暗号化 平文から暗号文を生成すること
復号化 暗号文から平文を生成すること
暗復号化に必要なパラメータ
盗聴 第三者が暗号文を入手すること
解読 第三者が暗号文を復号化すること

暗号方式の比較

暗号通信において用いられる方式は、共通鍵暗号と公開鍵暗号と、大きく分けて 2 つあります。

共通鍵暗号

通信の当事者間で秘密鍵を持ち、暗号化と復号化とで同じ鍵を利用する方式です。

秘密鍵は重要な機密情報であり、厳重に扱う必要があります。

一般的に、暗復号化の処理速度は速いです。

ただし、通信の接続先ごとに異なる共通鍵を生成する必要があり、管理が煩雑になるうえ、送信の途中で第三者に盗聴され、解読されるリスクも高いです。

公開鍵暗号

暗号化と復号化とで、それぞれ別の鍵を利用する方式です。

基本的な用途である暗号通信においては、受信者が暗号化用の公開鍵と復号化用の秘密鍵とを用意し、公開鍵を送信者に共有したうえで、秘密鍵は厳重に秘匿します。

送信者は公開鍵により平文を暗号化して送信し、受信者は秘密鍵で暗号文を復号します。

一般的に、鍵ペアを 1 つ作ればよく、鍵の管理コストを抑えられるうえ、第三者に秘密鍵が盗聴されるリスクも低いです。

ただし、暗復号化にかかる処理速度は遅いです。

特徴比較と代表的なアルゴリズム

各暗号方式における特徴比較と代表的なアルゴリズムは下記の通りです。

共通鍵暗号 公開鍵暗号
鍵の生成者 基本的に送信者 受信者 / 送信者
鍵の数 接続先数(異なる秘密鍵) 1 ペア(公開鍵 + 秘密鍵)
鍵の保管 厳重 秘密鍵のみ厳重
安全性 比較的低い 高い
処理速度 速い 遅い
秘密通信
電子署名 不可
代表的なアルゴリズム AES RSA / 楕円曲線暗号

RSA 暗号は、公開鍵暗号方式であり、最も代表的なアルゴリズムの 1 つです。

RSA 暗号の基礎

RSA 暗号は、考案者である Rivest、Shamir、Adleman の頭文字を取って名付けられました。

彼らの所属していたマサチューセッツ工科大学が特許を取得し、その後設立された RSA セキュリティ社が独占ライセンスを所有していました。

しかし、現在は特許が切れ、誰でも自由に利用することができます。

理論

通常、平文は数バイト連続した 1 つの整数とみなされ、事前に決定しておいた整数により分割され、ブロック化されます。

ここで、ある平文ブロックを \(M_i\) とします。

暗号化

平文ブロック \(M_i\) を、暗号文ブロック \(C_i\) に変換します。

この変換では、\(M_i\) を \(e\) 乗して \(n\) で割った剰余を \(C_i\) とします。

$$ C_i = {M_i^e} \bmod n $$

これを、全ての平文ブロックに対して繰り返し行います。

復号化

暗号文ブロック \(C_i\) を、平文ブロック \(M_i\) に変換します。

この変換では、\(C_i\) を \(d\) 乗して \(n\) で割った剰余を \(M_i\) とします。

$$ M_i = {C_i^d} \bmod n$$

これを、全ての暗号文ブロックに対して繰り返し行います。

鍵の作成

暗復号化処理において利用したパラメータの \(n\) と \(e\) が公開鍵であり、\(d\) が秘密鍵となります。

鍵は、下記フローに従い事前に作成します。

  1. 異なる2つ素数 \(p\) と \(q\) を用意
  2. \(p\) と \(q\) の積 \( n\) を用意
  3. \(p \ – \ 1\) と \(q \ – \ 1\) の最小公倍数 \(m\) を用意
  4. \(m\) との最大公約数が \(1\) である任意の整数 \(e\) を用意
  5. \(e \cdot d \bmod m = 1 \) を満たす整数 \(d\) を用意

\(p\) と \(q\) は、値の異なる大きな素数です。

\(p\) と \(q\) の積 \( n \) は、法として利用されるため、法鍵とも呼ばれます。

\(p \ – \ 1\) と \(q \ – \ 1\) の最小公倍数 \(m\) と、整数 \(e\) との最大公約数が \(1\) であることから、\(m\) と \(e\) とは、互いに素であることが分かります。

最後の条件式は、\(m\) を法としたとき、\(e\) と \(d\) の積が \(1\) になる必要があることを意味しています。

法とは、特定の数学的体系を生み出す整数値です。

整数 \(n\) の法が与えられたとき、整数 \(x\) を \(n\) で除した剰余 \(r\) が求められます。

例えば、\( n = 4 \) とすると、下表になります。

\(x\) 0 1 2 3 4 5 6 7 \(\cdots\)
\(r\) 0 1 2 3 0 1 2 3 \(\cdots\)

周期性が生まれ、限られた範囲内の数値を用いた四則演算が可能になります。

小さな体系内では、べき乗計算による莫大な中間結果を避けることができ、暗号技術においては都合の良い閉じた世界になります。

解読困難性

RSA 暗号の安全性は、素因数分解が難しく、膨大な時間が掛かることにより担保されています。

解読には、秘密鍵 \(d\) が必要です。

秘密鍵を取得するには、法鍵 \(n\) を素因数分解し、\(p\) と \(q\) を求める必要がありますが、簡単ではありません。

適当な素数で \(n\) により割り切れるか否かを確認するなど、ブルートフォースアタックとほぼ同様の手法を試す他なく、\(p\) と \(q\) が十分に大きい場合、有効時間内に見つけることは難しいです。

用途

RSA 暗号の用途は、秘密通信と電子署名と、大きく分けて 2 つあります。

秘密通信

RSA 暗号の最も基本的な用途であり、暗号通信が目的です。

受信者が鍵を作成し、暗号化鍵 \(e\) と共通鍵 \(n\) を公開したうえで、復号化鍵 \(d\) を秘匿します。

鍵の作成(受信者)

「鍵の作成」に従い、鍵を準備します。

  • 暗号化鍵 \(e\) → 公開
  • 共通鍵 \(n\) → 公開
  • 復号化鍵 \(d\) → 秘匿
鍵の公開と秘匿(受信者)

暗号化鍵 \(e\) と共通鍵 \(n\) を公開します。

公開鍵は、基本的に盗聴されても問題はないため、メールや Web ページ、鍵センタなど、オンラインにて共有します。

つまり、「私に秘密のメッセージを送る場合は、この公開鍵により暗号化して送信してください」ということを意味します。

一方で、秘密鍵である復号化鍵 \(d\) は、盗聴されないよう厳重に管理します。

メッセージの暗号化(送信者)

受信者が提供する \((e,n)\) を用いて、変換式「\(C = {M^e} \bmod n\)」によりメッセージ(平文)を暗号化します。

その後、暗号文を送信します。

暗号文の復号化(受信者)

復号化鍵 \(d\) と共通鍵 \(n\) を用いて、変換式「\(M = {C^d} \bmod n\)」により暗号文を復号化します。

これにより、受信者は送信者のメッセージ(平文)を確認することができます。

以上が、秘密通信の基本的なフローになります。

STEP.3 における送信時、暗号文が盗聴されたとしても、復号化鍵 \(d\) は受信者のみが知る情報であるため、盗聴者による解読は難しいです。

たとえ、盗聴者が復号化鍵 \(d\) を推測して \(d^{\prime}\) を作り、暗号文を復号化したとしても、完全な平文を取得する可能性は低いです。

電子署名

RSA 暗号の応用的な用途であり、認証が目的です。

電子署名とは、電子文書における作成者の正当性を証明する技術であり、なりすましや情報の改ざんを防止する仕組みです。

紙文書において、本人であることを示すサインや印鑑に相当します。

送信者が鍵を作成し、復号化鍵 \(d\) と共通鍵 \(n\) を公開したうえで、暗号化鍵 \(e\) を秘匿します。

鍵の作成(送信者)

「鍵の作成」に従い、鍵を準備します。

  • 暗号化鍵 \(e\) → 秘匿
  • 共通鍵 \(n\) → 公開
  • 復号化鍵 \(d\) → 公開
鍵の公開と秘匿(送信者)

復号化鍵 \(d\) と共通鍵 \(n\) を、メールや Web ページ、鍵センタなど、オンラインにて公開します。

つまり、「私が作成した電子文書(暗号化済)を参照する場合は、この鍵で復号化してください」ということを意味します。

一方で、暗号化鍵 \(e\) は、盗聴されないよう厳重に管理します。

電子文書の暗号化(送信者)

自ら用意した \((e,n)\) を用いて、変換式「\(C = {M^e} \bmod n\)」により、電子文書(平文)を暗号化します。

その後、暗号文を何らかのプラットフォームにて公表します。

暗号文の復号化(受信者)

送信者が公開している復号化鍵 \(d\) と共通鍵 \(n\) を用いて、変換式「\(M = {C^d} \bmod n\)」により暗号文を復号化します。

これにより、受信者は送信者の電子文書(平文)を確認することができます。

送信者の復号化鍵により復号化が成功することは、その電子文書の作成者が送信者本人であることを示します。

以上が、電子署名の基本的なフローになります。

悪意を持った第三者が、上記フローの送信者になりすまし、偽造文書を作成する場合を考えます。

第三者は、公開されている復号化鍵 \(d\) と共通鍵 \(n\)  から \(e^{\prime}\) を推測し、偽造文書を暗号化して公表します。

しかし、偽造文書の暗号文は、\((d,n)\) により正しく復号化されず、意味不明な文字列のメッセージ等が生成されます。

そのため、受信者は、電子文書が偽造であることや、第三者が嘘の情報を流そうとしたと判断することができます。

RSA 暗号のコンポーネント

RSA 暗号を実装するうえで必要な数学的モジュールをまとめます。

下表は、鍵の作成や暗復号化に関わる構成要素の一覧です。

要素 パラメータ 条件または変換式
素数(1) \(p\) \(q\) と異なる大きな素数
素数(2) \(q\) \(p\) と異なる大きな素数
共通鍵
※ 秘密通信の公開鍵(1)
\(n\) \(n = p \cdot q\)
最小公倍数 \(m\) \(m = lcm(p \ – \ 1,q \ – \ 1)\)
暗号化鍵
※ 秘密通信の公開鍵(2)
\(e\) \(\gcd(e,m) = 1\)
復号化鍵
※ 秘密通信の秘密鍵
\(d\) \({e \cdot d} \bmod m = 1\)
暗号化 \(C_i = {M_i^e} \bmod n\)
復号化 \(M_i = {C_i^d} \bmod n\)

各構成要素を取得する過程や変換で必要なモジュールは下記 5 つです。

  • 冪剰余計算
  • 最大公約数の算出
  • 最小公倍数の算出
  • 素数判定
  • 逆数の算出

冪剰余計算

\(b^e \bmod m\) を求めます。

ここでは、最も基本的なバイナリ法を用います。

2 進数で表現された指数をフラグとし、積と法を取りながら反復的に計算していく方法です。

例題として、\(8^{23} \bmod 29\) を計算します。

\(23\) を 2 進数表記すると、\(10111\) です。

これより、

$$23 = 2^4 \cdot 1 + 2^3 \cdot 0 + 2^2 \cdot 1 + 2^1 \cdot 1 + 2^0 \cdot 1 = 16 + 4 + 2 + 1$$

と表せます。

また、\(8^{23} = 8^1 \cdot 8^2 \cdot 8^4 \cdot 8^{16}\) です。

このとき、下記が成り立ちます。

部分計算式 結果 フラグ
\(8^1 \bmod 29\) \(8\)
\(8^2 \bmod 29\) \(6\)
\(8^4 \bmod 29 = 6 \cdot 6 \bmod 29\) \(7\)
\(8^8 \bmod 29 = 7 \cdot 7 \bmod 29\) \(20\) ×
\(8^{16} \bmod 29 = 20 \cdot 20 \bmod 29\) \(23\)

このうち、フラグが 〇 の結果を用いて、

\(8^1 \cdot 8^2 \bmod 29 = 8 \cdot 6 \bmod 29 = 19\)

\(8^1 \cdot 8^2 \cdot 8^4 \bmod 29 = 19 \cdot 7 \bmod 29 = 17\)

\(8^1 \cdot 8^2 \cdot 8^4 \cdot 8^{16} \bmod 29 = 17 \cdot 23 \bmod 29 = 14\)

となります。

このように、中間結果があまり大きくならないうえ、乗算回数を抑えながら、効率良く冪剰余計算を行うことができます。

上記の「計算例」と同様のプロセスをプログラムで再現します。

#include <iostream>
using namespace std;

/*
    冪剰余計算 : b^e mod m
*/

long long powmod( long long b, long long e, long long m ) {

    long long result = 1; // 結果
    
    // 0 乗の場合、結果は 1
    if( e == 0 ) return result;
    
    // 指数が 0 になるまで法を取りながら積を計算
    while( e > 0 ) {
    
        // 奇数の場合: 最下位ビットが 1 = 求める解の因数として利用される
        if( e & 1 ) {
            e--; // 偶数にする
            result = result * b % m; // 解の更新
        }
        // 偶数の場合: 最下位ビットが 0 = 求める解の因数として利用されない
        else {
            e >>= 1; // 右に 1 ビットシフト
            b = b * b % m; // 部分計算
        }
    }
    
    return result;
}

/*
    冪剰余計算の結果を標準出力
*/

int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 4 ) {
        cout << "Please input (b, e, m)" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int b = atoi( argv[ 1 ] );
    int e = atoi( argv[ 2 ] );
    int m = atoi( argv[ 3 ] );

    // 冪剰余計算
    long long result = powmod( b, e, m );

    // 標準出力
    cout << b << " ^ " << e << " mod " << m << " = " << result << endl;

    return 0;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

参考までに、プログラムにおける e(2 進数)と b(10 進数)、result(10 進数)の変化は下表の通りです。

e(2 進数) b(10 進数) result(10 進数)
10111 8 1
10110 8 8
1011 6 8
1010 6 19
101 7 19
100 7 17
10 20 17
1 23 17
0 23 14

最大公約数の算出

与えられた自然数 \((a,b)\) における最大公約数を求めます。

「ユーグリッドの互除法」を利用します。

$$ \gcd(a,b) = \gcd(b,a \bmod b) $$
#include <iostream>
using namespace std;

/*
    最大公約数の算出
*/

int gcd( int a, int b ) {
    
    // gcd(a,b) = gcd(b,a mod b) の法則を利用
    while( b > 0 ) {
        int t = a % b;
        a = b;
        b = t;
    }
    return a;
}

/*
    マンドライン引数に与えられた自然数の最大公約数を標準出力
*/

int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 3 ) {
        cout << "Please input (a,b)" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int a = atoi( argv[ 1 ] );
    int b = atoi( argv[ 2 ] );

    // 最大公約数の取得
    int result = gcd( a, b );

    // 標準出力
    cout << "GCD(" << a << "," << b << ") = " << result << endl;

    return 0;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

最小公倍数の算出

与えられた自然数 \((a,b)\) における最小公倍数を求めます。

下記の法則を利用します。

$$ a \cdot b = \gcd(a,b) \cdot lcm(a,b) $$
#include <iostream>
using namespace std;

int gcd( int a, int b ); // プロトタイプ宣言

/*
    最小公倍数の算出
*/

int lcm( int a, int b ) {
    
    // a * b = gcd(a,b) * lcm(a,b) の法則を利用
    return ( a * b ) / gcd( a, b );
}

/*
    コマンドライン引数に与えられた自然数の最小公倍数を標準出力
*/

int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 3 ) {
        cout << "Please input (a,b)" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int a = atoi( argv[ 1 ] );
    int b = atoi( argv[ 2 ] );

    // 最大公約数の取得
    int result = lcm( a, b );

    // 標準出力
    cout << "LCM(" << a << "," << b << ") = " << result << endl;

    return 0;
}

/*
    最大公約数の算出
*/

int gcd( int a, int b ) {
    
    // gcd(a,b) = gcd(b,a mod b) の法則を利用
    while( b > 0 ) {
        int t = a % b;
        a = b;
        b = t;
    }

    return a;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

素数判定

素数判定を行います。

ここでは、基本的なアルゴリズムを、参考も含め、4 つまとめます。

単純法

与えられた自然数 \(n\) の素数判定を行います。

ある自然数 \(k\) が合成数であるとき、\(\sqrt{k}\) より小さい素因数を必ず持ちます。

この性質を利用し、割り切れる数があるか否かを順次チェックします。

#include <iostream>
using namespace std;

/*
    単純法による素数判定
*/

// 引数に与えられた自然数の素数判定結果をリターン
bool simple( int n ) {
    
    if( n < 2 ) return false; // 2 未満は排除
    if( n == 2 ) return true; // 2 は素数
    if( n % 2 == 0 ) return false; // 2 以外の偶数は合成数(除算による偶数判定)
    
    // 3 以上の奇数でチェック
    for( int k = 3; k * k <= n; k += 2 ) { 
        if( n % k == 0 ) {
            return false; // 割り切れる場合は合成数
        }
    }

    return true; // 素数
}

/*
    コマンドライン引数に与えられた自然数以下の素数を標準出力
*/

int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 2 ) {
        cout << "Please input N" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int N = atoi( argv[ 1 ] );

    // 標準出力
    for( int n = 0; n <= N; n++ ) {
        if( simple( n ) ) {
            cout << n << " ";
        }
    }
    cout << endl;

    return 0;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

エラトステネスの篩

与えられた自然数 \(N\) 以下の自然数における素数判定を行います。

単純法をベースに合成数のチェックを効率化した判定方法です。

アルゴリズムは下記の通りです。

  1. \(n\) までの整数を整列
  2. \(k\) が残っている場合、\(k\) 以外の \(k\) の倍数を除外
  3. \(k\) が \(\sqrt{n}\) を超えるまで 2. を反復
  4. 処理終了(最後まで残った数が素数)
#include <iostream>
#include <vector>
using namespace std;

/*
    エラトステネスの篩による素数判定
*/

// 引数に与えられた自然数以下の素数判定結果の配列をリターン
vector<bool> eratosthenes( int n ) {
    
    // 配列のインデックスに自然数値が対応
    vector<bool> result( n + 1, true );
    
    result[ 0 ] = false; // 0 は非素数
    result[ 1 ] = false; // 1 は非素数
    for ( int k = 2; k <= n; k++ ) {
        
        // 合成数であることが判明している場合はスキップ
        if ( !result[ k ] ) continue;

        // k は素数であるが、k の倍数は合成数
        for ( int mk = 2 * k; mk <= n; mk += k ) {
            result[ mk ] = false;
        }
    }

    return result;
}

/*
    コマンドライン引数に与えられた自然数以下の素数を標準出力
*/

int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 2 ) {
        cout << "Please input N" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int N = atoi( argv[ 1 ] );

    // N 以下の自然数における素数判定結果の取得
    vector<bool> result = eratosthenes( N );

    // 素数を標準出力
    for( int n = 0; n <= N; n++ ) {
        if( result[ n ] ) {
            cout << n << " ";
        }
    }
    cout << endl;

    return 0;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

ミラーラビン法

与えられた自然数 \(n\) の素数判定を行います。

確率的素数判定法の 1 つです。

\(y = x^m \bmod n\) の形式における判定式を利用し、素数か否かをチェックします。

アルゴリズムは下記の通りです。

  1. \(n \ – \ 1 = 2^k m\) を満たす \((k,m)\) を算出
  2. \(2 \leqq a \leqq n \ – \ 1\) を満たす整数 \(a\) を無作為に用意
  3. \(y = a^m \bmod n\) を計算
  4. \(y = 1\) または \(y = n \ – \ 1\) の場合、素数であり、処理終了
  5. \(y = y^2 \bmod n\) を計算
  6. \(y = n \ – \ 1\) の場合は素数、\(y = 1\) の場合は合成数であり、処理終了
  7. \(0 \leqq i \leqq k \ – \ 1\) の場合、5. へ
  8. 処理終了(合成数)
#include <iostream>
#include <ctime>
using namespace std;

long long powmod( long long b, long long e, long long m ); // プロトタイプ宣言

/*
    ミラーラビン法による素数判定
*/

// 引数に与えられた自然数の素数判定結果をリターン
bool rabin( int n ) {
    srand( ( unsigned int )time( NULL ) );
    
    if( n < 2 ) return false; // 2 未満は非素数
    if( n == 2 ) return true; // 2 は素数
    if( !( n & 1 ) ) return false; // 2 以外の偶数は合成数(論理演算による偶数判定)

    /* n-1 = (2^k)*m を満たす (k,m) を算出 */

    // この時点で n は奇数であるため、n-1 は偶数であり、m は奇数
    int k = 0;
    int m = n - 1;
    while( !( m & 1 ) ) {
        m >>= 1; // 右に 1 ビットシフト = m を 1/2
        k++;
    }

    /* [2, n-1] を満たす乱数の生成 */
    int a = rand( ) % ( n - 2 ) + 2;

    /* 判定式 y = a^m mod n に基づくチェック */ 
    
    long long y = powmod( a, m, n );
    if( y == 1 || y == n - 1 ) {
        return true; // 素数
    }

    /* 判定式 y = y^2 mod n (0 <= i <= k-1) に基づくチェック */

    int i = 0;
    while( i < k ) {
        y = powmod( y, 2, n );
        if( y == n - 1 ) return true; // 素数
        if( y == 1 ) return false; // 合成数
        i++;
    }

    /* 判定式に基づくチェック終了 */
    return false; // 合成数
}

/*
    コマンドライン引数に与えられた自然数以下の素数を標準出力
*/
int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 2 ) {
        cout << "Please input n" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int N = atoi( argv[ 1 ] );

    // 標準出力
    for( int n = 0; n <= N; n++ ) {
        if( rabin( n ) ) {
            cout << n << " ";
        }
    }
    cout << endl;

    return 0;
}

/*
    冪剰余計算 : b^e mod m
*/

long long powmod( long long b, long long e, long long m ) {

    long long result = 1; // 結果

    // 0 乗の場合、結果は 1
    if( e == 0 ) return result;

    // 指数が 0 になるまで法を取りながら積を計算
    while( e > 0 ) {

        // 奇数の場合: 最下位ビットが 1 = 求める解の因数として利用される
        if( e & 1 ) {
            e--; // 偶数にする
            result = result * b % m; // 解の更新
        }

        // 偶数の場合: 最下位ビットが 0 = 求める解の因数として利用されない
        else {
            e >>= 1; // 右に 1 ビットシフト
            b = b * b % m; // 部分計算
        }
    }

    return result;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

フェルマーテスト

与えられた自然数 \(n\) の素数判定を行います。

確率的素数判定法の 1 つです。

「フェルマーの小定理」の対偶命題を利用し、合成数か否かをチェックします。

アルゴリズムは下記の通りです。

  1. \(2 \leqq a \leqq n \ – \ 1\) を満たす整数 \(a\) を無作為に用意
  2. \(\gcd(a,n) \neq 1\) の場合、合成数であり、処理終了
  3. \(y = a^{n \ – \ 1} \bmod n\) を計算
  4. \(y \neq 1\) の場合、合成数であり、処理終了
  5. 処理終了(素数)
#include <iostream>
#include <ctime>
using namespace std;

// プロトタイプ宣言
int gcd( int a, int b );
long long powmod( long long b, long long e, long long m );

/*
    フェルマーテストによる素数判定
*/

// 引数に与えられた自然数の素数判定結果をリターン
// trial: 素数判定の試行回数

bool fermat( int n, int trial ) { 
    srand( ( unsigned int )time( NULL ) );
    
    if( n < 2 ) return false; // 2 未満は排除
    if( n == 2 ) return true; // 2 は素数
    
    // trial の回数だけ素数判定を試行
    for( int i = 0; i < trial; i++ ) {
        
        // [2, n-1] の乱数を生成
        int a = rand( ) % ( n - 2 ) + 2;
        // 最大公約数が 1 以外の場合は合成数
        if( gcd( a, n ) != 1 ) {
            return false;
        }
        // 判定式 y = a^(n-1) mod n の値が 1 以外の場合は合成数
        if( powmod( a, n - 1, n ) != 1 ) {
            return false;
        }
    }

    return true;
}

/*
    コマンドライン引数に与えられた自然数以下の素数を標準出力
*/

int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 3 ) {
        cout << "Please input (N, trial)" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int N = atoi( argv[ 1 ] );
    int trial = atoi( argv[ 2 ] );

    // 標準出力
    for( int n = 0; n <= N; n++ ) {
        if( fermat( n , trial ) ) {
            cout << n << " ";
        }
    }
    cout << endl;

    return 0;
}

/*
    最大公約数の算出
*/

int gcd( int a, int b ) {
    
    // gcd(a,b) = gcd(b,a mod b) の法則を利用
    while( b > 0 ) {
        int t = a % b;
        a = b;
        b = t;
    }

    return a;
}

/*
    冪剰余計算 : b^e mod m
*/

long long powmod( long long b, long long e, long long m ) {

    long long result = 1; // 結果

    // 0 乗の場合、結果は 1
    if( e == 0 ) return result;

    // 指数が 0 になるまで法を取りながら積を計算
    while( e > 0 ) {

        // 奇数の場合: 最下位ビットが 1 = 求める解の因数として利用される
        if( e & 1 ) {
            e--; // 偶数にする
            result = result * b % m; // 解の更新
        }

        // 偶数の場合: 最下位ビットが 0 = 求める解の因数として利用されない
        else {
            e >>= 1; // 右に 1 ビットシフト
            b = b * b % m; // 部分計算
        }
    }

    return result;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

逆数の算出

\({e \cdot d} \bmod m = 1\) を満たす逆数 \(d\) を \((e,m)\) から算出します。

法をとる整数の逆数とは、掛けて \(1\) になる数をであり、\(d\) は \(e\) の逆数です。

逆数は「拡張ユーグリッド互除法」の応用により求めます。

この法則は、下記の等式を満たす \((x,y)\) を求める方法です。

$$ ax + by = \gcd(a,b)$$

ここで、

$${e \cdot d} \bmod m = 1$$

を満たすことは、

$$ex + my = 1 = \gcd(e,m)$$

を満たすことと同値であり、\(x = d\) です。

したがって、

$$ax + by = 1$$

を満たす \(x\) を求める拡張ユーグリッドの互除法における解法が必要です。

例題として、$$7x + 60y = 1$$ を満たす \((x,y)\) を 1 組求めます。

まず、下式を立てます。

\begin{eqnarray}
0 \cdot a + 1 \cdot b & = & b \\
1 \cdot a + 0 \cdot b & = & a
\end{eqnarray}

次に、初期値である \(a = 7\) および \(b = 60\) を代入します。

\begin{eqnarray}
0 \cdot a + 1 \cdot b & = & 60 \tag{1} \\
1 \cdot a + 0 \cdot b & = & 7 \tag{2}
\end{eqnarray}

このとき \(60 = 7 \times 8 + 4\) より、

$$\gcd(60,7) = \gcd(7,4) $$

\((1) \ – \ (2) \times 8 = 4\) であることから \((3)\) 式を得ます。

\begin{eqnarray}
1 \cdot a + 0 \cdot b & = & 7 \tag{2} \\
-8 \cdot a + 1 \cdot b &= & 4 \tag{3}
\end{eqnarray}

同様に、\(7 = 4 \times 1 + 3\) より、

$$\gcd(7,4) = \gcd(4,3)$$

\((2) \ – \ (3) \times 1 = 3\) であることから \((4)\) 式を得ます。

\begin{eqnarray}
-8 \cdot a + 1 \cdot b & = & 4 \tag{3} \\
\ 9 \cdot a \ – \ 1 \cdot b & = & 3 \tag{4}
\end{eqnarray}

\(4 = 3 \times 1 + 1\) より、

$$\gcd(4,3) = \gcd(3,1)$$

\((3) \ – \ (4) \times 1 = 1\) であることから \((5)\) 式を得ます。

\begin{eqnarray}
9 \cdot a \ – \ 1 \cdot b & = & 3 \tag{4} \\
-17 \cdot a + 2 \cdot b & = & 1 \tag{5}
\end{eqnarray}

このとき \((5)\) 式に注目して、

$$ a \cdot (-17) + b \cdot 2 = 1 $$

求める \((x, y)\) の1つが \((-17,2)\) であることが分かります。

ただし、RSA 暗号における秘密鍵 \(d\) の場合、正の整数であることが好ましいです。

下記の同値変形を利用し、\(x\) に \(b\) を足すことにより、正に調整します。

$$ax + by = 1 \ \Longleftrightarrow \ a \cdot (x+b) + b \cdot (y \ – \ a) = 1$$

ここでの場合、\(a \cdot (-17 \ + \ 60) + b \cdot (2 \ – \ 7) = 1\) より、

$$a \cdot 43 + b \cdot (-5) = 1$$

となります。

以上より、\(x = d = 43\) となります。

上記の「計算例」と同等のプロセスをプログラムで再現します。

#include <iostream>
using namespace std;

/*
    拡張ユークリッド互除法を応用した逆数の算出
*/

// ax + by = 1 を満たす (x,y) の算出
// RSA 暗号における関係式: ed + my = 1 | a=e, b=m, x=d
// d: RSA 暗号における秘密鍵

void exgcd( int a, int b, int *px, int *py ) {
    
    // 初期の関係式
    int x1 = 0, y1 = 1, r1 = b;
    int x2 = 1, y2 = 0, r2 = a;
    
    // 最終的な解
    int x, y;
    // 計算過程における商と剰余
    int qq, rr;
    // 計算過程において新たに立式される関係式の係数
    int xx, yy;
    
    while( true ) {
        // 商と剰余の計算
        qq = r1 / r2;
        rr = r1 % r2;
        
        // 新たな式の係数を計算
        xx = x1 - qq * x2;
        yy = y1 - qq * y2;
        // 余りが 0 のときの係数が求める解であるため処理終了
        if( rr == 0 ) {
            x = x2;
            y = y2;
            break;
        }
        // 関係式の更新
        x1 = x2; y1 = y2; r1 = r2;
        x2 = xx; y2 = yy; r2 = rr;
    }
    
    // x が 0 以下の場合は変換
    // ax + by = 1 のとき a(x+b) + b(y-a) = 1
    while( x <= 0 ) {
        x += b;
        y -= a;
    }
    // 引数が指定するアドレスに結果を格納
    *px = x;
    *py = y;
}

/*
    コマンドライン引数に与えられた自然数の最大公約数を標準出力
*/

int main( int argc, char *argv[] ) {

    // 引数を忘れた場合の Segmentation fault 対策
    if( argc < 3 ) {
        cout << "Please input (a,b)" << endl;
        exit( 1 );
    }

    // 文字列型の引数を整数型に変換
    int a = atoi( argv[ 1 ] );
    int b = atoi( argv[ 2 ] );

    // 結果格納先のアドレス
    int px, py;

    // 拡張ユークリッド互除法の実行
    exgcd( a, b, &px, &py );

    // 標準出力
    cout << a << "x + " << b << "y = 1 -> (x,y) = (" << px << "," << py << ")" << endl;

    return 0;
}

Windows と Mac における実行例です。

実行例(Windows)
実行例(Mac)

RSA 暗号のシミュレーション

RSA 暗号を用いた秘密通信の簡易シミュレーションを実装します。

簡単な模擬であり、商用暗号には程遠く、安全性や実用性は低いですが、データの流れをイメージしながら、暗号通信を模擬体験することができます。

プログラム

「RSA 暗号のコンポーネント」で紹介したモジュールを rsa.cpp にまとめました。

このファイルを利用することで、秘密通信のプログラムを実装します。

ヘッダファイルである rsa.hpp にてプロトタイプ宣言を行い、rsa.cpp に関数を定義します。

秘密通信のプログラムでは、rsa.hpp をインクルードして利用します。

rsa.hpp
#ifndef _RSA_HPP_
#define _RSA_HPP_

#include <iostream>
#include <ctime>
#include <vector>
using namespace std;

long long powmod( long long b, long long e, long long m ); // 冪剰余計算
int gcd( int a, int b ); // 最大公約数の算出
int lcm( int a, int b ); // 最小公倍数の算出
bool simple( int n ); // 単純法による素数判定
bool rabin( int n ); // ラビン法による素数判定
vector<bool> eratosthenes( int n ); // エラトステネスの篩による素数判定
bool fermat( int n, int trial ); // フェルマーテストによる素数判定
int exgcd( int a, int b ); // 拡張ユークリッド互除法を応用した逆数の算出

#endif // _RSA_HPP_
rsa.cpp
#include <iostream>
#include <ctime>
#include <vector>
#include "rsa.hpp"
using namespace std;

/*
    冪剰余計算 : b^e mod m
*/

long long powmod( long long b, long long e, long long m ) {

    long long result = 1; // 結果

    // 0 乗の場合、結果は 1
    if( e == 0 ) return result;

    // 指数が 0 になるまで法を取りながら積を計算
    while( e > 0 ) {

        // 奇数の場合: 最下位ビットが 1 = 求める解の因数として利用される
        if( e & 1 ) {
            e--; // 偶数にする
            result = result * b % m; // 解の更新
        }

        // 偶数の場合: 最下位ビットが 0 = 求める解の因数として利用されない
        else {
            e >>= 1; // 右に 1 ビットシフト
            b = b * b % m; // 部分計算
        }
    }

    return result;
}

/*
    最大公約数の算出
*/

int gcd( int a, int b ) {
    
    // gcd(a,b) = gcd(b,a mod b) の法則を利用
    while( b > 0 ) {
        int t = a % b;
        a = b;
        b = t;
    }
    return a;
}

/*
    最小公倍数の算出
*/

int lcm( int a, int b ) {
    
    // a * b = gcd(a,b) * lcm(a,b) の法則を利用
    return ( a * b ) / gcd( a, b );
}

/*
    単純法による素数判定
*/

// 引数に与えられた自然数の素数判定結果をリターン

bool simple( int n ) {
    
    if( n < 2 ) return false; // 2 未満は排除
    if( n == 2 ) return true; // 2 は素数
    if( n % 2 == 0 ) return false; // 2 以外の偶数は合成数(除算による偶数判定)
    
    // 3 以上の奇数でチェック
    for( int k = 3; k * k <= n; k += 2 ) { 
        if( n % k == 0 ) {
            return false; // 割り切れる場合は合成数
        }
    }
    return true; // 素数
}

/*
    エラトステネスの篩による素数判定
*/

// 引数に与えられた自然数以下の素数判定結果の配列をリターン

vector eratosthenes( int n ) {
    
    // 配列のインデックスに自然数値が対応
    vector<bool> result( n + 1, true );
    
    result[ 0 ] = false; // 0 は非素数
    result[ 1 ] = false; // 1 は非素数

    for ( int k = 2; k <= n; k++ ) {
        
        // 合成数であることが判明している場合はスキップ
        if ( !result[ k ] ) continue;

        // k は素数であるが、k の倍数は合成数
        for ( int mk = 2 * k; mk <= n; mk += k ) {
            result[ mk ] = false;
        }
    }
    return result;
}

/*
    ミラーラビン法による素数判定
*/

// 引数に与えられた自然数の素数判定結果をリターン

bool rabin( int n ) {
    
    srand( ( unsigned int )time( NULL ) );

    if( n < 2 ) return false; // 2 未満は非素数
    if( n == 2 ) return true; // 2 は素数
    if( !( n & 1 ) ) return false; // 2 以外の偶数は合成数(論理演算による偶数判定)

    /* n-1 = (2^k)*m を満たす (k,m) を算出 */

    // この時点で n は奇数であるため、n-1 は偶数であり、m は奇数

    int k = 0;
    int m = n - 1;

    while( !( m & 1 ) ) {
        m >>= 1; // 右に 1 ビットシフト = m を 1/2
        k++;
    }

    /* [2, n-1] を満たす乱数の生成 */
    
    int a = rand( ) % ( n - 2 ) + 2;

    /* 判定式 y = a^m mod n に基づくチェック */ 
    
    long long y = powmod( a, m, n );

    if( y == 1 || y == n - 1 ) {
        return true; // 素数
    }

    /* 判定式 y = y^2 mod n (0 <= i <= k-1) に基づくチェック */

    int i = 0;
    while( i < k ) {
        y = powmod( y, 2, n );
        if( y == n - 1 ) return true; // 素数
        if( y == 1 ) return false; // 合成数
        i++;
    }

    /* 判定式に基づくチェック終了 */

    return false; // 合成数
}

/*
    フェルマーテストによる素数判定
*/

// 引数に与えられた自然数の素数判定結果をリターン
// trial: 素数判定の試行回数

bool fermat( int n, int trial ) { 

    srand( ( unsigned int )time( NULL ) );
    
    if( n < 2 ) return false; // 2 未満は排除
    if( n == 2 ) return true; // 2 は素数
    
    // trial の回数だけ素数判定を試行
    for( int i = 0; i < trial; i++ ) {
        
        // [2, n-1] の乱数を生成
        int a = rand( ) % ( n - 2 ) + 2;

        // 最大公約数が 1 以外の場合は合成数
        if( gcd( a, n ) != 1 ) {
            return false;
        }

        // 判定式 y = a^(n-1) mod n の値が 1 以外の場合は合成数
        if( powmod( a, n - 1, n ) != 1 ) {
            return false;
        }
    }
    return true;
}

/*
    拡張ユークリッド互除法を応用した逆数の算出
*/

// ax + by = 1 を満たす (x,y) を算出 & x をリターン
// RSA 暗号における関係式: ed + my = 1 | a=e, b=m, x=d
// d: RSA 暗号における秘密鍵

int exgcd( int a, int b ) {
    
    // 初期の関係式
    int x1 = 0, y1 = 1, r1 = b;
    int x2 = 1, y2 = 0, r2 = a;
    
    // 最終的な解
    int x;

    // 計算過程における商と剰余
    int qq, rr;

    // 計算過程において新たに立式される関係式の係数
    int xx, yy;
    
    while( true ) {

        // 商と剰余の計算
        qq = r1 / r2;
        rr = r1 % r2;
        
        // 新たな式の係数を計算
        xx = x1 - qq * x2;
        yy = y1 - qq * y2;

        // 余りが 0 のときの係数が求める解であるため処理終了
        if( rr == 0 ) {
            x = x2;
            break;
        }

        // 関係式の更新
        x1 = x2; y1 = y2; r1 = r2;
        x2 = xx; y2 = yy; r2 = rr;
    }
    
    // x が 0 以下の場合は変換
    // ax + by = 1 のとき a(x+b) + b(y-a) = 1
    while( x <= 0 ) x += b;

    return x;
}

鍵の作成

共通鍵 \(n\)、暗号化鍵 \(e\)、復号化鍵 \(d\) を作成し、コマンドラインに標準出力します。

コマンドライン引数に指定する自然数 \(N\) 以下の素数を利用して鍵を作成します。

ここでは、素数判定に単純法を用いました。

#include <iostream>
#include <vector>
#include "rsa.hpp"
using namespace std;

/*
	RSA 暗号で利用する鍵の作成
*/

int main( int argc, char *argv[] ) {

	// 引数を忘れた場合の Segmentation fault 対策
	if( argc < 2 ) {
		cout << "Please input N" << endl;
		exit( 1 );
	}
	
	// コマンドライン引数に与えられた整数 N 以下の素数を利用
	int N = atoi( argv[ 1 ] );

	// 乱数の種に現在時刻を設定
	srand( ( unsigned int )time( NULL ) );

	// 素数リスト
	vector<int> primeNum;
	int size = 0;

	// 単純法による素数判定により素数リストを作成
	for( int n = 0; n <= N; n++ ) {
		if( simple( n ) ) {
			primeNum.push_back( n );
		}
	}
	size = primeNum.size( );
	
	// 異なる素数 p, q を用意
	int p, q;
	p = primeNum[ rand( ) % size ];
	do {
		q = primeNum[ rand( ) % size ];
	} while( p == q );
	
	// 公開鍵(1)の作成
	int n = p * q;

	// 最小公倍数の算出
	int m = lcm( p - 1, q - 1 );

	// 公開鍵(2)
	int e = m; // 初期値に m を利用
	if( !( e & 1 ) ) e++; // 奇数に調整

	// 秘密鍵
	int d;
	do {
		// 公開鍵(2)の作成
		e += 2;
		while( gcd( e, m ) != 1 ) e += 2; // m と e は互いに素
		// 秘密鍵の作成
		d = exgcd( e, m );
	} while( e == d );

	// 鍵の標準出力
	cout << "e: " << e << endl;
	cout << "d: " << d << endl;
	cout << "n: " << n << endl;
	
	return 0;
}

暗号化

暗号化鍵 \(e\) と、共通鍵 \(n\) を用いて、任意のメッセージ \(M\)(平文)を暗号化します。

暗号文 \(C\) は標準出力されます。

#include <iostream>
#include "rsa.hpp"
using namespace std;

/*
	暗号化
*/

int main( void ) {
	int M, e, n;
	cout << "M: "; cin >> M;
	cout << "e: "; cin >> e;
	cout << "n: "; cin >> n;
	cout << M << " ^ " << e << " mod " << n << " = C = " << powmod( M, e, n ) << endl;
	return 0;
}

復号化

復号化鍵 \(d\)と、共通鍵 \(n\) を用いて、暗号文 \(C\) を復号化します。

復号化されたメッセージ \(M\) (平文)は標準出力されます。

#include <iostream>
#include "rsa.hpp"
using namespace std;

/*
	復号化
*/

int main( void ) {
	int C, d, n;
	cout << "C: "; cin >> C;
	cout << "d: "; cin >> d;
	cout << "n: "; cin >> n;
	cout << C << " ^ " << d << " mod " << n << " = M = " << powmod( C, d, n ) << endl;
	return 0;
}

実行

コマンドラインにて、受信者と送信者の一人二役により、シミュレーションを行います。

ここでは、Windows のコマンドプロンプトを用いて実行します。

Mac のターミナルで実行した場合も同様の結果を得ることができます。

鍵の作成(受信者)

makekey.cpp を実行します。

初回はコンパイルが必要です。

ここでは、\(1000\) 以下の自然数に含まれる素数を利用して鍵を作成します。

実行例(Windows)
メッセージの暗号化(送信者)
  1. encryption.cpp の実行(初回は要コンパイル)
  2. 任意のメッセージ \(M\)(自然数)を入力
  3. STEP.1 にて標準出力された \((e,n)\) を入力
  4. 暗号文 \(C\) の標準出力を確認

ここでは、メッセージ \(M\) を「\(24738\)」として暗号化します。

実行例(Windows)
暗号文の復号化(受信者)
  1. decryption.cpp の実行(初回は要コンパイル)
  2. 暗号文 (C) を入力
  3. STEP.1 にて標準出力された ((d,n)) を入力
  4. 復号化されたメッセージ(平文)の標準出力を確認
実行例(Windows)

暗号化したメッセージ \(M\) の「\(24738\)」が復号化されていることが分かります。

メッセージ \(M\) を「\(25892\)」としたうえで暗号化し、正しく復号化されていることが分かります。

実行例(Mac)

秘密通信のシミュレーションは以上です。

おすすめの参考書

本投稿で割愛した数学的知識や実装上の注意点など、詳細情報を学習できるおすすめの参考書を紹介します。

学生時代から現在まで利用している書籍です。

暗号技術のすべて

学生時代に利用し、とても重宝した参考書の 1 つです。

講義の補足や、論文を読むうえでの事前知識の習得のために活用しました。

数学的な基礎知識や基本技術などについて、丁寧な説明が積み重ねられているため、初学者でもスムーズに学ぶことができます。

辞書的用途としても手元に置いておくと心強い 1 冊です。

暗号技術のすべて

著者 : IPUSIRON / 出版社 : 翔泳社

暗号技術入門 第3版

読み物として、暗号技術を体系的に学ぶことができます。

各種暗号の仕組みや具体的な活用シーンなど、基本知識や全体像を掴むことができます。

数学的な事前知識がなくとも、スラスラ読むことができます。

本格的な学習を始める前の 1 冊として最適です。

暗号技術入門

著者 : 結城 浩 / 出版社 : SB クリエイティブ

暗号から学ぶ代数学

暗号技術を題材に代数学を学ぶことができる一石二鳥の参考書です。

学生時代に出会いたかった 1 冊です。

ユーグリッドの互除法やフェルマーの小定理などの基本的な定理をはじめ、代数学の基礎である「群」「体」「環」や、代表的な暗号技術について学ぶことができます。

暗号から学ぶ代数学

著者 : 結城 浩 / 出版社 : SB クリエイティブ

実行環境

Windows

MinGW-w64 を用いた C/C++ の実行環境にて検証を行いました。

構築方法は下記を参照ください。

プログラミング初心者向け!C/C++ のシンプルな開発環境を構築する

Mac

Apple Clang を用いた C/C++ の実行環境にて検証を行いました。

Clang は、デフォルトでインストールされています。