読者です 読者をやめる 読者になる 読者になる

理系学生日記

おまえはいつまで学生気分なのか

忍者TOOLS

BashのGlobに関わるオプション

technology

前に Bash の Glob に関するエントリを書きました。

他にも Glob には様々な応用があります。 あまり知られていないような使い方も多いので、それらをご紹介していくコーナーです。

extglob

Glob で正規表現と not 条件を表現できるようになる。 詳細については、

に書きました。

nullglob

通常、Glob を指定したもののそれが合致するファイル/ディレクトリが存在しない場合、Bash は指定した Glob をファイル名として解釈します。 何を言っているかというと、これを見れば分かって頂けるでしょうか。

# *.hoge というファイルが存在しないので、Bash は '*.hoge' というファイル名をそのままコマンドに渡す
$ ls *.hoge
ls: cannot access '*.hoge': No such file or directory

この挙動がどういうときに困るかというと…

#!/bin/bash

for f in *.hoge; do
    echo "file name is $f"
done

上記のようなスクリプトを走らせると、*.hoge に合致するファイルが存在しなかった結果、*.hoge というリテラルがそのまま echo に渡ってしまい、出力は file name is *.hoge になってしまいます。 *.hoge に合致するファイルが無いんであれば、単純にこの for ループは空回りして欲しいですよね。

そういうとき、shopt -s nullglob で nullglob オプションを有効化します。

#!/bin/bash

shopt -s nullglob

for f in *.hoge; do
    echo $f
done

nullglob は、「Glob に合致するファイル/ディレクトリが存在しない場合、Glob を空文字列に展開する」というオプションです。 この結果、上記の nullglob を有効化した for ループは空回りし、何も出力せずに終了させることができるようになります。

また、ls *.hoge を実行するしようとすると、*.hoge が空文字に展開されますので、ls が実行されることになります。

failglob

failglob は、指定した Glob を展開しようとしたときに合致するファイル/ディレクトリが存在しない場合、エラーとしてコマンド実行も実施しないというオプションです。 インタラクティブに bash を使う場合、typo とかにすぐ気付くことができます。

# failglob が無効になっている場合
$ ls *.hoge
ls: cannot access '*.hoge': No such file or directory

# failglob を有効化した場合、ls は実行されないことが分かる
$ shopt -s failglob
$ ls *.hoge
bash: no match: *.hoge

dotglob

Glob は、いわゆる隠しファイル (先頭が . から始まるファイル) には合致しないのがデフォルトの動作です。 実はこの挙動、dotglob オプションで変更することができます。

# こうやって隠しファイルを作っても、デフォルトの設定だと Glob に合致しない
$ touch .hiddenfile
$ ls *
test.txt

# dotglob を有効化すると合致するようになる
$ shopt -s dotglob
$ ls *
.hiddenfile  test.txt

隠しファイルを含めて処理をしたい時とかに使うと処理が楽になります。

shopt -s dotglob
for f in *; do
    echo $f   # 隠しファイルも含める
done

ブラウザのバージョンについての公式情報はどこを見れば良いのか

technology

最近のブラウザ、自動的にアップデートされていくから基本的にブラウザのバージョンをユーザが意識しなくなってるし、開発者もいちいちバージョンナンバーの変更に追随しなくなってる感があります。 そういうわけでちょっと調べようと思ったら、わりかし苦戦しておりまして、今でも順調に苦戦しています。もういいでしょうか。

Firefox

Firefox は何だかんだいってリリースノートがきれいに整備されているので、非常に分かりやすいです。 本日時点での最新バージョンは 48.0 ですね。

Firefox のリリースサイクルは、5〜8 週に 1 回のリリースということになっています。

スケジュールはこちら。9 月に v49 が公開される予定です。

Chrome

Chrome については、こちらがバージョンとリリーススケジュールですかね。

現在の最新バージョンは v52、9/6 に v53 が公開される予定です (変更の可能性はあります)

Safari

Safari はホントに分かりづらくて、これはやっぱり Apple が余計な情報はユーザに公開しないとか、そういうところもあるんですかね。 あと、iOS に同梱されているというのもメンドくささに拍車をかけてる。

iOS 上の Safari のバージョン確認は UserAgent を確認するしかないという話で、マジかという思い。 とりあえず、iOS 9.3 には Safari 9.0 が入っていた。

Internet Explorer 11

IE については、メジャーバージョン(9 とか 10 とか) が違ったら、もはや別ブラウザという感覚があるので、そこはまた他ブラウザと比べると面倒なかんじがある。

あと、リリースノートは公開されていないのでは感。どこにあるんだ。

Internet Explorer 11 will have a version number that starts with 11 (for example, 11.0.9600). The version number will change based on the updates that have been installed for Internet Explorer. To see the version number and the most recent update installed, go to the Help menu and select About Internet Explorer.

Edge

BashのGlobは積極的に利用しましょう

technology

bash には glob というものがあります。glob ってなによっていう人も ls *.sh とかを展開する bash の機能ですよっていうと分かるかと思います。 この glob の機能って多用されますがあまりマニュアルとか読んだ人もいないと思うので、簡単にまとめてみます。

1. Pattern Matching

Glob のパターンマッチングに使用できる文字のパターンっていうのは、通常、次の文字です。

  • *: 何にでもマッチする
  • ?: 任意の一文字にマッチする
  • [...]: [] の間に記述された任意の文字にマッチする。これはちょっとややこしいので、もうちょっとまとめます。

