🔑

[Deno]Web Crypto APIに入力したPEM形式の秘密鍵からPEM形式の公開鍵を出力するのめっちゃ難しいな!(0_0)

に公開

Intro

Node.jsにはCryptoモジュールがあり、これがPEM形式の鍵をよしなにエンコードしてくれるので以下のように数行書けば簡単にPEM形式の秘密鍵からPEM形式の公開鍵を取り出せます。

const crypto = require("crypto");
const PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n";
const PUBLIC_KEY = crypto.createPublicKey(PRIVATE_KEY).export({ type: "spki", format: "pem" });
console.log(PUBLIC_KEY);
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----

こんな感じでPEM形式の秘密鍵からPEM形式の公開鍵を取り出すやつをDenoで作る必要があったのでやってみたのですが、全然うまくいかない!!
それもそのはずDenoに使われているWeb Crypto APIにはPEM形式をエンコードするどころかPEMに関する機能が一切実装されておらず、自作するかサードパーティライブラリを使うしか方法がないんですね。
ただWebCryptoに使えるPEM形式を変換するサードパーティライブラリはNode.jsぐらいしかなく、大体のWebCryptoユースケースでは自作したものを使っていました。

ここまでの話はPEM形式をエンコードするサードパーティライブラリの一つであるjsrsasignの作者kjurさんの記事で詳しく解説されています。

http://blog.livedoor.jp/k_urushima/archives/1758899.html
http://blog.livedoor.jp/k_urushima/archives/1759093.html
http://blog.livedoor.jp/k_urushima/archives/1759966.html

では早速PEM形式の秘密鍵からPEM形式の公開鍵を取り出すために自作するか……と思いきや、参考資料をいくら探してもこのStack Overflow解答例ぐらいしか出てきませんでした。

https://stackoverflow.com/questions/66297358/webcrypto-derive-a-rsa-public-key-from-private-key-in-pem-encoding

誰もWebCryptoを使っていないのである!
そんなわけでStack Overflowの解答例を参考にしたものを数時間かけて作りました。それがこちら。

#!/usr/bin/env -S deno run --allow-read=id_rsa
// SPDX-License-Identifier: MIT
// /// license
// MIT License
// 
// Copyright (c) 2022 黒ヰ樹
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// ///

function stob(s: string) {
  return Uint8Array.from(s, (c) => c.charCodeAt(0));
}

function btos(b: ArrayBuffer) {
  return String.fromCharCode(...new Uint8Array(b));
}

async function importPrivateKey(pem: string) {
  const header = "-----BEGIN PRIVATE KEY-----";
  const footer = "-----END PRIVATE KEY-----";
  let b64 = pem;
  b64 = b64.split("\\n").join("");
  b64 = b64.split("\n").join("");
  if (b64.startsWith('"')) b64 = b64.slice(1);
  if (b64.endsWith('"')) b64 = b64.slice(0, -1);
  if (b64.startsWith(header)) b64 = b64.slice(header.length);
  if (b64.endsWith(footer)) b64 = b64.slice(0, -1 * footer.length);
  const der = stob(atob(b64));
  const result = await crypto.subtle.importKey(
    "pkcs8",
    der,
    {
      name: "RSASSA-PKCS1-v1_5",
      hash: "SHA-256",
    },
    true,
    ["sign"],
  );
  return result;
}

async function privateKeyToPublicKey(key: CryptoKey) {
  const jwk = await crypto.subtle.exportKey("jwk", key);
  delete jwk.d;
  delete jwk.p;
  delete jwk.q;
  delete jwk.dp;
  delete jwk.dq;
  delete jwk.qi;
  delete jwk.oth;
  jwk.key_ops = ["verify"];
  const result = await crypto.subtle.importKey(
    "jwk",
    jwk,
    {
      name: "RSASSA-PKCS1-v1_5",
      hash: "SHA-256",
    },
    true,
    ["verify"],
  );
  return result;
}

async function exportPublicKey(key: CryptoKey) {
  const der = await crypto.subtle.exportKey("spki", key);
  let b64 = btoa(btos(der));
  let pem = "-----BEGIN PUBLIC KEY-----" + "\n";
  while (b64.length > 64) {
    pem += b64.substring(0, 64) + "\n";
    b64 = b64.substring(64);
  }
  pem += b64.substring(0, b64.length) + "\n";
  pem += "-----END PUBLIC KEY-----" + "\n";
  return pem;
}

