3
1

More than 1 year has passed since last update.

JavaScriptで楕円曲線暗号の署名と検証をする(Web Crypto API)

Posted at

ソースコードだけ見たい人のために

鍵ペア作成

$ openssl ecparam -genkey -name prime256v1 -out p256.keypair
$ openssl ec -in p256.keypair -outform PEM -pubout -out p256.publickey.pem
read EC key
writing EC key
$ openssl ec -in p256.keypair -outform PEM -out p256.privatekey.pem
read EC key
writing EC key
$ openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in p256.privatekey.pem -out p256.pkcs8.pem

生成されたモノ:

  • p256.keypair: 鍵ペア(秘密鍵)
  • p256.publickey.pem: 公開鍵 --- 署名検証に使う
  • p256.privatekey.pem: 秘密鍵
  • p256.pkcs8.pem: 秘密鍵(PKCS#8) --- 署名に使う

ソースコード

<html>
<head>
<meta http-eqix="Content-Type" content="text/html; charset=UTF-8">
<script type="text/javascript"src="jquery-3.6.1.js"></script>
<script>
$(function() {

	function stob(str) {
		return Uint8Array.from(str, c => c.charCodeAt(0));
	}
	
	// String to ArrayBuffer
	function s2buf(str) {
		const buf = new ArrayBuffer(str.length);
		const view = new Uint8Array(buf);
		for (let i = 0, len = str.length; i < len; i++) {
			view[i] = str.charCodeAt(i);
		}
		return buf;
	}
	
	// ArrayByffer to base64
	function buf2b(buffer) {
		return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
	}
	
	// base64 to ArrayBuffer
	function b2buf(str) {
		return Uint8Array.from(atob(str), c => c.charCodeAt(0));
	}
	
	// 署名
	$('#submit-sign').on('click', event => {
		console.log('# submit sign');
		
		var reader = new FileReader();
		reader.readAsText($('#input-privatekey').prop('files')[0]);
		reader.onload = async () => {
			console.log("private key (pem): \n" + reader.result);
			
			var privatekey = reader.result
				.replace(/-----BEGIN .*PRIVATE KEY-----/, "")
				.replace(/-----END .*PRIVATE KEY-----/, "")
				.replace(/\r?\n/g, '');
			console.log("private key (base64): " + privatekey);
			
			var privatekeySpec = await window.crypto.subtle.importKey(
				'pkcs8',
				s2buf(atob(privatekey)),
				{
					name: "ECDSA",
					namedCurve: "P-256",
					hash: "SHA-256"
				},
				true,
				["sign"]
			);
			console.log("private key (spec): " + privatekeySpec);
			
			var sig = await window.crypto.subtle.sign(
				{name: "ECDSA", hash: {name: "SHA-256"}},
				privatekeySpec,
				stob($('#input-challenge1').val())
			);
			console.log(buf2b(sig));
			$('#input-sign').val(buf2b(sig));
		};
	});
	
	// 検証
	$('#submit-verify').on('click', event => {
		console.log('# submit verify');
		
		var reader = new FileReader();
		reader.readAsText($('#input-publickey').prop('files')[0]);
		reader.onload = async () => {
			console.log("public key (pem): \n" + reader.result);
			
			var publickey = reader.result
				.replace(/-----BEGIN .*PUBLIC KEY-----/, "")
				.replace(/-----END .*PUBLIC KEY-----/, "")
				.replace(/\r?\n/g, '');
			console.log("public key (base64): " + publickey);
			
			var publickeySpec = await window.crypto.subtle.importKey(
				'spki',
				s2buf(atob(publickey)),
				{
					name: "ECDSA",
					namedCurve: "P-256",
					hash: "SHA-256"
				},
				true,
				["verify"]
			);
			console.log("public key (spec): " + publickeySpec);
			
			var result = await window.crypto.subtle.verify(
				{name: "ECDSA", hash: {name: "SHA-256"}},
				publickeySpec,
				b2buf($('#input-sign').val()),
				stob($('#input-challenge1').val())
			);
			console.log(result);
		};
	});
});
</script>
</head>
<body>
<h1>署名</h1>
<form id='f1'>
<table>
	<tr><td>チャレンジ: </td><td><input type='text' id='input-challenge1' name='challenge' size='60' value='a1234'></td></tr>
	<tr><td>秘密鍵(pkcs#8): </td><td><input type='file' id='input-privatekey'></td></tr>
</table>
<input type='button' id='submit-sign' value='署名する'>
</form>

<h1>検証</h1>
<form id='f2'>
<table>
	<tr><td>チャレンジ: </td><td><input type='text' id='input-challenge2' name='challenge' size='60' value='a1234'></td></tr>
	<tr><td>署名(base64): </td><td><input type='text' id='input-sign' name='sign' size='100'></td></tr>
	<tr><td>公開鍵: </td><td><input type='file' id='input-publickey'></td></tr>
</table>
<input type='button' id='submit-verify' value='検証する'>
</form>
</body>
</html>

実行結果

2022-10-28_140922.png

解説

はじめに

PEM形式の鍵を読み込み、Web Crypto APIを使い、楕円曲線暗号での署名と検証を行います。

手順は、

  1. crypto.subtle.importKey() で鍵を読み込み、
  2. crypto.subtle.sign() で署名をして、
  3. crypto.subtle.verify() で検証する。

だけなのですが、関数の引数の型がキモくて型変換が必要な部分が難しい点です。

[署名・検証] 鍵ペア生成

OpenSSLを使って楕円曲線暗号の鍵ペア(PEM形式)を生成します。ちなみに「鍵ペア」と呼んでいますが、公開鍵暗号方式の場合、秘密鍵=鍵情報すべて、公開鍵=鍵情報の一部、であるため、実態は鍵ペア≒秘密鍵であり、秘密鍵から公開鍵を取り出すことができます。

$ openssl ecparam -genkey -name prime256v1 -out p256.keypair

-nameは曲線名で、使用できる曲線一覧は openssl ecparam -list_curves で表示できます。この記事では prime256v1 (P-256) を使います。

生成した鍵ペアから公開鍵(PEM形式)と秘密鍵(PEM形式)を取り出します。

$ openssl ec -in p256.keypair -outform PEM -pubout -out p256.publickey.pem
read EC key
writing EC key
$ openssl ec -in p256.keypair -outform PEM -out p256.privatekey.pem
read EC key
writing EC key

Web Crypto APIでは、秘密鍵はPKCS#8しか取り扱えないので、取り出した秘密鍵をPKCS#8形式に変換します。ちなみにPKCS#8とは、秘密鍵を格納する一般的なフォーマットのことで、おおよそ「秘密鍵」+「秘密鍵のアルゴリズムなどのメタ情報」が格納されている、と思っておけばいいです。

$ openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in p256.privatekey.pem -out p256.pkcs8.pem

PKCS#8の見分け方

PEM形式のファイルは、中身を見ることで判別することができます。ヘッダーとフッター部分で判別します。

  • PKCS#8
-----BEGIN PRIVATE KEY-----
(Base64エンコード)
-----END PRIVATE KEY-----
  • 楕円曲線暗号の秘密鍵
-----BEGIN EC PRIVATE KEY-----
(Base64エンコード)
-----END EC PRIVATE KEY-----
  • RSA暗号の秘密鍵(PKCS#1)
-----BEGIN RSA PRIVATE KEY-----
(Base64エンコード)
-----END RSA PRIVATE KEY-----

PEM形式とDER形式

  • DER形式:バイナリ形式。
  • PEM形式:DER形式をBase64エンコードし、ヘッダーとフッターを付けた形式。

PEMとDERは、単にBase64エンコードされているか否かであるため、お互い変換可能です。

[署名] 秘密鍵の読み込み

まず、PEM形式のヘッダーとフッターと改行を取り、Base64部分のみを取り出します。

		var reader = new FileReader();
		reader.readAsText($('#input-privatekey').prop('files')[0]);
		reader.onload = async () => {
			var privatekey = reader.result
				.replace(/-----BEGIN .*PRIVATE KEY-----/, "")
				.replace(/-----END .*PRIVATE KEY-----/, "")
				.replace(/\r?\n/g, '');

次に、crypto.subtle.importKey()で秘密鍵を読み込みます。

  • 第1引数は鍵のフォーマット名で、秘密鍵はpkcs8を指定します。
  • 第2引数は鍵データですが、型はArrayBuffer(*)にする必要があります。そのため、読み込んだ秘密鍵(Base64)をatob()でバイナリにし、s2buf()ArrayBufferに変換します。
  • 第3引数はアルゴリズムですが、楕円曲線の場合は単に名前(文字列)だけでなく曲線名も必要なため、EcKeyImportParamsで指定する必要があります。
  • 第4引数は、鍵がエクスポート可能かどうかを指定します。
  • 第5引数は鍵の用途で、署名に使うためsignを指定します。それ以外に指定できる値はこちらを参照してください。
  • 戻り値は、CryptoKey型(のPromise)で、これを署名に使います。

(*) 実際は、ArrayBufferTypedArrayDataViewJsonWebKeyのいずれかの型で良いです。

			var privatekeySpec = await window.crypto.subtle.importKey(
				'pkcs8',
				s2buf(atob(privatekey)),
				{
					name: "ECDSA",
					namedCurve: "P-256",
					hash: "SHA-256"
				},
				true,
				["sign"]
			);

[署名] 署名する

crypto.subtle.sign()で署名します。

  • 第1引数はアルゴリズムで、楕円曲線暗号の場合はEcdsaParamsを指定します。
  • 第2引数は鍵データで、先ほどcrypto.subtle.importKey()で読み込んだ鍵を指定します。
  • 第3引数は署名元データですが、ArrayBuffer型(*)であるため、stob()ArrayBuffer型に変換しています。
  • 戻り値は署名ですが、ArrayBuffer型であるため、buf2b()でBase64エンコードして表示しています。

(*) 実際は、ArrayBufferTypedArrayDataViewのいずれかの型で良いです。

			var sig = await window.crypto.subtle.sign(
				{name: "ECDSA", hash: {name: "SHA-256"}},
				privatekeySpec,
				stob($('#input-challenge1').val())
			);
			$('#input-sign').val(buf2b(sig));

[検証] 公開鍵の読み込み

秘密鍵と同様に、まず、PEM形式のヘッダーとフッターと改行を取り、Base64部分のみを取り出します。

		var reader = new FileReader();
		reader.readAsText($('#input-publickey').prop('files')[0]);
		reader.onload = async () => {
			var publickey = reader.result
				.replace(/-----BEGIN .*PUBLIC KEY-----/, "")
				.replace(/-----END .*PUBLIC KEY-----/, "")
				.replace(/\r?\n/g, '');

次に、こちらも秘密鍵と同様に、crypto.subtle.importKey()で公開鍵を読み込みます。引数も秘密鍵の読み込みと同じです。

  • 第1引数は鍵のフォーマット名で、公開鍵はspkiを指定します。
  • 第2引数は鍵データで、型はArrayBufferにする必要があります。秘密鍵と同様に、読み込んだ秘密鍵(Base64)をatob()でバイナリにし、s2buf()ArrayBufferに変換します。
  • 第3引数はアルゴリズムで、秘密鍵と同様に、楕円曲線の場合は単に名前(文字列)だけでなく曲線名も必要なため、EcKeyImportParamsで指定する必要があります。
  • 第4引数は、鍵がエクスポート可能かどうかを指定します。
  • 第5引数は鍵の用途で、検証に使うためverifyを指定します。
  • 戻り値は、CryptoKey型(のPromise)で、これを検証に使います。
			var publickeySpec = await window.crypto.subtle.importKey(
				'spki',
				s2buf(atob(publickey)),
				{
					name: "ECDSA",
					namedCurve: "P-256",
					hash: "SHA-256"
				},
				true,
				["verify"]
			);

[検証] 署名を検証する

crypto.subtle.verify()で検証します。

  • 第1引数はアルゴリズムで、楕円曲線暗号の場合はEcdsaParamsを指定します。
  • 第2引数は鍵データで、先ほどcrypto.subtle.importKey()で読み込んだ鍵を指定します。
  • 第3引数は署名ですが、ArrayBuffer型であるため、b2buf()でBase64からArrayBuffer型に変換しています。
  • 第4引数は署名元データですが、ArrayBuffer型であるため、stob()ArrayBuffer型に変換しています。
  • 戻り値は検証結果でboolean型(のPromise)が返ります。
			var result = await window.crypto.subtle.verify(
				{name: "ECDSA", hash: {name: "SHA-256"}},
				publickeySpec,
				b2buf($('#input-sign').val()),
				stob($('#input-challenge1').val())
			);

参考

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1