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

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

## 鍵ペア作成

```bat
$ 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
<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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208281/1fe94e1a-350f-6910-49ee-1ad4519d1d8a.png)

# 解説

## はじめに

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

手順は、

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

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

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

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

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

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

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

```bat
$ 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とは、秘密鍵を格納する一般的なフォーマットのことで、おおよそ「秘密鍵」＋「秘密鍵のアルゴリズムなどのメタ情報」が格納されている、と思っておけばいいです。

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

:::note info
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-----
```
:::

:::note info
PEM形式とDER形式

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

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

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

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

```javascript
		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()`で秘密鍵を読み込みます。

- 第１引数は鍵のフォーマット名で、秘密鍵は`pkcs8`を指定します。
- 第２引数は鍵データですが、型は`ArrayBuffer`(*)にする必要があります。そのため、読み込んだ秘密鍵(Base64)を`atob()`でバイナリにし、`s2buf()`で`ArrayBuffer`に変換します。
- 第３引数はアルゴリズムですが、楕円曲線の場合は単に名前（文字列）だけでなく曲線名も必要なため、[`EcKeyImportParams`型](https://developer.mozilla.org/en-US/docs/Web/API/EcKeyImportParams)で指定する必要があります。
- 第４引数は、鍵がエクスポート可能かどうかを指定します。
- 第５引数は鍵の用途で、署名に使うため`sign`を指定します。それ以外に指定できる値は[こちらを参照](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey)してください。
- 戻り値は、`CryptoKey`型（の`Promise`）で、これを署名に使います。

(*) 実際は、[`ArrayBuffer`、`TypedArray`、`DataView`、`JsonWebKey`のいずれかの型](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey)で良いです。

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

## [署名] 署名する

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

- 第１引数はアルゴリズムで、楕円曲線暗号の場合は[`EcdsaParams`型](https://developer.mozilla.org/en-US/docs/Web/API/EcdsaParams)を指定します。
- 第２引数は鍵データで、先ほど`crypto.subtle.importKey()`で読み込んだ鍵を指定します。
- 第３引数は署名元データですが、`ArrayBuffer`型(*)であるため、`stob()`で`ArrayBuffer`型に変換しています。
- 戻り値は署名ですが、`ArrayBuffer`型であるため、`buf2b()`でBase64エンコードして表示しています。

(*) 実際は、[`ArrayBuffer`、`TypedArray`、`DataView`のいずれかの型](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign)で良いです。

```javascript
			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部分のみを取り出します。

```javascript
		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()`で公開鍵を読み込みます。引数も秘密鍵の読み込みと同じです。

- 第１引数は鍵のフォーマット名で、公開鍵は`spki`を指定します。
- 第２引数は鍵データで、型は`ArrayBuffer`にする必要があります。秘密鍵と同様に、読み込んだ秘密鍵(Base64)を`atob()`でバイナリにし、`s2buf()`で`ArrayBuffer`に変換します。
- 第３引数はアルゴリズムで、秘密鍵と同様に、楕円曲線の場合は単に名前（文字列）だけでなく曲線名も必要なため、[`EcKeyImportParams`型](https://developer.mozilla.org/en-US/docs/Web/API/EcKeyImportParams)で指定する必要があります。
- 第４引数は、鍵がエクスポート可能かどうかを指定します。
- 第５引数は鍵の用途で、検証に使うため`verify`を指定します。
- 戻り値は、`CryptoKey`型（の`Promise`）で、これを検証に使います。

```javascript
			var publickeySpec = await window.crypto.subtle.importKey(
				'spki',
				s2buf(atob(publickey)),
				{
					name: "ECDSA",
					namedCurve: "P-256",
					hash: "SHA-256"
				},
				true,
				["verify"]
			);
```

## [検証] 署名を検証する

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

- 第１引数はアルゴリズムで、楕円曲線暗号の場合は[`EcdsaParams`型](https://developer.mozilla.org/en-US/docs/Web/API/EcdsaParams)を指定します。
- 第２引数は鍵データで、先ほど`crypto.subtle.importKey()`で読み込んだ鍵を指定します。
- 第３引数は署名ですが、`ArrayBuffer`型であるため、`b2buf()`でBase64から`ArrayBuffer`型に変換しています。
- 第４引数は署名元データですが、`ArrayBuffer`型であるため、`stob()`で`ArrayBuffer`型に変換しています。
- 戻り値は検証結果で`boolean`型（の`Promise`）が返ります。

```javascript
			var result = await window.crypto.subtle.verify(
				{name: "ECDSA", hash: {name: "SHA-256"}},
				publickeySpec,
				b2buf($('#input-sign').val()),
				stob($('#input-challenge1').val())
			);
```

# 参考

- MDN
    - [importkey()](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey)
    - [sign()](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign)
    - [verify()](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/verify)
- [Zennの記事](https://zenn.dev/tkithrta/articles/d5865b67d18d9c)
    - RSA暗号だが、Web Crypto APIを使ってPEM形式の読み込み方法を書いたサイトはここしかない。