[...]

[...]... には通常として文字の集合を指定できますが、多少表現力のあるものも指定できます。 次のように、ハイフン- で指定された範囲のみにマッチさせたりできますし、

$ ls test[1-4].txt
test1.txt  test2.txt  test3.txt  test4.txt

[ の直後に ! あるいは ^ を指定することで、文字集合を除外させることもできます。

$ ls test[!1-4].txt
test5.txt  test6.txt  test7.txt  test8.txt  test9.txt
$ ls test[^1-4].txt
test5.txt  test6.txt  test7.txt  test8.txt  test9.txt

[...] の中には、文字クラス名を [:class:] の形で指定できます。[:alnum:] とか [:punct:] とかですね。

$ ls test[[:digit:]].txt

2. Glob の拡張表現

だいたい *.txt とかで用が足りるときは多いのですが、「このファイルとはマッチさせたくない」(not 条件) とか、正規表現を使いたいとか、そういう要望はデフォルトの Pattern Matching 用の文字では満たせません。このままでは、毎日歯軋りをしながら眠りにつくしかない。 Bash にはこの Pattern Matching の機能を、以下のようにして拡張することができます。

$ setopt -s extglob

これにより、正規表現の表現力や not 表現が使えるようになります。

  • ?(pattern-list): pattern-list に記述されたパターン 0 回または 1 回の出現とマッチ。
  • *(pattern-list): pattern-list に記述されたパターンの 0 回以上の出現とマッチ。
  • *(pattern-list): pattern-list に記述されたパターンの 1 回以上の出現とマッチ。
  • @(pattern-list): pattern-list に記述されたパターンの 1 回の出現とマッチ。
  • !(pattern-list): pattern-list に記述されたパターンのいずれでもないものとマッチ。
$ shopt -s extglob
$ ls test+([[:digit:]]).txt
test1.txt  test10.txt  test2.txt  test3.txt  test4.txt  test5.txt  test6.txt  test7.txt  test8.txt  test9.txt

$ ls test!(1|2|5|8|9).txt
test10.txt  test3.txt  test4.txt  test6.txt  test7.txt

特に、not 表現が記述できるようになるのは大きい。 例えば、"カレントディレクトリの hoge.sh 以外のスクリプト" は次のようにして取得できます。

$ ls !(hoge).sh

3. ls | grep パターンには気をつけよう

シェルスクリプトの中に ls | grep パターンがあるときは、glob に置き換えられないかを検討する価値があります。 ls | grep パターンが良くないケースとしては、ファイル名にスペース等が入っているような場合が挙げられます。

たとえば、hello world.txt hello-world.txt が存在するディレクトリがあるとしましょう。

$ ls
'hello world.txt'  hello-world.txt

このディレクトリに対して、以下のスクリプトを実行してみます。

#!/bin/bash
for f in $(ls | grep \\.txt$); do
    echo $f
done

結果はこちらで、"hello world.txt" が 2 行に分割されてしまっていることがわかります。

hello
world.txt
hello-world.txt

ls | grep の結果がスペースで分割されてしまってるわけですね。 このような問題は、Glob にすることで解決します。

for f in *.txt; do
    echo $f
done

# 実行すると: 
hello world.txt
hello-world.txt

hoge.txt 以外を処理したいのであれば、拡張 Glob を使用して !(hoge).txt にすれば良いですし、Glob の使いどころは非常に多いと思います。

シェルスクリプトでのtrapの使い方

technology

シェルスクリプトでシグナルをトラップするには、みたいなことを考えることがあって、そういえば trap という日頃なかなか思い出されない不憫なビルトインがあることを思い出しました。trap は bash のビルトインとして存在する便利なヤツですが、忘れられがちなので、ここで一つ、trap コマンドとはどういうものかを書いてみることにしました。

trap の基本

trap は、その名前の通り、シグナルを補足(trap) するためのシグナルハンドラを設定するためのビルトインです。 シェルスクリプトを長時間動かすみたいなユースケースは結構あって、別言語で実装されたバッチプログラムの起動だったり、ファイルツリーを探索して何か集計したりするとか、他システムとファイルを送受信したりです。 こういう長時間動かすケースではときどき、「あれ、意図せずシグナルを送られちゃったりしたときどうしよう」みたいなことを考えなければならないケースがあります。

  • この処理をしてるときにシグナルを受け取ってしまったら、変な一時ファイルが置かれたままになっちゃう
  • シグナルが発生したときは、ログだけは出して終了しなきゃ

みたいなかんじ。

実際に trap を使ってみる

まぁ良くある使用例としてはこんなかんじです。 起動すると無限ループになりますが、Ctrl-C で SIGINT を発行すると、"exiting..." という文字列を出力した後に停止します。

trap 'echo exiting...; exit' SIGINT
count=0

while :; do
   sleep 1
   count=$((count+1))
   echo $count
done

trap 使い方

trap は次のように、シグナルを受信したときに実行するコマンド列を第二引数に、その対象のシグナルを第三引数以降に指定します。

trap 'commands to be executed' signals

シグナルを無視したい (デフォルトのシグナルハンドラを動かさない) ときは、コマンドを空文字列 '' で指定すれば良いですし、デフォルトのシグナルハンドラに復元したいときは、- を指定することになります。 例えば、先程のスクリプトの trap の行を trap '' SIGINT に書き換えると、Ctrl-C でも停止しないスクリプトのできあがりです。

それでまぁ、例としては上記のように echo を使った例が使用されるわけですが、いくらなんだってシグナルハンドラが 1 行で書けることなんざ、そうそうありません。 次のように ; で区切ってやればいくつでもコマンド列を指定することは可能ですが、クソみたいに可読性が悪くなります。

trap 'echo hello; echo world; exit' SIGINT

実は trap の第二引数には関数名が記述できるので、次のようにすることで可読性が高まります。

function echo_hello() {
    echo "hello"
    echo "world"
    exit
}

trap echo_hello SIGINT

疑似シグナル

というように、trap ではシグナルハンドラを指定できるわけですが、ここで指定できるシグナルというのは SIG から始まる実シグナルだけではありません。 疑似シグナルという、シグナルのようであってシグナルではないものを指定することができ、これによって便利な使い方ができるようになります。

疑似シグナルとしては、以下のようなものがあります。

  1. ERR
  2. EXIT
  3. DEBUG

ERR

ERR は、コマンドの実行結果が非零のステータスであった場合に送出される疑似シグナルです。 こちらも百聞は一見にしかずなので、実行例を見てみましょう。 次のコードでは、エラーが発生したときに「どのファイルの」「どの行で」「どのコマンドが」エラーとなったのかを標準出力に出力するようにしました。

#!/bin/bash

trap 'echo "[$BASH_SOURCE:$LINENO] - "$BASH_COMMAND" returns not zero status"' ERR

echo "hello world"
ls /not-exist-file

上記のスクリプトを動かすと、次のような実行結果になり、ls がエラーになったことが分かります。 このようなデバッグ情報の埋め込みや、共通的なエラー処理に使用すると便利です。

hello world
ls: cannot access '/not-exist-file': No such file or directory
[./trap-err.sh:5] - ls /not-exist-file returns not zero status

EXIT

EXIT 疑似シグナルは、スクリプトが終了した時点で送出される(と考える)疑似シグナルです。 一時ファイルを片付けるときなどに使ったりしますね。

#!/bin/bash

function cleanup() {
    rm -f /cleanup-target
    echo "cleaned up!"
}

trap cleanup EXIT

DEBUG

DEBUG 疑似シグナルは、コマンド毎に送出される(と考える)疑似シグナルで、名前の通り、デバッグ時に使用することが多いです。 下記のような行をスクリプトに仕込むと、一行一行の実行前にユーザ入力を要求させることができ、シェルが何を実行しようとしているのかを確認しながら実行を進めることができたりします。

trap '(read -p "[$BASH_SOURCE:$LINENO] $BASH_COMMAND?")' DEBUG
$ ./trap-test.sh
[./trap-test.sh:11] trap echo_hello SIGINT?
[./trap-test.sh:13] count=0?
[./trap-test.sh:14] :?
[./trap-test.sh:15] sleep 1?
[./trap-test.sh:16] count=$((count+1))?
[./trap-test.sh:17] echo $count?
1
[./trap-test.sh:14] :?
[./trap-test.sh:15] sleep 1?
[./trap-test.sh:16] count=$((count+1))?
[./trap-test.sh:17] echo $count?
2

そんなこんなで、bash のことは嫌いでも、trap のことは嫌いにならないでください。

参考文献

  1. Using traps in your scripts
  2. How "Exit Traps" Can Make Your Bash Scripts Way More Robust And Reliable

lsの結果をawkすることについての注意点

technology

重箱の隅を突つくような話ですが、ls の結果を awk するのは筋が悪いと言われています。ファイルパーミッションを抜くとか、そういうときに、このような使われ方が為されますが、だいたいの場合は代替案(もうちょっと相応しいコマンド) があるので注意しましょう。 ls -l | awk とかが良くないとされるのは、ぼくの知る限り、これは次の 2 つの理由に依っています。

  1. ls -l 等で表示されるカラム順は定義されていないため、システム間で順番が異なる可能性がある
  2. ls が表示するファイル名というのは任意の文字を含み得る

1 つ目については、まぁそうだよね、という話ですが、2 つ目については具体的な例があった方が分かりやすいかもしれません。

以下のように、改行を含むファイル名というのは容易に作成することができます。

$ touch "Hello
World"
$ touch "Hello
World2"

ここで ls してみると、あたかも普通に表示できます。

$ ls -l
total 0
-rw-r--r-- 1 kiririmode staff 0  7 23 15:34 'Hello'$'\n''World'
-rw-r--r-- 1 kiririmode staff 0  7 23 15:34 'Hello'$'\n''World2'

が、ファイル名に改行コードを含むのは間違いないので、これを awk に渡してしまうと想定通りの出力にはなりません。

$ ls -l | awk '{ print $0 }'
total 0
-rw-r--r-- 1 kiririmode staff 0  7 23 15:34 Hello
World
-rw-r--r-- 1 kiririmode staff 0  7 23 15:34 Hello
World2

信頼できない入力文字列をそのまま信じてしまうとマズいという意味では、Web アプリケーションにおけるサニタイズとかと同じようなものかもしれないですね。 とはいえ、それを気にしないといけない状況っていうのは滅多にないと思いますが。

ちなみに、制御文字とかも余裕でファイル名に入れることができるので、いろいろ楽しんで人に迷惑をかけて怒られたら良いですね。

$ touch "$(echo -e "ab\ac")"

Quitter便利

software environment

Quitter よかった。

Quitter、一定時間(アプリ毎に設定可能) 使ってない特定のアプリを自動的に終了させたりしてくれるアプリで、Twitter とか切るようにして生産性を上げようっていうものなんだけど、「辞書」アプリとか Kindle とかが立ち上がったままでアプリの切り替え(Command + Tab 押下) の度にウザいなーって思ってたのが解消されて大変よろしい。 画面上から、使っていないアプリが自動的にいなくなるっていうところも良い。スッキリする。 Quitter そのものは、メニューバーに常駐するだけで、ほとんど意識しなくて良いところも良い。無意識のまま QOL が向上するのお得感ある。

PRGパターンとは何か

technology

最近はじめてフロントエンドの開発をすることになって、PRG パターンって常識だよみたいな雰囲気でウワワワワー! ってかんじになりました。義務教育ではそんなの教えてくれなかった。 そういうわけなので、涙ごしに霞むモニタと向きあいながら PRG パターンについて調べてみました。

PRG というのは、Post/Redirect/Get の略で、

  1. 二重サブミット対策
  2. セキュリティ対策

の双方の文脈で使用されるパターンのようです。 PRG パターンについての言及は、基本的にこちらの解説に到達しているようですし、PRG パターンの命名もこのページ上でされていることもありますので、まぁここを読んでいれば間違いないんじゃないでしょうか。 ]

