Windows OpenSSL 無しで pem 形式のサーバー証明書と秘密鍵を IP アドレスベースで作成する (node.js版)

node.js で node-forge というパッケージを使用して pem 形式のサーバー証明書と秘密鍵を .pem 形式で作成する方法を紹介します。また既存の .pfx ファイルからサーバー証明書や秘密鍵を pem 形式で出力する方法も紹介します。

やりたいこと

以前 PythonPowerShell を使って自己署名のサーバー証明書 (所謂オレオレ証明書) や秘密鍵を pem 形式で作成する方法を紹介したのですが、JavaScript で開発している人こそ https をより使うのでは、ということで node.js 環境で node-forge というパッケージを使用して pem 形式のサーバー証明書や秘密鍵を作成してみたいと思います。また既存の .pfx ファイルからサーバー証明書や秘密鍵を .pem 形式で取り出してみます。なお、IP アドレスベースでの証明書の作り方で紹介していますが、ドメインの証明書の作り方も触れていますので参考にしてください。

環境

OS
Microsoft Windows 10 21H2
node.js
v20.10.0
node-forge
1.3.1

準備

node-forge をインストールします。

npm install node-forge

ES Modules 派なんですが、node-forge は ES Modules 対応されていないので、念のため package.json"type" が設定されていないか "commonjs" となっているか確認してください。

If the nearest parent package.json lacks a "type" field, or contains "type": "commonjs", .js files are treated as CommonJS.
Modules: Packages | Node.js v21.7.1 Documentation

また以下の手順では適当なスクリプトファイル (index.js 等) を作成して node index.js で実行してもよいですし、node で対話型コマンドラインを立ち上げて逐次実行しても問題ありませんので好きなやり方で実行してください。

証明書と秘密鍵を新規作成して pem 形式で出力する方法

キーペアの作成

まずは秘密鍵と公開鍵のペアを作成します。
証明書や秘密鍵を PEM 形式で作成する場合は pki モジュールのみインポートすれば大丈夫です。また後程使用するので fs.writeFileSync もインポートしておきます。

const { pki } = require('node-forge');
const { writeFileSync } = require('node:fs');

const keys = pki.rsa.generateKeyPair(2048);

ここでは RSA で作成していますが ED25519 にも対応しています。

const keys = pki.ed25519.generateKeyPair();

証明書の初期設定

  1. pki.createCertificate() で X.509 オブジェクトを作成します。

    const cert = pki.createCertificate();
    cert.publicKey = keys.publicKey;
  2. cert.publicKey に前項で作成したキーペアの公開鍵を設定します。

    cert.publicKey = keys.publicKey;
  3. cert.serialNumber に適当な数字文字列を設定します。

    cert.serialNumber = '01';

    シリアルナンバーは RFC では以下の通り発行する CA 内で一意にする必要がありますが、まあオレオレ証明書なので自分で識別できれば気にしなくて大丈夫でしょう。 デフォルトで '00' が設定されているので、そもそも設定しなくてもよいかもしれません。

    The serial number MUST be a positive integer assigned by the CA to each certificate. It MUST be unique for each certificate issued by a given CA (i.e., the issuer name and serial number identify a unique certificate). CAs MUST force the serialNumber to be a non-negative integer.
    RFC 5280: Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile

  4. cert.validity.notBeforecert.validity.notAfter で証明書有効期限を設定します。ここでは一旦本日から 1 年間とします。

    cert.validity.notBefore = new Date();
    cert.validity.notAfter = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());

証明書コンテンツの設定

  1. 以下の通り Subject 設定用オブジェクトを用意します。

    const subjects = [
      { shortName:'CN',value:'xxx.xxx.xxx.xxx' },
      { shortName:'C',value:'JP' },
      { shortName:'ST',value:'Tokyo' },
    ];

    ここでは shortName: 'CN' のように X.500 の DN (Distinguished Name) ベースで設定していますが、name: 'commonName' のように設定することもできます。
    なお、IP アドレスではなくドメイン名で証明書を作成する場合は CNvalue は IP アドレスではなくドメイン名にしてください。

  2. Subject を設定します。またオレオレなので Issuer にも同内容で設定します。

    cert.setSubject(subjects);
    cert.setIssuer(subjects);
  3. 次に Extension 設定用のオブジェクトを用意します。

    const extensions = [{
      name: 'subjectAltName',
      altNames: [{
        type: 7,
        ip: 'xxx.xxx.xxx.xxx'
      }]
    }];

    IP アドレスではなくドメイン名で証明書を作成する場合は、以下のようにします。

    altNames: [{
      type: 7,
      value: 'xxx.domain.com'
    }]

    この type は RFC 5280 に則っています。

    SubjectAltName ::= GeneralNames
    
    GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
    
    GeneralName ::= CHOICE {
      otherName                       [0]     OtherName,
      rfc822Name                      [1]     IA5String,
      dNSName                         [2]     IA5String,
      x400Address                     [3]     ORAddress,
      directoryName                   [4]     Name,
      ediPartyName                    [5]     EDIPartyName,
      uniformResourceIdentifier       [6]     IA5String,
      iPAddress                       [7]     OCTET STRING,
      registeredID                    [8]     OBJECT IDENTIFIER }

    RFC 5280: Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile

  4. Extension を設定します。

    cert.setExtensions(extensions);

