🔑

Node.jsでECDSA署名してブラウザで検証する(標準APIのみを使用)

2023/05/09に公開

やりたいこと

  • Node.jsで crypto モジュールを使ってECDSA(SHA-256, P-256曲線)で署名
  • Node.jsとWebブラウザ上で上記の署名を検証
    • Node.jsでは crypto モジュールを使い、Webブラウザでは Web Crypto API を使う

勘の良い人であればすぐに気づくと思うが、 JWT(厳密にはJWA)のES256相当 の署名アルゴリズムを外部ライブラリを使わず、標準APIのみで実装したいという話である。

手っ取り早く済ませるのであれば、適当なJWTもしくはJWSを扱えるライブラリを使えばいいのだが、ちょっとした部分で署名を使いたいだけなので、JWSは大げさだろうということで実装してみた。

秘密鍵/公開鍵の生成

鍵を作るのは別にopensslを叩いてもいいのだが、折角なのでNode.jsで生成してみた。

import { generateKeyPairSync } from 'node:crypto';
import { writeFile } from 'node:fs/promises';

const { privateKey, publicKey } = generateKeyPairSync('ec', {
   namedCurve: 'P-256',
   privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
   publicKeyEncoding: { format: 'pem', type: 'spki' }
});

await writeFile('privatekey', privateKey);
await writeFile('publickey', publicKey);

Node.jsで署名

要点として、署名する際に dsaEncoding: 'ieee-p1363' を指定しなければならない。これは crypto モジュールでは署名をDERでエンコードするのが規定であるのに対して、Web Crypto APIはIEEE P1363エンコードを前提としているためである。

import { createPrivateKey, createSign, } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const keystring = await readFile('privatekey', 'utf8');
const privatekey = createPrivateKey(keystring);

// データは別なんでもいいが、JWSに倣ってbase64化したJSONを例として使う
const data = Buffer.from(JSON.stringify({
    name: 'taro',
    age: 20
})).toString('base64');

const signature = createSign('sha256')
    .update(data)
    .sign({key: privatekey, dsaEncoding: 'ieee-p1363'}, 'base64');

console.log(`data : ${data}`);
console.log(`signature : ${signature}`);

このスクリプトを実行すると、データと署名が出力されるので、これを検証プログラムに入力する。

Node.jsで検証

検証でも署名と同様に dsaEncoding: 'ieee-p1363' を指定しなければならない。

import { createPublicKey, createVerify } from 'node:crypto';
import { readFile } from 'node:fs/promises';

// 署名スクリプトから出力された値を設定
const data = '...';
const signature = '...';

const keystring = await readFile('privatekey', 'utf8');
const publickey = createPublicKey(keystring);

const result = createVerify('sha256')
    .update(data)
    .verify({key: publickey, dsaEncoding: 'ieee-p1363'}, Buffer.from(signature, 'base64'));
console.log(result);

Webブラウザ(Web Crypto API)で検証

若干ユーティリティ関数に手を加えているが、MDNに記載されているサンプルコードの通り。

// Node.jsでもv15以降であればこの部分のimportをコメントインすれば動作する。
// ただし、v18以下では`Experimental`扱いなので注意する。 
// import { webcrypto as crypto } from 'node:crypto';

// 生成した公開鍵を設定
const keystring = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`;

// 署名スクリプトから出力された値を設定
const data = '...';
const signature = '...';

const encoder = new TextEncoder();

function decodeBase64(str) {
    return Uint8Array.from(atob(str), c => c.charCodeAt(0));
}

function removePemBoundary(pem) {
    const pemHeader = '-----BEGIN PRIVATE KEY-----';
    const pemFooter = '-----END PRIVATE KEY-----';
    return pem.trim().substring(pemHeader.length, pem.length - pemFooter.length);
}

const publickey = await crypto.subtle.importKey(
    'spki',
    decodeBase64(removePemBoundary(keystring)),
    {name: 'ECDSA', namedCurve: 'P-256'},
    true,
    ['verify']
);

const result = await crypto.subtle.verify(
    {name: 'ECDSA', hash: {name: 'SHA-256'}},
    publickey,
    decodeBase64(signature),
    encoder.encode(data)
);
console.log(result);

補足

Node.js v20で Web Crypto API がstableになるので、v20以降であれば crypto モジュールを使わずに全部 Web Crypto APIを使ってしまうのが楽だと思う。(とはいえ、v18のEoLが 2025-04-30 の予定なので結構先は長い…。)

Web Crypto API のみで鍵の生成から署名/検証までを行う例は全てMDNにある。

Discussion