PRG パターン

PRG パターンのない素直な実装の問題

PRG パターンの意味する Post/Redirect/Get は、画面を表示する際のクライアント (UserAgent) - サーバ間のやりとりを示しています。ここでいう画面っていうのは、「ユーザが Web サイトのページ上で入力した情報を POST メソッドで送信するときに表示する画面」のことです。 普通にサーバ側の処理を実装しようとすると、POST メソッドのリクエストを受信した後、その BODY 部に含まれる情報を元にして DB 更新等の処理を行い、結果を表示する完了画面をクライアントに返却する、って実装になります。 これを図示すると以下のような図になります。(図は wikipedia:Post/Redirect/Get より)

f:id:kiririmode:20160618170234p:plain

この画面遷移の何が問題になるかというと、入力した情報の二重サブミットです。ユーザが意図せず二重サブミットを行ってしまうことにより、二重課金や二重購入なんて事象が発生します。 このケースにおいては、たとえばユーザによる以下のような操作により、二重サブミットが発生します。

  1. 完了画面において、ユーザが画面をリロードする (F5 ボタンなど)
  2. 完了画面で「戻る」ボタンを押下して入力画面に戻った後、「進む」ボタンを押下する
  3. 完了画面で「戻る」ボタンを押下して入力画面の戻った後、「Submit」ボタンを押して入力した情報を再送信する

