JWS Signature につまづいて読む ASN.1

この記事は 第二のドワンゴ Advent Calendar 2019 の 13 日目の記事です。

qiita.com


この記事は、Content-Type: application/jose+json なリクエストを試そうと JWS を実装したときに、JWS Signature につまづいて PHPer が ASN.1 の上辺をなでたときの話です。

JWS は、デジタル署名(以下、署名とします)または MAC によって改ざんやなりすましから保護された、署名または MAC の検証に必要な情報を含むメッセージのデータ表現のことです。

JOSE Header 、JWS Payload、JWS Signature の 3 つのフィールドから構成されており、これらを JOSE Header の構造に応じて JWS Compact Serialization または JWS JSON Serialization と呼ばれる方法でシリアライズして使用するしくみとなっています。

記事内では JWS を組み立てる例を記しますが、例はこのように組み立てるといった方法を推奨するものではありません。くれぐれも実装の参考にはしないようご注意ください。JWS および JOSE サブセット全般の作成には、十分に検証された実績のあるライブラリを使用しましょう。

JWS を組み立ててみる

何はさておき JWS を組み立ててみましょう(各スニペットの冒頭の <?phpシンタックスハイライトの都合によるものです)。

1. Base64url Encoding 用関数の準備

JWS では、空白や改行コード、パディング( = )、その他不要な文字( / + )を含まない、base64 エンコーディングした文字列を使用します。ここでは base64_encode() の結果から URL unsafe な文字列を取り除く関数を準備しました。

<?php

/**
 * @param  string $data
 * @return string
 */
function base64_url_encode(string $data): string
{
    return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}

2. 署名に使用するキーペアの作成

今回は公開鍵暗号方式に P-256 曲線を使った ECDSA、ハッシュ関数に SHA-256 を用いた署名を行います。署名に使用するキーペアを作成します。

<?php

// ...

$resource = openssl_pkey_new([
    'private_key_type' => OPENSSL_KEYTYPE_EC,
    'curve_name'       => 'prime256v1',
]);

3. 作成した秘密鍵から公開鍵を取り出せるようにしておく

JWS では署名または MAC の検証に必要な情報を JOSE Header に含める必要がありますので、あらかじめ秘密鍵に対応する公開鍵を取り出せるようにしておきます。

<?php

// ...

openssl_pkey_export($resource, $privkey);
$details = openssl_pkey_get_details($resource);
openssl_free_key($resource);

4. JOSE Header の準備

alg パラメータは署名または MAC に使用した暗号アルゴリズムハッシュ関数を表します。以下のコードでは ES256 を指定していますが、これは公開鍵暗号方式に P-256 曲線を使った ECDSA、ハッシュ関数に SHA-256 を署名に使用していることを意味します。 alg パラメータに指定できる値とその意味については RFC 7158 で定義されています。

jwk パラメータは RFC 7157 で定義されている JWK というデータ表現を用いた、署名の作成に使用した秘密鍵に対応する公開鍵を表します。

<?php

// ...

$header = json_encode([
    'alg' => 'ES256',
    'jwk' => [
        'kty' => 'EC',
        'crv' => 'P-256',
        'x'   => base64_url_encode($details['ec']['x']),
        'y'   => base64_url_encode($details['ec']['y']),
    ],
]);

5. JWS Payload の準備

JWS Payload には実際のメッセージを詰めます。

<?php

// ...

$payload = json_encode([
    'name' => 'John Doe'
]);

6. JWS Signature の準備

JOSE Header と JWS Payload を . で結合したデータに対し、先ほど作成した秘密鍵を使って署名します(ここで行った署名が今回のポイント)。

<?php

// ...

$signingInput = base64_url_encode($header) . '.' . base64_url_encode($payload);

openssl_sign($signingInput, $signature, $privkey, OPENSSL_ALGO_SHA256);

7. JWS Compact Serialization によるシリアライズ

最後に、それぞれの要素を . で連結( JWS Compact Serialization 形式でのシリアライズ)して JWS の完成…と思っていました。

<?php

// ...

$jws = $signingInput . '.' . base64_url_encode($signature);

echo $jws;

以上のスクリプトを実行した結果が次の文字列になります(表示のために改行していますが、本来は 1 行の文字列です)。

eyJhbGciOiJFUzI1NiIsImp3ayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6Ii1HbVQteEN
HQ041WS1iSmRndllpeHNvcWlJSWVieXBhdTZneHc5N2RpcVUiLCJ5IjoiMk5iYWZUS3hSbFZQQjM0YU
FuU2VJVXc0ektGRnNaejdhR2I4OXBGNWpxdyJ9fQ
.
eyJuYW1lIjoiSm9obiBEb2UifQ
.
MEUCIQCLmZZnA3L1aYSBT4vPJmSDiJBgt13SJs-aubHbHqgYvgIgN_1pXJH0wBBvACZA0BlZpJgpMW_I
ncIHEjfJ2Q3HvFoArray

