はじめに
📝 本記事は、ブロックチェーンの仕組みを扱ってきたシリーズの続きです。これまでに「ブロックチェーンの『ブロック』と『チェーン』って結局なに?ハッシュで図解する」でデータ構造としての改ざん検知を、「ブロックチェーンのPoW(Proof of Work)って結局なに?nonceを探して図解する」で改ざん防止の仕組みを、前回「ブロックチェーンの最長チェーンと51%攻撃って結局なに?2つのノードで図解する」で管理者のいないネットワークが1本のチェーンに合意する仕組みを扱いました。
前回、51%攻撃で何ができて何ができないのかを整理しました。そこで「他人のコインを盗むことはできない」と書き、その理由を「取引には、その持ち主の秘密鍵による電子署名が必要です」とだけ説明しています。電子署名そのものには、まだ踏み込んでいません。
同時に、これまでのミニ実装には、もう1つ単純化が残ったままです。ブロックに載せる取引を "AがBに100円送った" というただの文字列にしていました。この文字列は、A 本人でなくても書けます。ハッシュで改ざんを検知し、PoW で書き換えを高コストにし、最長チェーンルールで1本に合意しても、最初から偽の取引を書き込む ことは、これだけでは防げません。
この穴を塞いでいるのが電子署名です。学習していて手こずったのは、「秘密鍵」「公開鍵」という言葉自体よりも、どちらで何をするのか、そしてなぜそれで本人だと分かるのかという点でした。本記事では、電子署名を最小の形で自作しながらこの部分を整理します。使うのはハッシュ関数だけです。
この記事を読むとわかること:
- なぜ「AがBに100円送った」という文字列だけでは所有権を守れないのか(なりすましの問題)
- 秘密鍵と公開鍵のペアがそれぞれ何をしているのか、署名と暗号化は何が違うのか
- ハッシュの一方向性だけで電子署名を組み立てられること(Lamport 署名)
- ウォレットが保管しているのはコインではなく何なのか
前提知識として、「ハッシュは一方向で、入力を1文字変えると出力が全く別物になる」という性質だけを使います。なお本記事では、楕円曲線暗号の数学的な中身や、公開鍵暗号による暗号化そのものには踏み込みません。「取引の所有権が、どうやって守られているのか」に集中します。
「AがBに100円送った」は誰でも書ける
これまでのミニ実装で、ブロックが持っていた取引は "AがBに100円送った" という1つの文字列でした。説明を単純にするための割り切りでしたが、この単純化には見過ごせない穴が開いています。
取引の文字列は、本人以外にも書ける
ここで、A になりすまして取引を偽造しようとする第三者を Z と呼ぶことにします。Z がブロックに "AがZに1000円送った" と書き込んでも、これまでの仕組みはそれを止められません。文字列はただの文字列であり、それを打ち込んだのが A なのか Z なのかという情報は、どこにも含まれていないからです。
ノードの検証は、取引の中身を見ていない
前回、各ノードは受け取ったブロックを自分で検証してから受け入れる、と整理しました。検証の内容は3つでした。ハッシュが整合しているか、PoW を満たしているか、前のブロックと正しく繋がっているか。
Z が書いた偽の取引は、この3つをすべて通過します。Z が自分で nonce を回して PoW を解き、直前のブロックのハッシュを正しく参照してブロックを作れば、そのブロックは形式として完全に正当だからです。
図1: これまでの検証をすり抜ける偽の取引
ここまでに積み上げてきた3つの仕組みが守っているのは、あくまで 記録されたあとの履歴 です。ハッシュで繋ぐデータ構造は書き換えを検知し、PoW はその書き換えを高コストにし、最長チェーンルールはどの記録を正とするかを管理者なしで決めます。いずれも「その取引を書いたのが誰か」には関与しません。
そのため、偽の取引であっても、いったんチェーンに取り込まれてしまえば、その後は他の取引と同じようにハッシュと PoW で頑丈に守られます。間違った記録が、正しい記録と同じ強度で保存される ということです。
合言葉を添えるだけでは解決しない
素直に思いつく対策は、取引に「A だけが知っている合言葉」を添えることでしょう。A が "AがBに100円送った(合言葉: ...)" と書き、ノードはその合言葉が正しいかどうかを確かめる、という方式です。
しかしこれは成立しません。ブロックチェーンは、誰でも中身を読める公開された台帳です。A が合言葉を書き込んだ瞬間、それはネットワーク全体に見えてしまいます。Z はそれを読み取り、同じ合言葉を添えて "AがZに1000円送った" を作れます。
問題の所在がはっきりしました。必要なのは、次の2つを同時に満たす仕組みです。
- 公開しても、他人がそれを使い回せない
- それでいて、誰でもそれが本物かどうかを確かめられる
作る側と確かめる側で、できることが違う。この非対称性を実現しているのが 電子署名 です。
鍵ペアと電子署名の3つの性質
前章で必要になったのは、「作れるのは本人だけ、確かめるのは誰でも」という非対称な仕組みでした。これを支えているのが 鍵ペア です。
対になった2つの鍵
電子署名では、1人が2つで1組の鍵を持ちます。
- 秘密鍵(private key): 本人だけが持ち、誰にも見せない値
- 公開鍵(public key): 秘密鍵から計算される値。誰に見せてもよい
この2つには決定的な向きがあります。秘密鍵から公開鍵はすぐ計算できますが、公開鍵から秘密鍵を逆算することは現実的にできません。
図2: 鍵ペアには向きがある
この「片方向にしか進めない」という性質は、これまで見てきたハッシュ関数とまったく同じ発想です。実際、本記事で作る署名は、その一方向性だけを材料にしています。
秘密鍵を持つのは A だけなので 秘密鍵を使う操作は A にしかできず、公開鍵は全世界に配って構わないので 公開鍵を使う操作は誰にでもできます。前章で求めた非対称性が、鍵の持ち方そのものから生まれます。
署名は「暗号化」ではない
公開鍵と秘密鍵を説明するとき、「秘密鍵で暗号化して、公開鍵で復号する」という言い回しをよく見かけます。この理解は整理し直す必要があります。
暗号化と電子署名は、目的も、どちらの鍵を使うかも異なります。
| 公開鍵暗号による暗号化 | 電子署名 | |
|---|---|---|
| 目的 | 中身を本人以外に読めなくする | 本人が作ったことを示す |
| 作るときに使う鍵 | 受け取る人の公開鍵 | 署名する人の秘密鍵 |
| 確かめる/戻すときに使う鍵 | 受け取る人の秘密鍵 | 署名した人の公開鍵 |
| 中身は読めるか | 読めない | 読める(署名は別のデータとして添える) |
ブロックチェーンの取引は、中身を隠すためのものではありません。"AがBに100円送った" という内容は、誰でも読めるまま台帳に記録されます。署名がやっているのは、その内容に 「これは A が作った」という証拠を1枚添えること です。
「秘密鍵で暗号化する」という説明が広まっているのは、RSA という特定の方式で、署名を作る計算が暗号化の計算とよく似た形になるためです。他の方式ではこの対応は成り立たず、本記事で作る署名にも暗号化にあたる操作は1つも出てきません。
電子署名が満たす3つの性質
電子署名は、次の3つを同時に満たします。
- 本人しか作れない: 署名を作るには秘密鍵が要る。秘密鍵を持たない Z は、A の公開鍵で検証を通る署名を作れない
- 誰でも検証できる: 検証に必要なのは公開鍵だけ。秘密鍵は検証には一切使わない。ネットワークのどのノードも、それぞれ独立に確かめられる
- 内容に紐づく: 署名は取引の内容ごとに作られる。取引を1文字でも変えると、その署名は検証を通らなくなる
3つ目が効いてくるのは、署名だけを他の取引に貼り替えるという攻撃を防ぐ点です。A の署名を Z が読み取れたとしても、それは "AがBに100円送った" に対する署名でしかなく、"AがZに1000円送った" に付け替えると検証で弾かれます。署名は公開されるが、他人が使い回せない という前章の要求は、ここで満たされます。
図3: 署名を作るのは本人だけ、確かめるのは誰でも
性質を並べただけでは、この3つを同時に満たすものが本当に作れるのかは見えてきません。次章では、ハッシュ関数だけを材料にして、実際にこの3つを満たす署名を組み立てます。
ハッシュの一方向性で署名を作る
ここからは、実際に署名を組み立てます。使うのは Lamport 署名 という方式です。1979年に Leslie Lamport が示したもので、材料は一方向のハッシュ関数だけです。現実のブロックチェーンが使っている方式とは異なりますが、電子署名の3つの性質がどこから出てくるのかを見るには、これ以上ないほど素直な形をしています。
まず1ビットだけ署名する
いきなり取引全体を署名する代わりに、署名したい情報が「0か1か」の1ビットだけ という世界から始めます。
A は、あらかじめ次の準備をしておきます。
- 秘密鍵: 何の意味も持たないランダムな値を2つ用意する。ビットが0のときに使う値と、1のときに使う値。ここでは s0、s1 と呼びます
- 公開鍵: s0 と s1 をそれぞれハッシュした値。ここでは h0、h1 と呼びます。この2つは先に公開しておきます
図4: 1ビット分の鍵ペア
署名は、拍子抜けするほど単純です。署名したいビットが0なら s0 を、1なら s1 を公開する。それだけです。公開されたその値が署名になります。
検証も同じくらい単純です。受け取った値をハッシュして、公開鍵の該当する側と一致するかを見ます。ビットが1なら、ハッシュした結果が h1 と一致すれば合格です。
この一往復に、3つの性質のうち2つがすでに現れています。h1 から s1 を逆算することはできないので、s1 を明かせるのは、最初にそれを作った A だけ です。そして検証に必要なのは公開鍵とハッシュ計算だけなので、誰でも確かめられます。
256ビットに広げる
1ビットの署名ができれば、あとは繰り返すだけです。
取引を SHA-256 でハッシュすると、長さ256ビットの0と1の並びになります。この 1ビットごとに、さきほどの手順をそのまま適用 します。
- 秘密鍵: 256ビット分 × (0用, 1用) で、ランダムな値が512個
- 公開鍵: その512個をそれぞれハッシュした値
- 署名: 各ビットに応じて選んだ秘密値256個の並び。残る256個は伏せたまま
図5: 取引のハッシュのビット列に沿って、片方だけを明かす
3つ目の性質である「内容に紐づく」は、この構造から自動的に出てきます。取引を1文字でも変えると、ハッシュの雪崩効果によってビット列が別物になります。ビット列が変われば 明かすべき秘密値の組み合わせも変わる ため、元の取引に対して作った署名は、変更後の取引では検証を通りません。
なぜ他人には作れないのか
Z が "AがZに1000円送った" に A の署名を付けるには、その取引のハッシュのビット列に対応した A の秘密値256個が必要です。Z が手に入れられるのは、公開鍵(= ハッシュ済みの値)と、A が過去に公開した署名だけで、後者から流用できるのはビットがたまたま一致している位置に限られます。残りの位置は、公開鍵からハッシュを逆算するしかありません。SHA-256 の出力から入力を求める試行は平均して 2 の 255 乗回に達し、桁として現実離れしています。
ここまでで3つの性質が揃いました。次章では、これを実際に動かします。
Python によるミニ実装
前章の手順を、そのまま実装します。使うのは標準ライブラリの hashlib と secrets だけで、外部の暗号ライブラリは使いません。
なお、秘密鍵はランダムな値なので、以下に載せる鍵・署名・アドレスの具体的な値は 実行のたびに変わります。一方、取引のハッシュ・ビット列・鍵と署名のサイズ・検証結果は、何度実行しても同じになります。
取引をビット列にする
まず、取引をハッシュして256ビットの0と1の並びに変換します。ここが署名の対象になります。
import hashlib
import secrets
BITS = 256 # SHA-256 のハッシュは256ビット。その1ビットごとに署名する
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def message_bits(message: str) -> str:
# メッセージをSHA-256でハッシュし、256桁の2進数の文字列にする
digest = hashlib.sha256(message.encode()).hexdigest()
return bin(int(digest, 16))[2:].zfill(BITS)
実行結果(準備):
=== 準備: 署名したい取引をハッシュにする ===
取引: 'AがBに100円送った'
hash: 62feca2238ff...
ビット列(先頭16ビット): 0110001011111110 ...(全256ビット)
シリーズを通して使ってきた "AがBに100円送った" が、0110001011111110... という256ビットの並びになりました。この先頭が 0, 1, 1 と続いている点は、あとで署名の中身と対応します。
鍵ペアを作る
秘密鍵は、ビットごとに用意した2つの乱数です。公開鍵は、その各値をハッシュしたものになります。
def generate_keys() -> tuple[list, list]:
# 秘密鍵: ビットごとに「0用」「1用」の2つの乱数を用意する
private_key = [(secrets.token_bytes(32), secrets.token_bytes(32)) for _ in range(BITS)]
# 公開鍵: 秘密鍵の各値をハッシュしたもの。ここから秘密鍵には戻せない
public_key = [(sha256(zero), sha256(one)) for zero, one in private_key]
return private_key, public_key
公開鍵の生成は、秘密鍵の各値にハッシュ関数をかけるだけです。これが前章の「秘密鍵から公開鍵は計算できるが、逆はできない」という向きの正体になります。secrets は暗号用途の乱数を生成する標準モジュールで、random を使わない理由は本章の末尾で扱います。
実行結果(ステップ1):
=== ステップ1: A が鍵ペアを作る ===
秘密鍵: 256ビット分 × (0用, 1用) の乱数 = 512個(16,384バイト)
公開鍵: その各値のハッシュ = 512個(16,384バイト)
1ビット目の秘密鍵: 0用=91a3827d30e0... 1用=e944487e7bb5...
1ビット目の公開鍵: 0用=10cb0d5fef78... 1用=5793f8e6bc24...
秘密鍵も公開鍵も16,384バイト、つまり16KB あります。鍵としてはかなり大きい部類です。この大きさは Lamport 署名の弱点の1つで、後の章であらためて取り上げます。
署名する
署名は、ビットに応じて「0用」「1用」のどちらか片方の秘密値を選び、それを並べたものです。
def sign(message: str, private_key: list) -> list[str]:
# ビットが0なら「0用」、1なら「1用」の秘密値だけを明かす
bits = message_bits(message)
return [private_key[i][int(bit)].hex() for i, bit in enumerate(bits)]
private_key[i][int(bit)] の部分が、前章の「片方だけを明かす」に対応します。ビットの "0" / "1" をそのまま添字に使い、選ばなかったほうの値には触れません。
実行結果(ステップ2):
=== ステップ2: A が秘密鍵で署名する ===
署名: 明かした秘密値 256個(8,192バイト)
1ビット目=0 → 0用の秘密値を公開: 91a3827d30e0...
2ビット目=1 → 1用の秘密値を公開: 125758c98032...
3ビット目=1 → 1用の秘密値を公開: 20eb7230aca2...
(残りの秘密値は伏せたまま)
1ビット目に公開された値 91a3827d30e0... は、ステップ1で表示された「1ビット目の秘密鍵・0用」と同じ値 です。署名とは該当する秘密値を明かす行為そのものである、ということが出力に現れています。
明かされたのは256個で、残る256個は伏せられたままです。秘密鍵512個のうち、ちょうど半分だけが公開された状態になります。
検証する
検証は、明かされた値をハッシュして、公開鍵の同じ位置と一致するかを確かめる作業です。
def verify(message: str, signature: list[str], public_key: list) -> bool:
# 明かされた値をハッシュし、公開鍵の同じ位置と一致するか確かめる
bits = message_bits(message)
for i, bit in enumerate(bits):
if sha256(bytes.fromhex(signature[i])) != public_key[i][int(bit)]:
return False
return True
秘密鍵はどこにも登場しません。必要なのは、取引の内容と、署名と、公開鍵の3つだけです。この関数はネットワークの誰でも実行できます。
ここまでの部品を使って、署名から検証までを一通り動かします。
MESSAGE = "AがBに100円送った"
TAMPERED = "AがBに10000円送った"
# --- ステップ1: A が鍵ペアを作る ---
private_key, public_key = generate_keys()
# --- ステップ2: A が秘密鍵で署名する ---
signature = sign(MESSAGE, private_key)
# --- ステップ3: 誰でも公開鍵で検証できる ---
ok = verify(MESSAGE, signature, public_key)
実行結果(ステップ3):
=== ステップ3: 誰でも公開鍵で検証できる ===
verify(取引, 署名, A の公開鍵) → True
「本人しか作れない」「誰でも検証できる」の2つが、これで動く形になりました。残るは3つ目の「内容に紐づく」です。
内容を変えると検証が落ちる
取引の金額を書き換えて、署名はそのまま で検証してみます。前々回・前回の改ざんデモと同じく、100円を10000円に変えます。
# --- ステップ4: 取引の内容を変えて、同じ署名で検証する ---
tampered_ok = verify(TAMPERED, signature, public_key)
実行結果(ステップ4):
=== ステップ4: 取引の内容を変えて、同じ署名で検証する ===
改ざん後の取引: 'AがBに10000円送った'
hash: f612d0c06a73...
ビット列(先頭16ビット): 1111011000010010 ...(256ビット中 120ビットが変化)
verify(改ざん後の取引, 署名, A の公開鍵) → False
0 を2文字足しただけで、ハッシュは 62feca2238ff... から f612d0c06a73... へ全く別の値になり、ビット列は256ビット中120ビットが変化しました。おおよそ半分です。
変化した120か所では、検証時に参照される公開鍵が「0用」と「1用」で入れ替わります。手元の署名が持っているのは変更前の組み合わせなので、ハッシュしても一致しません。検証は False になります。
他人にはなりすませない
最後に、Z が A になりすまして署名を作ろうとする場合です。Z は自分の鍵ペアしか持っていないので、自分の秘密鍵で署名を作るしかありません。
# --- ステップ5: Z が A になりすまして署名を作ろうとする ---
z_private_key, z_public_key = generate_keys()
forged = sign(MESSAGE, z_private_key)
forged_ok = verify(MESSAGE, forged, public_key)
実行結果(ステップ5):
=== ステップ5: Z が A になりすまして署名を作ろうとする ===
Z は自分の秘密鍵で署名を作る: 77349eb72491...
verify(取引, Z の署名, A の公開鍵) → False
→ A の秘密鍵を持たない限り、A の公開鍵で検証を通る署名は作れない
取引の内容そのものは正しい "AがBに100円送った" のままです。それでも、A の公開鍵で検証すると False になります。Z が明かした値をハッシュしても、A の公開鍵とは無関係な値にしかならないからです。
前章で触れたとおり、Z に残された道は、A の公開鍵からハッシュを逆算して秘密値を求めることだけです。そのための試行回数は現実離れした桁になります。
乱数の質が安全性を支える
実装で1つだけ、見落とすと台無しになる点があります。秘密鍵の生成に使う乱数です。
random が生成するのは疑似乱数で、内部状態を推測されると以降の出力を再現されてしまいます。秘密鍵が再現できるということは、他人が同じ署名を作れるということです。secrets は、この用途のために OS の乱数源を使います。
ハッシュの一方向性がどれだけ強くても、その手前で秘密鍵が推測できてしまえば意味がありません。署名の安全性は、鍵の作り方にも同じだけ依存しています。
部品として掲載したコード(ビット列化から検証まで)を合わせて、空行を除けば26行です。これだけで、「本人しか作れない」「誰でも検証できる」「内容に紐づく」の3つが揃った署名が動きました。
アドレスとウォレット
署名が手元で動くようになったので、これをブロックチェーンに戻します。
ノードの検証に4つ目が加わる
これまでブロックに載せていたのは取引の文字列だけでした。署名を導入すると、ブロックには 取引の内容・署名・署名した人の公開鍵 の3つが一緒に入ります。署名もブロックのハッシュ材料に含まれるため、あとから署名だけを差し替えれば、前々回で見たとおりハッシュがずれて検知されます。
そして、ノードがブロックを受け入れるときの検証に、4つ目の項目が加わります。
図6: 署名の検証が加わると、偽の取引は通らない
図1で素通りしていた偽の取引が、ここで止まります。しかも止めているのは中央の審査機関ではなく、取引を受け取った各ノードが手元で行う数十行の計算です。
前回の51%攻撃で「他人のコインを盗むことはできない」と書いた理由も、これで説明がつきます。過半数の計算力が左右できるのは、どのブロックが正しいチェーンに残るか までです。署名の検証は計算力とは無関係に、各ノードが独立して行います。A の秘密鍵を持たない攻撃者は、どれだけ計算力を積んでも、A の公開鍵で検証を通る取引を作れません。作れないものは、チェーンに載せる以前の段階で弾かれます。
計算力による防壁と、署名による防壁は、別々の層として働いています。
アドレスは公開鍵をハッシュしたもの
取引の宛先には、公開鍵そのものではなく、それをハッシュした短い値を使います。これが アドレス です。
def address(public_key: list) -> str:
# 公開鍵全体をハッシュし、先頭20バイトをアドレスとする
joined = "".join(zero + one for zero, one in public_key)
return sha256(joined.encode())[:40]
実行結果(参考):
=== 参考: 公開鍵をハッシュしてアドレスにする ===
A のアドレス: ddd49154bafc7490e08519bb44c87324ca0fed50
Z のアドレス: a762f756d4b6be87c2b776aa2017f0d050c00237
16,384バイトあった公開鍵が、20バイトの短い識別子になりました。ハッシュを挟む利点は2つあります。宛先として扱える長さに収まることと、使うまで公開鍵そのものを晒さずに済む ことです。実際の Bitcoin ではさらに別のハッシュ関数を重ね、打ち間違いを検出できる符号化を施したうえで表示しています。
「アドレス宛に送る」という操作の中身は、そのアドレスの元になった公開鍵に対応する秘密鍵の持ち主だけが、後でそれを動かせる状態にする ことです。宛先の指定と、使用権限の指定が、同じ1つの値で表されています。
ウォレットが保管しているのは鍵
ここまで来ると、ウォレットという言葉の意味も置き換わります。
コインは、ウォレットの中には入っていません。誰がいくら持っているかは、すべてブロックチェーン上の取引の記録として存在します。その記録は公開されていて、誰でも読めます。
ウォレットが保管しているのは 秘密鍵 です。そこから公開鍵とアドレスが導かれます。
図7: 残高はチェーンにあり、ウォレットにあるのは鍵
残高を 読む ことは誰にでもできて、それを 動かす ことは秘密鍵を持つ人にしかできません。ウォレットは財布というより、金庫の鍵をしまっておく鍵束に近い存在です。
鍵を失ったとき、鍵が漏れたとき
秘密鍵と資産がこう結びついていると、鍵に起きる事故がそのまま資産に直結します。しかも、失う場合と漏らす場合で、起きることの性質が異なります。
秘密鍵を失った場合、チェーン上の記録は消えません。そのアドレスの残高は全世界から見え続け、それでいて動かすための署名を作れる人がいなくなります。管理者が存在しないので、本人確認をして再発行してくれる窓口もありません。非中央集権という設計の裏返しが、そのまま現れる場面です。
秘密鍵が漏れた場合、それを手に入れた相手は正当な署名を作れます。ネットワークから見て、その取引は本人が作ったものと区別がつきません。検証はすべて通り、他のノードは正常な取引として受け入れます。前回見たとおり、いったんチェーンに取り込まれた取引を後から取り消すことは極めて困難です。
取引所などのサービスに資産を預ける場合、秘密鍵を保管しているのは利用者ではなく、その事業者です。「自分の鍵でなければ、自分のコインではない」という言い回しは、この構図を指しています。
ミニ実装と現実の違い
Lamport 署名で3つの性質は再現できましたが、現実のブロックチェーンはこの方式を使っていません。差を整理しておきます。
| 項目 | 本記事のミニ実装 | 現実の Bitcoin など |
|---|---|---|
| 署名方式 | Lamport 署名(ハッシュのみ) | ECDSA(楕円曲線 secp256k1) |
| 秘密鍵のサイズ | 16,384バイト | 32バイト |
| 公開鍵のサイズ | 16,384バイト | 33バイト(圧縮形式) |
| 署名のサイズ | 8,192バイト | 70バイト前後 |
| 同じ鍵で署名できる回数 | 1回だけ | 何度でも |
| アドレスの作り方 | 公開鍵のハッシュの先頭20バイト | ハッシュを重ね、誤り検出つきの形式に符号化 |
| 鍵の管理 | 変数に置くだけ | 1つの種から多数の鍵を導く |
決定的な違いは2つです。
1つは サイズ です。ミニ実装の署名1つは8KB あり、これは取引1件あたりのデータとしては大きすぎます。ECDSA の署名は70バイト前後なので、100分の1以下に収まります。ブロックに入る取引数がそのまま処理能力に直結する以上、この差は無視できません。
もう1つは 同じ鍵を何度使えるか です。Lamport 署名では、1回署名するたびに各ビットで片方の秘密値が露出します。同じ鍵で2つ目の取引に署名すると、ビットが食い違う位置では0用と1用の両方が公開されてしまいます。両方が揃った位置は、第三者が0にも1にもできる状態になり、公開済みの署名を組み替えて別の取引に対する有効な署名を作られる余地が生まれます。このため Lamport 署名は 一度限りの署名(one-time signature) と呼ばれ、取引のたびに新しい鍵ペアを用意する必要があります。ECDSA には、この制約がありません。
📝 ハッシュ関数だけで組み立てる署名は、過去のものになったわけではありません。楕円曲線を使う方式は、量子計算機が実用化された場合に破られる可能性が指摘されている一方、ハッシュを基礎とする方式はその影響を受けにくいと考えられています。耐量子暗号の候補として研究されている署名方式のいくつかは、本記事で作った Lamport 署名を出発点にした発展形です。
これらの違いはありますが、秘密鍵を持つ人だけが署名を作れて、公開鍵さえあれば誰でも検証できる という骨格は、どの方式でも変わりません。本記事のミニ実装は、そこからサイズと再利用性の工夫を削った最小版にあたります。
ここまでで触れなかったこと
本記事は「所有権がどう守られているのか」に絞ったため、以下は意図的に省きました。いずれも、電子署名の先に広がるテーマです。
- 楕円曲線暗号の中身: ECDSA が具体的にどう署名を作り、どう検証するのかには踏み込みませんでした。「秘密鍵から公開鍵は計算できるが逆はできない」という向きを、ハッシュではなく楕円曲線上の演算で作っている方式です
- 公開鍵暗号による暗号化: 署名と対になるもう1つの使い方です。鍵の使う向きが逆になり、中身を本人以外に読めなくすることが目的になります
- シードフレーズと鍵の導出: 実際のウォレットは、1つの種となる値から多数の鍵ペアを規則的に導きます。バックアップとして単語の並びを控えるのは、この種を保存する操作にあたります
- 取引の条件づけ: 複数人の署名が揃わないと動かせないマルチシグや、残高をどう数えるか(UTXO モデル)など、署名の上に載る仕組みがあります
まとめ
ここまでの内容をまとめると、
- 取引の文字列は誰でも書ける。ハッシュで繋ぐデータ構造も、PoW も、最長チェーンルールも、守っているのは記録されたあとの履歴であり、それを誰が書いたかには関与しない
- 鍵ペア は、秘密鍵から公開鍵は計算できるが逆はできないという向きを持つ。この向きが「作れるのは本人だけ、確かめるのは誰でも」という非対称性を生む
- 電子署名 は3つの性質を同時に満たす。本人しか作れない、誰でも検証できる、内容に紐づく。3つ目があるため、署名を別の取引に貼り替えることもできない
- Lamport 署名 は、ハッシュの一方向性だけでこの3つを実現する。ビットごとに2つの秘密値を用意し、片方だけを明かすという構造から、3つの性質がそのまま導かれる
- アドレス は公開鍵をハッシュした識別子で、ウォレット が保管しているのはコインではなく秘密鍵。残高はチェーン上にあり、読むのは誰にでもできて、動かせるのは鍵の持ち主だけ
「なぜ他人になりすまして取引を作れないのか」という問いに対しては、取引を作る操作にだけ秘密鍵が要り、それを確かめる操作には公開鍵しか要らないから という答えになります。この非対称性があるため、検証はネットワーク全体で分担でき、偽造だけが本人に限定されます。
ブロックチェーンが「なぜ改ざんできないのか」を、ハッシュで繋ぐデータ構造(改ざんの検知)、PoW(改ざんの防止)、多数のノードによる合意(正しい1本の決定)、そして本記事の 電子署名(なりすましの防止) という4つの角度から見てきました。前の3つが記録の履歴を守るのに対し、電子署名は記録が生まれる入り口を守っています。層が違うため、どれか1つでは成り立たず、重なって初めて資産を扱える仕組みになります。
最後までお読みいただきありがとうございました。
シリーズの記事一覧
ブロックチェーンの仕組みを、ミニ実装つきで1つずつ扱ってきたシリーズです。
- ブロックチェーンの「ブロック」と「チェーン」って結局なに?ハッシュで図解する : 改ざんの検知
- ブロックチェーンのPoW(Proof of Work)って結局なに?nonceを探して図解する : 改ざんの防止
- ブロックチェーンの最長チェーンと51%攻撃って結局なに?2つのノードで図解する : 正しい1本の決定
- ブロックチェーンの電子署名とウォレットって結局なに?ハッシュだけで署名を作って図解する(本記事) : なりすましの防止