🔑

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

2022/07/19に公開

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の解答例を参考にしたものを数時間かけて作りました。それがこちら。

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);

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 > 0) {
    pem += b64.substring(0, 64) + "\n";
    b64 = b64.substring(64);
  }
  pem += "-----END PUBLIC KEY-----\n";
  return pem;
}

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 --watch 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 > 0) {
/* 32 */    pem += b64.substring(0, 64) + "\n";
/* 32 */    b64 = b64.substring(64);
/* 32 */  }
/* 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のおかげで数行で書けるようになりました。

const s = await crypto.subtle.digest("SHA-256", new TextEncoder().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
const s = await crypto.subtle.digest("SHA-512", new TextEncoder().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をいかに使いこなしてより良くしていくか、人の腕が試される技術だと思いました。

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

2024/01/05更新:関数名を変更したり変数や処理を分かりやすくした最新のコードを反映しました。解説内容は変わっていません。

Discussion