証明書に署名

最初に作成した秘密鍵でオレオレ署名します。

cert.sign(keys.privateKey);

pem 形式で出力

  1. 証明書と秘密鍵を pem 形式に変換します。
    const certPem = pki.certificateToPem(cert);
    const keyPem = pki.privateKeyToPem(key.privateKey); 
    
  2. それぞれファイルに書き出します。
    writeFileSync('./cert.pem', certPem, { encoding: 'utf-8' });
    writeFileSync('./key.pem', keyPem, { encoding: 'utf-8' });

おまけ PKCS #12 (PFX) 形式で出力

Node の https サーバーは PFX ファイルで証明書と秘密鍵を設定することができるので、pem ではなく pfx での出力方法も紹介します。

  1. node-forge からインポートするモジュールに pkcs12asn1 を追加します。

    const { pki, pkcs12, asn1 } = require('node-forge');
  2. pkcs12.toPkcs12Asn1() メソッドに作成済の秘密鍵と証明書を渡して PKCS #12 の ASN.1 オブジェクトにします。

    const p12Asn1 = pkcs12.toPkcs12Asn1(keys.privateKey, cert, 'password', {algorithm: '3des'});
  3. asn1.toDer() メソッドに PKCS #12 の ASN.1 オブジェクトを渡して DER 形式に変換します。

    const p12Der = asn1.toDer(p12Asn1);
  4. ファイルに書き出します。

    writeFileSync('cert.pfx', p12Der.getBytes(), 'binary');

既存の pfx ファイルから PEM 形式の証明書と秘密鍵を取り出す

次に node-forge を使用して既存の pfx ファイルから pem 形式の証明書と秘密鍵を取り出す方法を紹介します。

  1. 必要なモジュールをインポートし、pfx ファイルを読み込みます。

    const { asn1, pkcs12, pki } = require('node-forge');
    const { readFileSync, writeFileSync } = require('node:fs');
    
    const p12Der = readFileSync('cert.pfx', 'binary');
  2. 読み込んだ pfx ファイルはバイナリの DER 形式なので asn1.fromDer() メソッドで node-forge の ASN.1 オブジェクトに変換します。

    const p12Asn1 = asn1.fromDer(p12Der); 
    
  3. node-forge の ASN.1 オブジェクトを node-forge の PKCS #12 オブジェクトに変換します。

    const p12 = pkcs12.pkcs12FromAsn1(p12Asn1);

証明書の取り出しと出力

  1. 証明書が格納されている SafeBag を取り出します。

    const certBags = p12.getBags({ bagType: pki.oids.certBag });

    pki.oids.certBag は OID 1.2.840.113549.1.12.10.1.3 のエイリアスで PKCS #12 Certificate Bag です。

  2. SafeBag 内の node-forge の証明書オブジェクトを pem に変換します。

    const certPem = pki.certificateToPem(certBags[pki.oids.certBag][0].cert);
  3. ファイルに出力します。

    writeFileSync('cert.pem', certPem, { encoding: 'utf-8' });

秘密鍵の取り出しと出力

  1. 秘密鍵が格納されている SafeBag を取り出します。

    const keyBags = p12.getBags({ bagType: pki.oids.pkcs8ShroudedKeyBag });

    pki.oids.pkcs8ShroudedKeyBag は OID 1.2.840.113549.1.12.10.1.2 のエイリアスで PKCS #12 PKCS #8-shrouded Key Bag です。通常こちらだと思うのですがもし見つからなければ以下のように PKCS #12 KeyBag (OID: 1.2.840.113549.1.12.10.1.1) でも探してみてください。

    const keyBags = p12.getBags({ bagType: pki.oids.keyBag });
  2. SafeBag 内の node-forge の秘密鍵オブジェクトを pem に変換します。

    const keyPem = pki.privateKeyToPem(keyBags['pki.oids.pkcs8ShroudedKeyBag'][0].key);
  3. ファイルに出力します。

    writeFileSync('key.pem', keyPem, { encoding: 'utf-8' });

あとがき

node-forge を使って JavaScript だけでオレオレ証明書と秘密鍵を pem 形式で作成してみました。node-forge は機能豊富で他にもいろいろできそうです。また RFC や X.500 シリーズなどスタンダードな仕様に基づいた内部構造となっているので、各仕様がわかっていないと直観的には使いにくい反面、各仕様を見ればやっていることがわかったり、理解も進むので勉強にはなりました。