はじめに
シェルスクリプト用に HMAC-SHA1 アルゴリズムをシェル関数で実装しました。これを使えば(CLI 上やシェルスクリプトでセキュリティリスクがある)openssl
コマンドを使わずに HMAC-SHA1 を求めることができます。おまけでバイナリフォーマットと Base64 エンコードでの出力にも対応しています。bash 専用ではなく、ほぼ全ての POSIX シェルに対応しています。
脆弱な SHA1 で安全とは何を言ってるんだ思うかもしれませんがそういう話ではないので(また記事を読まずに指摘する人がいそう)。実際に HMAC-SHA1 が使われている(使わなきゃいけない)例があるんだからしょうがない。SHA1 は私が選んだことではありませんし SHA1 は話の本質ではありません。
どこで使うの?
世の中はとっくに OAuth 2.0 の時代ですが、今もまだしぶとく残っている OAuth 1.0 の認証で使います。詳細は省きますが OAuth 1.0 の認証フローの途中の署名の計算で HMAC-SHA1 を使います(参考 RFC5859)。その他の具体例は知りませんが、他の用途でも使われることがあるかもしれません。
このライブラリが必要な人は限られていると思いますが、心当たりがある人はこのライブラリを使うようにすることで多少のパフォーマンスの低下と引き換えに安全なシェルスクリプトを手にすることができます。実際にはこれを使うだけではおそらく不十分で、その他の部分のシェルスクリプトの書き換えが必要になるでしょう。理由は後述しています。
Base64 エンコードでの出力にも対応しているので副次的なメリットとして openssl
コマンドへの依存をなくすことができます。ただ、どちらにしろインストールするであろう curl
や wget
コマンドの依存関係として勝手にインストールされる気もします。足りないコマンドはインストールすれば良いだけの話なので、これはあまり重要なことではありません。
なぜ作ったのか?
openssl
コマンドを使った HMAC-SHA1 の算出方法の問題点に気づいてしまったからです。なにもかも今更な話で 10 年ぐらい前にやっておけという話なのですが、私がこの問題点に気づいたのがつい最近のことなので仕方ありません。
openssl コマンドの問題点
openssl
コマンドを使っての HMAC-SHA1 の算出には、セキュリティ上のちょっとした問題があります。OpenSSL よりもセキュリティを重視した LibreSSL を使おうとかいう話ではなくて、あくまで現在のバージョンの CLI の openssl
コマンドの問題で、(OepnSSL のフォークの)LibreSSL 版の openssl
コマンドも同様で、おそらく HMAC の生成に限った話です。もちろん将来的には解決される可能性はあります。
openssl
コマンドを使って HMAC-SHA1 を求めるには例えば次のように書きます。
$ printf '%s' 'value' | openssl sha1 -hmac 'secret_key'
SHA1(stdin)= b75db159dc00e1e84e251a1ea6176359e7427901
# 上記は RedHat系 Linux (AlmaLinux など) で以下のエラーになる(ことがある?)
Error setting context
806B38B54A7F0000:error:0300009E:digital envelope routines:do_sigver_init:no default digest:crypto/evp/m_sigver.c:372:
# 通常の指定方法(man openssl-dgst 参照)
$ printf '%s' 'value' | openssl dgst -sha1 -mac HMAC -macopt 'key:secret_key'
SHA1(stdin)= b75db159dc00e1e84e251a1ea6176359e7427901
# 別の指定方法(man openssl-mac 参照)
$ printf '%s' 'value' | openssl mac -digest SHA1 -macopt 'key:secret_key' HMAC
B75DB159DC00E1E84E251A1EA6176359E7427901
-hmac
オプションの引数には任意のキー(上記の例では secret_key
)を指定します。キーには秘密情報(アクセスシークレットトークンなど)を使用することが多いと思いますが、この secret_key
の文字列は、同じコンピュータにログインしている他のユーザーに漏れます。
コマンドライン引数は漏洩する
コマンドライン引数は同じコンピュータにログインしている別のユーザーが見ることができます。たとえ参照先が root ユーザーのプロセスであったとしても、ps
コマンドや proc ファイルシステムなどを使ってコマンドライン引数を見ることができてしまうのです。
guest@server:~$ ps axu | grep root
︙
root 889 省略 /usr/sbin/thermald --systemd --dbus-enable --adaptive
root 895 省略 /usr/libexec/udisks2/udisksd
root 897 省略 /usr/bin/containerd
root 918 省略 /usr/sbin/ModemManager
root 931 省略 /usr/sbin/libvirtd
root 937 省略 /sbin/agetty -o -p -- \u --noclear tty1 linux
root 1008 省略 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
︙
変数で渡せば隠せるのでは?と考えるかもしれませんが、変数の内容は展開されてからプログラムが実行されるので、この方法で隠すことはできません。
koichi@server:~$ time=1234
koichi@server:~$ sleep "$time"
$ ps aux | grep sleep
koichi 775612 省略 sleep 1234
guest 775614 省略 grep --color=auto sleep
ということでコマンドライン引数でパスワードなどの秘密情報を渡してはいけないということです。
コマンドライン引数を隠す方法
コマンドライン引数が見えてしまうのであれば、それを隠す設定があるんじゃないか?と考えるのではないかと思います。一応 Linux には hidepid という機能があります。しかしこれはコマンドライン引数だけではなくプロセスそのものを隠してしまいます。安全にはなりますが(専用のユーザーで動作している)システム監視ツールから他のユーザーのプロセス情報が全く見えなくなってしまうので、安易に有効にできるとは限りません。また、hidepid は Linux カーネルの機能です。他の OS にも同等の機能があるかは調べていませんが、原則としてコマンドライン引数は見えてしまうものとして考えるべきでしょう。個人的にはプロセス ID は見えてもいいが、コマンドライン引数だけを隠す方法が欲しいところです。
一部のコマンドは自身のコマンドライン引数を隠す処理を実装しています。ただしこれはコマンド自身がコマンド起動後にコマンドライン引数を上書きしているので、起動直後などの僅かなタイミングで見えてしまいます。意図せず見えてしまったみたいな場合を軽減することはできますが、意図的にパスワードを読み取ろうと監視している人を防ぐことはできません。
コマンドライン引数を使わない方法
コマンドライン引数を使わずに安全に別のプログラムに秘密情報を渡すには次のような方法が考えられます。
- ファイルで渡す方法
- 環境変数で渡す方法
- パイプまたは名前付きパイプから渡す方法
- ソケット通信で渡す方法
その他の方法として、そもそも外部コマンドを使わずにシェル自身(シェル関数)で実装する方法があります。シェル関数やシェルビルトインコマンドは外部コマンドと同じようにコマンドライン引数を使いますが、別のプログラムを実行するのではなくシェル自身で実行するため秘密情報が漏れることはありません。私が実装したのがこのシェル関数で実装する方法というわけです。
opensslコマンドのHMACキーは引数以外で渡せない
openssl
コマンドでもコマンドライン引数を使わずにファイルなどで HMAC キーを渡せばいいじゃないかと思うかもしれませんが、どうやら現在のバージョンの openssl
コマンドにはコマンドライン引数以外の方法で HMAC キーを渡す方法がないらしいのです。
もちろんこの問題に気づいているのは私だけではありません。GitHub 上の OpenSSL プロジェクトでもファイルからののキーの読み取りに対応してほしいと 2020 年 11 月に要望がでています。
一応マイルストーンに追加されており、将来対応する予定なのだと思いますが、他にも対応しなければならないことは山ほどあるようで優先順位は高くないでしょう。
以下のページでも秘密情報を漏らさずに HMAC を生成する方法はないのか?という話が出ています。
sha1hmac - HMACキー算出の代替コマンド
openssl
コマンドがダメなら、別のコマンドを使えばいいじゃない。ということで代替のコマンドはあります。例えば libkcapi - Linux Kernel Crypto API User Space Interface Library に含まれている sha1hmac
コマンドです。このコマンドは HMAC キーをファイルまたは名前付きパイプから読み取ることができます。
# インストール方法(AlmaLinux)
$ dnf install hmaccalc
# 参考 openssl を使った同等の例
$ printf '%s' 'value' | openssl dgst -sha1 -hex -hmac 'secret_key'
SHA1(stdin)= 096098e715e2bd92b7974954529e2696af81b9d0
# これはコマンドライン引数でキーを渡しているからダメ
$ printf '%s' 'value' | sha1hmac -K 'secret_key' # -k は大文字
b75db159dc00e1e84e251a1ea6176359e7427901 -
# HMAC キーをファイルで渡す
$ printf '%s' 'secret_key' > keyfile
$ printf '%s' 'value' | sha1hmac -k keyfile # -k は小文字
b75db159dc00e1e84e251a1ea6176359e7427901 -
# プロセス置換( <(...) )はファイル(名前付きパイプ)として扱われる
$ printf '%s' 'value' | sha1hmac -k <(printf '%s' 'secret_key')
b75db159dc00e1e84e251a1ea6176359e7427901 -
ただしどうやら Linux カーネルの API を呼び出しているようで(注意 詳しく調べていません)おそらく Linux でしか動作しません。さらに Debian / Ubuntu 系では(まだ?)パッケージがないようです。Linux 系 OS では一応使えるが少々使いづらいコマンドのようです。
その他にも代替のコマンドはあると思いますが、少し調べましたが見つかりませんでした。しかし心配する必要はありません。多くのプログラム言語では HMAC を算出するライブラリがあるので、自分で簡単なラッパーコマンドを作ってしまえば良いのです。他の言語で作って良いという前提なら代わりになるコマンドはいくらでも作れます。それならなぜ私は作っているのか?それはシェル関数なのであなたのシェルスクリプトに含めて配布することができるというメリットがあるからです。いや、スクリプト言語なら他の言語でもシェルスクリプトに含められますね。まあ選択肢は多いほうが良いと思います。スクリプト言語のランタイムがインストールされていなくても動きますしね。あとは私の勉強のためです。アルゴリズムの元となっている数学的な理論はわかりませんが、実装自体は HMAC も SHA1 もバイト列をこねくり回しているだけだというのがわかってスッキリしました。
みんなコマンドライン引数の漏洩問題を忘れてないか?
世の中に問題がある例であふれてる
今回は HMAC-SHA1 の実装にフォーカスを当てていますが、コマンドライン引数に気をつけなければいけないものは、これだけではありません。例えば curl
や wget
など、コマンドライン引数でパスワードやアクセストークンなどの秘密情報を使うコマンドでも同様です。
一つ例を見てみましょう。これは HashiCorp の Vault のチュートリアルからの引用です。Vault は秘密情報を管理するためのソフトウェアです。
curl \
--header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
--data '{ "data": {"password": "my-long-password"} }' \
http://127.0.0.1:8200/v1/secret/data/creds | jq -r ".data"
上記の VALUT_TOKEN
変数の中身や my-long-password
はコマンドライン引数で指定しているので、同じコンピュータにログインしている他のユーザーに漏れます。
コマンドライン引数でアクセストークンを指定している例なんていくらでも見つかりますよね?
漏れちゃってもキニシナイの時代?
世の中でこんなに良いとは言えない使い方をあちこち見かけるという現状は、もしかしたら他のユーザーに漏れちゃっても気にしない時代になっているからなのかもしれません。コンピュータはずっと前から一人一台の時代になっていますし「他のユーザーなんて存在しない」ので気にする必要はないのでしょう。
サーバーコンピュータはもはやシステムを動かすためだけに使われ、デプロイの自動化などでサーバーの管理をするためにユーザーが直接ログインすることは少なくなりました。プログラマがサーバーコンピュータに乗り込んで実際に動作しているバージョン管理もされていないソースコードを直接編集するなんて時代は過去のものです(もし現在も続けているのであればやめましょう)。
サーバーにログインするとしたらシステムメンテナンスのためなのでログインできるユーザー全員が root 権限を持っていても不思議ではありません。root 権限を持っているのならコマンドライン引数を読み取らずとも何でもできてしまいます。クラウドサービスではログを他のサーバーに転送したりするのでサーバーにログインする必要もなくログ監視のみができる権限のユーザーを作成することもありません。現在のコンピューターの利用方法は、使用しているコンピュータの root 権限を持っている、さもなくばログインすらできないのどちらかになってしまったと思います。このような使い方であれば「他のユーザーなんて存在しない」のは正しいのかもしれません。
とは言え例外はいくらでもあります。すぐに思いつくのは安価な共有レンタルサーバーです。複数のユーザーが一つのコンピュータを共有しているのでコマンドライン引数には気をつける必要があります。もちろんその他にもパーミッションなどにも気をつけなければなりません。複数の一般ユーザーがログインするシステムや特定のシステムでしか使わないシェルスクリプトなら、さほど気にしなくても良いと思いますが、汎用的でどういう使い方をされるかがわからないシェルスクリプトなどでは、他のユーザーからコマンドライン引数が見えてしまうということを考慮しておくのがベストです。
「今はだいたい問題ないだろうから気にしなくて良い」と話を打ち切って思考停止するのではなく、どのような問題があってリスクはどれだけあるのかを正しく判断できる知識を身につけることが重要であると私は考えます。
コマンドライン引数の漏洩は特にシェルスクリプトで問題になる
この問題は多くの場合シェルスクリプトで問題になります。他の言語でも問題にならないわけではないのですが事例は少ないでしょう。他の言語で問題にならないのは言語用のライブラリを使うので、外部コマンドの呼び出しが必要なく一つのプロセスで処理が完結するからです。したがってこれは(ほぼ)シェルスクリプト特有のセキュリティの考慮事項ということになります。
どうしてこんな脆弱な使い方ができるコマンドがあるのかと思いたくもなりますが、これは使い方の問題なのでコマンド自体に脆弱性があるということにはならないのでしょう。しかし「シェルスクリプトを使って実装した仕組み」にコマンドライン引数から漏洩があれば、コマンドに脆弱性はなくとも構築したシステムには脆弱性があるということになります。手元でシェルで動いたからと言って、それをそのままシェルスクリプトにして良いとは限りません。シェルスクリプトにはシェルスクリプトにしての、対話シェルの使い方とは異なる書き方があります。
コマンドライン引数で秘密情報を渡すのはテストや開発や個人利用にとどめ本番環境での運用では他のプログラミング言語で実装したものを使うというのは良い方針でしょう。何事も適材適所です。
sh-hmac-sha1 について
HMAC-SHA1 のシェルスクリプト実装ですが、私が一から書いたわけではありません。もし一から書かないといけないとしたら面倒でやらなかったと思います。
HMAC-SHA1のコードはbash-totopから
HMAC-SHA1 をシェルスクリプトで実装しようと思ったのは、すぐに bash-totp を見つけることができたからです。これは bash 用ですが、完全にシェル言語で実装されたコードでした。このプロジェクトは正確には OTP manager のプロジェクトで不要なコードがかなり含まれていたのですが、必要な部分のみに削っていったら 100 行程度になり、ざっと見た所このコードなら bash から POSIX シェル用に移植するのは難しくないと判断して作業を開始しました。私のコードは倍ぐらいになっていますが、これは POSIX シェル用に書き換えたのに加えて、バイナリ出力と base64 といった追加機能があるからです。
bashからPOSIXシェル用への移植
追加機能を除く SHA1 ハッシュと HMAC 計算部分は元の bash 用コードを純粋な POSIX シェルで動くコードに置き換えるという形で書き換えていきました。純粋な POSIX シェル用のコードは bash でも動作するので一歩一歩置き換えることができます。元のコードは完全に bash のみで動くコードでしたがシェル言語だけにこだわらずに、外部コマンドとして od
、tr
、fold
コマンドを利用しています。これらのコマンドがなくても作れないことはないのですが、コードが増えるのとメッセージのサイズが大きくなったときのパフォーマンス低下を懸念しての妥協です。od
、tr
、fold
コマンドがインストールされていない環境は私が知る限りないので移植性については問題ないでしょう。
パフォーマンスについて
パフォーマンスは実用上問題ないと思われますが速くはないので注意してください。openssl
コマンドだと 20ms 程度のものが 200ms ぐらいになります。メッセージサイズが大きくなればこの差は更に広がります。例えばメッセージサイズが 1 KB や 10 KB の場合 openssl
コマンドの処理速度は殆ど変わりませんが、シェルスクリプトでの実装だと 500ms や 3000ms にまで遅くなります。ですが今回の私が想定している用途ではそんなに大きなメッセージサイズになることはなく十分実用になると考えています。
対応シェルについて
対応シェルは一応全ての POSIX シェルですが、サポートが終了している環境のシェルは対象外としています。動かないシェルも一応あって、例えばサポート切れの NetBSD 7.2 の NetBSD sh(ash ベース)では動作しません。これは以下のコードが動かないからです。
: $(( v = (123 + 456) ))
# 普通はこのように書く(上記のような変な書き方はしない)
v=$(( 123 + 456 ))
このコードは NetBSD 8.0(2018 年リリース)以降で動くようになりました。算術式展開での変数代入を使っている理由は、擬似的な配列を使うことが出来るからです。
i=1 v1=789
: $(( v$((i+1)) = (123 + 456 + v$i ) ))
# 補足 v$((i+1)) という書き方は BusyBox 1.30.0 より前のバージョンでは動作しない
echo $v2 # => 1368
他の POSIX シェルでは上記のこのコードは動作します。このような変な書き方をしなくても eval
を使えば対応させるのは簡単ですが、サポート期限切れのシェルに対応する必要ないだろうということで対象外としています。
こぼれ話
mksh は符号付き 32 ビット 整数
HMAC-SHA1 は符号なし 32 ビットで処理するのが一番適しています。しかしシェル言語は最低でも 32 ビットと POSIX で標準化されており、実際にシェルや実行環境によって異なります。64 ビット環境では符号付き 64 ビット、32 ビット環境では符号付き 32 ビットが一般的ですが、この例外が mksh です。mksh は 64 ビット環境でも符号付き 32 ビットで計算を行います。一応 typeset -Ui 変数名
で符号なし 32 ビットで計算するように変数に属性をつけることが出来るのですが、他のシェルでも 32 ビット環境で動かす場合を考慮して(ただしテストはしていません)、符号付き 32 ビット 整数でも正しく動くようにしています。対応が必要があったのはマイナス値の場合でのビットシフトぐらいでそこまで大変ではありませんでした。どこで計算が狂っているのか探すのは面倒ではありましたが。
POSIX awkで実装するのは難しかった
最初は計算速度が遅いだろうから POSIX awk で実装しようと思ったのですが、ビット演算は GNU awk には実装されていますが POSIX awk にはないことに気づいて断念しました。awk はシェル言語よりも速く配列も使えて便利なのですが、ビット演算に関してはシェル言語の方が得意だというのは意外でした。POSIX の範囲に限定するのであればバイナリデータを扱うのはシェル言語の方が良さそうです。
ちなみに bash-totp の作者は nawk で動作する HMAC-SHA1 も実装しており、そのために xor などのビット演算関数を実装しています。さずがにこれらを全部パクる実装するとコードも長くなりメンテナンスも大変になりそうです。awk の計算処理も速いわけではないので、これだけいろんな計算をしてると awk で実装して速くなるかも不透明です。
しかし、やっぱりビット演算とかバイナリの計算は他の言語で作るのが楽ですね。C 言語なら普通に思い描いたものを書けば動くのになーと思いながら、シェルはどういう実装になっていてどういう挙動をするんだろうと考えなければいけないので面倒でした。
ShellCheckがパーサーエラーになった
POSIX シェルでの擬似的な配列は今回始めて使ってみました。正確には参照だけなら使ったことがあるのですが、代入も(eval
使わないで)できるじゃんと気づきました。シェルプログラミングは長くやっていますが、まだ気づいてない書き方が見つかるものですね。
一つは単に自分が使ってみたいから、もう一つは eval
を極力減らしたいという理由で、今回使ってみたのですが、ShellCheck でコードをチェックしてみたらこんな感じのエラーがでてしまいました。
: $(( v$((i+1)) = (123 + 456 + v$i ) ))
^-- SC1073 (error): Couldn't parse this $((..)) expression. Fix to allow more checks.
^-- SC1009 (info): The mentioned syntax error was in this $((..)) expression.
^-- SC1072 (error): Fix any mentioned problems and try again.
これはパーサーエラーで、このエラーが出てしまうとシェルスクリプト全体の文法の解釈自体が混乱してしまい、つまりこのエラーがその他の部分のコードのチェックが行えなくなってしまいます。ちょっとこれはクリティカルな問題なので状況をまとめて(あとで…)ShellCheck に報告する予定です。
さいごに
ということで、シェルスクリプトで HMAC-SHA1 を使っているよという人にために、セキュリティ問題がなく openssl
コマンド依存を排除できるシェルスクリプトライブラリを開発しましたという話でした。
ライセンスは元の bash-totp のライセンスを引き継いで Unlicense license にしています。個人的にはより明確に定義されている 0BSD ライセンスに変更したいと考えているのですが、これって可能なのでしょうかね? ソースコードは完全に参考にしており一行一行置き換えていっただけですが、テセウスの船のようにコードは空行や括弧のみの行を除き完全に置き換えられ元のコードは全くありません。それにアルゴリズムには著作権はないはずですし。
元の Unlicense license はほぼパブリックドメインのライセンスで 0BSD ライセンスも同等のライセンスなので変更しても問題なさそうな気がするのですが、よくわからないのでそのままにしています。少なくとも私はコードの権利を主張するつもりはないので、どのような使われ方をしても私が何かを言うことはありません。どうぞご自由にお使いください。