はじめに
楕円曲線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 で動作を確認しています。
packagemainimport("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=")typerawSignaturestruct{R,S*big.Int}funcmain(){sign()verify()}funcsign(){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))}funcverify(){fmt.Println("================================ start verification ================================\n")// PEM ブロックを取得block,_:=pem.Decode([]byte(targetPublicKeyPEM))ifblock==nil||block.Type!="PUBLIC KEY"{panic("invalid public key pem data")}publicKey,_:=x509.ParsePKIXPublicKey(block.Bytes)asn1Signature,_:=base64.StdEncoding.DecodeString(targetSignature)varsigrawSignatureasn1.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 で動作を確認しています。
constcrypto=require("crypto");constmsg="Hello, ECDSA!";consttargetPublicKeyPEM=`-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExW7riGWvlxmRofxQNuRhsF9anb+8
F/1NRGzZziCC/utzFMXSg9YwzaRb0Yw+K2n0+1IkWH7lQT9j4DZhF6Npfg==
-----END PUBLIC KEY-----`consttargetSignature="MEUCICzZzFaPemBrWBLNlbbEG+CyXEdAbum9YnOe7lK0rNonAiEA8p1QN/1VcuWRvrPSDnELXedMfiP1FPtk/dmP3Sf/7gA="main();functionmain(){sign();verify();}functionsign(){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",}));// 署名生成constsigner=crypto.createSign("SHA256");// ハッシュ関数を指定signer.update(msg);signer.end();constsignature=signer.sign(privateKey,"base64");// 署名は ASN.1 エンコード され、 Base64 形式で出力されているconsole.info(`asn1 base64 encoded signature: ${signature}\n`);}functionverify(){console.info("================================ start verification ================================\n")constpublicKey=crypto.createPublicKey(targetPublicKeyPEM)// 署名検証constverifier=crypto.createVerify("SHA256");// ハッシュ関数を指定verifier.update(msg);verifier.end();constvalid=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 形式を扱う箇所はかなり強引な書き方をしています。適切なライブラリを使ったほうが良いです)
packagecom.example.ecdsaimportandroidx.appcompat.app.AppCompatActivityimportandroid.os.Bundleimportandroid.security.keystore.KeyGenParameterSpecimportandroid.security.keystore.KeyPropertiesimportandroid.util.Base64importjava.security.KeyFactoryimportjava.security.KeyPairGeneratorimportjava.security.Signatureimportjava.security.interfaces.ECPublicKeyimportjava.security.spec.X509EncodedKeySpecclassMainActivity:AppCompatActivity(){companionobject{constvalmsg="Hello, ECDSA!"constvaltargetPublicKeyPEM="""-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExW7riGWvlxmRofxQNuRhsF9anb+8\
F/1NRGzZziCC/utzFMXSg9YwzaRb0Yw+K2n0+1IkWH7lQT9j4DZhF6Npfg==
-----END PUBLIC KEY-----"""constvaltargetSignature="MEUCICzZzFaPemBrWBLNlbbEG+CyXEdAbum9YnOe7lK0rNonAiEA8p1QN/1VcuWRvrPSDnELXedMfiP1FPtk/dmP3Sf/7gA="}overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)sign()verify()}privatefunsign(){println("================================ start signing ================================")valparameterSpec:KeyGenParameterSpec=KeyGenParameterSpec.Builder("ECPrivateKey",KeyProperties.PURPOSE_SIGNorKeyProperties.PURPOSE_VERIFY).run{setDigests(KeyProperties.DIGEST_SHA256)// ハッシュ関数を指定build()}// キーペア生成valkeyPair=KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC,"AndroidKeyStore").let{it.initialize(parameterSpec)it.generateKeyPair()}valpublicKey=keyPair.publicasECPublicKey// 秘密鍵は 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-----")// 署名生成valsignature=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")))}privatefunverify(){println("================================ start verification ================================\n")valsignature=Base64.decode(targetSignature,Base64.DEFAULT)valspec=X509EncodedKeySpec(Base64.decode(targetPublicKeyPEM.trim().replace("-----BEGIN PUBLIC KEY-----\n","").replace("-----END PUBLIC KEY-----",""),Base64.DEFAULT))valpubKey=KeyFactory.getInstance("EC").generatePublic(spec)valvalid: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 で動作を確認しています。
letmsg="Hello, ECDSA!"lettargetPublicKeyPEM="""
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV4ZTwqTk5Sd5no5ibjjTXSTZCHQV
vpe4qdp2rodC\nMdgCmdl/ZyuCpg/6PH6arDviA2HYVR13/ssin6/Etp93RQ==
-----END PUBLIC KEY-----
"""lettargetSignature="MEUCIAU0/hEz2+RRIwzXkau64jfmUSbFoFMltXEGtl3LHlZHAiEAqak5H/QdRlheYpSpfTGTInQs\nWOUq0mDavgif8+X5uAM="funcecdsa(){sign()verify()}funcsign(){print("================================ start signing ================================\n")// P-256 をパラメータに指定して秘密鍵を生成letprivateKey=try!ECPrivateKey.make(for:.prime256v1)// 秘密鍵を SEC 1, ASN.1 DER エンコード & PEM 形式で出力print(privateKey.pemString)print()// 公開鍵を PKIX, ASN.1 DER エンコード & PEM 形式で出力letpublicKey=try!privateKey.extractPublicKey()print(publicKey.pemString)print()// 署名生成letsignature=try!msg.sign(with:privateKey)// 署名を ASN.1 エンコードしたものを Base64 形式で出力print("asn1 base64 encoded signature: \(signature.asn1.base64EncodedString())\n")}funcverify(){print("================================ start verification ================================\n")letpublicKey=try!ECPublicKey(key:targetPublicKeyPEM)letsignature=try!ECSignature.init(asn1:Data(base64Encoded:targetSignature,options:Data.Base64DecodingOptions.ignoreUnknownCharacters)!)letvalid=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
まとめ
標準化をされている技術ではあるものの、各言語ごとに書き方の癖があってハマりがちな処理を並べました。デバッグのしづらさはデジタル署名のセキュリティの高さの裏返しではあるものの、ハマってしまった方にこの記事が少しでも役にたてば嬉しいです。
記載・認識ミス、もっと良い書き方などありましたら、ご指摘お願いします。