Open6

「Implementing SSL / TLS」を Rust で

dmystkdmystk

前提

本はこちらです。全文英語なのでご注意ください。

https://www.amazon.co.jp/dp/0470920416

原文ではサンプルコードが C 言語で記述されていますが、今回は Rust で書き直しながら読み進めます。
Rust についても初学者なので、色々と書き留めていきます。

書いたコードは こちら に置いてあります。

dmystkdmystk

Chapter 1 : Understanding Internet Security (1)

Chapter 1 には SSL / TLS を理解するための前提知識である TCP / IP と HTTP の概要について書いてある。

この章では簡単な HTTP クライアントとサーバを実装する。ここでは SSL / TLS の前提部分を理解することにフォーカスしているため、実装する内容にはセキュリティ機能はない。ここで実装した内容をベースに次章以降でセキュリティ機能を追加していく。

コードを掲載する都合上、コメントが長くなってしまうため、Chapter 1 をいくつかのコメントに分けて投稿する。このコメントでは Chapter 1 の HTTP クライアントの骨格部分の実装までを述べる。

SSL / TLS の前提

インターネット通信には Socket が使用されるが、そもそも Socket とは TCP / IP によって確立された接続の参照である。従って、Socket 作成時にはその背後で TCP の 3-way handshake が実施され、Socket 利用時にはデータの順序制御などの恩恵が享受できる。

SSL / TLS は TCP / IP によって確立された接続に対して、追加の handshake を行ってトンネル(暗号化された安全な経路)を作成するように定義されている。つまり Socket の外側で handshake を別途実施する形になる。

HTTP クライアントの実装

まずは HTTP クライアントを実装する。ここで実装するクライアントはごく簡単なもので、引数で渡された URL に対して HTTP の GET リクエストを送信し、レスポンスの内容を標準出力に書き出すだけのツールである。

準備

前準備として Rust の環境を作っておく。以下のコマンドを実行する。

$ cargo new impl_ssl_tls --bin && cd impl_ssl_tls
$ rm src/main.rs
$ mkdir -p src/bin
$ touch src/bin/client.rs

最終的にクライアントとサーバの両方を実装するので、今回は main.rs は使用せず、代わりに client.rs と server.rs の2つのバイナリターゲットを用意する方針でいく。Cargo のマルチバイナリについては ここ に書いてある。ここではさしあたり必要となる client.rs のみを作成している。

バイナリターゲットを認識させるため、Cargo.toml の末尾に以下の内容を追記しておく。

Cargo.toml
[[bin]]
name = "client"
test = false
bench = false

各バイナリの実行は以下のコマンドで実行できる。

$ cargo run --bin <name> -- <cli-args>

Socket の作成

いよいよ実装開始である。まずは Socket の作成から始める。

Rust では TcpStream::connect(host, port) とすれば、それだけで Socket 作成からホストへの接続まで面倒を見てくれる。C 言語で必要だったディスクリプタ管理などは一切必要ない。[1]

client.rs
use std::net::TcpStream;

fn main() {
    let host = "www.example.com";
    let port = 80;

    let mut stream = TcpStream::connect((host, port)).unwrap_or_else(|e| {
        eprintln!("{}", e);
        std::process::exit(-1);
    });
    println!("Socket created.");
}

上の内容を実行して Sokcet created. と表示されれば Socket での接続に成功している。また host の値を適当な文字列に変更して実行すれば DNS で失敗するのが確認できる。

HTTP リクエストの送信

次に Socket を介して HTTP GET リクエストを送信する。なお、本書で使用しているのは HTTP / 1.1 である。HTTP / 1.1 の仕様は RFC 2616 で定義されている。[2]

HTTP / 1.1 ではリクエストをテキスト形式で送信できる( HTTP / 2 ではバイナリ形式に変更された)ので、実装は簡単である。以下のような形式の文字列を Socket で送信すればよい:

GET /index.html HTTP/1.1↵
Host: www.example.com↵
Connection: close↵
↵

上記の例は http://www.example.com/index.html にリクエストを送った場合の例である。 は改行文字 \r\n を表す。この文字列を送信する処理を、先ほどの main 関数の末尾に書き足せばよい。

以下が追加の実装である。冒頭の use std::io::Write; を忘れるとコンパイルエラーになるので注意する。

client.rs
use std::io::Write;  // need to use stream.write()
use std::net::TcpStream;

fn main() {
    // ... 省略 ...

    // send HTTP GET request
    let path = "/index.html";
    let request = format!(concat!(
        "GET {} HTTP/1.1\r\n",
        "Host: {}\r\n",
        "Connection: close\r\n\r\n",
    ), path, host);
    stream.write(request.as_bytes()).unwrap_or_else(|e| {
        eprintln!("Failed to send request: {}", e);
        std::process::exit(-1);
    });
}

HTTP レスポンスの受信

HTTP リクエストを送信すると、同じ Socket を介してサーバからレスポンスが送信されてくる。今回は単にこれを読み出して標準出力に出力する。

前回と同様に main 関数の末尾に読み出し処理を追記すればよい。冒頭の use 宣言に Read を追加しているので注意すること。

client.rs
use std::io::{Write, Read};  // need to use stream.read() and stream.write()
use std::net::TcpStream;

fn main() {
    // ... 省略 ...

    // recieve response
    const MAX_CHUNK_SIZE: usize = 1024;
    let mut buf: [u8; MAX_CHUNK_SIZE] = [0; MAX_CHUNK_SIZE];
    loop {
        let read_size = stream.read(&mut buf).unwrap_or_else(|e| {
            eprintln!("Failed to recieve request: {}", e);
            std::process::exit(-1);
        });
        if read_size == 0 {
            break;
        }
        print!("{}", std::str::from_utf8(&buf[0..read_size]).unwrap());
    }
}

サイズが予測できないレスポンスを取得する際には、レスポンスを Chunk という適当な大きさの単位に分けて、少しずつデータを受信するのが一般的である。ここではレスポンスを 1024 バイトの Chunk に分けて繰り返し取得している。Rust の read() は最後までデータを読み出したら 0 を返す仕様なので、そのタイミングで読み出しループから抜けるようにしている。

これでリクエストの送信とレスポンスの受信ができるようになった。cargo run してリクエストに成功すると、レスポンスの内容が画面に表示される。ここでは http://www.example.com/index.html にリクエストを送信しているので、中身は HTTP ヘッダーと HTML である。

HTTP/1.1 200 OK
Accept-Ranges: bytes
Age: 43484
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
    :

コード中の hostpath を変更して、リクエスト先を変えると楽しい。

ツールとしての体裁を整える

ここまでは hostport をハードコーディングしていたが、これをコマンドライン引数で受け取れるようにする。次にような形で使用できるようにしたい:

$ client http://www.example.com/index.html

Rust でコマンドライン引数を読み込むには std::env を使えばよい。

client.rs
fn main() {
    // check command line arguments
    let args: Vec<String> = std::env::args().collect();
    if args.len() != 2 {
        eprintln!("Usage: {} <URL>", args[0]);
        std::process::exit(-1);
    }

    // ... 省略 ...
}

これで引数に指定された http://www.example.com/index.html のような文字列を取得することができるようになった。今度はこの文字列から hostpathport を取り出さなければならない。この URL の例では、それぞれ www.example.com/index.html80 である。

本書では簡素な URL のパース処理を実装しているが、今回は既製品があるのでそれを利用する。Rust の url クレート を使えば、今回必要な情報は全て読み取れる。

url クレートは標準ライブラリではないので、Cargo.toml に依存関係を追加する必要がある。

Cargo.toml
[dependencies]
url = "^2.2.2"

あとは url クレートの仕様を確認しながら実装するだけである。

client.rs
use std::io::{Write, Read};
use std::net::TcpStream;
use url::Url;

