LoginSignup
15
11

More than 3 years have passed since last update.

ECDSA の署名生成・検証を複数言語・環境でどう書くかまとめてみた

Last updated at Posted at 2020-01-24

はじめに

楕円曲線DSA (ECDSA) は楕円曲線暗号を利用した電子署名方式で、通信のセキュリティ確保のために広く使われています。様々なプログラミング言語の標準的なライブラリでサポートされているため、言語をまたいで利用することができます。

ただし、実際に言語をまたいで署名生成 & 検証をしようとしてみると API の違いやフォーマット方式によりハマること多々あったため、書き方をまとめておくことにしました。

この記事に書かれていること

Golang, Node.js, Kotlin, Swift での(できる限り)標準ライブラリを利用した キーペア生成、署名生成・検証方法を記載します。

以降の記述は基本的に ECDSA を前提に書かれています。

全体的に、エラーハンドリングは省略しているので注意してください。

この記事に登場するキーワード

キーワード 概要
EC 楕円曲線、もしくは楕円曲線暗号のこと。
P-256 利用する楕円曲線の種類。キーペア生成、署名生成・検証時にパラメータとして指定します。NIST で規定されているものとしては他には P-384 などがあります。
キーペア 秘密鍵とそれに対応する公開鍵のペアのこと。 ECDSA では秘密鍵は 1 つの整数, 公開鍵は楕円曲線上の点を表す 2 つの整数で構成されます。
SHA256 256 ビット(32 バイト)のハッシュ値を生成するハッシュ関数。
r, s ECDSA の署名値。 r, s のどちらも整数値です。
ASN.1 データ構造を定義するための標準インターフェイス記述言語(wikipedia のグーグル翻訳まま)
DER エンコード ASN.1 の標準符号化規則の一つ。秘密鍵・公開鍵や署名データのシリアライズに利用する。
PEM 形式 DER エンコードの結果のバイナリデータを base64 エンコードして -----BEGIN [TYPE]-----, -----END [TYPE]----- で囲ったもの。

Golang, Node.js, Kotlin, Swift での ECDSA

それぞれの言語でキーペア生成、署名生成、署名検証をおこないます。

公開鍵は PEM 形式, 署名データは ASN.1 エンコードした結果のバイナリデータを Base64 形式で出力します。

公開鍵を PEM 形式としたのはただ単によく見かけるから、という理由からです。(検証をおこなうという意味では DER エンコード されたバイナリデータを Base64 もしくは Hex 形式で出力するだけで十分だと、後から気づきました...)

Golang

Golang は標準ライブラリが充実しているため、さほど苦労することなく扱うことができます。

参考: golang ecdsa パッケージ

以下のコードは version: 1.13.3 で動作を確認しています。

package main

import (
    "crypto/ecdsa"
    "crypto/elliptic"
    "crypto/rand"
    "crypto/sha256"
    "crypto/x509"
    "encoding/asn1"
    "encoding/base64"
    "encoding/pem"
    "fmt"
    "math/big"
    "os"
)