このうち、PRG パターンは 上 2 つに直接対応します。

PRG パターン

PRG パターンを使った UserAgent - サーバ間は以下のような通信になります。

f:id:kiririmode:20160618170301p:plain

前の図よりも複雑になっていますが、UserAgent が POST でリクエストを送信した際、サーバはそれを処理するとともに、HTTP ステータス 3xx を UserAgent に返却します。通常、302 または 303 が使用されることが多いみたいですね (理由は後述します)。 UserAgent は 3xx に従って、GET リクエストをサーバに送ります。サーバはその GET リクエストに応答する形で、完了画面を返すという流れになります。

この PRG パターンの何がポイントかというと、ユーザがブラウザのリロード等をした際にサーバに送信されるリクエストが、(ユーザが入力した情報を含んだ) POST ではなく、情報を含まない GET で行われるという点です。こういう GET だったら、重複してリクエストをされようが、単に同じ完了画面を返却してやれば良いですね。

あれ 3 番目の対策は

前述のとおり、二重サブミットを起こす方法として、

  1. 完了画面で「戻る」ボタンを押下して入力画面の戻った後、「Submit」ボタンを押して入力した情報を再送信する

ていうのもありますが、PRG パターンに関して述べられているのは、「戻る」ボタンを押下したときの画面はキャッシュを無効化しておいて、input フィールドに値を保存させないようブラウザに指示しておくことです。 この他、トークンを使う方法があり、PRG のページにも「Prevent resubmits」でトークンについて触れられています。 ただ、実際にこの手の二重サブミットを防ぐ方法は、以下のようなページを参照すれば良いんじゃないですかね。

セキュリティ対策

PRG パターンについては、上述のとおり主に二重サブミット対策として述べられることが多いですが、セキュリティ対策として用いられることがあります。 具体例として、まずは問題となるケースを示します。

  1. PC を使うユーザが、ログイン画面でログインパスワードを入力
  2. Submit ボタンを押して、ログイン成功
  3. ユーザが PC を離れる
  4. 悪意のある第三者が当該 PC・ブラウザを使用し、リロード (あるいは「戻る」ボタン→ 「進む」ボタンを押下)

これにより、3. のタイミングで例えセッションが切れていたとしても、4. で第三者がログインに成功しますし、あるいは、再送信される POST リクエストを解析すれば、パスワードを入手することが可能なケースがあります。 これも結局、秘匿すべき情報を含む POST リクエストが再送信されることが問題なので、PRG パターンを採用することでこの問題を解決することができます。

