はじめに
楕円曲線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 は標準ライブラリが充実しているため、さほど苦労することなく扱うことができます。
以下のコードは 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 デコードするライブラリは数多く存在していたので、必要があれば簡単に取得はできそうです)
以下のコードは 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 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
まとめ
標準化をされている技術ではあるものの、各言語ごとに書き方の癖があってハマりがちな処理を並べました。デバッグのしづらさはデジタル署名のセキュリティの高さの裏返しではあるものの、ハマってしまった方にこの記事が少しでも役にたてば嬉しいです。
記載・認識ミス、もっと良い書き方などありましたら、ご指摘お願いします。