【PHP】ふんわり理解でECC公開鍵を座標情報からPEMへ変換する

本投稿は TECOTEC Advent Calendar 2024 の14日目の記事です。 

こんにちは、決済認証システム開発事業部の久野真奈です。普段はエンジニアとして主にPHPでWebアプリケーションのシステムの開発を行っています。

最近業務でECC周りの勉強をする機会がありましたので、今回はPHPで座標情報(x, y)からPEM形式のECC公開鍵を生成する方法をご紹介します。 PHPの標準ライブラリや拡張モジュールのみを使用してPEMへの変換を行うには低レイヤの知識が必要となり、少し難易度高めです。諸々の都合により業務では他の方法を選択したのですが、今回は勉強がてら低レイヤの実装にも挑戦してみようと思います。

筆者は文系出身プログラマなので数式は苦手です。 そのため本記事では数式はほとんど使わず、難しい話はふんわり理解でどうにかします!

前提知識

ECCとは

ECC(楕円曲線暗号)は公開鍵暗号方式のひとつです。RSAよりも小さいサイズで高いセキュリティを担保できるアルゴリズムとして近年浸透してきています。

ECCの仕組み

まず、楕円曲線は楕円ではありません。曲線上の2点を通る直線を取ったとき、その曲線と必ずどこかもう1点で交わる性質を持つ曲線のことです。 「曲線上の2点」は重ね合わせて1点とすることもできます。

ECCではこの性質を利用し、下記Gifのような操作を行うことで秘密鍵から公開鍵を算出します。

ECC Gif

ある座標の接線と楕円曲線が交わる点をx軸に対して反対側に飛ばす操作を 座標を足し合わせる と表現しますが、「何回足し合わせを行ったか」が秘密鍵で、「最終的な座標(x, y)」が公開鍵となります。

楕円曲線の種類

ECCを使用するには、どのような形の楕円曲線を使用するのかや、足し合わせる最初の座標をどこにするのか、などのパラメータを鍵の作成者と使用者で示し合わせておく必要があります。

いくつかの標準化団体はECCで扱いやすい楕円曲線を定義しており、これらを 名前つき楕円曲線 と呼びます。 団体によって名前が変わるのが混乱の元ですが、RFC8422 付録Aに対応表がありますので転載しておきます。

+-----------+------------+------------+
| SECG      | ANSI X9.62 | NIST       |
+-----------+------------+------------+
| sect163k1 |            | NIST K-163 |
| sect163r1 |            |            |
| sect163r2 |            | NIST B-163 |
| sect193r1 |            |            |
| sect193r2 |            |            |
| sect233k1 |            | NIST K-233 |
| sect233r1 |            | NIST B-233 |
| sect239k1 |            |            |
| sect283k1 |            | NIST K-283 |
| sect283r1 |            | NIST B-283 |
| sect409k1 |            | NIST K-409 |
| sect409r1 |            | NIST B-409 |
| sect571k1 |            | NIST K-571 |
| sect571r1 |            | NIST B-571 |
| secp160k1 |            |            |
| secp160r1 |            |            |
| secp160r2 |            |            |
| secp192k1 |            |            |
| secp192r1 | prime192v1 | NIST P-192 |
| secp224k1 |            |            |
| secp224r1 |            | NIST P-224 |
| secp256k1 |            |            |
| secp256r1 | prime256v1 | NIST P-256 |
| secp384r1 |            | NIST P-384 |
| secp521r1 |            | NIST P-521 |
+-----------+------------+------------+

例えば「secp256r1」「prime256v1」「NIST P-256」は呼び方は異なりますが、全て同じ楕円曲線を表しています。

今回の実装ではsecp256r1を使用します。

ちなみに256とは、グラフのx軸、y軸の最大値が256ビットの素数になっていることを表しています。 グラフのサイズを表しているんだな、くらいの理解で大丈夫です。

公開鍵の圧縮

公開鍵はx、yをそれぞれ数値で表現するのが基本ですが、よりデータサイズを小さくする(=圧縮する)ことも可能です。 ECCでは、公開鍵となる座標(x, y)が楕円曲線上に位置し、どのような形の楕円曲線を使用するかも予め示し合わされているので、xが分かればyも簡単に分かります。これを利用し、圧縮公開鍵はxの数値とyの正負のみで表現されます。

今回の実装では非圧縮公開鍵を使用します。