HTTPS双方向認証の環境を作る

technology java

以下のエントリで HTTPS 通信を試してみましたが、このような試験環境を作るのはわりとメンドい。 メンドいことを何度もやりたくないので、実施した内容をエントリに残しておきます。

クライアント認証を含めた HTTPS の双方向認証を行うためには、以下が必要になります。

  1. CA の作成
  2. サーバ証明書の作成
  3. クライアント証明書の作成
  4. HTTPS に対応したサーバの立ち上げ
  5. (Java の場合) KeyStore、TrustStore の作成

CA を作成する

CA が使用する秘密鍵を作成した後、証明書を作成するかたちになります。

# CA が使用する秘密鍵を ca.key として作成する 
$ openssl genrsa -out ca.key 2048
# CA の証明書を ca.crt として作成する
$ openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj '/CN=kiririmode CA'

サーバ証明書を作成する

サーバ証明書の作成には、CSR を作成した上で、CA に署名してもらえば良いです。

# サーバ証明書用の CSR を作成する
$ openssl genrsa -out server.key 2048
$ openssl req -new -key server.key -out server.csr -subj '/CN=server.kiririmode.com'

# CA に CSR に署名してもらい、サーバ証明書 (server.crt) を作成する
$ openssl x509 -req -days 3650 -CA ca.crt -CAkey ca.key -CAcreateserial -in server.csr -out server.crt

クライアント証明書を作成する

ここでは 2 つ作成してみます。やってることはサーバ証明書と同じですね。

$ openssl genrsa -out client1.key 2048
$ openssl req -new -key client1.key -out client1.csr -subj '/CN=com.kiririmode.client1'
$ openssl x509 -req -days 3650 -CA ca.crt -CAkey ca.key -CAcreateserial -in client1.csr -out client1.crt

$ openssl genrsa -out client2.key 2048
$ openssl req -new -key client2.key -out client2.csr -subj '/CN=com.kiririmode.client2'
$ openssl x509 -req -days 3650 -CA ca.crt -CAkey ca.key -CAcreateserial -in client2.csr -out client2.crt

HTTPS に対応したサーバの立ち上げ

せっかく openssl を使っているのであれば、openssl にサーバも任せてしまえば良いです。以下の 1 行だけで、HTTPS サーバが立ち上がります。 -Verify はクライアント証明を必須とするオプションです。

$ openssl s_server -accept 14433 -cert server.crt -key server.key -Verify 5 -CAfile ./ca.crt -www -debug

(Javaの場合)KeyStore、TrustStore の作成

KeyStore の作成については注意すべき点があって、クライアント証明書を keytool でそのままインポートしたとしても、秘密鍵の情報が KeyStore に格納されない。 これを何とかするためには、一度 X.509 の証明書と鍵を PKCS12 形式に変換してやってから、keytool でインポートする。

$ openssl pkcs12 -export -in client1.crt -inkey client1.key -out client1.p12 -name client1 -CAfile ca.crt -caname ca
$ keytool -importkeystore -destkeystore keystore.ks -srckeystore client1.p12 -srcstoretype PKCS12 -srcstorepass password -alias client1

$ openssl pkcs12 -export -in client2.crt -inkey client2.key -out client2.p12 -name client2 -CAfile ca.crt -caname ca
$ keytool -importkeystore -destkeystore keystore.ks -srckeystore client2.p12 -srcstoretype PKCS12 -srcstorepass password -alias client2

TrustStore については、単に作れば良いんじゃないですかね。

$ keytool -import -keystore truststore.ks -file ca.crt     -alias kiririmodeca
$ keytool -import -keystore truststore.ks -file server.crt -alias server

参考: importing an existing x509 certificate and private key in Java keystore to use in ssl - Stack Overflow

JavaのJSSEでクライアント証明書を自由に選択できるようにする

java

HTTPS で API を呼び出すっていうシーンは頻繁にあって、その API を使うには、通常何らかの認証が求められます。 認証にも色々あるんだけど、そのうちの一つが HTTPS のクライアント認証です。 普通に HTTPS 通信をしたいだけだと意識しないことも多いのですが、宛先サーバ毎にクライアント証明書を使い分ける、なんてニーズが生じたときにはこのあたりの理解をしておくことは不可避になります。

こういうのを Java でやれって言われたときに、エッどうやって実現するの、みたいなかんじだったので、調べたり実装してみて調べた結果をまとめてみます。

前提知識としては、以下のようなことが分かっていれば良いと思います。

知識編

2 つの証明書ストア (TrustStore と KeyStore)

まず、Java における SSL/TLS の実装は JSSE (Java Secure Socket Extension) によって提供されるのですが、ここで KeyStore と TrustStore という用語が出てきます。 KeyStore と TrustStore はすごく重要な概念なんですが、異常に混乱を招きがちだし、そもそも JSSE のリファレンスガイドからしてこんなの分かんねーよという説明しかされていない。なんだこれ、暗号か?

キーストアは、鍵データのデータベースです。鍵データにはさまざまな用途があり、それには認証やデータ整合性も含まれます。利用できるキーストアには様々なタイプがあり、その中にはPKCS12やOracleのJKSも含まれます。 (略) トラストストアとは、トラストの対象を決めるときに使用するキーストアです。すでに信頼しているエンティティからデータを受け取る場合、およびそのエンティティが発信元を名乗るエンティティであることを確認できる場合は、データは実際にそのエンティティから届いたものであると仮定できます。