いかにもそれっぽいですが、この JWS の署名は検証することができず、エラーとなります。

openssl_sign() が DER 形式の ASN.1 を返している

署名を作成するためにコールした openssl_sign() の内部では OpenSSL の EVP 関数コール されており、今回のように公開鍵暗号方式に ECDSA、ハッシュ関数に SHA-256 を指定している場合に返される署名は DER 形式の ASN.1 構造を持つバイナリデータのようです。

実際に手順 6 で得られた署名を hexdump したところ、 DER 形式の ASN.1 構造であることが確認できました。

string(142) "30450220641e301c1a18c8cce0b24aa70e4f459331008da2a55c1f16007d6da2457096af022100a81e994696b53878c9bcf84d931d5bbd950c8043bccbd7789d94a0d1337f32ed"

今回のように ES256 を使用している場合には、どのような JWS Signature であるべきなのでしょうか。関連する RFC をたどっているといくつかの定義を見つけることができました。

RFC 7515 の Appendix-A.3. には、ES256 による JWS Signature の例が記載されていました。

   The Elliptic Curve Digital Signature Algorithm (ECDSA) private part d
   is then passed to an ECDSA signing function, which also takes the
   curve type, P-256, the hash type, SHA-256, and the JWS Signing Input
   as inputs.  The result of the digital signature is the Elliptic Curve

   (EC) point (R, S), where R and S are unsigned integers.  In this
   example, the R and S values, given as octet sequences representing
   big-endian integers are:

   +--------+----------------------------------------------------------+
   | Result | Value                                                    |
   | Name   |                                                          |
   +--------+----------------------------------------------------------+
   | R      | [14, 209, 33, 83, 121, 99, 108, 72, 60, 47, 127, 21, 88, |
   |        | 7, 212, 2, 163, 178, 40, 3, 58, 249, 124, 126, 23, 129,  |
   |        | 154, 195, 22, 158, 166, 101]                             |
   | S      | [197, 10, 7, 211, 140, 60, 112, 229, 216, 241, 45, 175,  |
   |        | 8, 74, 84, 128, 166, 101, 144, 197, 242, 147, 80, 154,   |
   |        | 143, 63, 127, 138, 131, 163, 84, 213]                    |
   +--------+----------------------------------------------------------+

   The JWS Signature is the value R || S.  Encoding the signature as
   BASE64URL(JWS Signature) produces this value (with line breaks for
   display purposes only):

     DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSA
     pmWQxfKTUJqPP3-Kg6NU1Q

RFC 7518 の ES256 の署名検証に関する節には The JWS Signature MUST be 64-octet sequence という記述がありました。

The ECDSA P-256 SHA-256 digital signature for a JWS is validated as
follows:

1.  The JWS Signature value MUST be a 64-octet sequence.  If it is
    not a 64-octet sequence, the validation has failed.

2.  Split the 64-octet sequence into two 32-octet sequences.  The
    first octet sequence represents R and the second S.  The values R
    and S are represented as octet sequences using the Integer-to-
    OctetString Conversion defined in Section 2.3.7 of SEC1 [SEC1]
    (in big-endian octet order).

3.  Submit the JWS Signing Input, R, S, and the public key (x, y) to
    the ECDSA P-256 SHA-256 validator.

RFC 3279 には ECDSA 署名の ASN.1 構造が定義されていました。

   The elliptic curve parameters in the subjectPublicKeyInfo field of
   the certificate of the issuer SHALL apply to the verification of the
   signature.

   When signing, the ECDSA algorithm generates two values.  These values
   are commonly referred to as r and s.  To easily transfer these two
   values as one signature, they MUST be ASN.1 encoded using the
   following ASN.1 structure:

      Ecdsa-Sig-Value  ::=  SEQUENCE  {
           r     INTEGER,
           s     INTEGER  }

以上のことから、openssl_sign() で得られた署名を一度分解し、RS を取り出して 64-octet sequence になるよう整形する必要がありそうなことがわかりました。

読む

ASN.1 構造のデータを読むといっても自分には知識がなかったため、以下のページを参考にしました。

Distinguished Encoding Rules - Win32 apps | Microsoft Docs

どうやら先ほどの hexdump して得られた文字列は、以下のように分解できるようです。

