署名は 2 つの整数
通信路上でどのようなバイト列に見えても、ECDSA 署名は数学的には正の整数の組 (r, s) にすぎません。どちらも 1 以上 n − 1 以下で、n は曲線の位数です(P-256 と secp256k1 では 2²⁵⁶ に近い数)。それ以外はすべてシリアライズの問題であり、エコシステムには互換性のない 2 つの方式が定着しました。どちらにも、もう一方にない情報は含まれていません。相互の変換は情報を失わない、純粋に機械的な処理です。
形式 1:ASN.1 DER(X.509 の系譜)
OpenSSL、X.509 証明書、TLS が使う形式は ASN.1 の世界に由来し、2 つの整数の組を、2 つの INTEGER からなる DER の SEQUENCE としてエンコードします。P-256 の例に注釈を付けると次のとおりです。
30 45 SEQUENCE, 0x45 = 69 bytes follow
02 21 INTEGER, 0x21 = 33 bytes (this is r)
00 c8 7f 3b ... 33 bytes: leading 00 because r's top bit is set
02 20 INTEGER, 0x20 = 32 bytes (this is s)
6a 12 09 ... 32 bytes: no padding needed, top bit is clear
この形式が少しややこしいのは、DER の整数の規則のためです。
- 整数はビッグエンディアンの 2 の補数表現で、最小長でなければなりません。先頭のゼロバイトは取り除く必要があります。
- ただし値は正の数なので、先頭バイトの最上位ビットが立っている場合(先頭バイトが
0x80以上)は、負の数と解釈されないように0x00を 1 バイトだけ前に付けなければなりません。rとsはそれぞれ独立に、このパディングバイトを持つ場合も持たない場合もあります。 - 結果として、エンコーディングは可変長になります。P-256 では通常 70、71、72 バイトのいずれかで、
rやsがたまたまゼロバイトで始まる場合はさらに短くなることもあります。署名長を固定と仮定したコードは、運の悪い乱数値が出るのを待っているバグです。 - DER は distinguished(一意な)エンコーディングで、1 つの値に対して有効なバイト列はちょうど 1 つです。厳格なパーサーは、最小長でない整数、仕様外のパディング、末尾の余分なデータを拒否します。Bitcoin が厳格な DER をコンセンサスルール(BIP-66)にしたのは、まさにパーサー間の解釈の違いをなくすためでした。
形式 2:生形式の r‖s(IEEE P1363、固定長)
もう 1 つの方式は ASN.1 をまったく使いません。r と s をそれぞれビッグエンディアンで曲線の座標サイズちょうどにパディングし、連結します。この形式は、採用した標準にちなんで IEEE P1363 形式と呼ばれることが多く、「raw」(生形式)やフラットな r‖s とも呼ばれます。
| 曲線 | 有限体の元のサイズ | 生形式の署名のサイズ |
|---|---|---|
| P-256 / secp256k1 | 32 バイト | 64 バイト |
| P-384 | 48 バイト | 96 バイト |
| P-521 | 66 バイト | 132 バイト |
固定長で、タグもパディングの規則もなく、バイト列を半分に分ければそのまま 2 つの整数が得られます。WebCrypto が出力するのはこの形式で、RFC 7518 §3.4 が JWS に義務付けているのもこの形式です(したがって JWT も同様で、実務上の影響は JWT における ES256 と RS256 の比較で解説しています)。また、ほとんどのブロックチェーンのスタックが内部で扱うのもこの形です。Ethereum の署名は 65 バイトの r‖s‖v(生形式の組にリカバリバイトを加えたもの)ですが、Bitcoin は前述のとおり、トランザクション署名を厳格な DER でエンコードします。
失敗のパターンは対称的で、非常によく見られます。DER を想定する API に 64 バイトの生形式の署名を渡すと ASN.1 パーサーの段階で失敗し(0x30 を期待)、WebCrypto や JWS の検証者に約 71 バイトの DER を渡すと単に長さが合わず、汎用的な「invalid signature」としてあっさり失敗します。2 つのスタックで形式が食い違うときは、明示的に変換してください。DER ⇄ 生形式の変換ツールはブラウザー上で双方向に変換し、デコードした r と s を表示します。
high-S、low-S と、有効な署名が拒否される理由
エンコーディングが一致していても、スタックをまたぐ検証を壊す微妙な問題がもう 1 つあります。ECDSA には本質的な対称性があり、(r, s) が有効な署名なら、(r, n − s) も同じ鍵による同じメッセージへの有効な署名です。署名を手にした人は誰でも、秘密鍵なしで署名をもう一方の形に反転できます。署名された内容は改変できなくても、署名のバイト列には展性があるということです。
署名のバイト列をハッシュしてデータを識別するプロトコル(典型例は Bitcoin のトランザクション ID)にとって、この第三者による展性は現実の問題です。対策は low-S の正規形です。s ≤ n/2 を要求し、そうでなければ s を n − s に置き換えて正規化します。Bitcoin は low-S をリレールールとして採用し(BIP-62 で提案され、BIP-146 で規定)、libsecp256k1 の検証は、呼び出し側が先に正規化しない限り low-S の署名しか受け入れません。ほかのエコシステムでも、セキュリティを重視する一部のライブラリはデフォルトで low-S の署名を生成し、検証時に high-S を拒否するよう設定できます。
どれがどの形式を使うか:ライブラリのデフォルト
| ライブラリ / API | デフォルトの形式 | もう一方の形式 |
|---|---|---|
| OpenSSL(CLI と EVP API) | DER | ECDSA 用の生形式モードは組み込まれていない。外部で変換するか、ECDSA_SIG の r と s から組み立てる |
Node.js の node:crypto | DER | sign/verify の dsaEncoding: "ieee-p1363" オプション(Node 12 LTS 系から利用可能) |
WebCrypto の crypto.subtle | 生形式(P1363)のみ | DER 非対応。前後で変換 |
Go の crypto/ecdsa | どちらでもない:Sign/Verify は r, s を *big.Int で受け渡し | DER には SignASN1 / VerifyASN1(Go 1.15 以降)、生形式は FillBytes で組み立て |
Python の cryptography(pyca) | DER | ヘルパー decode_dss_signature / encode_dss_signature で r と s を扱える |
Python の ecdsa パッケージ | デフォルトは生形式 | 引数 sigencode_der / sigdecode_der |
| Java(JCA Signature) | DER(SHA256withECDSA) | 最近の JDK(9 以降)では SHA256withECDSAinP1363Format |
いくつかをコードで示します。Node.js は、同じ鍵からどちらのエンコーディングでも出力できます。
import { generateKeyPairSync, sign } from "node:crypto";
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const msg = Buffer.from("same signature, two encodings");
const der = sign("sha256", msg, privateKey);
const raw = sign("sha256", msg, { key: privateKey, dsaEncoding: "ieee-p1363" });
console.log(der.length); // 70..72 (varies per signature)
console.log(raw.length); // always 64
Go は内部の整数を明示的に扱うので、変換の仕方も明快です。生形式の各半分を固定幅でゼロ埋めする FillBytes に注目してください。
der, err := ecdsa.SignASN1(rand.Reader, priv, digest) // ASN.1 DER
if err != nil { return err }
r, s, err := ecdsa.Sign(rand.Reader, priv, digest) // the integers themselves
if err != nil { return err }
raw := make([]byte, 64)
r.FillBytes(raw[:32]) // zero-pads on the left — critical for short values
s.FillBytes(raw[32:])
Python の pyca cryptography は DER で署名し、整数を取り出すためのヘルパーを備えています。
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256())) # DER bytes
r, s = decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") # 64-byte P1363
デバッグのチェックリスト
2 つのシステム間で ECDSA 署名の検証が通らないときは、このリストを順に確認してください。
- 長さの確認:ちょうど 64/96/132 バイトなら生形式です。
0x30で始まり、もっともらしい長さバイトが続くなら DER です。それ以外の場合は、外側の層(base64、hex)のデコードを誤っているか、コピーして貼り付けた際に途中で切れています。 - 形式の一致:検証側の API が、署名側が生成した形式を想定しているかを確認します。署名し直すのではなく、DER ⇄ 生形式の変換ツールで変換してください。
- ゼロ埋め:生形式の署名が断続的に(
rとsがそれぞれ約 256 回に 1 回の割合でゼロバイトから始まるため、合わせておよそ 128 回に 1 回)失敗する場合は、rとsを固定幅になるよう左側からゼロで埋めずに、先頭のゼロバイトを落としてしまう変換を疑ってください。 - high-S:断続的に(およそ 50% の確率で)失敗し、片方にブロックチェーンやセキュリティを強化したスタックが関わっている場合は、
sをn/2と比較して正規化してください。 - それ以外の原因(誤ったハッシュ、誤った曲線、ハッシュ値をさらにハッシュする誤り)は、形式の層ではなくアルゴリズムの層の問題です。その背景については、まず ECDSA と RSA の比較を読んでください。
心強いまとめをすると、DER と生形式の違いは純粋に配管の問題です。変換に暗号処理は関わらず、鍵は何も変わりません。両者がバイト配置について(そして必要な場面では low-S についても)合意すれば、午後をまるごと費やした「invalid signature」は、たいてい最初から有効だったことがわかります。