でも、これらが分かっていないとクライアント認証だったりは実現できないし、そもそも JSSE がなかなか読み解けない。 そういうわけですから、まずは TrustStore と KeyStore を整理してみます。

SSL においては、通常、サーバ証明書によって、通信を行うサーバが「間違いなく自分の通信したいサーバであること」(逆に言えば、悪意のある第三者が用意したサーバでないこと)を確認することになります。PKI の仕組み上、このためには、サーバがクライアントにサーバ証明書を提示し、クライアントが「信頼してる機関 (CA) がこのサーバ証明書を(少なくとも間接的に)発行している」という判断が必要です。 逆にクライアント証明では、クライアントがサーバに対してクライアント証明書を提示し、サーバが「信頼してる機関 (CA) がこのクライアント証明書を(少なくとも間接的に)発行している」と判断することが必要になります。

このために、クライアント側では以下の情報を保持しておく必要があります。

  1. 自分がどの証明書(CA 証明書、サーバ証明書) を信じるのか
  2. 自分がどのクライアント証明書をサーバに提示するのか

この 1. の情報を保持しておくのが TrustStore、2. の情報を保持しておくのが KeyStore です。TrustStore は Trust、つまり自分の信頼するものが何かを蓄えるもの、KeyStore は自分が提示する秘密情報 (Key) を蓄えるもの、って考えるとちょっと覚えやすくなるかもしれません。 ちなみに、この TrustStore と KeyStore はどちらも Java でいう "KeyStore" とよばれるファイル(実際にはファイルでなくても良いですが、ファイルの形を取ることが多い) に保存されたりするのが話をややこしくする元凶なんじゃないかと思います。

まぁこのあたりは JSSE よりも キーストアとトラストストア (SSL をサポートする Java CAPS の構成) の記述もわかりやすいですね。

JSSE では、「キーストア」および「トラストストア」と呼ばれるファイルを使用します。キーストアは、アダプタでクライアント認証に使用され、トラストストアは、SSL 認証でサーバーを認証する際に使用されます。

要するに、TrustStore が相手が正しいかを検証するのに使用するもので、KeyStore は自分が正しいかを相手に伝えるのに使用するものです。どちらも証明書を保持しています。

TrustManager と KeyManager

TrustStore、KeyStore はともにストレージみたいに証明書を保存するためのものなので、それを使う人が必要です。

TrustStore を使って相手から送られてきたサーバ証明書が信頼できるかを検証する責務を負うのが TrustManager、 KeyStore を使って、どのクライアント証明書を相手に送付するかを決めるのが KeyManager です。

これらはともにインタフェースとして定義されていて、自由に実装をつくることができます。 ただ、実際にこれらの実装を自分でインスタンス化するってことは滅多になくて、これらのファクトリクラスである KeyManagerFactory や TrustManagerFactory を作成し、これらから KeyManager、TrustManager を作成するってことが大半です。

SSL についての情報を保持する SSLContext

この図は、JSSE のリファレンスガイドに登場する図なんですが、前述の TrustManager や KeyManager を使って、SSLSocket (名前の通り SSL のソケットを表現するクラス) を作る SSLSocketFactory を構築するのが SSLContext クラスです。この SSLContext は、SSL 実装に関する情報を保持しています。

f:id:kiririmode:20160612022046j:plain

クライアント認証に関する何らかの操作を実現しないといけない場合、往々にして KeyManager をカスタマイズする必要がありますが、カスタマイズした KeyManager を使ってくれ、ということを SSLContext に教える必要があります。 Apache の httpComponent などの HTTP クライアント実装も、だいたいこの SSLContext を差し替えることができるようになっているので、その差し替えによってクライアント証明書の選択とかを HTTP クライアントライブラリ側に反映させることができます。

httpComponent だと以下のようなかたちで、httpClient を作成する段階で SSLContext を自作のものに設定できます。

try (CloseableHttpClient client = HttpClients.custom().setSSLContext(sslContext).build()) {
  HttpGet getMethod = new HttpGet("https://server.kiririmode.com:14433/index.html");

  client.execute(getMethod);
}

デフォルトのクライアント証明書の選択

さて、それではクライアント証明書がデフォルト実装でどのように実装されるかを見てみました。 あくまで java.security をカスタマイズしていない前提ですが、何も意識しないで HTTPS 通信を行った場合、クライアント証明書は以下の条件に合致するものが選択されます。

  1. KeyManager が読み込む KeyStore に格納されているクライアント証明書である
  2. サーバが Certificate Request の中で送信してくる信頼する CA が発行している
  3. 鍵のアルゴリズムも合致する

複数のクライアント証明書が上記の条件に合致した場合は、最初に発見されたものが選択されます。 つまり、同じ CA が発行しているようなクライアント証明書が複数あった場合、対向先システムとか関係なしにそのうちの 1 つが選択されてしまうってことになります。 この振舞いについては、割と不都合あるケースが多いんじゃないでしょうか。

実装編

というわけで、上記のような基本知識を元にして、「クライアント証明書を対向サーバ毎に別々に選択する」っていうのを作ってみます。

クライアント証明書を自由に選択できる KeyManager