30 45                                                      ; SEQUENCE (45 Bytes)
   02 20                                                   ; INTEGER  (20 Bytes)
   |  | 64 1e 30 1c 1a 18 c8 cc e0 b2 4a a7 0e 4f 45 93    ; R Value 
   |  | 31 00 8d a2 a5 5c 1f 16 00 7d 6d a2 45 70 96 af    ; R Value 
   02 21                                                   ; INTEGER  (21 Bytes)
        00 a8 1e 99 46 96 b5 38 78 c9 bc f8 4d 93 1d 5b bd ; S Value 
        95 0c 80 43 bc cb d7 78 9d 94 a0 d1 33 7f 32 ed    ; S Value 

この R ValueS Value とコメントした部分の値を取り出して、実際に整形していきたいと思います。

JWS Signature を正しくつくる

R ValueS Value を取り出すコードを追加してみます(このやり方は ES256 および ES384 でのみ有効で、ES512 の場合は R と S の Length が 80 を超えるため手直しが必要です)。

<?php

//...

$hexSignature = bin2hex($signature);

// 先頭の Tag, Length, R Value の Type までで 6 文字
// その後の 2 文字は R Value の Length を表す Hex のため、ここから R の長さを求める
$rLen = hexdec(mb_substr($hexSignature, 6, 2)) * 2;

// 先頭の Tag, Length, Value Type と Length までで 8 文字
// さらに R Value の長さと L Value の Type と Length を足して
// S が最初に現れる位置を求める
$sIndexOf = 8 + $rLen + 4;

// 先頭の Tag, Length, Value Type と Length の直後から $rLen までを取り出す
$rRaw = mb_substr($hexSignature, 8, $rLen);

// S が最初に現れる位置から末尾までを取り出す
$sRaw = mb_substr($hexSignature, $sIndexOf);

var_dump($rRaw);
var_dump($sRaw);

問題なく R ValueS Value を取り出せていることを確認しました。

string(140) "3044022022833e88da0e7e4ec5645a2c911fe57e4fe6a201215ca58c69667dc2150930e6022047fefef0199a55d8077c8e4d27977b86f48a8148b98bf3a4e2f3bd002edf40bc"
string(64) "22833e88da0e7e4ec5645a2c911fe57e4fe6a201215ca58c69667dc2150930e6"
string(64) "47fefef0199a55d8077c8e4d27977b86f48a8148b98bf3a4e2f3bd002edf40bc"

次にそれぞれの値をチェックし、適宜 Zero Suppression や Zero Padding して整形するようにします。

<?php

// ...

/**
 * @param  string $hex
 * @param  int    $length
 * @return string
 */
function formatOctetSequence(string $hex, int $length): string
{
    while (mb_substr($hex, 0, 2) === '00' && mb_substr($hex, 2, 2) > '7f') {
        $hex = mb_substr($hex, 2);
    }

    return str_pad($hex, $length, '0', STR_PAD_LEFT);
}

// 32-octet sequence を R と S それぞれで作る
$R = formatOctetSequence($rRaw, 64);
$S = formatOctetSequence($sRaw, 64);

var_dump($R);
var_dump($S);

RS それぞれで 32-octet sequence に整形されていることを確認しました。

string(64) "85b5f5b74356ee2e8531dc31c919f4f009a5922500ec7d2cd1606af0bf9825cc"
string(64) "7a1ca09581fa5d36b904b921c1d4e3cfd3ac8c408bd0a524c6805cd0e503af67"

最後にこの RS を結合したものをバイナリに戻しておきます。これを base64_url_encode() することで正しい JWS Signature を得られます。

<?php

//...

$jwsSignature = hex2bin($R . $S);

リベンジ

こうして得られた新しい JWS が以下になります(例により改行しています)。

eyJhbGciOiJFUzI1NiIsImp3ayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6InN2c1RpMGxj
VXduY25rTU9tTjdBUG9vcF80dkY0RVRDYmpSNG4xZ2JNNEEiLCJ5IjoiQ216Mm9wZmk5cUNIT2pVN0oz
cjhldi0zOGZRT05OMlVjMDdNT0k0ZkVTTSJ9fQ
.
eyJuYW1lIjoiSm9obiBEb2UifQ
.
4D7-QOhx5RxIjApci0w0tFrG-5KzTdJVbP45MqOg6LzNqy2tRvv0NQo4BroPFZxs_YBoqE3ARVbw4yXA
D8PM1g

検証のために、今回の署名に使った秘密鍵に対応する公開鍵も出力しておきました。

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsvsTi0lcUwncnkMOmN7APoop/4vF
4ETCbjR4n1gbM4AKbPail+L2oIc6NTsnevx6/7fx9A403ZRzTsw4jh8RIw==
-----END PUBLIC KEY-----

jwt.io を使ってチェックしたところ、問題なく署名の検証に成功することを確認できました。

雰囲気でやっている

OpenSSL による署名データの構造が ECDSA 以外の場合にどうなのか、といったところまではまだ追いきれてなく、まだまだ雰囲気でやっており強い気持ちで頑張っていきたいところです。