fn main() {
    // ... 省略 ...

    // parse and validate input URL
    let url = Url::parse(&args[1]).unwrap_or_else(|e| {
        eprintln!("Malformed URL: {}", e);
        std::process::exit(-1);
    });
    if url.scheme() != "http" {
        eprintln!("Unsupported shceme: {}", url.scheme());
        std::process::exit(-1);
    }

    let host = url.host_str().unwrap();
    let path = url.path();
    let port = url.port_or_known_default().unwrap();

    // ... 省略 ...
}

url クレートは有能すぎて色々な形式の URL を読み込めてしまうので、HTTP 以外のスキーマが指定された場合はサポート対象外扱いでエラーとしている。

これで HTTP クライアントの骨格部分の実装は完了である。以下のコマンドで GET リクエストを発行して遊べる。

$ cargo run --bin client -- http://www.example.com/index.html

ここまでの client.rs の全文を掲載しておく。コード整理のために随所に手を加えている。

client.rs

use std::io::{Write, Read};  // need to use stream.read() and stream.write()
use std::net::TcpStream;
use url::Url;

/// Exit program with printing a message to stderr.
macro_rules! exit_with_message {
    ($($arg:tt)*) => {
        eprintln!($($arg)*);
        std::process::exit(-1);
    }
}

fn main() {
    // check command line arguments
    let args: Vec<String> = std::env::args().collect();
    if args.len() != 2 {
        exit_with_message!("Usage: {} <URL>", args[0]);
    }

    // parse and validate input URL
    let url = Url::parse(&args[1]).unwrap_or_else(|e| {
        exit_with_message!("Malformed URL: {}", e);
    });
    if url.scheme() != "http" {
        exit_with_message!("Unsupported shceme: {}", url.scheme());
    }

    let host = url.host_str().unwrap();
    let path = url.path();
    let port = url.port_or_known_default().unwrap();

    // connect to host
    let mut stream = TcpStream::connect((host, port)).unwrap_or_else(|e| {
        exit_with_message!("{}", e);
    });

    // send HTTP GET request
    let request = format!(concat!(
        "GET {} HTTP/1.1\r\n",
        "Host: {}\r\n",
        "Connection: close\r\n\r\n",
    ), path, host);
    stream.write(request.as_bytes()).unwrap_or_else(|e| {
        exit_with_message!("Failed to send request: {}", e);
    });

    // recieve response
    read_chunks(&stream, |chunk| {
        print!("{}", std::str::from_utf8(chunk).unwrap());
    }).unwrap_or_else(|e| {
        exit_with_message!("Failed to recieve response: {}", e);
    })
}

/// Unwrap Ok value or terminate function with Err as return value
/// i.e. this macro is only for functions that have Result(..) as return type.
/// This macro is to avoid deep nesting by `match` expression.
macro_rules! unwrap_or_return_err {
    ($e:expr) => {
        match $e {
            Ok(x) => x,
            Err(e) => return Err(e),
        }
    };
}

/// Maximum size of chunk.
const MAX_CHUNK_SIZE: usize = 1024;

/// Read bytes from a stream chunk by chunk and process it.
fn read_chunks(mut stream: &TcpStream, f: fn(&[u8])) -> std::io::Result<()> {
    let mut buf: [u8; MAX_CHUNK_SIZE] = [0; MAX_CHUNK_SIZE];
    loop {
        let read_size = unwrap_or_return_err!(stream.read(&mut buf));
        if read_size == 0 {
            return Ok(())
        } else {
            f(&buf[0..read_size]);
        }
    }
}
脚注
  1. 一応 libc クレート を使えば C 言語のレベルに近い Socket プログラミングが行えるらしい。今回は使わない。 ↩︎

  2. HTTP / 1.1 の仕様は RFC 2616 の後、RFC 7230RFC 7235 によって拡張されている。HTTP のバージョンの歴史については MDN の こちら が非常にわかりやすい。最新の HTTP / 3 については左記の MDN のページには記載がないが、それについては こちら がわかりやすい。 ↩︎

dmystkdmystk

Chapter 1 : Understanding Internet Security (2)

Chapter 1 の続き。HTTP クライアントで認証なしの Proxy サーバを利用できるように改修する。

Proxy のサポートのための対応箇所

Proxy 経由で HTTP リクエストを行う際のフローは以下のようになる:

  1. HTTP クライアントが Proxy サーバへ Socket 接続する
  2. HTTP クライアントが Proxy サーバへ HTTP リクエストを発行する
  3. Proxy サーバが HTTP のリクエスト先を確認する
  4. Proxy サーバがリクエスト先サーバへ Socket 接続する
  5. Proxy サーバがリクエスト先サーバへ HTTP リクエストを発行する
  6. リクエスト先サーバが Proxy サーバへレスポンスを返却する
  7. Proxy サーバが HTTP クライアントへレスポンスを返却する

ボールド体になっている部分がクライアントの処理に関わる項目である。

上記のうち、明らかに 1 については対応が必要である。具体的には、Proxy を利用する場合、Socket の接続先として Proxy サーバのホストとポートを使用するようにコードを修正する必要がある。

また、それに伴って 2 の HTTP リクエスト発行においても若干の対応が必要になる。これまでは HTTP GET の取得対象に相対パスを指定していたが、Proxy 利用時には Socket の接続先とリクエスト先のホストとポートが異なるため、取得対象を完全な URL で指定する必要がある。例を挙げると、

GET /index.html HTTP/1.1↵
Host: www.example.com↵
Connection: close↵
↵

というリクエストを

GET http://www.example.com/index.html HTTP/1.1↵
Host: www.example.com↵
Connection: close↵
↵

に修正しなければならない。なお、上記の例ではポート指定を省略しているが、リクエスト先のポートが指定されている場合は、それも HTTP GET の取得対象の記述の中に含める必要がある。

7 については Socket から得たレスポンスを標準出力に書き出すだけなので、特に対応すべき箇所はない。

Proxy のテスト環境の準備

最初に Proxy のテストができる環境を整えておく。今回は こちら の Docker コンテナを利用して Proxy サーバを立てることにした。

$ git clone https://github.com/megmogmog1965/squid-for-http-proxy-testing.git
$ cd squid-for-http-proxy-testing
$ docker-compose build
$ docker-compose up -d

Proxy 自体が動作しているかどうかは curl で確認できる。

$ curl http://www.example.com --proxy http://localhost:3128

ちなみに squid-for-http-proxy-testing はデフォルトだと認証なしになる。認証を有効化するには .env を編集する。さしあたり認証なしのままで問題ないので、認証有効化の方法は必要になったタイミングで述べる。

Proxy あり/なしを分岐する

実装を始めるにあたって Proxy あり/なしで処理を適当に分けておく。

client.rs
fn main() {
    // ... 省略 ...

    let proxy = Some("http://localhost:3128").map(|arg| {
        Url::parse(arg).unwrap_or_else(|e| {
            exit_with_message!("Malformed proxy URL: {}", e);
        })
    });

    // request HTTP GET
    if proxy.is_some() {
        let proxy_url = proxy.unwrap();
        request_http_get_with_proxy(&url, &proxy_url).unwrap_or_else(|e| {
            exit_with_message!("{}", e);
        });
    } else {
        request_http_get(&url).unwrap_or_else(|e| {
            exit_with_message!("{}", e);
        });
    }
}

fn request_http_get(url: &Url) -> std::io::Result<()> {
    // ... 省略 ...
}

fn request_http_get_with_proxy(url: &Url, proxy_url: &Url) -> std::io::Result<()> {
    // TODO: implement
}

proxy が利用する Proxy サーバである。Proxy の利用は任意なので、Rust の Option で表現している。ここでは適当な値で仮置きしているが、後ほどコマンドライン引数から取得するように修正する。

request_http_get_with_proxy に Proxy ありの処理を実装していく。request_http_get は Chapter.1 (1) の内容を関数に移しただけのものなので割愛する。