最初に作らないといけないのは、KeyManager の実装なんですが、実際にはクライアント証明書の形式としては通常 X.509 しかないはずなので、X509Keymanager インタフェースを実装する形で良いと思います。 クライアント証明書を選択するのは chooseClientAlias メソッドなので、これのみ実装を差し替えれば良い。というわけで、移譲させるようにします。

実装自体は、以下のとおりクライアント証明書を選択するのは Strategy パターンにしておきました。

@FunctionalInterface
public interface AliasSelectionStrategy {
  
  String selectAlias(String[] keyType, Principal[] issuers, Socket socket);

}
public class StrategyKeyManagger implements X509KeyManager {
  
  private X509KeyManager defaultKeyManager;
  private AliasSelectionStrategy aliasSelectionStrategy;
  
  public StrategyKeyManagger(X509KeyManager defaultKeyManager, AliasSelectionStrategy strategy) {
    this.defaultKeyManager = defaultKeyManager;
    this.aliasSelectionStrategy = strategy;
  }

  @Override
  public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
    String alias = aliasSelectionStrategy.selectAlias(keyType, issuers, socket);
    return (alias != null)? alias : defaultKeyManager.chooseClientAlias(keyType, issuers, socket);
  }

  @Override
  public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
    return defaultKeyManager.chooseServerAlias(keyType, issuers, socket);
  }
  // 以下略

SSLContext の差し替え

あとは、この KeyManager を使用して SSL 通信を行うように SSLContext を差し替えれば良いです。 これは以下のステップで実施します。

  1. デフォルトで使用される KeyManager を取得する
  2. そのうち、X509KeyManager の実装クラスを上記で作成した StrategyKeymanager に差し替える
  3. 差し替えた KeyManager で SSLContext を初期化する
  private static SSLContext getSSLContext(
      String keyStorePath, String keyStoreType, char[] keyStorePassword, AliasSelectionStrategy strategy,
      String trustStorePath, String trustStoreType, char[] trustStorePassword
      ) throws IOException, GeneralSecurityException {
    
    TrustManager[] trustManagers = getTrustManager(trustStorePath, trustStoreType, trustStorePassword);
    // 1. デフォルトで使用される KeyManager を取得する
    KeyManager[] keyManagers = getKeyManagers(keyStorePath, keyStoreType, keyStorePassword);
    
    // 2. KeyManager を差し替える
    for (int i = 0; i < keyManagers.length; i++) {
      if (keyManagers[i] instanceof X509KeyManager) {
        keyManagers[i] = new StrategyKeyManagger((X509KeyManager)keyManagers[i], strategy);
        break;
      }
    }
    
    // 3. 差し替えた KeyManager で SSLContext を初期化する
    SSLContext sslContext = SSLContext.getInstance("SSL");
    sslContext.init(keyManagers, trustManagers, null);
    return sslContext;
  }
    
  private static KeyManager[] getKeyManagers(String keyStorePath, String keyStoreType, char[] keyStorePassword) throws IOException, GeneralSecurityException {
    String algorithm = KeyManagerFactory.getDefaultAlgorithm();
    KeyManagerFactory kmf = KeyManagerFactory.getInstance(algorithm);
    
    KeyStore keyStore = null;
    try (InputStream is = Files.newInputStream(Paths.get(keyStorePath))) {
      keyStore = KeyStore.getInstance(keyStoreType);
      keyStore.load(is, keyStorePassword);
    }
    
    kmf.init(keyStore, keyStorePassword);
    return kmf.getKeyManagers();
  }    

クライアント証明書の選択の例

ここでは、対向サーバのホスト名毎にクライアント証明書を切り替える実装を作ってみました。

public class HostBasedAliasSelectionStrategy implements AliasSelectionStrategy {

    private Map<String, String> aliasMap;

    public HostBasedAliasSelectionStrategy(Map<String, String> hostAliasMap) {
        this.aliasMap = hostAliasMap;
    }

    @Override
    public String selectAlias(String[] keyType, Principal[] issuers, Socket socket) {
        // 対向ホスト名のクライアント証明書用 alias が Map に登録されていれば、それを使用する
        String hostName = socket.getInetAddress().getHostName();
        if (aliasMap.containsKey(hostName)) {
            return aliasMap.get(hostName);
        }
        return null;
    }
}

組み合わせる

あとはこれらを使って、HTTPS 通信をすれば良いでしょう。