const (
    msg = "Hello, ECDSA!"

    targetPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExW7riGWvlxmRofxQNuRhsF9anb+8
F/1NRGzZziCC/utzFMXSg9YwzaRb0Yw+K2n0+1IkWH7lQT9j4DZhF6Npfg==
-----END PUBLIC KEY-----`
    targetSignature = "MEUCICzZzFaPemBrWBLNlbbEG+CyXEdAbum9YnOe7lK0rNonAiEA8p1QN/1VcuWRvrPSDnELXedMfiP1FPtk/dmP3Sf/7gA="
)

type rawSignature struct {
    R, S *big.Int
}

func main() {
    sign()
    verify()
}

func sign() {
    fmt.Println("================================ start signing ================================\n")

    // P-256 をパラメータに指定してキーペアを生成
    privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
    publicKey := privateKey.PublicKey

    // 秘密鍵の整数値を出力
    fmt.Printf("private key is %d\n", privateKey.D)
    fmt.Println()

    // 秘密鍵を SEC 1, ASN.1 DER エンコード
    sec1FormPrivateKey, _ := x509.MarshalECPrivateKey(privateKey)
    // PEM 形式で出力
    _ = pem.Encode(os.Stdout, &pem.Block{
        Type:    "EC PRIVATE KEY",
        Headers: nil,
        Bytes:   sec1FormPrivateKey,
    })
    fmt.Println()

    // 公開鍵の整数値のペアを出力
    fmt.Printf("public key is (x: %d, y: %d)\n", publicKey.X, publicKey.Y)
    fmt.Println()

    // 公開鍵を PKIX, ASN.1 DER エンコード
    pkiFormPublicKey, _ := x509.MarshalPKIXPublicKey(&publicKey)
    // PEM 形式で出力
    _ = pem.Encode(os.Stdout, &pem.Block{
        Type:    "PUBLIC KEY",
        Bytes:   pkiFormPublicKey,
    })
    fmt.Println()

    // メッセージのハッシュ値を取得
    hash := sha256.Sum256([]byte(msg))

    // 署名生成
    r, s, _ := ecdsa.Sign(rand.Reader, privateKey, hash[:])
    fmt.Printf("signature: (r: %d, s: %d)\n", r, s)

    // 署名を ASN.1 エンコード
    asn1Signature, _ := asn1.Marshal(rawSignature{r, s})
    // Base64 形式で出力
    fmt.Printf("asn1 base64 encoded signature: %s\n\n", base64.StdEncoding.EncodeToString(asn1Signature))
}

func verify() {
    fmt.Println("================================ start verification ================================\n")

    // PEM ブロックを取得
    block, _ := pem.Decode([]byte(targetPublicKeyPEM))
    if block == nil || block.Type != "PUBLIC KEY" {
        panic("invalid public key pem data")
    }

    publicKey, _ := x509.ParsePKIXPublicKey(block.Bytes)

    asn1Signature, _ := base64.StdEncoding.DecodeString(targetSignature)

    var sig rawSignature
    asn1.Unmarshal(asn1Signature, &sig)

    // メッセージのハッシュ値を取得
    hash := sha256.Sum256([]byte(msg))

    // 署名検証
    valid := ecdsa.Verify(publicKey.(*ecdsa.PublicKey), hash[:], sig.R, sig.S)
    fmt.Printf("signature was verified: %t\n", valid)
}

出力結果:

================================ start signing ================================

private key is 86406366532313532520773863615456167011096149492537621067924417740068666801996

-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIL8IRTYAiQtNKvAMDxMtucbcrF40K9lPEJr1eFG3JP9MoAoGCCqGSM49
AwEHoUQDQgAECLCYIbdaHGU4phHj28OXTy04YcKD2wsL0fqbSCP4pMQIghdIGvCd
jwZ9nntlLfpdY/d6Wnp/GcwEosAYSCQFjg==
-----END EC PRIVATE KEY-----

public key is (x: 3930517846499297788187286115327721111010190045004457380847771725537278993604, y: 3848353591206560331525005698968473002174715783271413427180613072969827353998)

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECLCYIbdaHGU4phHj28OXTy04YcKD
2wsL0fqbSCP4pMQIghdIGvCdjwZ9nntlLfpdY/d6Wnp/GcwEosAYSCQFjg==
-----END PUBLIC KEY-----

signature: (r: 47681670912106433244589806297495994583210073185120436994285114290076291204903, s: 93735733074916934648422947032629918680486834787857816571963967793396929295074)
asn1 base64 encoded signature: MEUCIGlq3o447llhyWn8G/p9GN3e1NMDC7zZm21OUIj+RIcnAiEAzzyLeJtUyecBmFvxA/bV0uXEuZ5B1fN4xyEcilv8cuI=

================================ start verification ================================

signature was verified: true

Node.js

Node.js も標準ライブラリを利用できますが、秘密鍵・公開鍵や署名はエンコードされた情報はとれるものの、整数値を直接取得することはできないようです。
(DER, PEM デコードするライブラリは数多く存在していたので、必要があれば簡単に取得はできそうです)

参考: Node.js crypto モジュール

以下のコードは version: 12.14.1 で動作を確認しています。

const crypto = require("crypto");

const msg = "Hello, ECDSA!";

const targetPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExW7riGWvlxmRofxQNuRhsF9anb+8
F/1NRGzZziCC/utzFMXSg9YwzaRb0Yw+K2n0+1IkWH7lQT9j4DZhF6Npfg==
-----END PUBLIC KEY-----`
const targetSignature = "MEUCICzZzFaPemBrWBLNlbbEG+CyXEdAbum9YnOe7lK0rNonAiEA8p1QN/1VcuWRvrPSDnELXedMfiP1FPtk/dmP3Sf/7gA="

main();

function main() {
  sign();
  verify();
}

function sign() {
  console.info("================================ start signing ================================\n")
  // P-256 をパラメータに指定してキーペアの生成
  const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", {
    namedCurve: "P-256",
  });

  // 秘密鍵を SEC 1, ASN.1 DER エンコード & PEM 形式で出力
  console.info(privateKey.export({
    type: "sec1",
    format: "pem",
  }));

  // 公開鍵を PKIX, ASN.1 DER エンコード & PEM 形式で出力
  console.info(publicKey.export({
    type: "spki",
    format: "pem",
  }));

  // 署名生成
  const signer = crypto.createSign("SHA256"); // ハッシュ関数を指定
  signer.update(msg);
  signer.end();
  const signature = signer.sign(privateKey, "base64");

  // 署名は ASN.1 エンコード され、 Base64 形式で出力されている
  console.info(`asn1 base64 encoded signature: ${signature}\n`);
}