Socket の接続先を Proxy サーバにする

Socket で Proxy サーバに接続するようにする。単純に Chapter.1 (1) の Socket への接続処理で指定していた hostport を Proxy のものに差し替えればよい。

client.rs
fn request_http_get_with_proxy(url: &Url, proxy_url: &Url) -> std::io::Result<()> {
    // connect to proxy
    let proxy_host = proxy_url.host_str().unwrap();
    let proxy_port = proxy_url.port_or_known_default().unwrap();
    let mut stream = unwrap_or_return_err!(
        TcpStream::connect((proxy_host, proxy_port))
    );
}

HTTP リクエストの取得対象を完全な URL にする

続いて HTTP リクエストの取得対象の記述を修正する。こちらも Chapter.1 (1) での同処理の path を完全な URL に差し替えるだけでよい。

client.rs
fn request_http_get_with_proxy(url: &Url, proxy_url: &Url) -> std::io::Result<()> {
    // ... 省略 ...

    // send HTTP GET request
    let host = url.host_str().unwrap();
    let path = url.as_str();  // need to use the full URL as path when using proxy
    let request = format!(concat!(
        "GET {} HTTP/1.1\r\n",
        "Host: {}\r\n",
        "Connection: close\r\n\r\n",
    ), path, host);
}

別途 Host タグを記述しているので、path に相対パスを指定しても問題ないような気もするが、絶対パスで指定する方が Proxy フレンドリーらしい。この辺は決め事なので、大人しく従っておくのが吉である。

request_http_get_with_proxy の残りを書く

以降は Chapter.1 (1) と全く同じでよいので、この時点で request_http_get_with_proxy は完成である。以下に全文を掲載しておく。

client.rs
fn request_http_get_with_proxy(url: &Url, proxy_url: &Url) -> std::io::Result<()> {
    // connect to proxy
    let proxy_host = proxy_url.host_str().unwrap();
    let proxy_port = proxy_url.port_or_known_default().unwrap();
    let mut stream = unwrap_or_return_err!(
        TcpStream::connect((proxy_host, proxy_port))
    );

    // send HTTP GET request
    let host = url.host_str().unwrap();
    let path = url.as_str();  // need to use the full URL as path when using proxy
    let request = format!(concat!(
        "GET {} HTTP/1.1\r\n",
        "Host: {}\r\n",
        "Connection: close\r\n\r\n",
    ), path, host);
    unwrap_or_return_err!(
        stream.write(request.as_bytes())
    );

    // recieve response
    read_chunks(&stream, |chunk| {
        print!("{}", std::str::from_utf8(chunk).unwrap());
    })
}

Docker を立ち上げて cargo run すれば動作確認が行える。なお、Proxy なしの動作に戻したい場合は main 関数の proxy を以下のようにする。

client.rs
    let proxy = None;

Proxy あり/なしで動かしてみて、HTTP レスポンスヘッダーに差分がないかを確認するとよい。私の環境では Proxy ありの場合のみ、以下の Via ヘッダーが付与された。

Via: 1.1 2686d978b868 (squid/4.15)

Proxy をコマンドライン引数で指定する

Proxy を以下のような形式でコマンドライン引数から引き受けられるようにしたい:

$ client http://www.example.com/index.html --proxy http://localhost:3128

これまでのように std::env を使うこともできるが、コマンドライン引数のパース処理を一から実装するのは大変なので、ここでは structopt を導入して対応する。

structopt は標準ライブラリではないので、Cargo.toml に依存関係を追加する必要がある。

Cargo.toml
[dependencies]
structopt = "^0.3.25"

structop では structopt 属性を宣言した構造体を定義することで、コマンドライン引数のパース処理を自動生成してくれる。structopt の使い方の詳細については こちら を参照のこと。今回必要な構造体は以下のようになる。

client.rs
use structopt::StructOpt;

/// Struct for CLI arguments.
#[derive(Debug, StructOpt)]
#[structopt(name = "client", about = "Simple CLI HTTP client.")]
struct Opt {
    #[structopt(name = "URL")]
    pub url: Url,
    #[structopt(short, long, help = "Set proxy server URL")]
    pub proxy: Option<Url>,
}

メンバー変数には FromStr を実装している型を使用できる。url::UrlFromStr を実装しているので、そのままメンバー変数型として指定できる。

これを使えば、これまで std::env を使って実装していたパース処理が以下のように書ける。

client.rs
fn main() {
    // check command line arguments
    let opt = Opt::from_args();
    let url = opt.url;
    if url.scheme() != "http" {
        exit_with_message!("Unsupported shceme: {}", url.scheme());
    }
    let proxy = opt.proxy;
    if proxy.is_some() && proxy.as_ref().unwrap().scheme() != "http" {
        exit_with_message!("Unsupported proxy shceme: {}", proxy.unwrap().scheme());
    }

    // ... 省略 ...
}

かなりスッキリしていてよい感じである。

structopt を使うと --help が自動でサポートされるので、それを利用して構造体がうまく定義できているかを確認するとよい。