const privateKeyPem = await Deno.readTextFile("id_rsa");
const PRIVATE_KEY = await importPrivateKey(privateKeyPem);
const PUBLIC_KEY = await privateKeyToPublicKey(PRIVATE_KEY);
const publicKeyPem = await exportPublicKey(PUBLIC_KEY);
console.log(publicKeyPem);

めっちゃ長いな! Node.jsなら数行なのに!

$ echo 'id_rsa' > .gitignore
$ ssh-keygen -b 4096 -m PKCS8 -t rsa -N '' -f id_rsa
$ curl -O https://tkithrta.gitlab.io/u/public-key-gen.ts
$ deno run --allow-read=id_rsa public-key-gen.ts
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----

URLから直接deno runする方法でも動かせますが秘密鍵を扱うスクリプトなので出来ればダウンロードして使ってほしいですね。

では今回このコードに数時間かけた理由を解説していきたいと思います。

PEM

まず最初に紹介したNode.jsで使用した秘密鍵、これはWebCryptoでは使えません。
なぜだか分かりますか?

-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----
$ ssh-keygen -b 4096 -m PEM -t rsa -N '' -f id_rsa

これはヘッダーとフッターに"RSA"が入っているから駄目なんですね。
ちなみにPKCS#1のPEM形式になります。

$ ssh-keygen -b 4096 -m PKCS8 -t rsa -N '' -f id_rsa

なのでPKCS#1ではなくPKCS#8(本記事ではPKCS8と呼びます)のPEM形式で出力します。

-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----

PKCS8だとWebCryptoのほかOpenSSLでも使いやすいので可能な限り秘密鍵はこの形式を使うようにしましょう。
私はもう手遅れでした。この問題を把握するのですでに数時間費やしました。

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

ちなみに最近のOpenSSHだとオプションを付けずに出力した場合OpenSSHの独自形式になります。
この形式はGo言語の標準パッケージなど一部の実装ではエンコードできなかったりします。

$ ssh-keygen -p -N '' -m PKCS8 -f id_rsa

いずれにせよこのコマンドを使えばPKCS8のPEM形式に再変換できるので間違えてPKCS#1やOpenSSHの独自形式で秘密鍵を作成してしまった際お試しください。

importPrivateKey

async function importPrivateKey(pem: string) {
/* 10 */  const header = "-----BEGIN PRIVATE KEY-----";
/* 10 */  const footer = "-----END PRIVATE KEY-----";
/* 10 */  let b64 = pem;
/* 13 */  b64 = b64.split("\\n").join("");
/* 10 */  b64 = b64.split("\n").join("");
/* 13 */  if (b64.startsWith('"')) b64 = b64.slice(1);
/* 13 */  if (b64.endsWith('"')) b64 = b64.slice(0, -1);
/* 10 */  if (b64.startsWith(header)) b64 = b64.slice(header.length);
/* 10 */  if (b64.endsWith(footer)) b64 = b64.slice(0, -1 * footer.length);
/* 11 */  const der = stob(atob(b64));
/* 12 */  const result = await crypto.subtle.importKey(
/* 12 */    "pkcs8",
/* 12 */    der,
/* 12 */    {
/* 12 */      name: "RSASSA-PKCS1-v1_5",
/* 12 */      hash: "SHA-256",
/* 12 */    },
/* 12 */    true,
/* 12 */    ["sign"],
/* 12 */  );
/* 12 */  return result;
}

これからは関数にコメントをつけて紹介をしていきます。最初に呼び出す関数はimportPrivateKey()です。
まず[10]の部分で改行コードを消してPEMのヘッダーとフッターを削ります。
こうすることでBase64で処理できるようになるので、そのまま[11]のstob()関数に代入してArrayBuffer型に変換します。

function stob(s: string) {
  return Uint8Array.from(s, (c) => c.charCodeAt(0));
}

