まず結論の概要。
最近はAzureやGoogleやYahooのAPIはどこもJWTを使っていて、著名検証のための公開鍵をやはりAPIで配布している。
公開鍵配布エンドポイントから取得できる内容は例えば以下のような内容である。
{
"keys": [
{
"kid": "X5eXk4xyojNFum1kl2Ytv8dlNP4-c57dO6QGTVBwaNk",
"nbf": 1493763266,
"use": "sig",
"kty": "RSA",
"e": "AQAB",
"n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCatsZ3l4RjKxH0jgdLq6CScb0P3ZGXYbPzXvmmLiWZizpb-h0qup5jznOvOr-Dhw9908584BSgC83YacjWNqEK3urxhyE2jWjwRm2N95WGgb5mzE5XmZIvkvyXnn7X8dvgFPF5QwIngGsDG8LyHuJWlaDhr_EPLMW4wHvH0zZCuRMARIJmmqiMy3VD4ftq4nS5s8vJL0pVSrkuNojtokp84AtkADCDU_BUhrc2sIgfnvZ03koCQRoZmWiHu86SuJZYkDFstVTVSR0hiXudFlfQ2rOhPlpObmku68lXw-7V-P7jwrQRFfQVXw"
}
]
}
ところがこれらがなんの値でどう使えばいいのかわからない。
本来ならPEMフォーマットの文字列が欲しい。これらの値から変換しないといけないらしい。
その解説。
e
Exponent。
RSA暗号における公開鍵を構成するふたつの整数のうちのひとつ。
n
Modulus。
RSA暗号における公開鍵を構成するふたつの整数のうちのひとつで、秘密鍵において使われているふたつの素数の積。
大きな素数 p と q が生成され、「n=pq」。
「M」odulusなのに「n」なので居心地が悪い。
どうやって整数にすればいいのか
これらはRSA暗号における公開鍵を構成するふたつの整数であるはずなのに整数になってない。
ふたつのプロセスを経て整数に変換しないといけない。
- URLに対応したBase64デコードをしてバイナリデータにする。
- OS2IPという規格に従ってバイナリデータを整数に変換する。
ふたつの整数にしたあとどうやってPEM文字列にするか
PEMフォーマットというのは以下のような形式の文字列で、SSHの公開/秘密鍵によく使われる。
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtVKUtcx/n9rt5afY/2WF
NvU6PlFMggCatsZ3l4RjKxH0jgdLq6CScb0P3ZGXYbPzXvmmLiWZizpb+h0qup5j
znOvOr+Dhw9908584BSgC83YacjWNqEK3urxhyE2jWjwRm2N95WGgb5mzE5XmZIv
kvyXnn7X8dvgFPF5QwIngGsDG8LyHuJWlaDhr/EPLMW4wHvH0zZCuRMARIJmmqiM
y3VD4ftq4nS5s8vJL0pVSrkuNojtokp84AtkADCDU/BUhrc2sIgfnvZ03koCQRoZ
mWiHu86SuJZYkDFstVTVSR0hiXudFlfQ2rOhPlpObmku68lXw+7V+P7jwrQRFfQV
XwIDAQAB
-----END PUBLIC KEY-----
変換には「phpseclib」というライブラリを使う
<?php
set_include_path(get_include_path() . PATH_SEPARATOR . './phpseclib');
include('Crypt/RSA.php');
include('Math/BigInteger.php');
$rsa = new Crypt_RSA();
$rsa->loadKey(
[
'e' => new Math_BigInteger($Base64デコードしたExponent, 256),
'n' => new Math_BigInteger($Base64デコードしたModulus, 256)
]
);
var_dump($rsa->getPublicKey()); //これがPEM文字列になる
PEM文字列に変換できれば openssl_get_publickey()
の引数として利用可能で、PHPで利用可能な公開鍵リソースが得られる。
順番に解説
仕事で Azure Active Directory B2C を使うことになった。
WebAPIの使い方を調べていくと、JWTを取得し、それを検証せよとある。
JWTについてはググればちゃんとでてきます。
一例 : https://hiyosi.tumblr.com/post/70073770678/jwt%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6%E7%B0%A1%E5%8D%98%E3%81%AB%E3%81%BE%E3%81%A8%E3%82%81%E3%81%A6%E3%81%BF%E3%81%9F
JWTの内容解析と著名検証(変換したPEM文字列の妥当性検証)には以下のサイトが便利でした。
https://jwt.io/
Azureの場合、
- WebAPIからJWTを取得する。
- JWTを解析し、ペイロード部分の「iss」の値として指定されているURLを取り出す。
- 取り出した「iss」のURLに「.well-known/openid-configuration?p=自分で指定したポリシー名」を追加したURLを新たなWebAPIとしてそこに接続する。
「/.well-known/openid-configuration」というURLは国際的な仕様で定められている値。 - 接続したAPIから得られた値のうち更に「jwks_uri」の値としてよこされてくるURLがあるので、さらにそこに接続する。
- そうするとやっと公開鍵の値が得られるが、PEM文字列がなく、「e」とか「n」とか言う意味不明な値しかない。
この意味不明な「e」「n」を材料にしてどうすればPHPで使用可能な公開鍵リソースに変換できるのかという課題だった。
調べてみると、AzureだけでなくGoogleもYahooも同じフォーマットで公開鍵を配布している。なのになぜググっても使い方が見つからないんだ?
「e」「n」を整数に変換する
参考 : https://qiita.com/bobunderson/items/d48f89e2b3e6ad9f9c4c#rsassa-pkcs1-v1_5
文字列→バイナリ
まず「URLセーフのBase64」でデコードする。
この「URLセーフのBase64」がややこしい。PHPの「base64_decode()」関数だけじゃダメなのかというと、ダメなのである。
Base64とはなにか
https://ja.wikipedia.org/wiki/Base64
すべての文字をアルファベットと一部の記号(ここまでで64種類)と「=」(これを含めると65種類)の文字のみで表現するための規格。
マルチバイト文字やバイナリデータを、昔のEメールのようにアスキー文字にしか対応してない規格内で扱うためのエンコード方式。
「パディング」というのは「余った部分を穴埋めするための記号【=】」のこと。
URLセーフのBase64とはなにか
URLにBase64を含ませると、+ と / が問題を引き起こすことがある。これらの文字がURLで特別な意味を持つために %XX の形にエスケープする必要が生じるためである。 他にも、+ と / が特別な意味をもつ個所(正規表現など)やその使用が制限される個所(XMLなど)でBase64を用いるときには、この二文字のかわりに !、-、. 等を用いることがある。
つまり、「+」「/」について例外的な処理が必要ということ。
PHPでURLセーフなBase64デコードを行う
最終的にうまく行ったコードでは以下のような関数を使った。
これは以下のライブラリからのパクリである。
https://github.com/ritou/php-Akita_JOSE/blob/master/src/Akita/JOSE/Base64.php
/**
* URLセーフなBase64デコードをする。
*
* base64_decode()関数の改良版
* * 入力文字の文字数が4の倍数でない場合、足りない分にパディング(=)を追加する。
* Base64の規格により、Base64エンコードされた文字列は4の倍数の文字数でなければならないため。
* * URLにおいて問題を起こす値(「+」と「/」)の取り扱いへの対応。base64urlというがPHPにはそのためのズバリな関数はない。
*
* @param string Base64エンコードされた文字列
* @return string/binary Base64デコードされたデータ
*/
function urlSafeBase64Decode($str) {
$dec = strtr($str, "-_", "+/"); //URL対応する場合 : 「+」「/」への対応
switch (strlen($dec) % 4) {
case 0:
break;
case 2:
$dec .= "==";
break;
case 3:
$dec .= "=";
break;
default:
return "";
}
return base64_decode($dec);
}
こういう関数、JWTの解析ライブラリが持っていたりもするんだけど、今回は理解のために自前コードに含めるつもりでおく。
公開鍵APIから得られた「e」「n」の値をこの関数で変換すると、それぞれバイナリのデータになる。
バイナリデータ→整数(Math_BigIntegerオブジェクト)
バイナリから整数に変換するために「OS2IP」という規格で定められたプロセスを通すようです。
phpseclibライブラリ
ちょっと話がそれますが、今回の変換には「phpseclib」というライブラリが必須になります。
http://phpseclib.sourceforge.net/index.html
PHPセキュリティライブラリって意味だと思います。
このライブラリが重要なメソッドやクラスを持っています。
- Math_BigInteger
- Crypt_RSA
など
このライブラリに以下のようなメソッドがあり、OS2IP規格によってバイナリを整数に変換するには
BigInteger(Math_Biginteger)のコンストラクタにバイナリ指定(256)でバイナリデータを渡せば
良さそうだということがわかります。
// https://github.com/phpseclib/phpseclib/blob/master/phpseclib/Crypt/RSA.php#L877
private function os2ip($x){
return new BigInteger($x, 256);
}
Math_Bigintegerインスタンスの「toString()」メソッドで実際の10進数が取得できます。
「"e": "AQAB",」を変換してみる
ダウンロードして解凍した phpseclib ライブラリを以下のように配置しているものとします。
test.php
└phpseclib
├Math
│└BigInteger.php
(その他は省略)
<?php
//test.php
set_include_path(get_include_path() . PATH_SEPARATOR . './phpseclib');
include('Math/BigInteger.php');
$e = "AQAB";
$bin_e = urlSafeBase64Decode($e);
$obj_e = new Math_BigInteger($bin_e, 256);
$int_e = $obj_e->toString();
echo('e の値(変換前) : '.$e.'<br>');
echo('e の値(変換後) : '.$int_e);
function urlSafeBase64Decode($str) {
$dec = strtr($str, "-_", "+/"); //URL対応する場合 : 「+」「/」への対応
switch (strlen($dec) % 4) {
case 0:
break;
case 2:
$dec .= "==";
break;
case 3:
$dec .= "=";
break;
default:
return "";
}
return base64_decode($dec);
}
「e」と「n」が整数に変換できたとして、それをどう使って公開鍵リソースにするのか
PHPで公開鍵リソースを得るには openssl_get_publickey()
メソッドにPEMフォーマットの文字列を渡せばいい。
なぜ公開鍵リソースにこだわるのかというとJWT解析ライブラリは大抵この公開鍵リソースなりPEMフォーマットの文字列なりを必要とするからだ。
PEMフォーマット
参考 : https://qiita.com/kunichiko/items/12cbccaadcbf41c72735
ざっくりいうと公開鍵のふたつの整数(e, n)を変換して整形した文字列。
「-----BEGIN PUBLIC KEY-----」「-----END PUBLIC KEY-----」で囲われていて、定められた位置で改行されたもの。
phpseclib ライブラリを利用する
- ライブラリのインクルード
- Crypt_RSAインスタンスの作成
- Crypt_RSAインスタンスのloadKey()メソッドで「e」「n」それぞれの Math_BigIntegerインスタンス をセットする。
整数そのものじゃなくて Math_BigIntegerクラスのインスタンスをセットする由。 - Crypt_RSAインスタンスのgetPublicKey() メソッドでPEM文字列が取り出せる。
上にも書きましたが以下のような感じ。
<?php
set_include_path(get_include_path() . PATH_SEPARATOR . './phpseclib');
include('Crypt/RSA.php');
include('Math/BigInteger.php');
$rsa = new Crypt_RSA();
$rsa->loadKey(
[
'e' => new Math_BigInteger($Base64デコードしたExponent, 256),
'n' => new Math_BigInteger($Base64デコードしたModulus, 256)
]
);
var_dump($rsa->getPublicKey()); //これがPEM文字列になる
これで公開鍵APIから配布された謎の「e」「n」の文字列からPEM文字列が作成できると思います。
サンプルプログラム
JWTや公開鍵の「e」「n」の値を検証するためのサンプルプログラムを作成し、GitHubに公開しました。
動作するものをWeb上に設置しました。
入力を想定しているJWTはたとえばAzureの「id_token」ですが、内容が第三者にバレたらまずいものはここで試さずにご自分のローカルにプログラムを設置して動かすほうが良いでしょう。送受信したデータの漏洩についてはこちらでは責任を負いかねます。
http://loveandcomic.com/program_sample/jwt_confirm/index.php
こういう感じのやつです。