$ cargo run --bin client -- --help
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/client --help`
client 0.1.0
Simple CLI HTTP client.

USAGE:
    client [OPTIONS] <URL>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -p, --proxy <proxy>    Set proxy server URL

ARGS:
    <URL>

以上で認証なし Proxy のサポートは完了である。ここまでのコードの全文は こちら を参照。

dmystkdmystk

Chapter 1 : Understanding Internet Security (3)

Chapter 1 の続き。HTTP クライアントで認証ありの Proxy サーバを利用できるように改修する。

Proxy の認証シーケンス

今回は BASIC 認証を使用する。以下の手順に従えばよい:

  1. Proxy サーバの URL からユーザ名とパスワードを取得する
  2. ユーザ名とパスワードを : で接続した文字列 <user>:<password> を BASE64 でエンコードする
  3. HTTP の Proxy-Authorization タグで認証方式 BASIC と共に 2 で得た文字列を送信する

つまり、結果として得られる HTTP リクエストは以下のようになる:

GET http://www.example.com/index.html HTTP/1.1↵
Host: www.example.com↵
Proxy-Authorization: BASIC c3F1aWQ6cGFzc3dvcmQ=↵
Connection: close↵
↵

認証あり Proxy サーバの準備

テスト環境を作成しておく。squid-for-http-proxy-testing の .env を以下のように変更すればよい。

.env
# Squid proxy port.
HTTP_PORT=3128

# Basic authentication.
-BASIC_AUTH_COMMENT_OUT=#### <-- comment out basic authentication settings.
+BASIC_AUTH_COMMENT_OUT=
BASIC_AUTH_USERNAME=squid
BASIC_AUTH_PASSWORD=password

curl を使って認証が有効になっていることを確かめられる。以下のコマンドでは HTTP ヘッダーのみを出力するようにオプション指定している。

$ # 407 Proxy Authentication Required
$ curl -v -s -x http://localhost:3128 http://www.example.com > /dev/null

$ # 200 OK
$ curl -v -s -x http://squid:password@localhost:3128 http://www.example.com > /dev/null

BASE64 の実装

前提として、Rust には base64 クレート があるので、単に BASE64 でエンコードしたいだけであれば自分で実装する必要はない。が、ここでは学習が目的のため、あえて自分の手で再実装する。

自分の手で実装した後に base64 クレートの ソースコード を見ると、BASE64 の仕様やパフォーマンスチューニング等について非常に勉強になるのでオススメ。

BASE64 の概要

BASE64 はバイナリデータを ASCII 文字列に変換するためのエンコード方式である。

その昔、バイナリデータを通信で送付した際、それを ASCII 文字として(非表示可能文字などの考慮をせずに)ログなどに印字しようとすることによる問題が多発したらしい。それを予防するためにバイナリデータを ASCII の表示可能文字に変換するエンコード方式がいくつか検討されたとのこと。BASE64 はそれらの中で最も人気のあるエンコード方式の1つである。

BASE64 では、バイナリデータを 6 bit ずつのチャンクに分解し、6 bit で表される 2^6 = 64 通り(これが BASE64 の名前の由来である)の各値に対して ASCII の表示可能文字を割り当てる。各値と ASCII 文字の具体的な対応関係については実装時に述べる。以下に変換のイメージを示す:

bytes: 11111111 01010101 00000000 ...
        ↓
6bits: 111111 110101 010100 000000 ...
        ↓
ASCII: '/' '1' 'U' 'A' ...

元々 8 bit 単位だったデータを 6 bit 単位に分割するので、2 bit ないし 4 bit の余りが出る可能性がある。余りに対しては不足分の下位ビットを 0 埋めすることで対応する。以下は 8 bit のデータ(余りが 2 bit になる)を処理した場合の例である:

bytes: 11111111
        ↓
6bits: 111111 110000
        ↓
ASCII: '/' 'w'

さらにこの変換で出力された ASCII 文字列の長さが 4 の倍数でない場合、4 の倍数になるようにパディング文字 = を挿入する必要がある。[1] 1つ上の例では出力された ASCII 文字は2つなので、パディング文字を2つ挿入する必要がある:

ASCII: '/' 'w'
        ↓
out  : '/' 'w' '=' '='

こうして得られた out が BASE64 のエンコード結果となる。なお、デコードの際には単にエンコードの流れを逆に辿っていけばよい。

モジュールの準備

BASE64 の処理は main.rs に書きたくないので、モジュールを分けておく。初のモジュールなので、lib.rs も合わせて準備しておく。

$ touch src/lib.rs
$ touch src/base64.rs
lib.rs
pub mod base64;

バイト列を 6 bit 列に変換する

BASE64 実装のファーストステップとしてバイト列を 6 bit の配列に変換する処理を書いていく。

8 bit と 6 bit の最小公倍数は 24 bit = 3 * 8 bit = 4 * 6 bit なので、3バイトごとに4つの 6 bit が得られる。これを利用して 3 バイトずつ再帰的に処理していく。まずは 3 バイトを 4 つの 6 bit に変換する関数を書く。

base64.rs
fn into_4_bit6(byte1: u8, byte2: u8, byte3: u8) -> (u8, u8, u8, u8) {
    let bit32 = u32::from_be_bytes([0, byte1, byte2, byte3]);
    ( ((bit32 >> 18) & LOW_6_BITS) as u8
    , ((bit32 >> 12) & LOW_6_BITS) as u8
    , ((bit32 >>  6) & LOW_6_BITS) as u8
    , ((bit32 >>  0) & LOW_6_BITS) as u8
    )
}

元のバイト列は3バイトの倍数とは限らないので、1 ないし 2 バイトの余りが発生する可能性がある。この余りを処理するための関数も書いておく。

base64.rs
fn into_3_bit6(byte1: u8, byte2: u8) -> (u8, u8, u8) {
    let bit32 = u32::from_be_bytes([byte1, byte2, 0, 0]);
    ( ((bit32 >> 26) & LOW_6_BITS) as u8
    , ((bit32 >> 20) & LOW_6_BITS) as u8
    , ((bit32 >> 14) & LOW_6_BITS) as u8
    )
}

fn into_2_bit6(byte: u8) -> (u8, u8) {
    let bit32 = u32::from_be_bytes([byte, 0, 0, 0]);
    ( ((bit32 >> 26) & LOW_6_BITS) as u8
    , ((bit32 >> 20) & LOW_6_BITS) as u8
    )
}

これで準備は整ったので、バイト列を再帰的に処理するための変換関数を書いていく。

base64.rs
fn into_bit6s(bytes: &[u8]) -> Vec<u8> {
    let mut bit6s = Vec::new();
    let mut rest = bytes;
    loop {
        match rest.len() {
            0 => {
                return bit6s;
            },
            1 => {
                let (bit6_1, bit6_2) = into_2_bit6(rest[0]);
                bit6s.push(bit6_1);
                bit6s.push(bit6_2);
                return bit6s;
            },
            2 => {
                let (bit6_1, bit6_2, bit6_3) = into_3_bit6(rest[0], rest[1]);
                bit6s.push(bit6_1);
                bit6s.push(bit6_2);
                bit6s.push(bit6_3);
                return bit6s;
            },
            _ => {
                let (bit6_1, bit6_2, bit6_3, bit6_4) = into_4_bit6(rest[0], rest[1], rest[2]);
                bit6s.push(bit6_1);
                bit6s.push(bit6_2);
                bit6s.push(bit6_3);
                bit6s.push(bit6_4);
            },
        }
        rest = &rest[3..];
    }
}

6 bit 列を ASCII 文字列に変換する

6 bit を ASCII 文字に変換するために辞書を用意する。

base64.rs
const BASE64_ENCODE_TABLE : &[u8]= concat!(
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ",   //  0-25
    "abcdefghijklmnopqrstuvwxyz",   // 26-51
    "01234567889+/",                // 52-63
).as_bytes();

これを使って単に BASE64_ENCODE_TABLE[bit6 as usize] とすれば 6 bit に対応する ASCII 文字が得られる。

エンコード処理の実装

ここまでで準備した関数を使えばエンコード処理は簡単に実装できる。

base64.rs
pub fn encode<T: AsRef<[u8]>>(input: T) -> String {
    let bit6s = into_bit6s(input.as_ref());
    let mut encoded: Vec<_> = bit6s.into_iter()
        .map(|bit6| { BASE64_ENCODE_TABLE[bit6 as usize] })
        .collect();

    while encoded.len() % 4 != 0 {
        encoded.push(b'=');
    }

    String::from_utf8(encoded).unwrap()
}

型引数の中で AsRef トレイトを使用しているが、こうしておくことで [u8] として解釈可能な型全般(= AsRef<[u8]> を実装している型)に対応できるようになり、汎用性が上がる。

ASCII 文字列を 6 bit 列に変換する

デコード処理を実装するために、今度は上記の流れを逆に辿っていく。まずは ASCII 文字列を 6 bit 列に変換するための関数を書いていく。

色々と方法は考えられるが、今回はエンコードの際と同様に ASCII 文字から 6 bit への辞書を用意することにした。ただし、入力の中には 6 bit に対応していない不正な文字が混じっている可能性があるため、それらも含めた辞書を用意している。不正な文字には INVALID_VALUE が対応するように実装している。[2]

base64.rs
const BASE64_DECODE_TABLE: [u8; 256] = generate_decode_table_from(
    &BASE64_ENCODE_TABLE
);

const INVALID_VALUE: u8 = 0xFF;

const fn generate_decode_table_from(encode_table: &[u8]) -> [u8; 256] {
    let mut decode_table = [INVALID_VALUE; 256];
    let mut index = 0;
    while index < 64 {
        decode_table[encode_table[index] as usize] = index as u8;
        index += 1;
    }
    decode_table
}

6 bit 列をバイト列に変換する

エンコードの時と考え方は同じである。まずは 4 つの 6 bit を 3 バイトに変換する関数を用意する。

base64.rs
fn into_3_byte(bit6_1: u8, bit6_2: u8, bit6_3: u8, bit6_4: u8) -> (u8, u8, u8) {
    ( (bit6_1 << 2) + (bit6_2 >> 4)
    , (bit6_2 << 4) + (bit6_3 >> 2)
    , (bit6_3 << 6) + (bit6_4 >> 0)
    )
}

エンコード文字列にパディングが含まれている場合、格納されている 6 bit が 4n + 3 ないし 4n + 2 個になるため、剰余分を処理するための関数も用意しておく。

base64.rs
fn into_2_byte(bit6_1: u8, bit6_2: u8, bit6_3: u8) -> (u8, u8) {
    ( (bit6_1 << 2) + (bit6_2 >> 4)
    , (bit6_2 << 4) + (bit6_3 >> 2)
    )
}

fn into_1_byte(bit6_1: u8, bit6_2: u8) -> u8 {
    (bit6_1 << 2) + (bit6_2 >> 4)
}

あとは変換のための関数を書くだけである。

base64.rs
fn into_bytes(bit6s: &[u8]) -> Vec<u8> {
    if bit6s.len() % 4 == 1 {
        // パディングが3つ付与されている場合にあたるがエンコードの規則上それはあり得ない
        panic!("Invalid 6-bits length. {}:{}", file!(), line!());
    }
    let mut bytes = Vec::new();
    let mut rest = bit6s;
    loop {
        match rest.len() {
            0 => {
                return bytes;
            },
            2 => {
                let byte = into_1_byte(rest[0], rest[1]);
                bytes.push(byte);
                return bytes;
            },
            3 => {
                let (byte1, byte2) = into_2_byte(rest[0], rest[1], rest[2]);
                bytes.push(byte1);
                bytes.push(byte2);
                return bytes;
            },
            _ => {
                let (byte1, byte2, byte3) = into_3_byte(rest[0], rest[1], rest[2], rest[3]);
                bytes.push(byte1);
                bytes.push(byte2);
                bytes.push(byte3);
            },
        }
        rest = &rest[4..];
    }
}

一応 6 bit 列の長さを 4 で割った余りが 1 の場合にパニックしているが、後ほど関数を呼ぶ側でもしっかりバリデーションすることで該当ケースには入らないように制御する。

入力のバリデーション

エンコードでは特に必要なかったが、デコード時はいくつかのエラーケースがあるため、バリデーションが必要となる。確認項目は大きく以下の4つである:

  1. 入力の長さが 4 の倍数になっているか
  2. BASE64 の定める ASCII 文字以外が含まれていないか
  3. 不正なパディングが挿入されていないか
  4. パディングが指定されている場合に最後の 6 bit の下位ビットはゼロ埋めされているか

まずは上記エラーに対応する列挙型を定義する。今回は こちら から拝借した。

base64.rs
#[derive(Debug)]
pub enum DecodeError {
    InvalidLength,
    InvalidByte(usize, u8),
    InvalidLastSymbol(usize, u8),
}

InvalidByte が先ほど挙げた確認項目の 2 と 3 の両方を受け持つため、定義項目は合計3つになっている。

あとは愚直に検証処理を書いていく。

base64.rs
fn validate_decoding_target(input: &[u8]) -> Result<(), DecodeError> {
    // インデックス範囲オーバーが怖いので空の場合は先に処理しておく
    if input.is_empty() {
        return Ok(());
    }

    // 入力の長さが4の倍数であることを確認する
    if input.len() % 4 != 0 {
        return Err(DecodeError::InvalidLength);
    }

    // 適切な ASCII 文字であることおよび不正パディングがないことを確認する
    let padding = count_padding(input);
    let invalid_value = input[..input.len()-padding].into_iter()
        .zip(0..input.len())
        .filter(|(value, _)| { BASE64_DECODE_TABLE[**value as usize] == INVALID_VALUE })
        .nth(0);
    if let Some((value, index)) = invalid_value {
        return Err(DecodeError::InvalidByte(index, *value));
    }

    // パディングがある場合に最後の要素の下位ビットがゼロ埋めされていることを確認する
    let last_non_pad_index = input.len() - padding - 1;
    let last_non_pad_elem = input[last_non_pad_index];
    let mask = match padding {
        2 => 0b0000_1111,
        1 => 0b0000_0011,
        _ => 0b0000_0000,
    };
    if BASE64_DECODE_TABLE[last_non_pad_elem as usize] & mask != 0 {
        return Err(DecodeError::InvalidLastSymbol(last_non_pad_index, last_non_pad_elem));
    }

    Ok(())
}

fn count_padding(input: &[u8]) -> usize {
    let (last, last2) = (input[input.len()-1], input[input.len()-2]);
    if last == PADDING && last2 == PADDING {
        2
    } else if last == PADDING {
        1
    } else {
        0
    }
}

デコード処理の実装

ここまでで必要な部品は揃ったので、デコード処理を書いていく。バリデーション → 6 bit 列に変換 → バイト列に変換 と順次処理していけば OK 。

base64.rs
pub fn decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, DecodeError> {
    let input = input.as_ref();
    if input.is_empty() {
        return Ok(vec!());
    }
    if let Err(e) = validate_decoding_target(input) {
        return Err(e);
    }

    let padding = count_padding(input);
    let bit6s: Vec<_> = input[..input.len()-padding].iter()
        .map(|symbol| { BASE64_DECODE_TABLE[*symbol as usize] })
        .collect();

    let decoded = into_bytes(bit6s.as_ref());

    Ok(decoded)
}

BASE64 については以上で完了である。テストを書いて色々試してみると楽しい。

Proxy の認証機能の実装

BASE64 を実装してようやく準備が整ったので、Proxy サーバの認証処理の追加を行っていく。

修正方針

Proxy の認証を行うには HTTP リクエストヘッダーに Proxy-Authorization タグを追加する必要がある。これは Proxy の URL にユーザとパスワードが指定された場合にのみ付与したいタグなので、Proxy へのリクエスト作成処理の部分を修正する必要がある。今回は request_http_get_with_proxy の該当箇所を以下のように修正することを考える:

client.rs
    let host = url.host_str().unwrap();
    let path = url.as_str();
    let auth = /* get from proxy URL*/;
    let request = format!(concat!(
        "GET {} HTTP/1.1\r\n",
        "Host: {}\r\n",
        "{}",
        "Connection: close\r\n\r\n",
    ), path, host, auth);

auth には Proxy-Authorization タグの内容全般が含まれることになるが、これを直接関数で返すようにするのはハードコードすぎてイマイチな感じがする。なので、認証情報のための構造体を用意して、そこからタグに変換できるようにする方針を採る。

Proxy 認証のための構造体の準備

HTTP の Proxy-Authorization タグは認証方式と認証文字列の2つの情報を要求するので、それに合わせてデータ構造を構築する。

client.rs
struct ProxyAuth {
    method: &'static str,
    credentials: String,
}

method には列挙型を使う方法もあるが、今のところは固定の文字列 "BASIC" しか使わないので静的文字列にしておく。

BASIC 認証の規則に従って、必要なメソッドを定義しておく。

client.rs
impl ProxyAuth {
    pub fn basic(username: &str, password: &str) -> ProxyAuth {
        let credentials = impl_ssl_tls::base64::encode(
            format!("{}:{}", username, password)
        );
        ProxyAuth { method: "BASIC", credentials }
    }
    pub fn as_tag(&self) -> String {
        format!("Proxy-Authorization: {} {}\r\n", self.method, self.credentials)
    }
}

Proxy 認証の取得処理

構造体が準備できたので、修正方針の項で /* get from proxy URL */ としていた部分を書いていく。

基本的には Url からユーザ名とパスワード取得すればよいだけだが、1点だけコーナーケースへの対応が必要である。Proxy の URL に http://user@proxy.com のようにパスワードの記述なしでユーザだけを指定される場合がある。パスワードなしで認証する手段はないので、この場合はエラーとする。[3]

以下が認証取得処理の実装である。

client.rs
fn get_proxy_auth(proxy: &Url) -> Result<Option<ProxyAuth>> {
    let user = proxy.username();
    let pass = proxy.password();

    // ユーザ名のみの指定は許さない
    if !user.is_empty() && pass.is_none() {
        // return something error
        return Err(Error::new(ErrorKind::Other,
            format!("Invalid proxy credentials: Expected password in {}", proxy)
        ));
    }

    // 認証なし
    if user.is_empty() {
        return Ok(None);
    }

    Ok(Some(ProxyAuth::basic(user, pass.unwrap())))
}

リクエスト作成処理の修正

これで認証情報が取得できるようになったので、後は修正方針の項で示したリクエスト作成処理を正式な形に修正すれば完了である。

client.rs
    let host = url.host_str().unwrap();
    let path = url.as_str();  // need to use the full URL when using proxy
    let auth = unwrap_or_return_err!(get_proxy_auth(&proxy_url))
        .map(|auth| { auth.as_tag() })
        .unwrap_or(String::new());
    let request = format!(concat!(
        "GET {} HTTP/1.1\r\n",
        "Host: {}\r\n",
        "{}",
        "Connection: close\r\n\r\n",
    ), path, host, auth);

以上で認証ありの Proxy サーバへの対応は完了である。Docker を立ち上げて以下のコマンドを実行すれば動作確認できる。

$ cargo run --bin client -- -p http://squid:password@localhost:3128 http://www.example.com

ここまでのコード全文は こちら を参照。

脚注
  1. 変換後の文字列を 4 の倍数の長さにするのは、8 bit と 6 bit の最小公倍数が 24 bit = 4 * 6 bit であることに由来すると思われる。しかし、パディングを挿入することによる実際上のメリットはほとんどない。本書には「パディング文字を確認することでデコード結果のバイト数がわかる」と書かれているが、実際にはパディング文字がなくともバイト数は計算可能である。Stack Overflow にはエンコード済み文字列を結合する際に有用とあるが、そのようなユースケースがいつ必要となるのかはわからない。また RFC にもパディングの目的は記載されていないらしい。そのような状況のため、実装によってはパディング文字の仕様を丸ごと無視しているケースもあるとのとこ。 ↩︎

  2. 対応する値がない可能性を表現するためには Option 型の使用が正道という気もするが、ここでは base64 クレートの ソースコード に倣って固定値にしている。少し調べてみたところ、Stack Overflow に「列挙型にはメモリのオーバーヘッドがある」とあるので、これを嫌った結果と思われる。 ↩︎

  3. 念のため BASIC 認証を定めた RFC 7617 も確認してみたが、パスワードがない場合についての記述は見当たらなかった。 ↩︎

dmystkdmystk

Chapter 1 : Understanding Internet Security (4)

Chapter 1 の続き。HTTP サーバを実装する。また、実装したサーバを Heroku にデプロイする。

Heroku にデプロイするのは、Proxy 経由での確認を行う際、Proxy サーバと HTTP サーバのドメインが一致している(=両方ともローカルホスト)と、Proxy がうまく接続を捌いてくれないためである。なので、HTTP サーバは Heroku 上で動かせるようにしておく。

実装する HTTP サーバの概要

実装するのは GET リクエストだけに対応した簡単な HTTP サーバである。GET 以外のメソッドがリクエストされた場合は 501 Not Implemented を返す。

基本的に SSL / TLS の学習に必要な最低限の機能しか実装しない。

事前準備

実装のためのバイナリターゲットを用意しておく。

$ touch src/bin/server.rs
Cargo.toml
[[bin]]
name = "server"
test = false
bench = false

HTTP サーバの実装

例によって、本書では C 言語での古式ゆかしい socketbindlistenaccept を使って実装しているが、Rust での HTTP サーバ実装はもっと簡単である。基本的に こちら で説明されている手順に従えば実装できる。

server.rs
use std::net::{TcpStream, TcpListener, SocketAddrV4, Ipv4Addr};

const DEFAULT_PORT: u16 = 7878;  // ポート番号は適当

fn main() {
    let listener = TcpListener::bind(
        SocketAddrV4::new(Ipv4Addr::LOCALHOST, DEFAULT_PORT)
    ).unwrap();

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                handle_connection(stream);
            },
            Err(e) => {
                eprintln!("Couldn't get client: {}", e);
            },
        }
    }
}

fn handle_connection(mut stream: TcpStream) {
    // TODO: implement here
}

TcpListener::bind() をコールすると裏で socketbindlisten の内容をまとめて処理してくれる。また listener.incoming() では accept を使ったループに当たる内容のイテレータを作成して渡してくれる。あとは handle_connection の中身を実装すれば完成である。とても楽。

handle_connection は以下のように実装してみた。GET 対象のパスどころかプロトコルバージョンの確認すらしていないが、今回の目的にとってはこれで十分である。

server.rs
use std::io::{Write, Read};
use std::cmp::Ordering;

const MAX_CHUNK_SIZE: usize = 1024;

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; MAX_CHUNK_SIZE];

    stream.read(&mut buffer).unwrap();
    println!("{}", String::from_utf8_lossy(&buffer[..]));

    let response = match buffer[..4].cmp(b"GET ") {
        Ordering::Equal => {
            let contents = include_str!("../../res/index.html");
            format!("HTTP/1.1 200 OK\r\n\r\n{}", contents)
        },
        _ => {
            let contents = include_str!("../../res/501.html");
            format!("HTTP/1.1 501 NOT IMPLEMENTED\r\n\r\n{}", contents)
        },
    };

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

それぞれのレスポンスで返す HTML ファイルとして res/index.htmlres/501.html を参照しているので、以下のコマンドで生成しておく。各 HTML ファイルの中身は適当でよいが、一応掲載しておく。

$ mkdir res
$ touch res/index.html
$ touch res/501.html
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Server</p>
  </body>
</html>
501.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>501 Not Implemented</title>
  </head>
  <body>
    <h1>501 Not Implemented</h1>
  </body>
</html>

fs クレート を使用せずに include_str!() で HTML ファイルを読み込んでいるが、include_str!() でファイルを読み込むと、バイナリビルド時、読み込まれたファイルは文字列としてバイナリの中に組み込まれる。後ほど Heroku にデプロイする際、全てがバイナリ1つにまとまっていた方が簡単なため、今回はこのような実装にしている。[1]

以下のコマンドで HTTP サーバを実行できる。ブラウザなどから http://localhost:7878 にアクセスすると res/index.html の中身が表示される。

$ cargo run --bin server

別ターミナルから curl を実行すれば GET 以外のメソッドでエラーが返されることも確認できる。data オプションを付与するとリクエストが POST で送信される。

$ curl --data "something-to-send" http://localhost:7878
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>501 Not Implemented</title>
  </head>
  <body>
    <h1>501 Not Implemented</h1>
  </body>
</html>

Heroku への対応

今後確認を行なっていくにあたり、Proxy サーバと HTTP サーバが両方ともローカルホストで動作していると都合が悪いことがあるため、HTTP サーバを Heroku にデプロイして動作させる。

Heroku のアカウント作成および Heroku CLI のインストールは実施されている前提とする。

Socket 設定の修正

Heroku へのデプロイの前にコードを少しだけ修正する。修正するのは以下の箇所である:

server.rs
    let listener = TcpListener::bind(
        SocketAddrV4::new(Ipv4Addr::LOCALHOST, DEFAULT_PORT)
    ).unwrap();

Ipv4Addr::LOCALHOST を指定しているが、これはローカルでの実行を想定しているためである。インターネットからの要求を受け付けるためには Ipv4Addr::UNSPECIFIED を指定する必要がある。[2]

また、ポート番号を固定値で指定しているが、Heroku では環境変数 PORT で渡される [3] ので、それを読み取って設定する必要がある。

修正後のコードは以下のようになる。

server.rs
const PORT_KEY: &'static str = "PORT";

fn main() {
    let port = std::env::var(PORT_KEY)
        .map(|port| { port.parse().unwrap() })
        .unwrap_or(DEFAULT_PORT);

    let listener = TcpListener::bind(
        SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)
    ).unwrap();

    // ... 省略 ...
}

Heroku へデプロイする

Heroku CLI を使用してアプリの設定を行う。Git に結びつくので、必要であれば git init を実行しておく。

$ heroku login
$ heroku create -a (your-app-name)
$ heroku git:remote -a (your-app-name)
$ heroku buildpacks:set emk/rust

Procfile を作成する。リリースビルドで生成されるバイナリの場所を指定すれば OK 。

$ touch Procfile
Procfile
web: ./target/release/server

リモートリポジトリの heroku に git push すればデプロイ完了である。

$ git push heroku main

http://(your-app-name).herokuapp.com にアクセスすれば動作確認できる。

以上で Chapter 1 : Understanding Internet Security は完了である。ここまでのコード全文は こちら を参照。Chapter 2 以降では、ここまでで実装したコードをベースにセキュリティ機能を実装していく。

脚注
  1. バイナリに取り込まずにコピー等をしたい場合はビルドスクリプト build.rs を用意する必要がある。ビルドスクリプトの仕様については こちら を参照。build.rs を使用したコピーの例は こちら を参照するとよい。 ↩︎

  2. Ipv4Addr::UNSPECIFIED の値は C 言語における INADDR_ANY に該当する。 ↩︎

  3. Heroku の 公式ドキュメント を参照。 ↩︎

dmystkdmystk

Chapter 2 : Protecting Against Eavesdroppers (1)

Chapter 2 では対称鍵暗号を使用したセキュリティ機能を実装する。具体的には、ブロック暗号アルゴリズムとして DES・AES を、ストリーム暗号アルゴリズムとして RC4 を実装する。

承前

SSL における暗号化の役割は、「攻撃者が攻撃成功までにかかる時間を、送信内容が価値を失うのに十分なだけ引き延ばす」ことにある。これは以下の内容による:

攻撃者の代表的な手口の1つにブルートフォース攻撃というものがある。送信内容の一部を推定し、それに一致する文字列が現れるまで考えうる全ての鍵のパターンを試してみるという攻撃手法である。

例えば、HTTP リクエストであれば、送信される内容は GET で始まる可能性が高い。従って、適当な鍵を使用して復号をかけ、得られた文字列の冒頭に GET が現れるまで繰り返せば、いずれは攻撃者が正しい鍵に行き当たり、内容を解読されてしまう。

ブルートフォース攻撃に対抗するには、鍵のサイズを十分に大きくすることで、攻撃者が全てのパターンの鍵を試すのにかかる時間を引き延ばす以外の方法はない。従って、SSL の暗号化の目的もこれに準ずることになる。

暗号化の方式には公開鍵暗号と対称鍵暗号の2つがあるが、Chapter 2 では後者を扱う。前者は Chapter 3 で扱うことになる。

余談だが、本書には Chapter 2 が最も技術的に濃密であると注意書きされている。怖い。

ブロック暗号

太古の原始的な暗号の手法は、ある文字を別の文字に置き換えることで暗号を作るというものであった。例えば、太古の代表的な換字式暗号であるシーザー暗号は、以下の対応表に示すように、アルファベットを3文字シフトすることで暗号を作る。

ABCDEFGHIJKLMNOPQRSTUVWXYZ
            ↓
DEFGHIJKLMNOPQRSTUVWXYZABC

この対応表に基づいて、実際に HELLO WORLD を暗号化してみると以下のようになる。実際はスペースを省略する場合が多いが、ここではわかりやすさのために残している。

HELLO WORLD
     ↓
KHOOR ZRUOG

シーザー暗号は平文の1文字に対して別の1文字を対応させる暗号化方法である。これは非常にシンプルで理解しやすいが、同時に攻撃者にとっては解読が容易な暗号である。

現代の暗号では、より解読を困難にするために、複数の文字を一度に処理する。特に固定長のデータを一度に処理する暗号化方式をブロック暗号アルゴリズムと呼ぶ。これは対称鍵暗号の最も一般的な用途の1つである。

DES の実装

ブロック暗号への理解を深めるため、まずは DES (Data Encryption Standard) を実装する。DES は既に安全な暗号とは見做されていないが、対称鍵暗号の学習のためのよい開始地点となる。

DES の仕様は こちら で定められている。DES は入力データを 8 バイト単位で処理するブロック暗号であり、鍵も同様に 8 バイトである。

現代の共通鍵暗号の多くがそうであるように、DES は XOR 演算に多くを依存している。XOR は同じ操作で変換と逆変換を行うことができるため、暗号化においては非常に有益である。以下に例を示す:

0011 xor 0101 → 0110
0110 xor 0101 → 0011

00110101 で XOR した結果は 0110 となるが、その 0110 に対して同様に 0101 で XOR をとると、元の値 0011 が得られる。これは 0101 を鍵として同じ操作を行うことで平文と暗号文を得ていることに相当する。

Initial Permutation

DES では最初に Initial Permutation と呼ばれるビットの置換処理(並べ替え)を行うことになっている。この処理は暗号の安全性には寄与せず、処理を行う目的も曖昧らしい。本書には特定のハードウェアへの最適化のためではないかと書かれている。

処理概要

Initial Permutation では、入力データの8つのバイトそれぞれから1ビットずつを取得して1つのバイトを作り、これを8回繰り返す。1回目では上から2番目のビットを取得する。以下の * になっている部分のビットを集めて1バイトを作るイメージである。

byte1: 0bX*XX_XXXX
byte2: 0bX*XX_XXXX
byte3: 0bX*XX_XXXX
byte4: 0bX*XX_XXXX
byte5: 0bX*XX_XXXX
byte6: 0bX*XX_XXXX
byte7: 0bX*XX_XXXX
byte8: 0bX*XX_XXXX

集めた *byte8 から順番に byte1 まで並べることで1つのバイトを作る。つまり、最上位ビットは byte8* になり、最下位ビットは byte1* になる。

これを上から2番目、4番目、6番目、と偶数4つを先に処理し、8番目に到達したら1番目に戻って、同様に奇数4つを処理していく。7番目のビットまで到達したら完了である。

実装の準備

今回はビット演算を多用するので、そのためのユーティリティと DES の実装の2つのモジュールを用意する。ビット演算のユーティリティはクレート内部でしか使用しないので、lib.rs の記述に pub を付与する必要はない。

$ touch src/des.rs
$ touch src/bit.rs
lib.rs
pub mod des;
mod bit;

ビットの取得・更新処理の実装

まずはビット演算ユーティリティの実装から始める。指定した位置のビットの値を取得したり更新したりできると便利なので、そのための関数を bit モジュールに用意しておく。データ型のバイト長に囚われたくないので、u8 の配列をターゲットに実装する。

bit.rs
// 引数 bytes の一番最初のバイトを最上位バイトと見做す。
// 引数 position は最上位ビットを 0 とするビット番号とする。

pub fn get_bit(bytes: &[u8], position: usize) -> u8 {
    let target = bytes[position / 8];
    let rem = position % 8;
    (target & (0b1000_0000 >> rem)) >> (7 - rem)
}

pub fn set_bit(bytes: &mut [u8], position: usize, value: u8) {
    let index = position / 8;
    let rem = position % 8;
    if value == 0 {
        bytes[index] = bytes[index] & !(0b1000_0000 >> rem);
    } else {
        bytes[index] = bytes[index] | (0b1000_0000 >> rem);
    }
}

set_bit では 0 以外の値が来たらビットに 1 を設定するようにしているが、引数が厳密に 1 であるかどうかをチェックするメリットがないのでそうしているだけである。特に深い意味はない。

ビットの置換処理の実装

先ほど実装した関数を利用してビット単位の置換関数を実装する。

bit.rs
pub fn permute(input: &[u8], output: &mut [u8], perm: &[usize]) {
    for i in 0..perm.len() {
        set_bit(output, i, get_bit(input, perm[i]));
    }
}

ここでは引数 perm で置換の内容を表すテーブルを受け取るように実装している。以下のテストコードが使い方のイメージを掴む役に立つかもしれない。

bit.rs
#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_permute_reverse() {
        let input = [ 0b1010_1010, 0b0101_0101, 0b1111_0000 ];
        let mut output = [0; 3];
        let reverse_perm = [
            23, 22, 21, 20, 19, 18, 17, 16,
            15, 14, 13, 12, 11, 10,  9,  8,
             7,  6,  5,  4,  3,  2,  1,  0,
        ];
        permute(&input, &mut output, &reverse_perm);
        assert_eq!(output, [ 0b0000_1111, 0b1010_1010, 0b0101_0101 ])
    }
}

reverse_perm が置換のテーブルである。input が合計3バイトなので 0-23 までのビットが存在するが、ここでは出力が入力のビットと逆順に(最上位ビットが最下位ビットに、最下位ビットが最上位ビットに)なるように置換を行なっている。

Initial Permutation の実装

これで置換処理は実装できたので、あとは Initial Permutation 用のテーブルを用意してやればよい。

des.rs
/// Initial Permutation Table
const ip_table: [usize; 64] = [
    57, 49, 41, 33, 25, 17,  9,  1,
    59, 51, 43, 35, 27, 19, 11,  3,
    61, 53, 45, 37, 29, 21, 13,  5,
    63, 55, 47, 39, 31, 23, 15,  7,
    56, 48, 40, 32, 24, 16,  8,  0,
    58, 50, 42, 34, 26, 18, 10,  2,
    60, 52, 44, 36, 28, 20, 12,  4,
    62, 54, 46, 38, 30, 22, 14,  6,
];

これで以下のように書けば Initial Permutation が行える。

crate::bit::permute(input, output, &ip_table);

Key Schedule

DES では「ラウンド」と呼ばれる一連の処理を繰り返し適用することで暗号を生成する。このような形式の暗号アルゴリズムを積暗号( Product Cipher )という。各ラウンドでは鍵から生成されたラウンド鍵が使用される。このラウンド鍵生成のためのアルゴリズムのことを一般に Key Schedule という。[1]

DES は 16 のラウンドからなり、16 個のラウンド鍵を生成する必要がある。ラウンド鍵は全て 48 ビットである。ラウンド鍵の生成は以下の手順で行われる:

  1. 鍵から 28 ビットのペアを生成する
  2. 28 ビットのペアそれぞれを左に 1 ないし 2 ビット分ローテートする
  3. ローテート済みのペアから 48 ビットのラウンド鍵を生成する
  4. 16 ラウンド全てのラウンド鍵が得られるまで 2 に戻って繰り返す

以下、それぞれのステップの詳細について述べる

鍵から 28 ビットのペアを生成する

最初に 8 バイトの鍵から 7 バイト分を取り出し、それを 28 ビットのペアに分ける。Initial Permutation と同様に、こちらも 8 バイトの各バイトから 1 ビットずつ取り出すことでバイトを構成していくが、各バイトの最下位ビットは使用しない。[2] また、Initial Permutation と異なり、ビットは上から1番目、2番目、3番目と取り出していく。さらに4番目を取り出す際には 4 ビット分しか取り出さない。つまり、以下の図における * の部分までしか使用しない。

byte1: 0b****_XXXX
byte2: 0b****_XXXX
byte3: 0b****_XXXX
byte4: 0b****_XXXX
byte5: 0b***X_XXXX
byte6: 0b***X_XXXX
byte7: 0b***X_XXXX
byte8: 0b***X_XXXX

4番目の半分まで取り出したら、今度は逆に7番目、6番目、5番目と取り出していき、最後に4番目の下半分を取り出す。以下の図の * の部分が後半で取得する 28 ビットである。

byte1: 0bXXXX_***X
byte2: 0bXXXX_***X
byte3: 0bXXXX_***X
byte4: 0bXXXX_***X
byte5: 0bXXX*_***X
byte6: 0bXXX*_***X
byte7: 0bXXX*_***X
byte8: 0bXXX*_***X

上記の操作を置換のテーブルで表現すると以下のようになる。

des.rs
/// Permuted Choice 1 Table
const pc1_table: [usize; 56] = [
    // 第1の 28 ビット
    56, 48, 40, 32, 24, 16,  8,  0,
    57, 49, 41, 33, 25, 17,  9,  1,
    58, 50, 42, 34, 26, 18, 10,  2,
    59, 51, 43, 35,
    // 第2の 28 ビット
    62, 54, 46, 38, 30, 22, 14,  6,
    61, 53, 45, 37, 29, 21, 13,  5,
    60, 52, 44, 36, 28, 20, 12,  4,
    27, 19, 11,  3,
];

28 ビットのペアそれぞれを左に 1 ないし 2 ビット分ローテートする

掲題の通りだが、ローテートするビット数はラウンド数に依存する。1・2・9・16 ラウンドでは 1 ビットで、それ以外は 2 ビットである。

実装では 1 ビットのローテート用の関数のみを用意する。

des.rs
/// 引数は7バイト=28ビットのペアの想定
fn rotate_left(target: &mut [u8]) {
    let carry_left  = (target[0] & 0b1000_0000) >> 3;
    let carry_right = (target[3] & 0b0000_1000) >> 3;

    target[0] = (target[0] << 1) | (target[1] >> 7);
    target[1] = (target[1] << 1) | (target[2] >> 7);
    target[2] = (target[2] << 1) | (target[3] >> 7);

    target[3] = (((target[3] << 1) | (target[4] >> 7)) & !0b0001_0000) | carry_left;

    target[4] = (target[4] << 1) | (target[5] >> 7);
    target[5] = (target[5] << 1) | (target[6] >> 7);
    target[6] = (target[6] << 1) | carry_right;
}

28 ビットのペアそれぞれをローテートする必要があるので、target[3] の処理が少し複雑になっている。

ローテート済みのペアから 48 ビットのラウンド鍵を生成する

基底のビット配列に則ってローテート済みの 28 ビットのペアから 48 ビットのラウンド鍵を生成する必要がある。28 ビットのペアを結合して得られる 7 バイトの配列に以下の置換テーブルを適用すればよい:

des.rs
/// Permuted Choice 2 Table
const pc2_table: [usize; 48] = [
    13, 16, 10, 23,  0,  4,
     2, 27, 14,  5, 20,  9,
    22, 18, 11,  3, 25,  7,
    15,  6, 26, 19, 12,  1,
    40, 51, 30, 36, 46, 54,
    29, 39, 50, 44, 32, 47,
    43, 48, 38, 55, 33, 52,
    45, 41, 49, 35, 28, 31,
];

以上でラウンド鍵の生成に必要な関数は揃った。

Expansion Function

DES の各ラウンドでは入力となる 8 バイトの下位 4 バイトとラウンド鍵で XOR 演算を行う。しかしながら、ラウンド鍵は 48 ビットなので、入力の下位 4 バイトを 48 ビットに拡張して XOR を行う。

拡張では以下のテーブルを参照する。

des.rs
const expansion_table: [usize; 48] = [
    31,  0,  1,  2,  3,  4,
     3,  4,  5,  6,  7,  8,
     7,  8,  9, 10, 11, 12,
    11, 12, 13, 14, 15, 16,
    15, 16, 17, 18, 19, 20,
    19, 20, 21, 22, 23, 24,
    23, 24, 25, 26, 27, 28,
    27, 28, 29, 30, 31,  0,
];

拡張は 4 バイトから 8 つの 6 ビットを生成することで実現される。各 6 ビットは上位および下位の 2 ビットが他の 6 ビットと重なるように生成される。

Substitution-Boxes (S-Box)

(工事中)

脚注
  1. 本書では生成されたラウンド鍵自体を Key Shcedule と呼ぶと書かれているが、Wikipedia にはアルゴリズムのことと書いてある。 ↩︎

  2. 各バイトの最下位ビットはパリティビットとして使用される。初期の DES 実装時には現代のものよりも信頼性の低いハードウェアが使用されていたことに由来すると本書には書いてある。 ↩︎