function verify() {
  console.info("================================ start verification ================================\n")
  const publicKey = crypto.createPublicKey(targetPublicKeyPEM)

  // 署名検証
  const verifier = crypto.createVerify("SHA256"); // ハッシュ関数を指定
  verifier.update(msg);
  verifier.end();
  const valid = verifier.verify(publicKey, targetSignature, "base64");
  console.info(`signature was verified: ${valid}`);
}

出力結果:

================================ start signing ================================

-----BEGIN EC PRIVATE KEY-----
MHcCAQEEILtZOGwW/gh1geY6yu4bfEuzSrwa4BJnuE37gwAsZb/IoAoGCCqGSM49
AwEHoUQDQgAEPu/QDDiV4ry2T4Ki9r9VIXgvLH09x/4J32HVdOXUlnVQegD52191
DQJ3Q2H41MTnD+uZdlGnQAUkgYSRt1A7jw==
-----END EC PRIVATE KEY-----

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPu/QDDiV4ry2T4Ki9r9VIXgvLH09
x/4J32HVdOXUlnVQegD52191DQJ3Q2H41MTnD+uZdlGnQAUkgYSRt1A7jw==
-----END PUBLIC KEY-----

asn1 base64 encoded signature: MEQCIBdoySVlQAjUSVb61H+7FzPI3+b4m4Agy62MO6/vVFkEAiAWPRjje4g/6/LpY/dUg+4dteQRK/qMI/kn3s0zIJbrTQ==

================================ start verification ================================

signature was verified: true

Kotlin (Android)

Android 開発においては Keystore システムでサポートされる API を利用することができます。
鍵データの管理を委譲できるのは大きなメリットである反面、秘密鍵の情報にアクセスする API が用意されていないようです。
例えば、独自にバックアップを取るなど、特殊なことをする場合は工夫が必要そうです。

署名については Node.js 同様に ASN.1 エンコード後のバイナリデータが返されます。

参考: Android Keystore システム

以下のコードは android SDK version: 29, kotlin version: 1.3.61 で動作を確認しています。