手順の整理

実装を行う前に、やるべきことを明確化しましょう。

PEMとは

今回は公開鍵をPEM形式にしたいので、最終形をしっかり理解しておく必要があります。

RFC7468 セクション2RFC7468 セクション13を整理すると、以下の手順でPEMを生成できるようです。

  1. ASN.1のSubjectPublicKeyInfo構造をDERエンコードする
  2. base64エンコードする
  3. 64文字ごとに改行する
  4. 公開鍵を表すラベルで挟む

結果例

-----BEGIN PUBLIC KEY-----
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEn1LlwLN/KBYQRVH6HfIMTzfEqJOVztLe
kLchp2hi78cCaMY81FBlYs8J9l7krc+M4aBeCGYFjba+hiXttJWPL7ydlE+5UG4U
Nkn3Eos8EiZByi9DVsyfy9eejh+8AXgp
-----END PUBLIC KEY-----

知らない単語がいくつか出てきました。1つ1つ確認していきましょう。

SubjectPublicKeyInfoとは

SubjectPublicKeyInfoについてはRFC5280 セクション4.1.2.7に説明があります。

This field is used to carry the public key and identify the algorithm with which the key is used (e.g., RSA, DSA, or Diffie-Hellman).

つまり、公開鍵データと使用アルゴリズムを表す構造のようです。 今回の場合、公開鍵データとして座標情報(x, y)、使用アルゴリズムとしてECCのsecp256r1楕円曲線の情報が入ります。

ASN.1とは

ASN.1はデータ構造を表現するための言語です。

RFC5280 セクション4.1ではSubjectPublicKeyInfoが以下のように定義されています。

   SubjectPublicKeyInfo  ::=  SEQUENCE  {
        algorithm            AlgorithmIdentifier,
        subjectPublicKey     BIT STRING  }

それぞれの要素の詳細は後述しますが、このように型を使ってデータ構造を表現する言語をASN.1といいます。

また、型にはそれぞれタグとなる数値が定義されています。 例えば以下のようなものがあります。

タグ
INTEGER 0x02
BIT STRING 0x03
OCTET STRING 0x04
OBJECT IDENTIFIER 0x06
SEQUENCE 0x30

INTEGERやBIT STRINGなどはプリミティブ型なのに対し、SubjectPublicKeyInfoの定義を見ての通り、SEQUENCEなどのオブジェクト型も存在します。

プリミティブ型とオブジェクト型でまたタグの仕組みが異なったりするのですが、詳細は以下記事に分かりやすく記載されていますので、本記事では割愛します。

tex2e.github.io

DERとは

DERはASN.1のエンコード方式の1つです。 データ構造を「型」「値の長さ」「値」の結合で表します。

オブジェクト型の場合は「値」の部分が入れ子になり「型」「値の長さ」「『値1の型』『値1の値の長さ』『値1の値』『値2の型』『値2の値の長さ』『値2の値』」の結合となります。

最終手順

つまり、公開鍵座標(x, y)からPEMへの変換をPHPで実装する場合の最終的な手順は以下のように整理できます。

  1. 公開鍵情報をASN.1で表現する
    1. 公開鍵(x, y)と使用アルゴリズムをSubjectPublicKeyInfo構造にする
  2. DERエンコードする
    1. 「型」「値の長さ」「値」を結合する
  3. PEMエンコードする
    1. base64エンコードする
    2. 64文字ごとに改行する
    3. 公開鍵を表すラベルで挟む

実装

いよいよ実装していきましょう。

1. 公開鍵情報をASN.1で表現する

公開鍵情報であるSubjectPublicKeyInfo構造をPHPのクラスで表現していきます。

まず、ASN.1を表現するための基底クラスを作成します。 フィールドに型と値を持ちますが、オブジェクト型を表現するため値はstringとarrayを許容するようにしました。

class Asn1Val
{
    const TYPES = [
        'INTEGER' => 1, // 整数型
        'BIT_STRING' => 2, // ビット列型
        'OCTET_STRING' => 3, // オクテット列型
        'OBJECT_IDENTIFIER' => 4, // OID型
        'SEQUENCE' => 5, // ビット列型
    ];

    const TAGS = [
        self::TYPES['INTEGER'] => "\x02",
        self::TYPES['BIT_STRING'] => "\x03",
        self::TYPES['OCTET_STRING'] => "\x04",
        self::TYPES['OBJECT_IDENTIFIER'] => "\x06",
        self::TYPES['SEQUENCE'] => "\x30",
    ];