PEM形式をPKCS8のDER形式に変換でき、WebCryptoに代入できるようになったため[12]でインポートします。
CryptoKey型を返すのでこのまま署名(Sign)する際使えるようになりました。
Node.jsでいうところのKeyObject型ですね。

しかしながらCryptoKey型は簡単に公開鍵を取り出せるObject型ではありません。
詰んだか!? と一瞬思いますが、どうやらCryptoKey型はJSON Web Key(本記事ではJWKと呼びます)で使えるObject型に変換できるため、そこから公開鍵取り出せばいいようです。

※Web Crypto APIの型については以下のドキュメントを読めば分かると思います。

https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
https://nodejs.org/api/webcrypto.html#subtleimportkeyformat-keydata-algorithm-extractable-keyusages

privateKeyToPublicKey

async function privateKeyToPublicKey(key: CryptoKey) {
/* 20 */  const jwk = await crypto.subtle.exportKey("jwk", key);
/* 21 */  delete jwk.d;
/* 21 */  delete jwk.p;
/* 21 */  delete jwk.q;
/* 21 */  delete jwk.dp;
/* 21 */  delete jwk.dq;
/* 21 */  delete jwk.qi;
/* 22 */  delete jwk.oth;
/* 23 */  jwk.key_ops = ["verify"];
/* 24 */  const result = await crypto.subtle.importKey(
/* 24 */    "jwk",
/* 24 */    jwk,
/* 24 */    {
/* 24 */      name: "RSASSA-PKCS1-v1_5",
/* 24 */      hash: "SHA-256",
/* 24 */    },
/* 24 */    true,
/* 24 */    ["verify"],
/* 24 */  );
/* 24 */  return result;
}

そこで登場するのがprivateKeyToPublicKey()関数です。
まずは[20]でCryptoKey型の秘密鍵をJWKに変換します。

{
  // The following fields are defined in Section 4.1 of JSON Web Key
  kty: "RSA",
  // The following fields are defined in Section 4.4 of JSON Web Key
  alg: "RS256",
  // The following fields are defined in Section 6.3.1 of JSON Web Algorithms
  n: "...",
  e: "AQAB",
  // The following fields are defined in Section 6.3.2 of JSON Web Algorithms
  d: "...",
  p: "...",
  q: "...",
  dp: "...",
  dq: "...",
  qi: "...",
  // The following fields are defined in Section 4.3 of JSON Web Key
  key_ops: [ "sign" ],
  // The following fields are defined in JSON Web Key Parameters Registration
  ext: true
}

こんな感じのオブジェクトを取り出せます。
秘密鍵と公開鍵が分かれており、公開鍵を取り出すというよりは秘密鍵を削る感じになります。
コメントに書いてあるように秘密鍵に関する6つのキーがあるため[21]のdeleteで消します。

dictionary JsonWebKey {
  // The following fields are defined in Section 6.3.2.7 of JSON Web Algorithms
  sequence<RsaOtherPrimesInfo> oth;
};

dictionary RsaOtherPrimesInfo {
  // The following fields are defined in Section 6.3.2.7 of JSON Web Algorithms
  DOMString r;
  DOMString d;
  DOMString t;
};

これはWeb Cryptography API - W3Cから引用してきたJSON Web Algorithms(本記事ではJWAと呼びます)の仕様です。
一応JWKの仕様では他に特殊なキーを持つothキーが秘密鍵にあってもよいことになっているので、これも[22]で消しておきます。
その後[23]のようにkey_opsキーにある["sign"]を["verify"]に書き換えれば問題なく使える公開鍵を作成することができます。

ただObject型のままでは使えないので再度[24]でCryptoKey型に変換します。
入力する鍵の型に違いがあっても返ってくるCryptoKey型内部の情報は同じです。
これで署名の検証(Verify)に使える公開鍵に変換できましたがまだCryptoKey型のままです。

※JWKとJWAの仕様については以下のドキュメントを読めば分かると思います。

https://datatracker.ietf.org/doc/html/rfc7517#section-4
https://datatracker.ietf.org/doc/html/rfc7518#section-6.3

exportPublicKey

