プログラミング
破損したビットコインアドレスの復旧 (2024)
Recovering garbled Bitcoin addresses (2024) (purplesyringa.moe)
要約
ZeroNetではサイトのアドレスやコンテンツの署名にビットコインアドレスが使用されていましたが、大文字・小文字の区別やタイプミスによりアドレスが破損し、サイトへのアクセスが失われる問題が発生しました。この記事では、ビットコインアドレスの構造とbase58エンコーディング、チェックサムの仕組みを解説し、破損したアドレスから元の有効なアドレスを復旧するためのPythonとRustを用いた試みを紹介しています。最終的には、自動化された高速な復旧アルゴリズムの開発を目指しています。
全文翻訳
破損したビットコインアドレスの復旧
2024年4月23日
Telegram
この記事には多くのコードスニペットが含まれています。それらはリポジトリからダウンロードできます。完成したプロジェクトはこちらで利用可能です。
昔々、ZeroNetという分散型ネットワークがありました。後に登場したIPFSのような一般的なコンテンツアドレス指定型ストレージネットワークとは異なり、ZeroNetはブログやフォーラムのように、所有者がリアルタイムで更新できる動的なサイトを可能にしました。その結果、サイトは不変のハッシュでアドレス指定できなくなりました。しかし、リード開発者は新しい暗号化技術を発明したくなかったので、おそらく最も賢明な決断を下しました。サイトはビットコインアドレスでアドレス指定され、そのコンテンツと更新はそのアドレスで署名されました。
ビットコインを使ったことがない方のために、そのアドレスは以下のようになります: 1Lbcfr7sAHTD9CgdQo3HTMTkV8LK4ZnX71。これに対し、ウェブ上の典型的なドメイン名は次のようになります: purplesyringa.moe。主な違いは、ビットコインアドレスは大文字と小文字を区別するのに対し、人々はアドレスが大文字と小文字を区別しないことに慣れていることです。これにより、http://1Lbcfr7sAHTD9CgdQo3HTMTkV8LK4ZnX71.zero の代わりに http://zero/1Lbcfr7sAHTD9CgdQo3HTMTkV8LK4ZnX71 を使用するようなハックが発生しましたが、それでも間違いは起こり、時には唯一の手がかりが小文字のアドレス、例えば 1lbcfr7sahtd9cgdqo3htmtkv8lk4znx71 になることがあります。そして、このようにして貴重な情報が失われるのです。
私は当時ZeroNetのアーカイブ作業をしていたので、人的ミスによる情報損失は修正する価値のある迷惑な問題でした。小文字のアドレスしか知らない場合、本当にそのサイトへのアクセスを失ったのでしょうか?元の С
アドレスを何とかして復旧することはできるのでしょうか?
アドレスとは何か?
ビットコインアドレスは公開鍵のエンコーディングであるという一般的な誤解があります。これは事実ではありません。代わりに、アドレスには公開鍵のハッシュのエンコーディングが含まれています。これはUX上の決定です。ハッシュは公開鍵よりも短いため使いやすいですが、セキュリティに影響がない程度には十分長いです。
サトシが他のUX上の考慮事項も行っていたことが判明しました。ビットコインアドレスは、公開鍵のハッシュだけでなく、チェックサムもエンコードしています。通貨を送金中にターゲットアドレスをタイプミスした場合、ビットコインクライアントはそれに気づき、トランザクションをキャンセルします。この保護はIBANが使用するものよりもはるかに信頼性が高いです。チェックサムはSHA256(SHA256(key_hash))の最初の4バイトです。
最後の重要な部分は、エンコーディングがあなたの好きなbase64ではなくbase58であることです。base58の違いは、0、O、I、lの文字が混同されやすいためエンコーディングから除外されていること、そして+と/がURIセーフではなく(潜在的に紛らわしい)ため除外されていること、そして=がパディングに無用であるため除外されていることです。
ここに興味深い図があります:
秘密鍵(ランダムな32バイト)はECDSAマジックを介して公開鍵(33バイト)にマッピングされます。次に、公開鍵はSHA-256とRIPEMD-160を介してハッシュ化され、20バイトの文字列になります。1バイトのアドレスタイプ(0x00)をハッシュの前に付けると、21バイトのペイロードが生成されます。このペイロードはSHA-256で2回ハッシュ化され、最初の4バイトがチェックサムとして使用されます。ペイロードとチェックサムが連結され、25バイトのデコードされたアドレスが生成されます。このアドレスはbase58でエンコードされ、人間が読める文字列(平均約34文字)になります。
秘密鍵(ランダムな32バイト)はECDSAマジックを介して公開鍵(33バイト)にマッピングされます。次に、公開鍵はSHA-256とRIPEMD-160を介してハッシュ化され、20バイトの文字列になります。1バイトのアドレスタイプ(0x00)をハッシュの前に付けると、21バイトのペイロードが生成されます。このペイロードはSHA-256で2回ハッシュ化され、最初の4バイトがチェックサムとして使用されます。ペイロードとチェックサムが連結され、25バイトのデコードされたアドレスが生成されます。このアドレスはbase58でエンコードされ、人間が読める文字列(平均約34文字)になります。
最初の試み
すべての小文字/大文字の組み合わせを総当たりで試して、有効なものがあるか確認できますか?それを試してみましょう。
import base58
import itertools
address_lowercase = "1lbcfr7sahtd9cgdqo3htmtkv8lk4znx71"
def try_both_cases(c):
yield c
if c.upper() != c:
yield c.upper()
for address in itertools.product(*map(try_both_cases, address_lowercase)):
address = "".join(address)
try:
base58.b58decode_check(address)
except ValueError:
pass
else:
print("Found valid address:", address)
$ time python3 attempt1.py
^CTraceback (most recent call last):
File "/home/purplesyringa/btccaserestore/attempt1.py", line 16, in <module>
base58.b58decode_check(address)
File "/home/purplesyringa/.local/lib/python3.11/site-packages/base58/__init__.py", line 152, in b58decode_check
result = b58decode(v, alphabet=alphabet, autofix=autofix)
File "/home/purplesyringa/.local/lib/python3.11/site-packages/base58/__init__.py", line 128, in b58decode
acc, mod = divmod(acc, 256)
^^^^^^^^^^^^^^^^
KeyboardInterrupt
real 1m47.770s
user 1m47.301s
sys 0m0.027s
2回目の試み
Pythonを使ったのは間違いでした。宇宙の熱的死を迎える前に終了しないでしょう。Rustで書き直しましょう。
use base58::FromBase58;
use itertools::Itertools;
use sha2::{digest::Update, Digest, Sha256};
fn main() {
let address_lowercase = "1lbcfr7sahtd9cgdqo3htmtkv8lk4znx71";
let addresses = address_lowercase
.bytes()
.map(|byte| {
if byte.to_ascii_uppercase() != byte {
vec![byte, byte.to_ascii_uppercase()]
} else {
vec![byte]
}
})
.multi_cartesian_product();
for address in addresses {
let address = String::from_utf8(address).unwrap();
let Ok(decoded_address) = address.from_base58() else { continue; };
if decoded_address.len() != 25 { continue; }
let round1 = Sha256::new().chain(&decoded_address[..21]).finalize();
let round2 = Sha256::new().chain(round1).finalize();
if decoded_address[21..] == round2[..4] {
eprintln!("Found valid address: {address}");
}
}
}
$ time cargo run --bin attempt2 --release
Finished `release` profile [optimized] target(s) in 0.01s
Running `target/release/attempt2`
Found valid address: 1Lbcfr7sAHTD9CgdQo3HTMTkV8LK4ZnX71
real 0m34.296s
user 0m34.275s
sys 0m0.013s
やった!これはまさに私たちが始めたアドレスです。
問題解決?まあ、はい、しかし私の場合は、アドレス復旧は自動化されたプロセスの一部でした。つまり、入力が完全にゴミであるかどうかさえ確信が持てません。
1hell0w0rldd9cgdqo3htmtkv8lk4znx71 は有効なビットコインアドレスに復元できるでしょうか?多分!誰かが実際にそのアドレスを使っていますか?知りません!彼らはVanitygenで運が良かったのかもしれません。
1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa を復旧するためにCPU時間を費やすべきでしょうか?おそらくそうではありませんが、偽陰性を避けながら自動的にチェックするにはどうすればよいでしょうか?
そして、これが最も高速な復旧アルゴリズムへの旅が始まった理由です。
3回目の試み
プロセスの最初のステップはbase58のデコードです。角を削ることができるかどうかを確認するために、base58エンコーディングが実際に行うことを調べてみましょう。
任意のバイト文字列から始めます。例: 00 d6 f6 4e e7 83 6a cf 6e 5a 93 7d 63 54 c3 a5 96 cd 24 2d fc 2f 78 fa 7c (簡潔にするために16進数で表現)。
このバイト文字列をビッグエンディアンの長い数値として解釈します: 5270856372487448678887896392566731007782045065082238990972。
次に、数値を基数58でエンコードします: 19, 34, 35, 38, 49, 6, 50, 9, 16, 26, 12, 8, 11, 39, 36, 23, 46, 2, 16, 26, 20, 26, 43, 28, 7, 19, 18, 3, 32, 45, 30, 6, 0。
この変換プロセスは、先頭のゼロバイトの数を保持しません(例: 00 ff と ff は、1つの基数58シーケンス、すなわち 4, 23 にマッピングされます)。そのため、バイト文字列からのすべての先頭ゼロを基数58表現に追加します。バイト文字列にはゼロバイトが1つだけあったので、0, 19, 34, 35, 38, 49, 6, 50, 9, 16, 26, 12, 8, 11, 39, 36, 23, 46, 2, 16, 26, 20, 26, 43, 28, 7, 19, 18, 3, 32, 45, 30, 6, 0 と記述します。
最後に、数値0-57をシーケンス 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz の対応する文字に置き換えます。例えば、0 は 1 にマッピングされ、57 は z にマッピングされます: 1Lbcfr7sAHTD9CgdQo3HTMTkV8LK4ZnX71。
逆に、デコードプロセスは次のようになります。
アルファベットの文字を数値0-57に置き換えます。
ビッグエンディアンの長い数値を基数58からデコードします。