(PEM 形式を扱う箇所はかなり強引な書き方をしています。適切なライブラリを使ったほうが良いです)

package com.example.ecdsa

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.interfaces.ECPublicKey
import java.security.spec.X509EncodedKeySpec

class MainActivity : AppCompatActivity() {

    companion object {
        const val msg = "Hello, ECDSA!"

        const val targetPublicKeyPEM = """-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExW7riGWvlxmRofxQNuRhsF9anb+8\
F/1NRGzZziCC/utzFMXSg9YwzaRb0Yw+K2n0+1IkWH7lQT9j4DZhF6Npfg==
-----END PUBLIC KEY-----"""
        const val targetSignature = "MEUCICzZzFaPemBrWBLNlbbEG+CyXEdAbum9YnOe7lK0rNonAiEA8p1QN/1VcuWRvrPSDnELXedMfiP1FPtk/dmP3Sf/7gA="
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        sign()
        verify()
    }

    private fun sign() {
        println("================================ start signing ================================")

        val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder(
            "ECPrivateKey",
            KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
        ).run {
            setDigests(KeyProperties.DIGEST_SHA256) // ハッシュ関数を指定
            build()
        }

        // キーペア生成
        val keyPair = KeyPairGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_EC,
            "AndroidKeyStore"
        ).let {
            it.initialize(parameterSpec)
            it.generateKeyPair()
        }

        val publicKey = keyPair.public as ECPublicKey

        // 秘密鍵は KeyStore 内で管理される前提であるためか、内部のデータにアクセスする API が見当たらなかった

        // 公開鍵の整数値のペアを出力
        println("public key is (x: ${publicKey.w.affineX}, y: ${publicKey.w.affineY})")

        // 公開鍵を PKIX, ASN.1 DER エンコード & PEM 形式で出力 (かなり強引...)
        println("-----BEGIN PUBLIC KEY-----")
        Base64.encodeToString(publicKey.encoded, Base64.DEFAULT).trim().chunked(64).forEach {
            println(it.replace("\n", "\\n"))
        }
        println("-----END PUBLIC KEY-----")

        // 署名生成
        val signature = Signature.getInstance("SHA256withECDSA").run {
            initSign(keyPair.private)
            update(msg.toByteArray())
            sign()
        }

        // 署名は ASN.1 エンコード されているため、Base64 形式で出力
        println(String.format(
            "asn1 base64 encoded signature: %s",
            Base64.encodeToString(signature, Base64.DEFAULT).trim().replace("\n", "\\n")
        ))

    }

    private fun verify() {
        println("================================ start verification ================================\n")
        val signature = Base64.decode(targetSignature, Base64.DEFAULT)

        val spec = X509EncodedKeySpec(
            Base64.decode(
                targetPublicKeyPEM.trim()
                    .replace("-----BEGIN PUBLIC KEY-----\n", "")
                    .replace("-----END PUBLIC KEY-----", ""),
                Base64.DEFAULT
            )
        )

        val pubKey = KeyFactory.getInstance("EC").generatePublic(spec)
        val valid: Boolean = Signature.getInstance("SHA256withECDSA").run {
            initVerify(pubKey)
            update(msg.toByteArray())
            verify(signature)
        }
        println("signature was verified: $valid")
    }
}

出力結果:

I/System.out: ================================ start signing ================================
I/System.out: public key is (x: 82167081552335602286200448410416710140443532428724715799809599812531686098238, y: 51175083263095894836653222459656189252260595373682081626878418494739276551753)
I/System.out: -----BEGIN PUBLIC KEY-----
I/System.out: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtajriseSquTJ0f2EQZQli7czMp6v
I/System.out: pHAHTW2Tq25e\nRT5xJBIYA6AgPrEdKuPtVcgamRFSKE82w1YEdxMBQCrWSQ==
I/System.out: -----END PUBLIC KEY-----
I/System.out: asn1 base64 encoded signature: MEQCIBeZSNHoN3VD7laNSDl0CGGgjrqGp50RCG6azqXmjrR/AiBKUHXJyXNLmIUCPwv33zvRfwfr\n83mfi5cJOV5Zf2QVgQ==
I/System.out: ================================ start verification ================================
I/System.out: signature was verified: true