async function exportPublicKey(key: CryptoKey) {
/* 30 */  const der = await crypto.subtle.exportKey("spki", key);
/* 31 */  let b64 = btoa(btos(der));
/* 32 */  let pem = "-----BEGIN PUBLIC KEY-----" + "\n";
/* 32 */  while (b64.length > 64) {
/* 32 */    pem += b64.substring(0, 64) + "\n";
/* 32 */    b64 = b64.substring(64);
/* 32 */  }
/* 32 */  pem += b64.substring(0, b64.length) + "\n";
/* 32 */  pem += "-----END PUBLIC KEY-----" + "\n";
/* 32 */  return pem;
}

最後にexportPublicKey()関数を使いCryptoKey型をPEM形式に変換します。
importPrivateKey()の逆のことをやっていますが、公開鍵の場合は[30]のDER形式がPKCS8ではなく主体者公開鍵情報(本記事ではSPKIと呼びます)であることに注意が必要です。
逆のことをやるので[31]ではbtos()にArrayBuffer型を代入することでBase64に変換しています。

function btos(b: ArrayBuffer) {
  return String.fromCharCode(...new Uint8Array(b));
}

あとは[32]でSPKIにあったPEMのヘッダーとフッターを加え、PEM形式の仕様に倣って中身を64文字ごとに改行します。

そういえば全く解説していませんでしたがWebCryptoのSubtleCryptoについてくるメソッドはもれなくPromiseが返ってきます。
なので関数にasyncをつけてSubtleCryptoのメソッドにはawaitをつけましょう。

これで完成です。

※DenoのWeb Crypto APIついては以下のドキュメントを読めば分かると思います。

https://doc.deno.land/deno/stable/~/SubtleCrypto
https://doc.deno.land/deno/stable/~/CryptoKey

Outro

当初参考にしたStack Overflowの解答例とは別物のコードになりましたが、非常にコンパクトな仕上がりになったのでとても満足しています。
特にTypedArray型で使えるスプレッド構文やfrom()メソッドが非常に便利で、from()メソッド内のmapFn引数を使うことでmap()メソッドを使う必要がなくなり、とてもシンプルな書き方ができるようになりました。
やっぱりバイナリを配列で書けるのはいいですね!
今回は登場しませんでしたがof()メソッドも便利で、Array型でもfrom()of()を使うことができます。
別途作ったハッシュ関数ツールもfrom()メソッドとEncoding APIのおかげで数行で書けるようになりました。

#!/usr/bin/env -S deno run
// SPDX-License-Identifier: Unlicense
// /// license
// This is free and unencumbered software released into the public domain.
// 
// Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
// 
// In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// 
// For more information, please refer to <https://unlicense.org>
// ///

const enc = new TextEncoder();
const s = await crypto.subtle.digest("SHA-256", enc.encode(Deno.args[0]));
const s256 = Array.from(new Uint8Array(s), (b) => b.toString(16).padStart(2, "0")).join("");

console.log(s256);
$ deno run https://tkithrta.gitlab.io/u/sha256-gen.ts 123456
8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
#!/usr/bin/env -S deno run
// SPDX-License-Identifier: Unlicense
// /// license
// This is free and unencumbered software released into the public domain.
// 
// Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
// 
// In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// 
// For more information, please refer to <https://unlicense.org>
// ///

const enc = new TextEncoder();
const s = await crypto.subtle.digest("SHA-512", enc.encode(Deno.args[0]));
const s512 = Array.from(new Uint8Array(s), (b) => b.toString(16).padStart(2, "0")).join("");

console.log(s512);
$ deno run https://tkithrta.gitlab.io/u/sha512-gen.ts 123456
ba3253876aed6bc22d4a6ff53d8406c6ad864195ed144ab5c87621b6c233b548baeae6956df346ec8c17f5ea10f35ee3cbc514797ed7ddd3145464e2a0bab413

こちらのsha-256とsha-512を出力するツールもぜひ使ってみてください。

Web Crypto APIは通常のCrypto実装とは異なり、本当に必要最低限の機能しか提供していないため不便を強いられる点がたくさんありますが出来ることは多いので、あとはJavaScriptとTypeScriptをいかに使いこなしてより良くしていくか、人の腕が試される技術だと思いました。

無事完成してよかったです。感動した!!

2025/05/02更新:リファクタリングした最新のコードを反映しました。解説内容は変わっていません。

Discussion