diff options
| author | Bitflash <btf01@proton.me> | 2026-08-03 16:08:55 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-03 16:08:55 -0300 |
| commit | 9176d5b3f32b907e4bafde5152828b2ae04ae8a1 (patch) | |
| tree | 4963b9d45c17d323c89e80802d77e93aadd7a983 /doc/verify-derivation.py | |
| parent | f6e197573464268174edc6f8d3b0d5c2b194b1ed (diff) | |
doc: write down the derivation, so the words work without us (#128)
The twelve words were only as good as this binary staying alive. The
derivation path, the address version byte and the public-key encoding
existed nowhere outside src/, so anyone holding a phrase and no working
Bitflash had the keys and no way to find them.
doc/derivation.md states all of it: BIP-39 to seed, BIP-32 to children,
both layouts this wallet has used (legacy m/i' and BIP-44
m/44'/4346950'/0'/{0,1}/i), secp256k1 with UNCOMPRESSED public keys, and
Base58Check with version byte 25.
The uncompressed part is the trap worth naming. Hashing the same key in
compressed form yields a different, perfectly valid-looking address that
holds nothing, and nothing in the source says out loud which one is
used -- it falls out of OpenSSL's default in code inherited from 0.1.0.
doc/verify-derivation.py implements the document from scratch, sharing
no code with this project and importing nothing outside the standard
library, then checks it against addresses the wallet actually produced.
Both tables were generated by a binary first: the legacy one by the
published v1.2.15, which knows only that layout, and the BIP-44 one by
the current tree. The script reproduces all eight.
That is the point of shipping the checker rather than only the prose: if
the two ever drift, the script fails and says which side is wrong.
Diffstat (limited to 'doc/verify-derivation.py')
| -rw-r--r-- | doc/verify-derivation.py | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/doc/verify-derivation.py b/doc/verify-derivation.py new file mode 100644 index 0000000..ba8e883 --- /dev/null +++ b/doc/verify-derivation.py | |||
| @@ -0,0 +1,159 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Check doc/derivation.md against the addresses the wallet really produces. | ||
| 3 | |||
| 4 | This implements the document and nothing else: BIP-39 to seed, BIP-32 to child | ||
| 5 | keys, uncompressed secp256k1 public keys, and Base58Check with version byte 25. | ||
| 6 | It shares no code with Bitflash and needs nothing outside the standard library, | ||
| 7 | so agreement between the two is evidence that the document is enough to recover | ||
| 8 | a wallet without this project's software. | ||
| 9 | |||
| 10 | python doc/verify-derivation.py | ||
| 11 | |||
| 12 | Exits non-zero if any address disagrees. | ||
| 13 | """ | ||
| 14 | |||
| 15 | import hashlib | ||
| 16 | import hmac | ||
| 17 | import sys | ||
| 18 | |||
| 19 | MNEMONIC = ("abandon abandon abandon abandon abandon abandon " | ||
| 20 | "abandon abandon abandon abandon abandon about") | ||
| 21 | COIN_TYPE = 4346950 # 0x425446, the ASCII bytes "BTF" | ||
| 22 | ADDRESS_VERSION = 25 # 0x19, so addresses start with "B" | ||
| 23 | HARDENED = 0x80000000 | ||
| 24 | |||
| 25 | LEGACY = [ | ||
| 26 | "BHJNmVvkVv59fgn7gmgP78BtciQi2HY8Hy", | ||
| 27 | "B5qh9opG8sH8Mv9k2xzHoTeSUd9BkT6XEZ", | ||
| 28 | "BCNH8uGyvkiz3uXjhA9iiF27PWAaTStNin", | ||
| 29 | "BC1QNaXuTPGDgLZy53PghZMHEmZcp5GLgD", | ||
| 30 | ] | ||
| 31 | |||
| 32 | BIP44_EXTERNAL = [ | ||
| 33 | "BF1ukigB1afmqxH64gJymPwUqFBrRqWbrb", | ||
| 34 | "BACY8pcuCapNQr897eRHroNrgN38GSsEzV", | ||
| 35 | "BMzkMN2k1phNBqy8RPb4Pv1i2QpwsWkKPa", | ||
| 36 | "BNPZ8U6FHXr9Ererk8psyM2RqEVtF6sfBQ", | ||
| 37 | ] | ||
| 38 | |||
| 39 | # ---- secp256k1, plain and slow on purpose: it should be readable ------------ | ||
| 40 | |||
| 41 | P = 2**256 - 2**32 - 977 | ||
| 42 | N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 | ||
| 43 | G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, | ||
| 44 | 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8) | ||
| 45 | |||
| 46 | |||
| 47 | def point_add(p, q): | ||
| 48 | if p is None: | ||
| 49 | return q | ||
| 50 | if q is None: | ||
| 51 | return p | ||
| 52 | if p[0] == q[0] and (p[1] + q[1]) % P == 0: | ||
| 53 | return None | ||
| 54 | if p == q: | ||
| 55 | lam = 3 * p[0] * p[0] * pow(2 * p[1], P - 2, P) % P | ||
| 56 | else: | ||
| 57 | lam = (q[1] - p[1]) * pow(q[0] - p[0], P - 2, P) % P | ||
| 58 | x = (lam * lam - p[0] - q[0]) % P | ||
| 59 | return (x, (lam * (p[0] - x) - p[1]) % P) | ||
| 60 | |||
| 61 | |||
| 62 | def point_mul(k, p=G): | ||
| 63 | result = None | ||
| 64 | while k: | ||
| 65 | if k & 1: | ||
| 66 | result = point_add(result, p) | ||
| 67 | p = point_add(p, p) | ||
| 68 | k >>= 1 | ||
| 69 | return result | ||
| 70 | |||
| 71 | |||
| 72 | def pub_uncompressed(point): | ||
| 73 | return b"\x04" + point[0].to_bytes(32, "big") + point[1].to_bytes(32, "big") | ||
| 74 | |||
| 75 | |||
| 76 | def pub_compressed(point): | ||
| 77 | return bytes([2 + (point[1] & 1)]) + point[0].to_bytes(32, "big") | ||
| 78 | |||
| 79 | |||
| 80 | # ---- encoding -------------------------------------------------------------- | ||
| 81 | |||
| 82 | def base58check(payload): | ||
| 83 | alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" | ||
| 84 | checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] | ||
| 85 | data = payload + checksum | ||
| 86 | number = int.from_bytes(data, "big") | ||
| 87 | out = "" | ||
| 88 | while number: | ||
| 89 | number, rem = divmod(number, 58) | ||
| 90 | out = alphabet[rem] + out | ||
| 91 | leading_zeros = len(data) - len(data.lstrip(b"\x00")) | ||
| 92 | return "1" * leading_zeros + out | ||
| 93 | |||
| 94 | |||
| 95 | def address(privkey): | ||
| 96 | pub = pub_uncompressed(point_mul(privkey)) | ||
| 97 | h160 = hashlib.new("ripemd160", hashlib.sha256(pub).digest()).digest() | ||
| 98 | return base58check(bytes([ADDRESS_VERSION]) + h160) | ||
| 99 | |||
| 100 | |||
| 101 | # ---- BIP-39 and BIP-32 ----------------------------------------------------- | ||
| 102 | |||
| 103 | def master_from_mnemonic(mnemonic, passphrase=""): | ||
| 104 | seed = hashlib.pbkdf2_hmac("sha512", mnemonic.encode("utf-8"), | ||
| 105 | ("mnemonic" + passphrase).encode("utf-8"), | ||
| 106 | 2048, 64) | ||
| 107 | i = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest() | ||
| 108 | return int.from_bytes(i[:32], "big"), i[32:] | ||
| 109 | |||
| 110 | |||
| 111 | def ckd_priv(key, chain, index): | ||
| 112 | if index >= HARDENED: | ||
| 113 | data = b"\x00" + key.to_bytes(32, "big") | ||
| 114 | else: | ||
| 115 | data = pub_compressed(point_mul(key)) | ||
| 116 | i = hmac.new(chain, data + index.to_bytes(4, "big"), hashlib.sha512).digest() | ||
| 117 | return (int.from_bytes(i[:32], "big") + key) % N, i[32:] | ||
| 118 | |||
| 119 | |||
| 120 | def derive(path): | ||
| 121 | key, chain = master_from_mnemonic(MNEMONIC) | ||
| 122 | for index in path: | ||
| 123 | key, chain = ckd_priv(key, chain, index) | ||
| 124 | return key | ||
| 125 | |||
| 126 | |||
| 127 | # ---- the check ------------------------------------------------------------- | ||
| 128 | |||
| 129 | def main(): | ||
| 130 | failures = 0 | ||
| 131 | |||
| 132 | print("legacy m/i'") | ||
| 133 | for i, expected in enumerate(LEGACY): | ||
| 134 | got = address(derive([i + HARDENED])) | ||
| 135 | ok = got == expected | ||
| 136 | failures += 0 if ok else 1 | ||
| 137 | print(" m/%d' %s %s" % (i, got, "ok" if ok else | ||
| 138 | "MISMATCH, wallet says " + expected)) | ||
| 139 | |||
| 140 | print() | ||
| 141 | print("bip-44 m/44'/%d'/0'/0/i" % COIN_TYPE) | ||
| 142 | for i, expected in enumerate(BIP44_EXTERNAL): | ||
| 143 | got = address(derive([44 + HARDENED, COIN_TYPE + HARDENED, | ||
| 144 | 0 + HARDENED, 0, i])) | ||
| 145 | ok = got == expected | ||
| 146 | failures += 0 if ok else 1 | ||
| 147 | print(" .../%d %s %s" % (i, got, "ok" if ok else | ||
| 148 | "MISMATCH, wallet says " + expected)) | ||
| 149 | |||
| 150 | print() | ||
| 151 | if failures: | ||
| 152 | print("%d address(es) disagree -- doc/derivation.md is wrong" % failures) | ||
| 153 | return 1 | ||
| 154 | print("all addresses match: the document reproduces the wallet") | ||
| 155 | return 0 | ||
| 156 | |||
| 157 | |||
| 158 | if __name__ == "__main__": | ||
| 159 | sys.exit(main()) | ||