Swift

Swift ではネイティブ API の扱いが煩雑だったため、外部ライブラリを利用しました...。

利用ライブラリ: BlueECC

ネイティブ API の扱いについてはこちらの記事が参考になります。

以下のコードは iOS: 13.1, Swift: 5.1.3 で動作を確認しています。

let msg = "Hello, ECDSA!"
let targetPublicKeyPEM = """
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV4ZTwqTk5Sd5no5ibjjTXSTZCHQV
vpe4qdp2rodC\nMdgCmdl/ZyuCpg/6PH6arDviA2HYVR13/ssin6/Etp93RQ==
-----END PUBLIC KEY-----
"""
let targetSignature = "MEUCIAU0/hEz2+RRIwzXkau64jfmUSbFoFMltXEGtl3LHlZHAiEAqak5H/QdRlheYpSpfTGTInQs\nWOUq0mDavgif8+X5uAM="

func ecdsa() {
    sign()
    verify()
}

func sign() {
    print("================================ start signing ================================\n")

    // P-256 をパラメータに指定して秘密鍵を生成
    let privateKey = try! ECPrivateKey.make(for: .prime256v1)

    // 秘密鍵を SEC 1, ASN.1 DER エンコード & PEM 形式で出力
    print(privateKey.pemString)
    print()

    // 公開鍵を PKIX, ASN.1 DER エンコード & PEM 形式で出力
    let publicKey = try! privateKey.extractPublicKey()
    print(publicKey.pemString)
    print()

    // 署名生成
    let signature = try! msg.sign(with: privateKey)

    // 署名を ASN.1 エンコードしたものを Base64 形式で出力
    print("asn1 base64 encoded signature: \(signature.asn1.base64EncodedString())\n")
}

func verify() {
    print("================================ start verification ================================\n")

    let publicKey = try! ECPublicKey(key: targetPublicKeyPEM)
    let signature = try! ECSignature.init(
        asn1: Data(base64Encoded: targetSignature,
                   options: Data.Base64DecodingOptions.ignoreUnknownCharacters)!)
    let valid = signature.verify(plaintext: msg, using: publicKey)
    print("signature was verified: \(valid)");
}

出力結果:

================================ start signing ================================

-----BEGIN EC PRIVATE KEY-----
MHcCAQEEILu54xIBwH3Vd45Fgx9yCCgOTynjxvIMh+PnL86qOx7roAoGCCqGSM49
AwEHoUQDQgAENa6T19s23zEVLBvUYyVbZjRGPqhUkYJcv7SA8J05F8Vql7Aw9GR+
G/uxgYFqe6j1MYQ2tPF9MN32cc+xG2OCUw==
-----END EC PRIVATE KEY-----

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENa6T19s23zEVLBvUYyVbZjRGPqhU
kYJcv7SA8J05F8Vql7Aw9GR+G/uxgYFqe6j1MYQ2tPF9MN32cc+xG2OCUw==
-----END PUBLIC KEY-----

asn1 base64 encoded signature: MEYCIQD+fGwKEVX8aTzdbRgpEy9/nWHAsAw0JQXAKH4IJo4uEgIhAJKfFkN1Akl18rrnyfwwsqMa2dWwWXLbX1yRaHLZwdRG

================================ start verification ================================

signature was verified: true

まとめ

標準化をされている技術ではあるものの、各言語ごとに書き方の癖があってハマりがちな処理を並べました。デバッグのしづらさはデジタル署名のセキュリティの高さの裏返しではあるものの、ハマってしまった方にこの記事が少しでも役にたてば嬉しいです。

記載・認識ミス、もっと良い書き方などありましたら、ご指摘お願いします。

参考

15
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
11