    public int $type;

    /** @var string|array<self> */
    public mixed $val;

    public function __construct(int $type, $val)
    {
        $this->type = $type;
        $this->val = $val;
    }
}

これを継承することでSubjectPublicKeyInfo構造をクラスで表現することにします。

改めて確認しますが、SubjectPublicKeyInfoはalgorithmとsubjectpublicKeyの2要素で定義されています。

     SubjectPublicKeyInfo  ::=  SEQUENCE  {
       algorithm         AlgorithmIdentifier,
       subjectPublicKey  BIT STRING
     }

片方ずつ詳細を見ていきましょう。

SubjectPublicKeyInfo.algorithm

SubjectPublicKeyInfo.algorithmはAlgorithmIdentifier型です。 RFC5480 セクション2.1によると、定義は以下のようになります。

     AlgorithmIdentifier  ::=  SEQUENCE  {
       algorithm   OBJECT IDENTIFIER,
       parameters  ANY DEFINED BY algorithm OPTIONAL
     }

同RFCセクション2.1.1より、 AlgorithmIdentifier.algorithmは

     id-ecPublicKey OBJECT IDENTIFIER ::= {
       iso(1) member-body(2) us(840) ansi-X9-62(10045) keyType(2) 1 }

AlgorithmIdentifier.parameterは

     ECParameters ::= CHOICE {
       namedCurve         OBJECT IDENTIFIER
       -- implicitCurve   NULL
       -- specifiedCurve  SpecifiedECDomain
     }

と定義されています。 CHOICEは列挙されている内のいずれか1つ、という型ですが、今回は名前付き楕円曲線を使用しているのでnamedCurveになります。

同RFCセクション2.1.1.1により、secp256r1のOBJECT IDENTIFIERは

  secp256r1 OBJECT IDENTIFIER ::= {
       iso(1) member-body(2) us(840) ansi-X9-62(10045) curves(3)
       prime(1) 7 
  }

と定義されています。

OBJECT IDENTIFIERの値はASN.1の仕様に沿ってエンコードされたバイナリデータです。 エンコード方法は以下記事で解説されておりますので、本記事では割愛します。

letsencrypt.org

これらを整理すると、SubjectPublicKeyInfo.algorithmは以下のように実装できます。

class ObjectIdentifier extends Asn1Val
{
    public function __construct(string $val)
    {
        parent::__construct(self::TYPES['OBJECT_IDENTIFIER'], $val);
    }
}

class AlgorithmIdentifier extends Asn1Val
{
    public function __construct(ObjectIdentifier $algorithm, Asn1Val $parameters)
    {
        parent::__construct(self::TYPES['SEQUENCE'], [$algorithm, $parameters]);
    }
}

class P256AlgorithmIdentifier extends AlgorithmIdentifier
{
    public function __construct()
    {
        // ECC公開鍵を表すOID 1.2.840.10045.2.1
        $eccPubKeyOid = new ObjectIdentifier("\x2A\x86\x48\xCE\x3D\x02\x01");
        // secp256r1曲線を表すOID 1.2.840.10045.3.1.7
        $p256Oid = new ObjectIdentifier("\x2A\x86\x48\xCE\x3D\x03\x01\x07");

        parent::__construct($eccPubKeyOid, $p256Oid);
    }
}

これでSubjectPublicKeyInfo.algorithmがPHPで実装できました。

SubjectPublicKeyInfo.subjectPublicKey

次にsubjectPublicKeyを見てみましょう。

RFC5480 セクション2.2で以下のように定義されています。

ECPoint ::= OCTET STRING

OCTET STRINGとはBIT STRINGの左側を0埋めして長さを8の倍数にしたものです。

座標(x, y)からOCTET STRINGへの変換方法はSEC1 セクション2.3に記載があります。 secp256r1を使用した非圧縮公開鍵の場合、手順を以下のように整理できます。

  1. x値を長さ{\frac{256}{8}}バイトのOCTET STRINGに変換する
  2. y値を長さ{\frac{256}{8}}バイトのOCTET STRINGに変換する
  3. 「0x04(非圧縮を表す)」「xのOCTET STRING」「yのOCTET STRING」を結合する

