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 | |
| 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.
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | doc/derivation.md | 125 | ||||
| -rw-r--r-- | doc/verify-derivation.py | 159 |
3 files changed, 290 insertions, 0 deletions
| @@ -90,6 +90,12 @@ words also cover the address the window shows you. **Both still matter** — the | |||
| 90 | phrase and the file back up different things, and the difference is where people | 90 | phrase and the file back up different things, and the difference is where people |
| 91 | lose money. | 91 | lose money. |
| 92 | 92 | ||
| 93 | The derivation is written down in [doc/derivation.md](doc/derivation.md), with | ||
| 94 | test vectors and a script that reproduces them from scratch. It is there so the | ||
| 95 | twelve words keep working even if this software does not: paths, address format | ||
| 96 | and encoding, enough to recover the keys with ordinary tools and no Bitflash | ||
| 97 | code at all. | ||
| 98 | |||
| 93 | ### The recovery phrase | 99 | ### The recovery phrase |
| 94 | 100 | ||
| 95 | ``` | 101 | ``` |
diff --git a/doc/derivation.md b/doc/derivation.md new file mode 100644 index 0000000..3ca8339 --- /dev/null +++ b/doc/derivation.md | |||
| @@ -0,0 +1,125 @@ | |||
| 1 | # Key derivation and address format | ||
| 2 | |||
| 3 | This document exists so that a Bitflash wallet can be rebuilt **without this | ||
| 4 | software**. If the project stops, the binaries are lost, or you simply do not | ||
| 5 | trust them, the twelve words plus what is written here are enough to recover | ||
| 6 | your private keys with ordinary cryptographic tools. | ||
| 7 | |||
| 8 | Everything below is verified by `doc/verify-derivation.py`, a self-contained | ||
| 9 | script that implements this document from scratch — no Bitflash code, no | ||
| 10 | libraries beyond Python's standard library — and checks that it reproduces the | ||
| 11 | addresses the wallet produces. If the two ever disagree, this document is wrong | ||
| 12 | and the script will say so. | ||
| 13 | |||
| 14 | ## Addresses | ||
| 15 | |||
| 16 | An address is the Base58Check encoding of a version byte followed by | ||
| 17 | `RIPEMD160(SHA256(public key))`. | ||
| 18 | |||
| 19 | | | | | ||
| 20 | |---|---| | ||
| 21 | | curve | secp256k1 | | ||
| 22 | | public key encoding | **uncompressed**, 65 bytes, `0x04 ‖ X ‖ Y` | | ||
| 23 | | address version byte | **25** (`0x19`), which is why addresses begin with `B` | | ||
| 24 | | checksum | first 4 bytes of `SHA256(SHA256(payload))`, appended before Base58 | | ||
| 25 | |||
| 26 | The public key is uncompressed. This matters more than anything else here: the | ||
| 27 | same private key hashed in compressed form gives a completely different, valid | ||
| 28 | looking address that holds nothing. Bitflash descends from Bitcoin 0.1.0, which | ||
| 29 | predates compressed keys. | ||
| 30 | |||
| 31 | Outputs paying an address use the usual script: | ||
| 32 | |||
| 33 | ``` | ||
| 34 | OP_DUP OP_HASH160 <hash160> OP_EQUALVERIFY OP_CHECKSIG | ||
| 35 | ``` | ||
| 36 | |||
| 37 | Mined coins and payments made directly to a public key use the older form | ||
| 38 | `<pubkey> OP_CHECKSIG`. A wallet scanning the chain must recognise both. | ||
| 39 | |||
| 40 | ## From twelve words to a master key | ||
| 41 | |||
| 42 | Standard BIP-39 and BIP-32, with no modifications: | ||
| 43 | |||
| 44 | 1. **Seed.** `PBKDF2-HMAC-SHA512(password = mnemonic, salt = "mnemonic" + passphrase, iterations = 2048, length = 64 bytes)`. Bitflash always uses an empty passphrase, so the salt is the literal string `mnemonic`. | ||
| 45 | 2. **Master key.** `I = HMAC-SHA512(key = "Bitcoin seed", data = seed)`. The master private key is `I[0..32]`, the master chain code is `I[32..64]`. | ||
| 46 | |||
| 47 | Child derivation is BIP-32 `CKDpriv`, hardened and non-hardened as usual. | ||
| 48 | |||
| 49 | ## Two layouts | ||
| 50 | |||
| 51 | A wallet records which layout it uses in `wallet.dat` under the key `hdschema`. | ||
| 52 | Wallets created before that field existed are treated as legacy. | ||
| 53 | |||
| 54 | ### Legacy (`hdschema` absent or 1) | ||
| 55 | |||
| 56 | ``` | ||
| 57 | m/i' | ||
| 58 | ``` | ||
| 59 | |||
| 60 | Hardened children of the master key, one flat sequence. Index 0 is the wallet's | ||
| 61 | default receiving address; the key pool and change take the following indices. | ||
| 62 | |||
| 63 | Used by every wallet created before BIP-44 support existed, and kept forever: | ||
| 64 | those phrases must keep working. | ||
| 65 | |||
| 66 | ### BIP-44 (`hdschema` = 2) | ||
| 67 | |||
| 68 | ``` | ||
| 69 | m/44'/4346950'/0'/0/i external chain (receiving) | ||
| 70 | m/44'/4346950'/0'/1/i internal chain (change) | ||
| 71 | ``` | ||
| 72 | |||
| 73 | Coin type **4346950** is `0x425446`, the ASCII bytes `BTF`. It is | ||
| 74 | [registered for Bitflash under the symbol BITFLASH](https://github.com/satoshilabs/slips/blob/master/slip-0044.md). | ||
| 75 | The account level is fixed at `0'`; Bitflash does not expose multiple accounts. | ||
| 76 | |||
| 77 | A wallet restored from a phrase scans **both** layouts, because the words alone | ||
| 78 | do not say which one produced them. | ||
| 79 | |||
| 80 | ## Test vectors | ||
| 81 | |||
| 82 | Using the public BIP-39 test mnemonic: | ||
| 83 | |||
| 84 | ``` | ||
| 85 | abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about | ||
| 86 | ``` | ||
| 87 | |||
| 88 | ### Legacy | ||
| 89 | |||
| 90 | | path | address | | ||
| 91 | |---|---| | ||
| 92 | | `m/0'` | `BHJNmVvkVv59fgn7gmgP78BtciQi2HY8Hy` | | ||
| 93 | | `m/1'` | `B5qh9opG8sH8Mv9k2xzHoTeSUd9BkT6XEZ` | | ||
| 94 | | `m/2'` | `BCNH8uGyvkiz3uXjhA9iiF27PWAaTStNin` | | ||
| 95 | | `m/3'` | `BC1QNaXuTPGDgLZy53PghZMHEmZcp5GLgD` | | ||
| 96 | |||
| 97 | ### BIP-44 external chain | ||
| 98 | |||
| 99 | | path | address | | ||
| 100 | |---|---| | ||
| 101 | | `m/44'/4346950'/0'/0/0` | `BF1ukigB1afmqxH64gJymPwUqFBrRqWbrb` | | ||
| 102 | | `m/44'/4346950'/0'/0/1` | `BACY8pcuCapNQr897eRHroNrgN38GSsEzV` | | ||
| 103 | | `m/44'/4346950'/0'/0/2` | `BMzkMN2k1phNBqy8RPb4Pv1i2QpwsWkKPa` | | ||
| 104 | | `m/44'/4346950'/0'/0/3` | `BNPZ8U6FHXr9Ererk8psyM2RqEVtF6sfBQ` | | ||
| 105 | |||
| 106 | Check them yourself: | ||
| 107 | |||
| 108 | ``` | ||
| 109 | python doc/verify-derivation.py | ||
| 110 | ``` | ||
| 111 | |||
| 112 | Both tables were produced by the wallet — the legacy one by the v1.2.15 binary, | ||
| 113 | which only knows that layout — and then reproduced independently by the script. | ||
| 114 | |||
| 115 | ## What this document does not give you | ||
| 116 | |||
| 117 | Deriving the keys is not the same as spending. To move recovered coins you still | ||
| 118 | need a node that knows the network: the chain, the peer protocol, and the | ||
| 119 | transaction format. This document is about **not losing the keys**, which is the | ||
| 120 | part that cannot be reconstructed later by reading source code that no longer | ||
| 121 | exists anywhere. | ||
| 122 | |||
| 123 | Note also that `wallet.dat` stores private keys unencrypted. A recovery phrase | ||
| 124 | protects you against losing the file; it does not protect the file. Anyone who | ||
| 125 | can read it can spend the coins. | ||
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()) | ||