diff options
| author | Bitflash <btf01@proton.me> | 2026-08-04 10:19:38 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-04 10:19:38 -0300 |
| commit | a2592156207ed04671d6532cec4997fb1dab1595 (patch) | |
| tree | 6355a64910d83f0901437eebd59fbc0e04db1712 /docs | |
| parent | f6c5b813075cae4228d241a3567e70b210504749 (diff) | |
docs: one documentation directory, not two (#137)
docs/ has held the README's images since the fair-launch release. I
added doc/ yesterday for the derivation spec without looking, and #108
then correctly used docs/ -- leaving the repository with both, one
letter apart, which is the kind of thing that quietly costs someone ten
minutes a year forever.
The mistake was mine, so the move is mine: derivation.md and
verify-derivation.py go to docs/, and the two README links follow.
No content changes. The verifier still reproduces the wallet from its
new path, which is the only thing here that can actually break.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/derivation.md | 125 | ||||
| -rw-r--r-- | docs/verify-derivation.py | 159 |
2 files changed, 284 insertions, 0 deletions
diff --git a/docs/derivation.md b/docs/derivation.md new file mode 100644 index 0000000..c5ee3ad --- /dev/null +++ b/docs/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 `docs/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 docs/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/docs/verify-derivation.py b/docs/verify-derivation.py new file mode 100644 index 0000000..ba8e883 --- /dev/null +++ b/docs/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()) | ||