また、ASN.1の仕様によりBIT STRINGは未使用ビット数をプレフィックスに付けたバイナリデータになります。 未使用ビット数とは、ビット数が8の倍数でない場合の余りビット数です。今回は0になります。

したがって、以下のように実装できます。 xとyにはPHPのGMP拡張を使用しています。

class SubjectPublicKey extends Asn1Val
{
    public function __construct(int $fieldSize, Gmp $x, Gmp $y)
    {
        $xBin = gmp_export($x);
        $yBin = gmp_export($y);

        $pointLength = $fieldSize / 8;
        $xOctet = str_pad($xBin, $pointLength, "\x00", STR_PAD_LEFT);
        $yOctet = str_pad($yBin, $pointLength, "\x00", STR_PAD_LEFT);

        // 本来は圧縮/非圧縮の場合分けが必要だが、今回は非圧縮固定とする
        $octetString = "\x04".$xOctet.$yOctet;

        // ビット列の値の最初は未使用ビット数
        $bitString = "\x00".$octetString;

        parent::__construct(self::TYPES['BIT_STRING'], $bitString);
    }
}

class P256SubjectPublicKey extends SubjectPublicKey
{
    const int FIELD_SIZE = 256;

    public function __construct(Gmp $x, Gmp $y)
    {
        parent::__construct(self::FIELD_SIZE, $x, $y);
    }
}

これでSubjectPublicKeyInfo.subjectPublicKeyがPHPで実装できました。

SubjectPublicKeyInfo

SubjectPublicKeyInfoはalgorithmとsubjectPublicKeyから成るので、以下のように実装できます。

class SubjectPublicKeyInfo extends Asn1Val
{
    public function __construct(AlgorithmIdentifier $algorithm, SubjectPublicKey $subjectPublicKey)
    {
        parent::__construct(self::TYPES['SEQUENCE'], [$algorithm, $subjectPublicKey]);
    }
}

2. DERエンコードする

DERは「型」「値の長さ」「値」の結合で、オブジェクト型の場合は値が入れ子になるように表現します。

これらは以下のように実装できます。

class DerEncoder
{
    public static function asn1ValToDer(Asn1Val $Asn1Val): string
    {
        if (is_iterable($Asn1Val->val)) {
            $explodedVal = '';
            foreach ($Asn1Val->val as $childVal) {
                $explodedVal .= self::asn1ValToDer($childVal);
            }
        } else {
            $explodedVal = $Asn1Val->val;
        }

        // 長さをバイナリデータに変換
        $encodedLen = chr(strlen($explodedVal));

        // 「型-長さ-値」に変換
        return Asn1Val::TAGS[$Asn1Val->type].$encodedLen.$explodedVal;
    }
}

3. PEMエンコードする

PEMはDERを以下の手順で変換したものです。

  1. base64エンコードする
  2. 64文字ごとに改行する
  3. 公開鍵を表すラベルで挟む

したがって以下のように実装できます。

class PemEncoder
{
    public static function derToPem(string $der): string
    {
        return
            '-----BEGIN PUBLIC KEY-----'.PHP_EOL
            .chunk_split(base64_encode($der), 64, PHP_EOL)
            .'-----END PUBLIC KEY-----';
    }
}

4. 最終コード

最終的には以下を実装することで、公開鍵座標(x, y)をPEMに変換できます。

class P256PubKeyConverter
{
    public static function pointToPem(Gmp $x, Gmp $y): string
    {
        $algorithm = new P256AlgorithmIdentifier();
        $subjectPublicKey = new P256SubjectPublicKey($x, $y);
        $subjectPublicKeyInfo = new SubjectPublicKeyInfo($algorithm, $subjectPublicKey);

        $der = DerEncoder::asn1ValToDer($subjectPublicKeyInfo);
        $pem = PemEncoder::derToPem($der);

        return $pem;
    }
}

まとめ

本記事では、ECCの概要や多様な公開鍵の形式を実装を交えて紹介しました。

目標は公開鍵の形式を変えたいという単純なものでしたが、日本語のまとまった記事が少ないため原文を当たらなければいけないことが多く大変でした。 ただ通常のWeb開発ではなかなか扱う機会のない部分でしたので、新たな知見も沢山得ることができました。

この記事が今後ECC周りを勉強したい方々のお役に立てば幸いです。

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。   採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。   ご興味を持っていただけましたら是非ご覧ください。 tecotec.co.jp