業務中に調べていたら発見したことなのですが、発見した後調べてみたら既にクラスメソッドさんの記事でも触れられていたことだったのでまとめる意味あるかなと思いつつ、自分で調べて突き止めたことなので記録に残しておいた方が自分の実力の証明になるかと思い、久々に技術系の記事を書いてみます。
dev.classmethod.jp
dev.classmethod.jp
TL;DR
この問題に気づいたきっかけ
もともとgolangのアプリを書いて気づいたわけではなく、KubernetesのCI/CD周りのエコシステムを調べている中でArgoCDの調査をしており、ArgoCDにSSH private keyを使ってgithub等のprivate repositoryを登録するのを試していたところ、ArgoCDのCLIがエラーを出したので、まずArgoCDのrepositoryにissue登録をしました。
github.com
issueにも書きましたが、出たエラーはこんな感じです。
$ argocd repo add git@github.com:<my private repository> --ssh-private-key-path ~/.ssh/id_rsa
FATA[0000] ssh: cannot decode encrypted private keys
そしてissue登録をした後にdelveでデバッグをしながらArgoCDのソースコードを見たところ、まずこの箇所で引っかかっていることが分かり、step inしながら調べたところ、ssh.ParsePrivateKey
関数にしか対応していないことが分かったので、単にArgoCDの問題かと思って一旦放置していました。
ただ、少し経った後「別に技術調査中なんだから、雑にこの箇所をssh.ParsePrivateKeyWithPassphrase
関数を使うように書き換えたCLIをビルドして試すのはありなんじゃないか?」と思ったので、雑に書き直した後ビルドして試したところ、またエラーが出てきて「あれ?」と問題に気づいたという流れです。
x/crypto/sshパッケージ自体に問題があると確信するためにした検証の仕方
上記の流れでArgoCDのCLIの挙動をdelveを使って追ったり、x/crypto/sshパッケージのソースコードを読んだりして、細かい検証をする前にx/crypto/sshパッケージ自体に問題がありそうだと思ってはいたのですが、VSCode経由でArgoCDのCLIをdelveでデバッグしていたときの変数の中身の表示がおかしかったりして、本当にx/crypto/sshパッケージの問題なのか他者に証明しづらかったので、ssh.ParsePrivateKeyWithPassphrase
関数の挙動がおかしいことを示すコードをgistに残し、挙動を確かめたところ、やはりssh.ParsePrivateKeyWithPassphrase
関数自体が手元のssh private key(パスフレーズ付き)をパース出来ないことが分かりました。
確認するのに使ったコードなどは上記のgistに全て書いているのですが、リンク先に飛ぶのも面倒なので以下に同内容のコードを説明付きで書いていきます。
使用したパスフレーズ付き秘密鍵を生成したOpenSSHのバージョン、秘密鍵の状態、Go言語のバージョンは以下の通りです。
$ ssh -V
OpenSSH_7.9p1, LibreSSL 2.7.3
$ ssh-keygen -l -f ~/.ssh/id_rsa
2048 SHA256:/<masked> taku@altair.local (RSA)
$ go version
go version go1.12.6 darwin/amd64
この環境で以下のGoのコードを実行します。
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"golang.org/x/crypto/ssh"
)
func main() {
sshPrivateKeyPath := os.Args[1]
keyData, err := ioutil.ReadFile(sshPrivateKeyPath)
if err != nil {
log.Fatal(err)
}
signer, err := ssh.ParsePrivateKey([]byte(keyData))
if err == nil {
fmt.Println(signer)
os.Exit(0)
}
passPhrase := os.Getenv("PASSPHRASE")
if passPhrase == "" {
fmt.Println("Please configure `PASSPHRASE` environment variable for your passphrase")
os.Exit(1)
}
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(passPhrase))
if err != nil {
log.Fatal(err)
}
fmt.Println("Parsed private key with passphrase!!")
fmt.Println(signer)
}
雑なコードですが、PASSPHRASE
環境変数が正しく設定されていて、実行時にパスフレーズ付き秘密鍵のパスを指定していればssh.ParsePrivateKeyWithPassphrase
関数を実行するようになっていて、signerが表示されるのが期待する動作というコードです。
これで意図通りに動くのか以下のように確かめました。
# ~/.ssh/id_rsa がパスフレーズが無い秘密鍵の時
$ go run main.go ~/.ssh/id_rsa
&{0xc0000ac120 0xc0000ac120}
# 事前にファイルを使って export PASSPHRASE="<~/.ssh/id_rsa のパスフレーズ>" と下準備して、パスフレーズ付き秘密鍵を使用する時
$ go run main.go ~/.ssh/id_rsa
2019/07/11 23:25:03 ssh: cannot decode encrypted private keys
exit status 1
上記のように、パスフレーズ付き秘密鍵の時にはエラーとなり、期待通りに動いていないことが分かりました。
このエラーが「SSH private key側の問題」なのか、「ssh.ParsePrivateKeyWithPassphrase
関数の問題」なのかを調べるため、delveを使って上記Goのソースコード実行時の状態を見たところ、以下の箇所でエラーが出ているところまで突き止めました。
詳しくはgistに残したコメントを参照して欲しいのですが、ここまで調べた段階で個人的には「エラーハンドリングが甘い(=ssh.ParsePrivateKeyWithPassphrase
関数の問題)のでは?」とほぼ確信はしていたのですが、普段扱っているわけではないgolangの話だったので、社内Slackの #golang チャンネルにgistのコードを添付して質問して他の人にもチェックしてもらったところ、やはりssh.ParsePrivateKeyWithPassphrase
関数の問題だと言われました。
自分で調べているときにも見つけてはいましたが、上記Slackチャンネルでも「以下issueに関係ある話なのでは?」と指摘され、ほぼ間違いなくx/crypto/sshパッケージ自体の問題だと判明。
github.com
何が問題だったかというと、OpenSSH 7.8からssh-keygenで生成される秘密鍵のフォーマットがOpenSSH形式になったのに、x/crypto/sshパッケージではまだそのOpenSSH形式のフォーマットのパスフレーズ付き秘密鍵をサポートできていなかったことがこのエラーを引き起こしていたのです。
自分自身ではこのOpenSSHのバージョン違いによる秘密鍵のフォーマットの差には気がつかなかったので、教えてもらえたことで最終的な原因に気がつけてよかったです。
教えてもらったときに参考に貼ってもらった記事は以下のものがありました。
qiita.com
検証後に気づいたことと感想
こういう風に色々調べきった後に改めて調べ直していたら、本記事の頭に書いたように既にクラスメソッドさんの記事でこの問題に言及されていたことに気づきました。
最初の方で見つけられていれば原因特定が早くなったと思いますが、今回自力で大半の原因を特定でき、分からない所は分かりそうな人に質問して特定するといったフローを実際に実行でき、経験が積めたので、今回はこれでよかったのかなと思います。
余談になりますが、このOpenSSH形式の秘密鍵をgolangで扱いたい場合は、x/crypto/sshに関するissueでコメントされているように、ScaleFT/sshkeysという3rd party製パッケージを使えば出来るみたいです。試したことはないので保証は出来ませんが。
golangの場合、delve(+VSCode)によるデバッグが非常に強力だというのも実感しました。またgolangを使ってデバッグをすることになったら今回の経験を活かしたいと思います。