始まり
WebブラウザだけでPuTTY PPKファイルの作成をしたい。
しかも真にブラウザだけで生成したく、サーバーで生成とかはダメ。
できるの?
結論、出来た。
しかしいくつかはまった点があったのでそれの記録。
詳細
.ppkファイルに関してはPuTTYのドキュメントに書いてあるので簡単に作れると思った。
以降掲載のコードは色々省略しちゃっている部分があります。
そのままでは動きませんが細かい説明は省略します。
鍵生成
鍵の生成自体はWeb Crypto APIで簡単に出来る
const keyPair = await window.crypto.subtle.generateKey({
"name":"ECDSA",
"namedCurve":"P-384"},
true,
["sign", "verify"]
);
const publicKey = await window.crypto.subtle.exportKey("raw",keyPair.publicKey);
//base64Udecode(data:string) => Uint8Array
//Base64URLのデコード
const prvKey = base64Udecode(
await window.crypto.subtle.exportKey("jwk",keyPair.privateKey).d);
はまったこと1
MACが一致しないと怒らた。
Version=3ではPrivate-MAC:に鍵ファイルのMACが書かれていて鍵ペアの正当性検証を行っている。
MACの値は
The final thing in the key file is the MAC:
Private-MAC: hex-mac-data
hex-mac-data is a hexadecimal-encoded value, 64 digits long (i.e. 32 bytes), >generated using the HMAC-SHA-256 algorithm with the following binary data as input:string: the algorithm-name header field.
string: the encryption-type header field.
string: the key-comment-string header field.
string: the binary public key data, as decoded from the base64 lines after the >‘Public-Lines’ header.
string: the plaintext of the binary private key data, as decoded from the base64 >lines after the ‘Private-Lines’ header. If that data was stored encrypted, then the >decrypted version of it is used in this MAC preimage, including the random padding >mentioned above.
The MAC key is derived from the passphrase: see section C.4.
だそうだ。
最初はこうやってみた
const ppkkey = await ppkKeyDerivation(passphrase);
const txtEnc = new TextEncoder();
// bufferJoin(buffers:Array<Uint8Array>) => Uint8Array
// buffersを結合して一つのUint8Arrayにする
const singTarget = bufferJoin([
txtEnc.encode(algorithmName),
txtEnc.encode(encryptType),
txtEnc.encode(comment),
publickKey,
privateKey,]);
const singKey = await window.crypto.subtle.importKey(
"raw",
ppkkey.macKey,
{"name":"HMAC","hash":"SHA-256"},
false,["sign"]);
const macValue = await window.crypto.subtle.sign(
{"name":"HMAC","hash":"SHA-256"},
singKey,
singTarget);
失敗した。
最初はmacKeyの生成を疑った
passphraseがない場合は"zero-length key"になると書いてある。
If encryption-type is ‘none’, then all three of these pieces of data have zero length. (The MAC is still generated and checked in the key file format, but it has a zero-length key.)
Web Cryptoのsignには0サイズの鍵は渡せないが
HMACのアルゴリズム上、0サイズと鍵の中身が0x00で埋まっているのは等価であるはずで
ppkKeyDerivation()はpassphraseがないとmacKeyは0x00....00(32)を返すので
passphrase無し状態では問題は無いはずだった。
悩んでも解決しなかったのでPuTTYのソースに頼った
が、残念ながら私はCは殆ど読めなかった。
デバッグ環境をどうにか用意して実動作を見ながら原因を探ってみた。
結果、単に値を繋げるのではなく値毎に頭に4バイトのサイズ情報を付ける必要があった
例えば
algorithm-name: ecdsa-sha2-nistp384
Encryption: none
とかなっていたら
誤:65636473612d736861322d6e697374703338346e6f6e65
正:0000001365636473612d736861322d6e69737470333834000000046e6f6e65
こんな感じにする必要がある。
これでpassphrase無しの.ppkは出来た。
はまったこと2
passphraseを付けたらまたMACがおかしい。
passphrase無しとの差はprivateKeyが暗号化されているか、macKeyもpassphraseから作られるという事。
ppk v3ではpassphraseから実際に使う鍵を作るのにArgon2を使う。
Argon2 takes two extra string inputs in addition to the passphrase and the salt: a secret key, and some ‘associated data’. In PPK's use of Argon2, these are both set to the empty string.
The ‘tag length’ parameter to Argon2 (i.e. the amount of data it is asked to output) is set to the sum of the lengths of all of the data items required, i.e. (cipher key length + IV length + MAC key length). The output data is interpreted as the concatenation of the cipher key, the IV and the MAC key, in that order.
So, for ‘aes256-cbc’, the tag length will be 32+16+32 = 80 bytes; of the 80 bytes of output data, the first 32 bytes are used as the 256-bit AES key, the next 16 as the CBC IV, and the final 32 bytes as the HMAC-SHA-256 key.
Argon2をブラウザで得るにはさすがに標準では使えないのでライブラリを導入する。
Argon2で80バイトを生成して
privateKeyはAES256-CBCで鍵は最初の32バイトでIVに次の16バイトを使う。
MACKeyは次の32バイトを使うとなっている。
ppkKeyDerivation(passphrase)はそこをうまく処理して各鍵を返してくれる関数。
ppkKeyDerivation(passphrase) => {
encryptKey:Uint8Array(32),
iv:Uint8Array(16),
macKey:Uint8Array(32),
}
PuTTYの動きを確認して鍵導出に問題がないことは確認できた。
では何が違うのか
暗号化の処理でAES-CBSではパディングが必要なのでランダム値で埋めて、
パディングも含めてHMACの対象にする。
In order to encrypt the private key data with AES, it must be a multiple of 16 bytes (the AES cipher block length). This is achieved by appending random padding to the data before encrypting it. When decoding it after decryption, the random data can be ignored: the internal structure of the data is enough to tell you when you've reached the end of the meaningful part.
const ppkkey = await ppkKeyDerivation(passphrase);
const encKey = await window.crypto.subtle.importKey(
"raw",
ppkkey.encryptKey,
"AES-CBC",
false,
["encrypt"]);
const plainPrivateKey = getSSHDataBlock(this.keyData.privateKey);
if(plainPrivateKey.length%16 !== 0){
const padding = window.crypto.getRandomValues(
new Uint8Array((16-plainPrivateKey.length%16))
);
const paddingKey = new Uint8Array(plainPrivateKey.length+padding.length);
paddingKey.set(plainPrivateKey);
paddingKey.set(padding,plainPrivateKey.length);
plainPrivateKey = paddingKey;
}
const encryptPrivateKey = new Uint8Array(
await window.crypto.subtle.encrypt({
name:"AES-CBC",
iv:ppkkey.iv},
encKey,
plainPrivateKey)
);
うまくいかない。
原因はパディングにある。
よくよく考えてみるとWeb Cypto APIのencrypt(AES-CBS)にはパディング方法の指定がない。
省略ではなくそもそも指定が出来ない。
調べてみるとパディングはPKCS#7しか無い
When operating in CBC mode, messages that are not exact multiples of the AES block size (16 bytes) can be padded under a variety of padding schemes. In the Web Crypto API, the only padding mode that is supported is that of PKCS#7, as described by Section 10.3, step 2, of [RFC2315].
つまりencrypt()を通すとPKCS#7のパディングがついてしまい
パディングはの分HMACの計算が合わなくなってしまっていた。
パディングが指定できない以上これはどうしようもない。
しかしPKCS#7パディングはどんなパディングになるか予測は出来る。
パディングのサイズそのもの値が入る。
それが入ることを前提にHMACがを計算すれば解決。
まあパディングはランダムデータでという仕様に合わないがとりあえずはいけるのでそれで。
Crypt-JSで{padding:CryptoJS.pad.NoPadding}を使うのも手。
おしまい
- .ppkをわざわざPuTTYGenを使わず扱うような需要はほとんどないらしく情報がほとんど探せなかった。
- WebCrypto encrypt()にパディングのオプション欲しいなあ、せめてNoPaddingがあれば。
- あとArgon2も
- あ、Ed25519とかもあったらいいなあ