SSH 秘密鍵ファイルの保護の強度気にしてますか？あるいは ssh-keygen の -a (rounds) オプションを調べた話

## 疑問

`ssh-keygen` コマンドで秘密鍵を生成するとき、 `ssh-keygen -t ed25519 -a 128` のように `-a` オプションで rounds を指定できる。デフォルト値は16になっている。 rounds とは何だろうか？どうも秘密鍵ファイルの保護の強度に影響するようだが。

TL;DR: [結論](#結論)までジャンプ

## ドキュメントを読む

`man ssh-keygen` を引用すると、

https://man.openbsd.org/ssh-keygen#a

> When saving a private key, this option specifies the number of KDF (key derivation function, currently [bcrypt_pbkdf(3)](https://man.openbsd.org/bcrypt_pbkdf.3)) rounds used. Higher numbers result in slower passphrase verification and increased resistance to brute-force password cracking (should the keys be stolen). The default is 16 rounds.

とのこと。秘密鍵ファイルを暗号化するための暗号鍵をパスフレーズから導出する `bcrypt_pbkdf` 関数のパラメータということだ。 `bcrypt_pbkdf` 関数は鍵導出関数である PBKDF2 を少し改変したものらしい。

PBKDF2 は rounds に似たパラメータとしてストレッチング回数あるいはイテレーションを持つ。入力されたパスワードとソルトにハッシュ関数を繰り返し適用する操作の回数のことで、導出された鍵の解読の困難さに直結し、コンピュータの計算性能が向上するに従って推奨される回数も引き上げられている。

2021年時点で OWASP が推奨[^1]するストレッチング回数は PBKDF2-HMAC-SHA-256 の場合で31万回。

https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html

2000年時点でも推奨回数は1000回だったというから、 rounds がストレッチング回数だとすれば少なすぎるように思える。

## 実装を読む

実際はどうなのか、 `bcrypt_pbkdf` 関数の実装を見ていく。

https://github.com/openssh/openssh-portable/blob/V_9_1_P1/openbsd-compat/bcrypt_pbkdf.c#L155-L161

```c
		for (i = 1; i < rounds; i++) {
			/* subsequent rounds, salt is previous output */
			crypto_hash_sha512(sha2salt, tmpout, sizeof(tmpout));
			bcrypt_hash(sha2pass, sha2salt, tmpout);
			for (j = 0; j < sizeof(out); j++)
				out[j] ^= tmpout[j];
		}
```

出力を固定長のブロックに区切ってブロックごとハッシュ関数を `rounds` 回適用している。まさにストレッチング回数のことだ。

ただし、ここではハッシュ関数として `bcrypt_hash` を呼んでいるんでいることに注意しなければならない。

https://github.com/openssh/openssh-portable/blob/V_9_1_P1/openbsd-compat/bcrypt_pbkdf.c#L45-L73

 `bcrypt_hash` 関数はパスワードハッシュ関数の `bcrypt` を若干改変したもので、鍵拡張のラウンドは64回に固定されている。

`n` ラウンドの `bcrypt` 関数を `m` 回適用すれば `n * m` ラウンドの `bcrypt` 関数を1回適用するのに近い解読困難性を得られると **仮定** するなら、 `bcrypt_pbkdf` 関数全体の解読困難性はラウンドが `ストレッチング回数 * 64` 回の `bcrypt` 関数に近いと言える……のだろうか？（さらにブロックの個数を掛けるべきかどうかは分からない。）だとすると `-a` オプションのデフォルト値16では `16 * 64 = 1024` ラウンドの `bcrypt` 関数に近い。

`bcrypt` 関数のラウンド数は2014年時点でも[4000回程度がいいとされている](https://security.stackexchange.com/a/83382)（現代ではもう何倍かすべきだろう）。 `-a 128` くらいにしてもいいのかもしれない。当然だが増やしすぎると秘密鍵ファイルのアンロックに時間がかかる。

## ベンチマークを取る

試しにいくつか秘密鍵ファイルを作り、アンロックにかかる時間を計測してみる。

まず作る。

```bash
for rounds in 16 32 64 128 256 512 1024; do
    ssh-keygen -a "$rounds" -f "key_$rounds" -N pass -C comment
done
```

[`hyperfine`](https://github.com/sharkdp/hyperfine) でベンチマークを取る。テストには秘密鍵ファイルのコメントを書き換えるサブコマンド `ssh-keygen -c` を使った。コマンドラインから `-N` でパスフレーズを渡せるサブコマンドはこれくらいなため。

```bash
% hyperfine \
    'ssh-keygen -c -f key_16 -N pass -C comment' \
    'ssh-keygen -c -f key_32 -N pass -C comment' \
    'ssh-keygen -c -f key_64 -N pass -C comment' \
    'ssh-keygen -c -f key_128 -N pass -C comment' \
    'ssh-keygen -c -f key_256 -N pass -C comment' \
    'ssh-keygen -c -f key_512 -N pass -C comment' \
    'ssh-keygen -c -f key_1024 -N pass -C comment'
```

<details>

<summary>実行結果</summary>

```
Benchmark 1: ssh-keygen -c -f key_16 -N pass -C comment
  Time (mean ± σ):     115.1 ms ±   0.7 ms    [User: 112.2 ms, System: 1.3 ms]
  Range (min … max):   114.5 ms … 117.5 ms    25 runs

Benchmark 2: ssh-keygen -c -f key_32 -N pass -C comment
  Time (mean ± σ):     226.3 ms ±   0.6 ms    [User: 222.8 ms, System: 1.5 ms]
  Range (min … max):   225.7 ms … 228.1 ms    13 runs

Benchmark 3: ssh-keygen -c -f key_64 -N pass -C comment
  Time (mean ± σ):     448.1 ms ±   0.5 ms    [User: 444.0 ms, System: 2.0 ms]
  Range (min … max):   447.6 ms … 449.2 ms    10 runs

Benchmark 4: ssh-keygen -c -f key_128 -N pass -C comment
  Time (mean ± σ):     890.8 ms ±   0.4 ms    [User: 886.2 ms, System: 2.7 ms]
  Range (min … max):   890.1 ms … 891.3 ms    10 runs

Benchmark 5: ssh-keygen -c -f key_256 -N pass -C comment
  Time (mean ± σ):      1.778 s ±  0.002 s    [User: 1.771 s, System: 0.005 s]
  Range (min … max):    1.776 s …  1.783 s    10 runs

Benchmark 6: ssh-keygen -c -f key_512 -N pass -C comment
  Time (mean ± σ):      3.554 s ±  0.008 s    [User: 3.542 s, System: 0.008 s]
  Range (min … max):    3.549 s …  3.568 s    10 runs

Benchmark 7: ssh-keygen -c -f key_1024 -N pass -C comment
  Time (mean ± σ):      7.102 s ±  0.025 s    [User: 7.081 s, System: 0.015 s]
  Range (min … max):    7.085 s …  7.171 s    10 runs

Summary
  'ssh-keygen -c -f key_16 -N pass -C comment' ran
    1.97 ± 0.01 times faster than 'ssh-keygen -c -f key_32 -N pass -C comment'
    3.89 ± 0.03 times faster than 'ssh-keygen -c -f key_64 -N pass -C comment'
    7.74 ± 0.05 times faster than 'ssh-keygen -c -f key_128 -N pass -C comment'
   15.45 ± 0.10 times faster than 'ssh-keygen -c -f key_256 -N pass -C comment'
   30.88 ± 0.21 times faster than 'ssh-keygen -c -f key_512 -N pass -C comment'
   61.70 ± 0.45 times faster than 'ssh-keygen -c -f key_1024 -N pass -C comment'
```

</details>

<details>

<summary>マシンスペック</summary>

```
% ssh -V
OpenSSH_8.6p1, LibreSSL 3.3.6

% system_profiler SPSoftwareDataType SPHardwareDataType
Software:

    System Software Overview:

      System Version: macOS 12.6 (21G115)
      Kernel Version: Darwin 21.6.0
      Boot Volume: Macintosh HD
      Boot Mode: Normal
      Computer Name: natsu
      User Name: uasi (uasi)
      Secure Virtual Memory: Enabled
      System Integrity Protection: Enabled
      Time since boot: 32 days 17:01

Hardware:

    Hardware Overview:

      Model Name: Mac Studio
      Model Identifier: Mac13,1
      Chip: Apple M1 Max
      Total Number of Cores: 10 (8 performance and 2 efficiency)
      Memory: 32 GB
      System Firmware Version: 7459.141.1
      OS Loader Version: 7459.141.1
      Serial Number (system): DXH63NHWCY
      Hardware UUID: 426393CF-E570-5F8B-96D1-083B7FDC0CC4
      Provisioning UDID: 00006001-000461C03445801E
      Activation Lock Status: Enabled
```

</details>

ベンチマーク結果は折りたたんでおくが、 rounds を倍にすると秘密鍵ファイルのアンロックにかかる時間もおよそ倍になる。 Mac Studio (M1 Max) で

- rounds = 16: 115.1 ms
- rounds = 128: 890.8 ms
- rounds = 256: 1.778 s

程度。現代のハードウェアなら rounds を128にしても実用的な範囲に収まるだろう。

## 結論

- `ssh-keygen` の `-a` オプションの値 rounds は秘密鍵ファイル保護用のパスフレーズから暗号鍵を導出する計算量のこと。大きいほど秘密鍵ファイルの解読困難性が上がるが、アンロックにかかる時間も比例して長くなる
- rounds のデフォルト値である16は2022年現在では低すぎるように思える（が確信はない）
- 現在のハードウェアでは50〜100程度でもアンロックが1秒以内で終わるため、高めに設定しても損はないはず

既存の秘密鍵ファイルの rounds を変更するには `ssh-keygen -p -a 128 -f /path/to/key` でいける。元々パスフレーズ変更用のコマンドなので新しいパスフレーズの入力も促されるが、同じにしてよい。

[^1]: そもそも可能ならより高性能な他の鍵導出関数を使うべきだという話も書かれている