ここでは、server.kiririmode.com 宛の通信には、client1 という alias で登録されたクライアント証明書が使用されます

  public static void main(String[] args) throws Exception{
    
    // server.kiririmode.com 宛の通信には、`client1` という alias で登録されたクライアント証明書を使用する
    Map<String, String> aliasMap = Collections.singletonMap("server.kiririmode.com", "client1");
    HostBasedAliasSelectionStrategy strategy = new HostBasedAliasSelectionStrategy(aliasMap);
    
    SSLContext sslContext = getSSLContext(
        "/path/to/keystore.ks", "JKS", "password".toCharArray(), strategy,
        "/path/to/truststore.ks", "JKS", "password".toCharArray()
        );
    try (CloseableHttpClient client = HttpClients.custom().setSSLContext(sslContext).build()) {
      HttpGet getMethod = new HttpGet("https://server.kiririmode.com:14433/index.html");
    
      client.execute(getMethod);
    }

参考文献

  1. JSSEリファレンス・ガイド
  2. Difference between trustStore and keyStore in Java - SSL
  3. Article: Creating Custom Key Managers
  4. 上級JSSE開発者のためのカスタムSSL

Stubby4JにおけるJSONでの正規表現、あるいはリクエスト中の特定のフィールドでレスポンスを変更する

technology java

Stubby4J を使用したテストにおいて、API リクエストとして送る JSON の中の値によって、レスポンスを切り替えたい場合がある。 もうちょっと具体的にいうと、

  1. リクエストの JSON の中の特定のフィールドの値によってのみ、レスポンスを切り替える
  2. 他のフィールドの値は実行時まで予想できない

こういうケースでレスポンスを切り替えるのは Stubby4J (v3.3.0) ではできないということが分かったので、その原因と、何とかする方法を考えてみる。

実装上できない理由

Stubby4J では、実際に送られてきた HTTP リクエストと、設定ファイル(YAML) 上に記述されたリクエストを StubRequest#equals(Object) で突合し、合致する場合にのみ、YAML ファイル上に記述されたレスポンスを返却するという動作になっている。 この「突合」(equals) の実装は、実際の HTTP リクエストの Content-Type によって挙動を替えるようになっている。application/json の場合は、「送信されてきた JSON」と「YAML ファイル上に記述された JSON」が、JSON のフィールド順を除いて完全一致すること が条件になっている。 以下に実装を示すけど、JSONCompare を使用しているということは、HTTP リクエスト上の JSON はもちろん、YAML 上に記述した JSON も valid である必要があるので、何らかの細工 (正規表現を記述するなど) もできない。

   private boolean postBodiesMatch(final boolean isDataStorePostStubbed, final String dataStorePostBody, final String thisAssertingPostBody) {
         // snip

         } else if (isSet(assertingContentType) && assertingContentType.contains(Common.HEADER_APPLICATION_JSON)) {
            try {
               return JSONCompare.compareJSON(dataStorePostBody, thisAssertingPostBody, JSONCompareMode.NON_EXTENSIBLE).passed();
            } catch (JSONException e) {
               return false;
            }
         } else if (isSet(assertingContentType) && assertingContentType.contains(Common.HEADER_APPLICATION_XML)) {

         // snip
   }

設計思想によってできない理由

Json request body matching by vishalmanohar · Pull Request #19 · azagniotov/stubby4j · GitHub で、JSON の中の値でレスポンスを切り替えられるような Pull Request があった。 これを利用すると、以下のようにリクエストの JSON に依存したレスポンスの設定がかけるようになる。

-  request:
      method: POST
      url: /templatize/response
   response:
      -  status: 200
         headers:
            content-type: application/json
         body: >
               <#assign requestBodyJson = requestBody?eval>
               {"foo": "${requestBodyJson.foo}"}

のだけれど、この Pull Request に対しては「HTTP リクエストと YAML 上のリクエストの突合は HTTP 1.1 レベルでしか実施しない」という設計思想が語られており、Merge されないままクローズされた。 実はこのあとに Content-Type に依存した突合の実装が取り込まれていることから鑑みるに、設計思想が変化しているみたいなのだけれど…。

何とかする方法

上記の PR と同じような修正を行ったり、あるいは現行の実装で使用している JSONCompare での比較ではなく、もっと柔軟な比較 (正規表現を許容する、jsonpath で比較すべきフィールドを指定する、JSON Schema を書けるようにする、等) をできるようにする、などはできるのだけど、とりあえず実現するだけなら以下で可能になる。

$ git diff  main/java/by/stub/yaml/stubs/StubRequest.java
diff --git a/main/java/by/stub/yaml/stubs/StubRequest.java b/main/java/by/stub/yaml/stubs/StubRequest.java
index 7e6a8a8..d76d6de 100644
--- a/main/java/by/stub/yaml/stubs/StubRequest.java
+++ b/main/java/by/stub/yaml/stubs/StubRequest.java
@@ -252,12 +252,6 @@ public class StubRequest {
          final boolean isAssertingValueSet = isSet(thisAssertingPostBody);
          if (!isAssertingValueSet) {
             return false;
-         } else if (isSet(assertingContentType) && assertingContentType.contains(Common.HEADER_APPLICATION_JSON)) {
-            try {
-               return JSONCompare.compareJSON(dataStorePostBody, thisAssertingPostBody, JSONCompareMode.NON_EXTENSIBLE).passed();
-            }

これはかなり強引な方法で、いってみれば「JSON とか知るか!! 文字列として扱うんや!!! 」という方法である。 つまり、リクエストが valid JSON でなくても、XML や Form であってもマッチしてしまうので、YAML 上でリクエストを記述するときにかなりの注意を払わなければならない。

上記修正を行った後で、gradle build -x test でビルドして実験し、以下のような YAML で Stubby4J を立ち上げる。

- request:
    method: POST
    url: /hello-world
    post: >
      \{\s*"hello"\s*:\s*"(.*?)"\s*.*\}

  response:
    status: 200
    headers:
      content-type: application/json
    body: Hello, <% post.1 %>

これに対して、リクエストを送信すると、きちんとレスポンスを切り替えられるようになる

$ curl -H'content-type: application/json' -d "{\"hello\":\"kiririmode\", \"hoge\":\"fuga\" }" http://localhost:8882/hello-world
Hello, kiririmode
$ curl -H'content-type: application/json' -d "{\"hello\":\"world\", \"var\":\"val\" }" http://localhost:8882/hello-world
Hello, world