PuTTY PPK(V3)をシェルスクリプトで作る

# はじめに
## 背景
本記事は、@ripple_naip氏の記事[「PuTTY PPK(V3)をブラウザで作る」](https://qiita.com/ripple_naip/items/46755f14fc2dfaab0fd8)に触発されて、シェルスクリプトでもPuTTY PPK(V3)のSSH認証鍵が作れないかどうかを検討した記事になります。

もちろん、PuTTYのツールセットはLinux版も存在しますので、コマンドライン版のPuTTYgenをインストールして使えば済む話ではあるのですが、本実装を通じて鍵の内部構造に親しむようにしよう、というのが趣旨となっております。

なお、各種鍵のアルゴリズムに対応するのは面倒だったので、今回はed25519限定となることをご了承ください。

## 環境
スクリプトは Ubuntu 18.04(Windows10 WSL1)/OpenSSL 1.1.1n/Argon2 0~20161029-1.1 で、鍵の利用については WinSCP 6.1.1/PuTTYgen 0.78 で動作確認をしております。
なお、OpenSSLでed25519を扱うために OpenSSL1.1.1以上、鍵を使うツールでPPK(v3)を扱うためにはPuTTYなら0.75以上、WinSCPなら5.18.5以上が必要です。

最後の Argon2 は、鍵をパスフレーズ保護する際のKDFとして使用するものです。Ubuntuのパッケージリポジトリに収録されている argon2パッケージ ( [phc-winner-argon2](https://github.com/P-H-C/phc-winner-argon2) ) をそのまま使っています。今回の環境ではそもそも変更できないのですが、アルゴリズムのバージョンは v1.0, v1.3 の選択肢の内デフォルトの v1.3 を使っています。( argon2 の新しいバージョンでは`-v`のオプションで v1.0 も選択できるようです )

# ツールの紹介
## 概要
ツールは、[gen-ed25519-ppk.sh](https://github.com/angel-p57/qiita-sample/blob/master/puttyppk/gen-ed25519-ppk.sh)として、githubのリポジトリにあげています。
次の画像の例のように、スクリプトを実行すると鍵ファイルが生成され、それをWindows側のPuTTYgenでロードして正常に認識できています。
※別途WinSCPを使ったログイン確認も行っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74396/830dc254-4dcf-fc83-1ac8-9318ae6fa1a7.png)

使用法については、リポジトリ上のスクリプトのコメントをご覧いただきたいのですが、コマンドラインオプションで以下の切り替えができるようになっています。

* パスフレーズ保護(暗号化)を行うか行わないか
* 既存のOpenSSH秘密鍵からのコンバートか、新規の鍵生成か  
  ※既存のOpenSSH秘密鍵はパスフレーズ保護されていない前提としています
* パスフレーズ保護をする場合に、パスフレーズをコマンドラインオプションで指定するか、プロンプトから入力するか
* 鍵データを標準出力に出力するか、指定のファイルに出力するか

## 動作内容
* 秘密鍵データについて  
  以前の記事[「OpenSSH/OpenSSL 鍵データ相互変換」の「楽じゃないやつ(ed25519)」の章](/angel_p_57/items/6e826105d50cbb0e0abe#%E6%A5%BD%E3%81%98%E3%82%83%E3%81%AA%E3%81%84%E3%82%84%E3%81%A4ed25519)にある通り、OpenSSHの秘密鍵の場合は、決まった位置に鍵データが埋め込まれています。なので、それを抽出するだけで済みます。  
  新規に鍵を作る場合ですが、秘密鍵は単なる32バイトのバイト列でしかないため、単にそれだけのランダムなデータを作れば済みます。  
  こうして作成したデータは、データ長を示す4バイトデータを付与して16進文字列化し、スクリプト内では `priv_hex` という変数に保存して使いまわします。
* 公開鍵データについて  
  秘密鍵データをもとに、OpenSSLのpkeyサブコマンドで公開鍵データを生成しています。  
  上記記事にあるとおり、OpenSSLの場合も秘密鍵・公開鍵ともコアなデータを除けば固定パターンのデータになっているため、「テンプレートに秘密鍵のコア部分の32バイトデータを埋め込んでpkeyサブコマンドに渡し、公開鍵を出力させる」「公開鍵からコア部分の32バイトデータを取り出す」という処理を行うだけです。  
  こうして作成したデータは、秘密鍵データと同様、データ長を付与して16進文字列化し、スクリプト内では `pub_hex` という変数に保存します。  
  実際に `Public-Lines`行の後続としてファイルに公開鍵を書き出す際は、次の図に示すように、先頭に方式を示す `ssh-ed25519` という文字列を、これまた文字列上情報として付与した上で base64エンコードします。これは、改行を除けば、OpenSSH形式の公開鍵のbase64文字列部分と同じ内容です。( ? はデータによって変動するものを表します )  
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74396/370007b0-d510-a2c8-4749-652754022826.png)
* 秘密鍵のエンコードについて  
  こちらは、秘密鍵データを `Private-Lines`行の後続としてファイルに書き出す際のデータの話になります。  
  パスフレーズ保護(暗号化)がない場合は、先頭にデータ長情報をつけた36バイトのデータをそのままbase64エンコードするだけですが、保護がある場合は OpenSSL の enc サブコマンドで aes256-cbc を指定し暗号化した上でbase64エンコードします。  
  その際、暗号化用の共通鍵データ(-K)および初期ベクトル(-iv)をパスフレーズから求めるために argon2 コマンドを使っています。指定するパラメータはソルトを除いてスクリプトで固定していますが、変更かけても多分使えると思います。ソルトのみ、16バイトのランダムデータとして実行時に生成しています。( 鍵ファイルに `Argon2-Salt`行として書き出す時は16進文字列化します )  
  なお、aes256-cbcは16バイトブロック単位での処理になるため、ランダムデータをパディングとして12バイト付与し、48バイトのデータとして暗号化にかけます。できる暗号化データも48バイト ( base64エンコードで64文字分 ) です。
* MACの計算  
  PuTTY PPK形式では、鍵データの整合性チェックのため、v3形式では sha256 によるHMAC値を計算して、`Private-MAC`行に16進文字列化して記録するようになっています。こちらは、OpenSSL の `dgst` サブコマンドで sha256 と `-mac HMAC` を指定して値を計算します。  
  この際HMAC計算用に使われる鍵データは、パスフレーズ保護がある場合は argon2 での共通鍵・初期ベクトルと一緒に生成したものを使います。保護がない場合はゼロデータ ( NUL文字32バイト分 ) です。
  そして肝心のHMACにかけるデータですが、こちらにはもろもろのデータにデータ長を付与した上で連結してつくります。公開鍵もbase64エンコードする前のデータを、秘密鍵なら暗号化する前のデータ ( 保護がない場合はbase64エンコードする前のデータ ) を、更にデータ長を付与する形になります。なお、コメントも連結する対象になっていますので、コメントを変更するだけでもHMAC値の再計算が必要となっています。  
  以下に、パスフレーズ保護ありの場合の連結データの内訳 ( コメント長15バイトの例 ) を図示します。暗号化の前に付与するパディングも含まれることに注意が必要です。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74396/68f00404-5205-9043-ce8f-7e918bda5b48.png)

# 補足
## スクリプトを組むにあたっての注意点
* バイナリ情報の取り扱い  
  今回取り扱うデータは非印字文字が多いため、ツール内では16進文字列化して取りまわしています。暗号化の際の共通鍵や初期ベクトルについては、opensslコマンド側で16進文字列をそのまま受け入れてくれるので問題がないのですが、一部生でバイナリデータをコマンドライン引数として指定する必要があり、少し注意が必要となります。  
  それはなぜかと言うと、コマンドライン引数ではどうやってもNUL文字(ゼロデータ)をバイナリのまま指定することができないからです。これに引っかかるのは、argon2 コマンドに指定するソルト文字列です。  
  幸いにして、ソルト文字列はツール側で好きに決めても良いものなので、ランダムデータを多めに作った上でNUL文字を抜いて、所定のバイト数を切り取るようにして作っています。  
  なお、HMAC計算時の鍵ですが、OpenSSLの `dgst`サブコマンドで `-hmac 鍵文字列` 形式のオプション指定を選ぶと同じ問題にぶちあたります。こちらは鍵を argon2 で導出していますから、NUL文字の回避は少しやっかいです。そのため、`-mac HMAC -macopt hexkey:16進文字列化した鍵` の指定を選んでバイナリを直に指定しないで済むような方法を採っています。
* 暗号化時のパディング  
  冒頭で紹介した[PuTTY PPK(V3)をブラウザで作る](https://qiita.com/ripple_naip/items/46755f14fc2dfaab0fd8)でも課題として挙げられていますが、実は OpenSSL の `enc` サブコマンドで暗号化する場合も、OpenSSLが自動でパディングしようとしますので注意が必要です。  
  どのようなパディングかと言うと、暗号化対象のデータがブロック長 ( 今回は16バイト ) の倍数になるように、1バイト以上ブロック長以下の、同一バイトの繰り返しを末尾に追加するというものです。なおこの「同一バイト」は追加するバイト数をそのままASCIIコードとした文字になります。なので、10文字の `0123456789` であれば `\006` という文字を6文字追加で `0123456789\006\006\006\006\006\006` というデータを作り出すような形になります。このパディングのパターンは確実性がそこまで高いわけではありませんが、復号時の整合性チェックに使うという役割も担います。( 復号してこのパターンが現れないようなら鍵やデータが不正とみなせる )  
  実際問題、別にこのパディング方式でお任せしても、ランダムでないだけで、鍵ファイルとしての整合性に問題が出る訳ではありませんが、なにか癪に障ります。そこで今回は、「ランダムバイトを自分で追加して、OpenSSLが追加したパディング分は後から捨てる」という方式を取りました。次の図に示すように、CBCで暗号化している状況では、自前のパディングが丁度ブロック境界になるようにしておけば、暗号化後にできた最後のブロックを捨てることで、欲しい暗号化データが得られるのです。  
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74396/6b82e1c4-da80-3616-4729-caa09b15d29a.png)  
  なお、今回は自前で復号する処理はありませんが、もしOpenSSLで復号を行う場合、この捨てたブロックの部分をなんとかしなければなりません。前述の通り「復号時の整合性チェック」に絡みますのでどうしてもエラーは避けられないのですが、それでもダミーを追加することで欲しい部分のデータは復号することができます。以下に "Hello, PPK!" をこの手法で暗号化・復号した例を挙げます。  
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74396/5e949ddc-f761-11ab-8252-8a7cb5f26bb1.png)

# おわりに
ということで、PPK(v3)をシェルスクリプトでどのように実装したかのお話でした。詳細が気になる方は、こちらの説明を元に、実際のスクリプトの中身をご覧いただくのが早いと思います。
実用上は正規のツールであるPuTTYgenを使えばよいわけで、そういう意味での役には立たないと思いますが、データの内部構造を整理するために、このようにツールを作って処理してみるというのは役に立つ場面もあるのではないかと思います。
ed25519以外の方式については、秘密鍵のデータを構成するのが面倒なので見送っていますが、似たような形で処理はできると思います。興味があれば同じようにツールで作成するよう挑戦してみるのも一興ではないでしょうか。