本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
デジタルアイデンティティについて、これまで 3 本の記事を書きました。
- デジタルアイデンティティとは何か ── 識別子・属性・認証の 3 要素と SSI の三者モデル
- 学認のメタデータを開いて読む ── SAML フェデレーションの中身を実データで確かめる
- アイデンティティ・ウォレットの 4 つの機能と、学認のトラストアンカー
どれも「署名を検証する」という操作を前提にしていますが、その署名が何をしているのかは説明していませんでした。本記事はその土台にあたる部分を、小さい数字と短いコードで追います。
数学的な証明は扱いません。「なぜその計算が成り立つのか」ではなく、「何と何をどう計算しているのか」だけを見ます。手順として追えれば、上の 3 本は読めるようになるはずです。
登場人物は 3 人
暗号の説明で慣例的に使われる 3 人を、そのまま使います。
| 役 | 持っているもの | |
|---|---|---|
| アリス | 署名する人 | 秘密鍵と公開鍵 |
| ボブ | 確かめる人 | アリスの公開鍵だけ |
| マロリー | なりすまそうとする人 | 自分の鍵(アリスの秘密鍵は持たない) |
この 3 人が実際のシステムで誰にあたるかは、場面によって変わります。まとめて並べると混乱するので、場面ごとに分けます。
| 場面 | アリス(署名する側) | ボブ(確かめる側) |
|---|---|---|
| TLS(Transport Layer Security、通信を保護する規格) | Web サーバ | ブラウザ |
| OpenID Connect | OP(OpenID Provider。一般には IdP(Identity Provider)とも呼ばれます) | RP(Relying Party、OP の言うことに依拠して判断するサービス側) |
| Verifiable Credentials の発行 | 発行者 | 受け取る側(ウォレット) |
| Verifiable Credentials の提示 | ウォレット(保持者) | 検証者 |
いくつか補足しておきます。
TLS の行は、サーバだけが証明書を出す一般的な構成を書いています。クライアント証明書を使う構成では、ブラウザ側も署名する側になります。役割は機器の種類ではなく、その場面で誰が秘密鍵を使うかで決まります。
OpenID Connect の仕様上の用語は OP(OpenID Provider)です。IdP という呼び方のほうが広く使われていますが、仕様の原文には出てきません。SAML(Security Assertion Markup Language、学認が使っている規格)では、RP にあたる役を SP(Service Provider)と呼びます。呼び名が違うだけで役割は同じです。
Verifiable Credentials の行は、発行者とウォレットの両方が署名する構成を書いています。ただし提示のときにウォレットが署名するかどうかは、使う仕様と構成によって変わります。
マロリーはどの場面でも攻撃者です。
署名で分かることは 1 つだけ
先に結論を書いておきます。署名を検証して言えるのは、次の 1 つだけです。
このデータは、この公開鍵と対になる秘密鍵の持ち主が作った。そして作られてから変わっていない。
逆に、言えないことが 2 つあります。
- その鍵の持ち主が誰なのか
- 内容が真実かどうか
1 つ目の穴が、証明書やトラストアンカー(信頼の起点)が必要になる理由です。本記事では署名そのものの手順までを扱い、この 1 つ目には最後に触れる程度にとどめます。2 つ目は署名では原理的に解けないため、扱いません。
実際に送られるもの
「アリスが {"name":"Alice"} に署名してボブに送る」と書くと、名前だけが送られるように読めます。実際には 3 つが送られます。
JWT(JSON Web Token、署名付きのデータをやりとりする形式のひとつ)の場合、こうなります。
eyJhbGciOiJFUzI1NiJ9 . eyJuYW1lIjoiQWxpY2UifQ . Qjg1XcAVAwhoSY0BfvMNUq4uWGD4tjM…
└─ ①ヘッダ ────────┘ └─ ②中身 ──────────┘ └─ ③署名 ──────────────────┘
①と②は base64url(RFC 4648 の URL 安全な文字集合を使う Base64 の一種)で書かれているだけで、暗号化されていません。デコードすれば誰でも読めます。
① {"alg":"ES256"}
② {"name":"Alice"}
③ 数値。読んでも意味は取れない
署名は中身を隠すためのものではなく、改竄を検出するためのものです。検証には①②③と公開鍵の全部を使います。③だけを見ても判定はできず、③は「①②から計算した結果と突き合わせる対象」という位置づけです。
公開鍵は、この 3 つには含まれていません。署名より前に、別途ボブに渡してあります。
ボブは何を計算し、何と比べているのか
ここが本題です。実際に多く使われる楕円曲線(ES256、JSON Web Algorithms における「P-256 曲線と SHA-256 を使う ECDSA」)は数字が大きすぎて追えないので、RSA(開発者 3 名の頭文字を取った方式)の、教科書的な小さい鍵を使います。
RSA を代役に選んだのは、「秘密鍵で作り、公開鍵だけで確かめられる」という肝心の性質が同じで、しかも掛け算とあまりだけで最後まで追えるからです。細部は違うので、違う箇所は途中で断ります。
公開鍵 = (n, e) = (3233, 17) アリスもボブも持っている
秘密鍵 = (n, d) = (3233, 2753) アリスだけが持っている
計算はこれだけです。依存パッケージなしで動きます。
// .mjs という拡張子で保存すると、そのまま node で実行できます
const powmod = (a, b, n) => { // a の b 乗を n で割ったあまり
let r = 1n; a %= n;
while (b > 0n) { if (b & 1n) r = r * a % n; a = a * a % n; b >>= 1n }
return r;
};
const n = 3233n, e = 17n, d = 2753n; // e が公開鍵、d が秘密鍵
const data = 42n; // アリスが署名するデータ
const s = powmod(data, d, n); // アリス:秘密鍵で変換 → 署名
const back = powmod(s, e, n); // ボブ:公開鍵で戻す
console.log(`署名 : ${data}^${d} mod ${n} = ${s}`);
console.log(`検証 : ${s}^${e} mod ${n} = ${back}`);
console.log(`比較 : ${back} と ${data} → ${back === data ? '一致' : '不一致'}`);
実行結果です。
署名 : 42^2753 mod 3233 = 3065
検証 : 3065^17 mod 3233 = 42
比較 : 42 と 42 → 一致
ボブの手元には 3 つあります。データ 42、事前にもらった公開鍵 (3233, 17)、いま届いた署名 3065 です。このうち計算に使うのは署名と公開鍵の 2 つで、出てきた数をデータ 42 と比べます。
一致したということは、行きの変換に使われたのが 17 と対になる 2753 だった、ということです。ここから先に進むには「2753 を持つのはアリスだけだ」という前提が要ります。その前提は計算から出てくるものではなく、外から与えるものです(この記事の最後で触れます)。
対になっていない鍵で作るとどうなるかも確かめられます。次の 2 行は、上のコードの続きとして書き足してください(powmod などを使い回します)。
const sFake = powmod(data, 1234n, n); // 適当な鍵で署名
console.log(`偽署名 : ${sFake} → ${powmod(sFake, e, n)} ≠ ${data}`);
偽署名 : 2038 → 2699 ≠ 42
17 と 1234 は対になっていないので、元の 42 に戻りません。
ここで見せているのは、素の計算だけです
実際の RSA 署名(RFC 8017 の RSASSA-PSS / RSASSA-PKCS1-v1_5)では、この計算に入る前にデータをハッシュし、さらにパディング(EMSA エンコーディング)を施します。RFC 8017 自身、この計算のような「プリミティブ」について、スキームから切り離しては安全性を提供しないと注意しています。
パディングは省いてよい飾りではありません。同じ鍵で試すと分かります。
const s2 = powmod(2n, d, n), s3 = powmod(3n, d, n);
console.log(`${s2} × ${s3} mod ${n} = ${s2 * s3 % n}、6 の署名は ${powmod(6n, d, n)}`);
2557 × 1503 mod 3233 = 2902、6 の署名は 2902
2 の署名と 3 の署名を掛け合わせると、6 の署名がそのまま出てきます。秘密鍵を知らなくても偽の署名が作れてしまう、ということです。パディングはこれを防ぐためにあります。
なお、この「署名から元の数字が戻ってくる」性質も RSA に特有のものです。楕円曲線(ES256)では署名から元の数字を取り出すことはできず、データも一緒に入れて計算し、成立するかどうかを見る形になります。判定できるという結果は同じで、途中の形が違います。
実印にたとえられるが、途中で当てはまらなくなる
日本語では実印と印鑑証明にたとえられることが多く、途中までは正確です。
| 実印の世界 | デジタル署名 |
|---|---|
| 実印(手元にあり、誰にも渡さない) | 秘密鍵 |
| 押した印影 | 署名 |
| 役所への印鑑登録 | 公開鍵の公開 |
| 印鑑証明書 | 証明書やトラストアンカー |
ただし「印影を見比べる」という部分は当てはまりません。同じ内容に同じ鍵で 3 回署名した結果を並べてみます。
import { generateKeyPairSync, sign } from 'node:crypto';
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
for (const i of [1, 2, 3]) {
console.log(`${i} 回目: ${sign('sha256', Buffer.from('ABC123'), privateKey).toString('base64url').slice(0, 52)}…`);
}
1 回目: MEUCIQD-vM6KcCbpiiOIGN2Ls_2pnBsnPLDuH1GvVmvqfm4lHwIg…
2 回目: MEUCIQD4Uw4FrP_JhUxilt13GnGMqChDoKttDp9mdX3FkVRM6wIg…
3 回目: MEUCIQCgGK-ycLq_lJgctjHJNfJJ_j8VFnP8jQx0dr4Ar0RcBAIg…
同じ内容でも毎回違う値になります。ここで使った ECDSA が、署名のたびに乱数(k と呼ばれます)を混ぜているためです。
ただし署名方式がすべてこうなるわけではありません。Ed25519 や RSASSA-PKCS1-v1_5、それに RFC 6979 で定められた決定的 ECDSA(FIPS 186-5 も取り込んでいます)は、同じ鍵で同じ内容に署名すれば必ず同じ署名になります。この記事の 42^2753 mod 3233 = 3065 も、何度計算しても 3065 です。
それでも「見比べる」は成立しません。署名はデータが少し違うだけで全く別の値になるので、実印の印影のように「これが見本」と手元に持っておける固定の形が、そもそも存在しないからです。
ボブが持っているのは印影の見本ではなく公開鍵で、やっているのは照合ではなく計算です。ここは比喩を捨てて「計算して、成立するか見る」と考えたほうが、後の話が通りやすくなります。
「いま本人か」を確かめるときは、確かめる側が先に送る
ここまでは、アリスが手元のデータに署名して送る形でした。「いま応答しているのはアリスか」を確かめたい場合は、署名する対象の決め方が変わります。
署名済みのデータを受け取っただけでは、それがいつ作られたものか分かりません。1 年前の署名かもしれませんし、誰かが記録しておいて後から送ってきたのかもしれません。署名自体は本物なので検証は通ってしまいます。これは使い回し(リプレイ)と呼ばれます。
対処は、確かめる側が毎回ちがう数字を出し、それに署名させることです。
順番は次のようになります。
① ボブ … 使い捨ての数字を作ってアリスに送る ← ここが起点
② アリス … その数字に署名して返す(数字 + 署名)
③ ボブ … 公開鍵で戻す
④ ボブ … 出てきた数が、自分が①で作った数字と同じか確かめる
数字そのものはボブが作ったものなので、送り返せること自体には意味がありません。意味を持つのは、その数字に対する署名が付いていることです。さっき作ったばかりの数字なので、過去の署名を使い回すことができません。
役割を分けると、数字は「いつ」を固定し、署名は「誰が」を示しています。
この数字が満たすべき条件は 3 つあります。毎回ちがうこと、予測できないこと、十分に長いことです。2 つ目が抜けやすく、次の数字が予測できると、攻撃者が先回りしてアリスに署名させておくことができます。連番や時刻ではなく、暗号用の乱数生成器で作る必要があります。
呼び名は場面ごとに変わります。OpenID Connect では nonce、Verifiable Credentials の提示では nonce または challenge と呼ばれ、どちらも署名される中身にその値が入ります。
TLS にも同じ発想の値があり、ClientHello の random がそれにあたります。ただし対応は完全ではありません。TLS 1.3 でサーバが署名するのは、この数字そのものではなく、そこまでのハンドシェイク記録(transcript)のハッシュです。random はその記録の一部として間接的に含まれ、しかもクライアントとサーバが 1 つずつ出します。「毎回ちがう値を混ぜて使い回しを防ぐ」という狙いは同じですが、「確かめる側が出した数字にそのまま署名させる」形ではありません。その形は TLS 1.2 以前のもので、TLS 1.3 は署名対象の先頭に固定バイト列を置いて、その性質を意図的に消しています。
鍵ペアはどうやって作るのか
「ペアを 2 つ同時に作る」という言い方をよく見かけますが、どちらが先に決まるかは方式によって違います。
楕円曲線の場合、秘密鍵の作り方は乱数を 1 つ選ぶだけです。素数を探す必要はありません。
import { randomBytes } from 'node:crypto';
console.log(randomBytes(32).toString('hex')); // これが秘密鍵のもとになる
256 ビットなので、2 の 256 乗、おおよそ 10 の 77 乗通りあります。厳密には 1 以上、曲線の位数未満という範囲に収める必要がありますが、P-256 ではその範囲が 2 の 256 乗にごく近いため、乱数がそのまま使えないことはほとんどありません。公開鍵は、全員が共有する決まった出発点にこの数を掛けたものとして計算されます。掛け算は一瞬で終わりますが、その逆は、現時点で知られている方法では現実的な時間で解けません。
RSA の場合は手順が 5 つあります。素数を 2 つ選び、掛けて n を作り、(p−1)×(q−1) を計算し、e を選び、e × d を (p−1)×(q−1) で割ったあまりが 1 になるような d を求めます。先ほどの例だと p=61、q=53、n=3233、e=17、d=2753 で、17 × 2753 = 46801 を 3120 で割るとあまりが 1 になります。
順番に注意が要ります。楕円曲線では秘密鍵(乱数)が先で、公開鍵はそこから計算されます。RSA は逆で、実際の規格では先に公開側の e を決め、それと両立する p と q を選び、最後に秘密側の d を求めます。NIST の FIPS 186-5 も e を p、q、d より先に選ぶと定めており、e は 65537 に固定されているのが普通です。「秘密鍵が先」と言えるのは楕円曲線のほうです。
両者に共通しているのは、公開鍵を配っても秘密鍵は復元できない、という点です。
「一方向」という言葉が指すものは 2 つある
ここは混ざりやすいところです。「一方向」と言われる箇所が 2 つあり、実際に一方向なのは片方だけです。
数字と署名の変換は双方向です。秘密鍵で行き、公開鍵で戻れます。これがあるからボブは検証できます。ただしこれは RSA の話で、楕円曲線では戻す形にはならず、「成立するか」を判定する形になります。
秘密鍵から公開鍵の導出は一方向です。逆は計算できません。これがあるから公開鍵を配っても安全です。
上が双方向だから検証が成り立ち、下が一方向だから安全になる、という関係になっています。
秘密鍵のパスフレーズは何を守っているのか
鍵を作るときにパスフレーズを設定できます。これは署名の計算に混ざるものではなく、秘密鍵のファイルを暗号化して保存するためのものです。
import { generateKeyPairSync, createPrivateKey } from 'node:crypto';
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase: 'himitsu' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
console.log(privateKey.split('\n')[0]);
try { createPrivateKey(privateKey); } catch (e) { console.log('パスフレーズ無し →', e.message); }
-----BEGIN ENCRYPTED PRIVATE KEY-----
パスフレーズ無し → error:07880109:common libcrypto routines::interrupted or cancelled
エラーの文言は Node に同梱される OpenSSL のバージョンによって変わります(上は Node 25.6.0 での出力)。読めなかったという結果が出ることが確認できれば十分です。
ファイルの見出しが ENCRYPTED PRIVATE KEY に変わります。正しいパスフレーズで開いた後は、通常の秘密鍵に戻り、署名の計算は変わりません。
鍵ファイルを入手した相手は、オフラインで何度でもパスフレーズを試せます。回数制限も通信もないため、試行を 1 回あたりどれだけ遅くできるかが、そのまま守りの強さになります。関係するのは次の 2 つです。
- パスフレーズ自体の質。辞書に載る語であれば試す候補が少なくて済みますが、ランダムで十分長ければ候補の数そのものが現実的でなくなります
- 鍵ファイルの保存形式。パスフレーズから鍵を導く関数(KDF、Key Derivation Function)の設計が、1 回の試行にかかる時間を決めます
「PEM(Privacy Enhanced Mail)ファイル」というのは Base64 で囲った入れ物の呼び名にすぎず、中身の KDF は主に 3 通りあります。手元(Apple M シリーズ、Node 25.6.0、OpenSSH 10.2p1)で試行速度を測ると、次のようになりました。
| 先頭行 | KDF | 1 秒あたりの試行回数 |
|---|---|---|
-----BEGIN RSA PRIVATE KEY-----(DEK-Info: が続く。OpenSSL の伝統的形式) | MD5・反復 1 回 | 約 180 万回 |
-----BEGIN ENCRYPTED PRIVATE KEY-----(PKCS#8。上のコード例で作ったのはこれ) | PBKDF2-HMAC-SHA256・2048 回 | 約 6,700 回 |
-----BEGIN OPENSSH PRIVATE KEY-----(ssh-keygen の形式) | bcrypt・24 ラウンド | 約 7 回 |
伝統的形式と ssh-keygen の形式では 5 桁の開きがあります。なお 3 行目は ssh-keygen コマンドを繰り返し起動して測ったものなので、プロセス起動の時間が含まれています。専用の解析ツールならもっと速いはずで、この値は攻撃側の下限と考えてください。桁の差として見るのが妥当です。
bcrypt のラウンド数は鍵ファイル自身に記録されていて、ssh-keygen -a <回数> で増やせます。
そのため「パスフレーズを付けたかどうか」だけでなく、「どの形式で、どんなパスフレーズか」まで見る必要があります。形式は先頭行で分かります。
head -1 ~/.ssh/id_ed25519
ただし ed25519 鍵の答えは必ず -----BEGIN OPENSSH PRIVATE KEY----- になります。この鍵種は伝統的な PEM 形式で保存できないためです。形式の違いが問題になるのは RSA 鍵のほうで、head -3 ~/.ssh/id_rsa に DEK-Info: の行が見えたら、それが総当たりの速い伝統的形式です。
そして先頭行はパスフレーズの有無を示しません。さきほどの PKCS#8 形式では見出しが ENCRYPTED PRIVATE KEY に変わりましたが、ssh-keygen の形式は入れ物の名前が同じままで、暗号化しているかどうかは中身にしか現れません。有無を確かめるには、空のパスフレーズで開けるか試します。
ssh-keygen -y -P "" -f ~/.ssh/id_ed25519 > /dev/null 2>&1 && echo "パスフレーズ無し" || echo "パスフレーズ有り"
なお、パスフレーズが守るのは鍵ファイルが持ち出された場合です。誤ってリポジトリにコミットした、バックアップが流出した、端末を紛失した、といった状況では、気づいて鍵を失効させるまでの時間を稼げます。一方、ssh-agent やキーチェーンに預けて使っている間は復号された鍵がメモリ上にあるため、稼働中の端末が乗っ取られた場合の防御にはなりません。
その範囲まで守る方法として、鍵を読み出せない場所に置くやり方があります。スマートフォンの Secure Enclave や StrongBox、サーバ側の HSM(Hardware Security Module)や HashiCorp Vault がこれにあたり、署名を依頼することはできても鍵自体は取り出せない構造になっています。
触れていないもの
- なぜその計算が成り立つのかという数学的な根拠。RSA であればオイラーの定理にあたる部分で、本記事では扱っていません
- ハッシュ関数とパディング(EMSA エンコーディング)。実際の署名ではこの 2 つを通してから上の計算に入ります。骨格は変わらないため本文の説明では省きましたが、実印の節のコードには
sign('sha256', ...)として現れています。省いたのは説明であってコードではありません。なおパディングは省いてよい飾りではありません - 楕円曲線の具体的な計算。ES256 は署名から元の数字を取り出せない形式で、RSA とは検証の手順が異なります
- 公開鍵がアリスのものだとボブがどう知るのか。ここは署名では解けず、証明書やトラストアンカーの話になります。既存記事のアイデンティティ・ウォレットの 4 つの機能と、学認のトラストアンカーで扱っています
- 量子計算による影響。調査した限りでは、NIST(National Institute of Standards and Technology、米国国立標準技術研究所)が 2024 年 8 月に後継の標準を FIPS(Federal Information Processing Standards、連邦情報処理標準)として公開しています。署名にあたるのは FIPS 204 と 205 で、FIPS 203 は鍵の受け渡しに使うものです。別途整理が必要です
まとめ
- 署名を検証して言えるのは「この公開鍵と対になる秘密鍵の持ち主が作り、その後変わっていない」という 1 点のみ
- ボブは印影を見比べているのではなく、署名と公開鍵から数を計算し、それを手元のデータと比べている
- 署名は方式によっては毎回変わり、変わらない方式でもデータごとに全く別の値になる。どちらにせよ「見本」にできる固定の形がないため、見比べるという操作は成立しない
- 「一方向」と呼ばれる箇所は 2 つあるが、実際に一方向なのは秘密鍵から公開鍵を導くほうだけ。数字と署名の変換は(RSA では)双方向で、だから検証できる
- 「いま本人か」を確かめるときは、確かめる側が先に使い捨ての数字を送る。その数字は毎回ちがい、予測できず、十分に長い必要がある
- 楕円曲線の秘密鍵は乱数 1 つで、公開鍵はそこから計算される。RSA は逆に公開側の e が先に決まり、素数 2 つから d を求める。共通するのは公開鍵から秘密鍵が復元できないこと
- パスフレーズは鍵ファイルの暗号化であり、署名の計算とは無関係。守れるのはファイルが持ち出された場合までで、強さはパスフレーズの質と保存形式の両方で決まる
コメント
…