upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBitflash <btf01@proton.me>2026-08-03 16:08:55 -0300
committerGitHub <noreply@github.com>2026-08-03 16:08:55 -0300
commit9176d5b3f32b907e4bafde5152828b2ae04ae8a1 (patch)
tree4963b9d45c17d323c89e80802d77e93aadd7a983
parentf6e197573464268174edc6f8d3b0d5c2b194b1ed (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.md6
-rw-r--r--doc/derivation.md125
-rw-r--r--doc/verify-derivation.py159
3 files changed, 290 insertions, 0 deletions
diff --git a/README.md b/README.md
index f41c866..6601384 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,12 @@ words also cover the address the window shows you. **Both still matter** — the
90phrase and the file back up different things, and the difference is where people 90phrase and the file back up different things, and the difference is where people
91lose money. 91lose money.
92 92
93The derivation is written down in [doc/derivation.md](doc/derivation.md), with
94test vectors and a script that reproduces them from scratch. It is there so the
95twelve words keep working even if this software does not: paths, address format
96and encoding, enough to recover the keys with ordinary tools and no Bitflash
97code 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
3This document exists so that a Bitflash wallet can be rebuilt **without this
4software**. If the project stops, the binaries are lost, or you simply do not
5trust them, the twelve words plus what is written here are enough to recover
6your private keys with ordinary cryptographic tools.
7
8Everything below is verified by `doc/verify-derivation.py`, a self-contained
9script that implements this document from scratch — no Bitflash code, no
10libraries beyond Python's standard library — and checks that it reproduces the
11addresses the wallet produces. If the two ever disagree, this document is wrong
12and the script will say so.
13
14## Addresses
15
16An 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
26The public key is uncompressed. This matters more than anything else here: the
27same private key hashed in compressed form gives a completely different, valid
28looking address that holds nothing. Bitflash descends from Bitcoin 0.1.0, which
29predates compressed keys.
30
31Outputs paying an address use the usual script:
32
33```
34OP_DUP OP_HASH160 <hash160> OP_EQUALVERIFY OP_CHECKSIG
35```
36
37Mined 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
42Standard BIP-39 and BIP-32, with no modifications:
43
441. **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`.
452. **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
47Child derivation is BIP-32 `CKDpriv`, hardened and non-hardened as usual.
48
49## Two layouts
50
51A wallet records which layout it uses in `wallet.dat` under the key `hdschema`.
52Wallets created before that field existed are treated as legacy.
53
54### Legacy (`hdschema` absent or 1)
55
56```
57m/i'
58```
59
60Hardened children of the master key, one flat sequence. Index 0 is the wallet's
61default receiving address; the key pool and change take the following indices.
62
63Used by every wallet created before BIP-44 support existed, and kept forever:
64those phrases must keep working.
65
66### BIP-44 (`hdschema` = 2)
67
68```
69m/44'/4346950'/0'/0/i external chain (receiving)
70m/44'/4346950'/0'/1/i internal chain (change)
71```
72
73Coin 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).
75The account level is fixed at `0'`; Bitflash does not expose multiple accounts.
76
77A wallet restored from a phrase scans **both** layouts, because the words alone
78do not say which one produced them.
79
80## Test vectors
81
82Using the public BIP-39 test mnemonic:
83
84```
85abandon 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
106Check them yourself:
107
108```
109python doc/verify-derivation.py
110```
111
112Both tables were produced by the wallet — the legacy one by the v1.2.15 binary,
113which only knows that layout — and then reproduced independently by the script.
114
115## What this document does not give you
116
117Deriving the keys is not the same as spending. To move recovered coins you still
118need a node that knows the network: the chain, the peer protocol, and the
119transaction format. This document is about **not losing the keys**, which is the
120part that cannot be reconstructed later by reading source code that no longer
121exists anywhere.
122
123Note also that `wallet.dat` stores private keys unencrypted. A recovery phrase
124protects you against losing the file; it does not protect the file. Anyone who
125can 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
4This implements the document and nothing else: BIP-39 to seed, BIP-32 to child
5keys, uncompressed secp256k1 public keys, and Base58Check with version byte 25.
6It shares no code with Bitflash and needs nothing outside the standard library,
7so agreement between the two is evidence that the document is enough to recover
8a wallet without this project's software.
9
10 python doc/verify-derivation.py
11
12Exits non-zero if any address disagrees.
13"""
14
15import hashlib
16import hmac
17import sys
18
19MNEMONIC = ("abandon abandon abandon abandon abandon abandon "
20 "abandon abandon abandon abandon abandon about")
21COIN_TYPE = 4346950 # 0x425446, the ASCII bytes "BTF"
22ADDRESS_VERSION = 25 # 0x19, so addresses start with "B"
23HARDENED = 0x80000000
24
25LEGACY = [
26 "BHJNmVvkVv59fgn7gmgP78BtciQi2HY8Hy",
27 "B5qh9opG8sH8Mv9k2xzHoTeSUd9BkT6XEZ",
28 "BCNH8uGyvkiz3uXjhA9iiF27PWAaTStNin",
29 "BC1QNaXuTPGDgLZy53PghZMHEmZcp5GLgD",
30]
31
32BIP44_EXTERNAL = [
33 "BF1ukigB1afmqxH64gJymPwUqFBrRqWbrb",
34 "BACY8pcuCapNQr897eRHroNrgN38GSsEzV",
35 "BMzkMN2k1phNBqy8RPb4Pv1i2QpwsWkKPa",
36 "BNPZ8U6FHXr9Ererk8psyM2RqEVtF6sfBQ",
37]
38
39# ---- secp256k1, plain and slow on purpose: it should be readable ------------
40
41P = 2**256 - 2**32 - 977
42N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
43G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
44 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
45
46
47def 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
62def 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
72def pub_uncompressed(point):
73 return b"\x04" + point[0].to_bytes(32, "big") + point[1].to_bytes(32, "big")
74
75
76def pub_compressed(point):
77 return bytes([2 + (point[1] & 1)]) + point[0].to_bytes(32, "big")
78
79
80# ---- encoding --------------------------------------------------------------
81
82def 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
95def 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
103def 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
111def 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
120def 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
129def 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
158if __name__ == "__main__":
159 sys.exit(main())