Edited at

WebCrypto APIでECDSAの署名と検証を試してみる

More than 1 year has passed since last update.

WebCrypto APIでECDH鍵交換を用いた暗号化を使ってみるでは、WebCrypto APIを使った暗号化と復号について触れましたが、今度は同じく楕円曲線暗号をベースとしたECDSA (Elliptic Curve Digital Signature Algorithm)でJWT (JSON Web Token)による電子署名を試してみます。正確には、SHA-256ハッシュを組み合わせた、SHA-256 with ECDSAによる電子署名、となります。


まずはJWTのペイロードを準備(署名する側)

JWTについてはWebCrypto APIでJSON Web Tokenの検証を試してみるで(検証のみ)触れていますので、全体の流れとJWTの書き方についてはこちらを参照して下さい。

今回も、JWT自体の形式は同様に、

[ヘッダ].[クレーム].[シグネチャ]

となりますが、今回はRSAではなくECDSAを用いますので、ヘッダは、

{"typ":"JWT","alg":"ES256"}

をURLセーフBase64でエンコードしたものとなります。

クレームの部分については、今回は試しに次のような形式(をURLセーフBase64にエンコードしたもの)にしてみます。

{"sub":"mailto:user@example.com","aud":"http://example.com","exp":"(現在時刻+12時間)"}

これらからシグネチャを生成するためのペイロードを生成するまでの手順をJavaScriptで実装する例は次のようになります。

function encodeBase64URL(data) {

let str = '';
if(typeof data === 'string') {
str = data;
}
else if(data instanceof Uint8Array) {
str = String.fromCharCode.apply(null, data);
}
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

let header = {
typ: 'JWT',
alg: 'ES256'
};

let claim = {
sub: 'mailto:user@example.com',
aud: 'http://example.com',
exp: Date.now() / 1000 | 0 + 12 * 60 * 60
};

let payload = encodeBase64URL(JSON.stringify(header)) + '.' + encodeBase64URL(JSON.stringify(claim));


鍵ペアの生成(署名する側)

署名に必要な鍵ペアの生成は、概ねECDHの場合と同様です。方式としてECDSA、usageとしてsignverifyを指定する点がECDHとの違いとなります。


WebCryptoで鍵ペアを生成

let keyPair, publicKeyJWK;

crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256'}, false, ['sign', 'verify'])
.then(key => { // 鍵ペアを生成
keyPair = key;
return crypto.subtle.exportKey('jwk', keyPair.publicKey);
}).then(jwk => { // 公開鍵をJWKとしてエクスポート
publicKeyJWK = jwk;
});



公開鍵のインポート(検証する側)

検証する側で署名者の公開鍵をインポートする手順も、概ねECDHの場合と同様です。署名者から受け取った公開鍵のフォーマットによって手順が異なる点もECDHと同様です。方式としてECDSA、usageとしてverify`を指定する点がECDHとの違いとなります。


WebCryptoでJWK公開鍵をインポート

let remotePublicKey, jwk = JSON.parse('(文字列シリアライズされたJWK)');

crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'ECDSA', namedCurve: 'P-256'},
false,
['verify']
).then(key => {
remotePublicKey = key;
});



署名してJWTを生成(署名する側)

鍵ペアの秘密鍵とJWTのペイロードから署名を行い、JWT全体を生成する手順は次のようになります。署名結果はUint8Array型のバイナリデータとなります。


WebCryptoで署名してJWTを生成

// JavaScriptのbtoa()ではURL-safe Base64が扱えないため変換が必要

function encodeBase64URL(data) {
if(!(data instanceof Uint8Array))
return null;
let output = '';
for(let i = 0 ; i < data.length ; i++)
output += String.fromCharCode(data[i]);
return btoa(data.replace(/\+/g, '-').replace(/\//g, '_')).replace(/=+$/, '');
}

function toBufferSource(t) {
var r = new Uint8Array(t.length);
for(var i = 0 ; i < r.length ; i++)
r[i] = t.charCodeAt(i);
return r;
}

let result;

crypto.subtle.sign(
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
keyPair.privateKey,
toBufferSource(payload)
).then(signature => {
result = payload + '.' + encodeBase64URL(signature);
});



署名を検証(検証する側)

インポートした公開鍵を用いたJWTの検証は次のような手順になります。crypto.subtle.verify()の結果をthen()で取得する際、boolean型の引数が結果として渡されます。


WebCryptoでJWTを検証

// JavaScriptのatob()ではURL-safe Base64が扱えないため変換が必要

function decodeBase64URL(data) {
if(typeof data !== 'string')
return null;
let decoded = atob(data.replace(/\-/g, '+').replace(/_/g, '/'));
let buffer = new Uint8Array(decoded.length);
for(let i = 0 ; i < data.length ; i++)
buffer[i] = decoded.charCodeAt(i);
return buffer;
}

let jwt = '(JWTを文字列表記したもの)',
let t = jwt.split('.');
let signature = toBufferSource(base64UrlDecode(t[2])),
payload = toBufferSource(t[0] + '.' + t[1]);

crypto.subtle.verify(
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
remotePublicKey,
signature,
payload
).then(result => {
console.log(result ? '検証成功' : '検証失敗');
});



JWTのシグネチャのフォーマットと他の実行環境に関する注意事項

SHA-256 with ECDSAによる電子署名は、各32オクテットのRとSの値で表現されます。

JWTのシグネチャに格納する際は、RFC 7518に記載されているように、RとSを単純に連結して64オクテットのバイナリデータとしてシグネチャを生成し、これをURLセーフBase64にエンコードします。

WebCrypto APIでcrypto.subtle.sign()を実行してSHA-256 with ECDSAによって署名する場合は、このRとSの単純連結した64オクテットのUint8Arrayを実行結果として取得できます。よって、これをURLセーフBase64にエンコードして、ペイロードと.を挟んで連結することで、JWTを生成することができます。

一方、Java 8等では、ECDSAによる電子署名はASN.1 DERフォーマットで扱われるため、JWTの作成や検証の際はRとSの単純連結との相互変換を行う必要があります。なお、WebCrypto APIでは、このASN.1 DERフォーマットには対応していません。

ASN.1 DERフォーマットのECDSA電子署名は次のようなフォーマットとなります。

0x30||(この次に続くデータのオクテット長(1オクテット))

||0x02||(Rのオクテット長(1オクテット))||R(符号付き整数)
||0x02||(Sのオクテット長(1オクテット))||S(符号付き整数)

(注: ||はバイナリデータの結合を表します)

ここで、RおよびSは符号なし256ビット整数であるのに対し、ASN.1 DERフォーマットでは符号付き整数を扱う点に注意が必要です。従って、R, Sそれぞれについて、先頭の1オクテットが0x80から0xffである場合、先頭に0x00を足すことで正の整数として扱われるように補正する必要があります。先頭に0x00の1オクテットを追加した場合、当然ながらRあるいはSのオクテット長、そして、0x30の次に格納するオクテット長も変化することになりますのでご注意下さい。