508 lines
43 KiB
Shell
508 lines
43 KiB
Shell
|
|
#!/bin/sh
|
||
|
|
# Generate the EURC receiving-address pool — and the wallet behind it.
|
||
|
|
#
|
||
|
|
# tools/gen-eurc-pool.sh [--count N] [--out FILE] generate (default 100,
|
||
|
|
# eurc-pool.txt)
|
||
|
|
# tools/gen-eurc-pool.sh verify FILE prove a paper backup
|
||
|
|
# reproduces FILE
|
||
|
|
# tools/gen-eurc-pool.sh derive --count N --out F [--start I]
|
||
|
|
# re-derive addresses from
|
||
|
|
# the paper words: recovery,
|
||
|
|
# and same-seed top-ups
|
||
|
|
#
|
||
|
|
# What this is: a BIP-39 wallet generator that never stores the wallet. It
|
||
|
|
# rolls 24 words (256-bit entropy from the OS), shows them ONCE for you to
|
||
|
|
# write on paper, quizzes three back, wipes the screen, and writes only the
|
||
|
|
# derived addresses (m/44'/60'/0'/0/i — the path every wallet speaks) to disk.
|
||
|
|
# The words on paper ARE the money: any BIP-39 wallet, hardware included,
|
||
|
|
# recovers every address and can sweep what arrived. Nothing here needs to
|
||
|
|
# stay on this machine, and nothing secret does.
|
||
|
|
#
|
||
|
|
# Pure Python stdlib — no pip, no packages, no network — so it runs on a
|
||
|
|
# machine with the cable pulled, which is how you should run it. It refuses
|
||
|
|
# to start with the network up unless you insist (--online-anyway), and it
|
||
|
|
# refuses to emit anything unless its own crypto first reproduces the
|
||
|
|
# published BIP-39/BIP-32/Keccak test vectors AND the canonical wordlist
|
||
|
|
# fingerprint: hand-rolled derivation is only tolerable because it proves
|
||
|
|
# itself against the record on every single run. (Cross-validated against
|
||
|
|
# eth-account at build time: 120 random-seed addresses, byte-identical.)
|
||
|
|
#
|
||
|
|
# This seed starting life on a networked computer is the accepted tradeoff
|
||
|
|
# for launching without hardware. The upgrade costs nothing later: the pool
|
||
|
|
# is append-only, so when a hardware wallet arrives, append ITS addresses
|
||
|
|
# (enable-eurc.sh --append), sweep the old ones, retire this seed.
|
||
|
|
|
||
|
|
set -eu
|
||
|
|
|
||
|
|
MODE=gen
|
||
|
|
COUNT=100
|
||
|
|
OUT=eurc-pool.txt
|
||
|
|
ONLINE_OK=0
|
||
|
|
START=0
|
||
|
|
|
||
|
|
case "${1:-}" in
|
||
|
|
verify)
|
||
|
|
MODE=verify; shift
|
||
|
|
# the pool file, then fall through to the option loop (--online-anyway
|
||
|
|
# applies here too: typing the words back in deserves the same air-gap)
|
||
|
|
[ $# -ge 1 ] && [ "${1#-}" = "$1" ] || { echo "gen-eurc-pool: verify needs a pool file" >&2; exit 1; }
|
||
|
|
OUT="$1"; shift ;;
|
||
|
|
derive)
|
||
|
|
MODE=derive; shift ;;
|
||
|
|
selftest)
|
||
|
|
MODE=selftest; shift ;;
|
||
|
|
esac
|
||
|
|
while [ $# -gt 0 ]; do
|
||
|
|
case "$1" in
|
||
|
|
--count) COUNT="${2:?--count needs a value}"; shift 2 ;;
|
||
|
|
--out) OUT="${2:?--out needs a value}"; shift 2 ;;
|
||
|
|
--start) START="${2:?--start needs a value}"; shift 2 ;;
|
||
|
|
--online-anyway) ONLINE_OK=1; shift ;;
|
||
|
|
-h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||
|
|
*) echo "gen-eurc-pool: unknown argument $1 (try --help)" >&2; exit 1 ;;
|
||
|
|
esac
|
||
|
|
done
|
||
|
|
|
||
|
|
case "$COUNT" in *[!0-9]*|'') echo "gen-eurc-pool: --count '$COUNT' is not a number" >&2; exit 1 ;; esac
|
||
|
|
case "$START" in *[!0-9]*|'') echo "gen-eurc-pool: --start '$START' is not a number" >&2; exit 1 ;; esac
|
||
|
|
if [ "$MODE" != derive ] && [ "$START" != 0 ]; then
|
||
|
|
echo "gen-eurc-pool: --start only applies to 'derive'" >&2; exit 1
|
||
|
|
fi
|
||
|
|
[ "$COUNT" -ge 1 ] && [ "$COUNT" -le 10000 ] || { echo "gen-eurc-pool: --count $COUNT is not sane" >&2; exit 1; }
|
||
|
|
|
||
|
|
# Generation wants an air-gap: the words exist in this process's memory and
|
||
|
|
# on this screen, and "the machine was offline" is the difference between
|
||
|
|
# trusting the OS and trusting the OS plus everything it is talking to.
|
||
|
|
# Verify mode types the words back in, so it deserves the same gate.
|
||
|
|
if [ "$MODE" != selftest ] && [ "$ONLINE_OK" -eq 0 ]; then
|
||
|
|
# Ask "is there a route off this machine", not "does an interface report
|
||
|
|
# carrier". operstate was the wrong question: WireGuard, OpenVPN tun and
|
||
|
|
# USB tethering never set carrier and sit at "unknown" forever, so a
|
||
|
|
# machine whose ONLY route was a live VPN tunnel passed the gate and
|
||
|
|
# reported itself air-gapped. (The dev box this was written on has exactly
|
||
|
|
# such an interface.) A missing /sys also read as air-gapped.
|
||
|
|
#
|
||
|
|
# A default route is the honest test, and it fails closed: if none of the
|
||
|
|
# tools below exist, the gate blocks rather than assumes.
|
||
|
|
route=""
|
||
|
|
if command -v ip >/dev/null 2>&1; then
|
||
|
|
route=$(ip route show default 2>/dev/null | head -3)
|
||
|
|
# A route via a tunnel counts; so does any default route at all.
|
||
|
|
[ -z "$route" ] && route=$(ip -6 route show default 2>/dev/null | head -3)
|
||
|
|
elif command -v route >/dev/null 2>&1; then
|
||
|
|
route=$(route -n 2>/dev/null | awk '$1 == "0.0.0.0" { print }' | head -3)
|
||
|
|
else
|
||
|
|
echo "gen-eurc-pool: cannot tell whether this machine is online (no ip(8)" >&2
|
||
|
|
echo " or route(8) found), and guessing 'offline' is not safe for a wallet." >&2
|
||
|
|
echo " Disconnect and pass --online-anyway if you are certain." >&2
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
if [ -n "$route" ]; then
|
||
|
|
echo "gen-eurc-pool: this machine still has a route to the internet:" >&2
|
||
|
|
printf ' %s\n' "$route" >&2
|
||
|
|
echo " Pull the cable, drop the wifi, and take down any VPN tunnel — or" >&2
|
||
|
|
echo " pass --online-anyway to accept generating wallet words online." >&2
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
PY=$(cat <<'PYSRC'
|
||
|
|
# Pure-stdlib BIP-39/BIP-32 EVM address derivation. No third-party imports —
|
||
|
|
# the whole point is running air-gapped. Every primitive is checked against
|
||
|
|
# published vectors in self_test() before anything is generated.
|
||
|
|
import hashlib, hmac, secrets, sys, unicodedata
|
||
|
|
|
||
|
|
# ── keccak-256 (original Keccak padding 0x01, NOT NIST SHA-3) ─────────
|
||
|
|
_RC = [0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
|
||
|
|
0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
|
||
|
|
0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
|
||
|
|
0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
|
||
|
|
0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
|
||
|
|
0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008]
|
||
|
|
_ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61],
|
||
|
|
[28, 55, 25, 21, 56], [27, 20, 39, 8, 14]] # r[x][y]
|
||
|
|
_M = (1 << 64) - 1
|
||
|
|
|
||
|
|
def _rotl(v, s):
|
||
|
|
return ((v << s) | (v >> (64 - s))) & _M
|
||
|
|
|
||
|
|
def _keccak_f(lanes): # lanes[x + 5y]
|
||
|
|
for rnd in range(24):
|
||
|
|
# theta
|
||
|
|
C = [lanes[x] ^ lanes[x + 5] ^ lanes[x + 10] ^ lanes[x + 15] ^ lanes[x + 20]
|
||
|
|
for x in range(5)]
|
||
|
|
D = [C[(x - 1) % 5] ^ _rotl(C[(x + 1) % 5], 1) for x in range(5)]
|
||
|
|
for x in range(5):
|
||
|
|
for y in range(5):
|
||
|
|
lanes[x + 5 * y] ^= D[x]
|
||
|
|
# rho + pi
|
||
|
|
B = [0] * 25
|
||
|
|
for x in range(5):
|
||
|
|
for y in range(5):
|
||
|
|
B[y + 5 * ((2 * x + 3 * y) % 5)] = _rotl(lanes[x + 5 * y], _ROT[x][y])
|
||
|
|
# chi
|
||
|
|
for x in range(5):
|
||
|
|
for y in range(5):
|
||
|
|
lanes[x + 5 * y] = B[x + 5 * y] ^ ((~B[(x + 1) % 5 + 5 * y]) & B[(x + 2) % 5 + 5 * y]) & _M
|
||
|
|
# iota
|
||
|
|
lanes[0] ^= _RC[rnd]
|
||
|
|
return lanes
|
||
|
|
|
||
|
|
def keccak256(data: bytes) -> bytes:
|
||
|
|
rate = 136
|
||
|
|
lanes = [0] * 25
|
||
|
|
# multi-rate padding: 0x01 ... 0x80, collapsing to a single 0x81 when
|
||
|
|
# exactly one pad byte fits
|
||
|
|
q = rate - (len(data) % rate)
|
||
|
|
padded = data + (b"\x81" if q == 1 else b"\x01" + b"\x00" * (q - 2) + b"\x80")
|
||
|
|
for off in range(0, len(padded), rate):
|
||
|
|
block = padded[off:off + rate]
|
||
|
|
for i in range(rate // 8):
|
||
|
|
lanes[i] ^= int.from_bytes(block[8 * i:8 * i + 8], "little")
|
||
|
|
_keccak_f(lanes)
|
||
|
|
out = b"".join(lanes[i].to_bytes(8, "little") for i in range(4))
|
||
|
|
return out[:32]
|
||
|
|
|
||
|
|
# ── secp256k1 ─────────────────────────────────────────────────────────
|
||
|
|
_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||
|
|
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||
|
|
_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
|
||
|
|
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
|
||
|
|
|
||
|
|
def _pt_add(a, b):
|
||
|
|
if a is None: return b
|
||
|
|
if b is None: return a
|
||
|
|
if a[0] == b[0] and (a[1] + b[1]) % _P == 0: return None
|
||
|
|
if a == b:
|
||
|
|
lam = (3 * a[0] * a[0]) * pow(2 * a[1], -1, _P) % _P
|
||
|
|
else:
|
||
|
|
lam = (b[1] - a[1]) * pow(b[0] - a[0], -1, _P) % _P
|
||
|
|
x = (lam * lam - a[0] - b[0]) % _P
|
||
|
|
return (x, (lam * (a[0] - x) - a[1]) % _P)
|
||
|
|
|
||
|
|
def _pt_mul(k, pt=_G):
|
||
|
|
acc = None
|
||
|
|
while k:
|
||
|
|
if k & 1: acc = _pt_add(acc, pt)
|
||
|
|
pt = _pt_add(pt, pt)
|
||
|
|
k >>= 1
|
||
|
|
return acc
|
||
|
|
|
||
|
|
def _compress(pt):
|
||
|
|
return bytes([2 + (pt[1] & 1)]) + pt[0].to_bytes(32, "big")
|
||
|
|
|
||
|
|
# ── BIP-39 ────────────────────────────────────────────────────────────
|
||
|
|
WORDS = ['abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident', 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', 'across', 'act', 'action', 'actor', 'actress', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit', 'adult', 'advance', 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent', 'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol', 'alert', 'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter', 'always', 'amateur', 'amazing', 'among', 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique', 'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'arch', 'arctic', 'area', 'arena', 'argue', 'arm', 'armed', 'armor', 'army', 'around', 'arrange', 'arrest', 'arrive', 'arrow', 'art', 'artefact', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset', 'assist', 'assume', 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction', 'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake', 'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge', 'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain', 'barrel', 'base', 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become', 'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit', 'best', 'betray', 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology', 'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless', 'blind', 'blood', 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body', 'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss', 'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread', 'breeze', 'brick', 'bridge', 'brief', 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze', 'broom', 'brother', 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb', 'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'busy', 'butter', 'buyer', 'buzz', 'cabbage', 'cabin', 'cable', 'cactus', 'cage', 'cake', 'call', 'calm', 'camera', 'camp', 'can', 'canal', 'cancel', 'candy', 'cannon', 'canoe', 'canvas', 'canyon', 'capable', 'capital', 'captain', 'car', 'carbon', 'card', 'cargo', 'carpet', 'carry', 'cart', 'case', 'cash', 'casino', 'castle', 'casual', 'cat', 'catalog', 'catch', 'category', 'cattle', 'caught', 'cause', 'caution', 'cave', 'ceiling', 'celery', 'cement', 'census', 'century', 'cereal', 'certain', 'chair', 'chalk', 'champion', 'change', 'chaos', 'chapter', 'charge', 'chase', 'chat', 'cheap', 'check', 'cheese', 'chef', 'cherry', 'chest', 'chicken', 'chief', 'child', 'chimney', 'choice', 'choose', 'chronic', 'chuckle', 'chunk', 'churn', 'cigar', 'cinnamon', 'circle', 'citizen', 'city', 'civil', 'claim', 'clap', 'clarify', 'claw', 'clay', 'clean', 'clerk', 'clever', 'click', 'client', 'cliff', 'climb', 'clinic', 'clip', 'clock', 'clog', 'close', 'cloth', 'cloud', 'clown', 'club', 'clump', 'cluster', 'clutch', 'coach', 'coast', 'coconut', 'code', 'coffee', 'coil', 'coin', 'collect', 'color', 'column', 'combine', 'come', 'comfort', 'comic', 'common', 'company', 'concert', 'conduct', 'confirm', 'congress', 'connect', 'consider', 'control', 'convince', 'cook', 'cool', 'copper', 'copy', 'coral', 'core', 'corn', 'correct', 'cost', 'cotton', 'couch', 'country', 'couple', 'course', 'cousin', 'cover', 'coyote', 'crack', 'cradle', 'craft', 'cram', 'crane', 'crash', 'crater', 'crawl', 'crazy', 'cream', 'credit', 'creek', 'crew', 'cricket', 'crime', 'crisp', 'critic', 'crop', 'cross', 'crouch', 'crowd', 'crucial', 'cruel', 'cruise', 'crumble', 'crunch', 'crush', 'cry', 'crystal', 'cube', 'culture', 'cup', 'cup
|
||
|
|
_INDEX = {w: i for i, w in enumerate(WORDS)}
|
||
|
|
|
||
|
|
def entropy_to_mnemonic(entropy: bytes) -> str:
|
||
|
|
cs_bits = len(entropy) * 8 // 32
|
||
|
|
checksum = hashlib.sha256(entropy).digest()
|
||
|
|
bits = int.from_bytes(entropy, "big") << cs_bits | (checksum[0] >> (8 - cs_bits))
|
||
|
|
total = len(entropy) * 8 + cs_bits
|
||
|
|
return " ".join(WORDS[(bits >> (total - 11 * (i + 1))) & 0x7FF]
|
||
|
|
for i in range(total // 11))
|
||
|
|
|
||
|
|
def mnemonic_to_entropy(mnemonic: str) -> bytes:
|
||
|
|
words = unicodedata.normalize("NFKD", mnemonic).strip().lower().split()
|
||
|
|
if len(words) not in (12, 15, 18, 21, 24):
|
||
|
|
raise ValueError(f"{len(words)} words — a mnemonic is 12/15/18/21/24")
|
||
|
|
bits = 0
|
||
|
|
for w in words:
|
||
|
|
if w not in _INDEX:
|
||
|
|
raise ValueError(f"'{w}' is not a BIP-39 word")
|
||
|
|
bits = bits << 11 | _INDEX[w]
|
||
|
|
cs_bits = len(words) * 11 // 33
|
||
|
|
ent_bits = len(words) * 11 - cs_bits
|
||
|
|
entropy = (bits >> cs_bits).to_bytes(ent_bits // 8, "big")
|
||
|
|
if bits & ((1 << cs_bits) - 1) != hashlib.sha256(entropy).digest()[0] >> (8 - cs_bits):
|
||
|
|
raise ValueError("checksum mismatch — a word is wrong or out of order")
|
||
|
|
return entropy
|
||
|
|
|
||
|
|
def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes:
|
||
|
|
m = unicodedata.normalize("NFKD", " ".join(mnemonic.strip().lower().split()))
|
||
|
|
s = unicodedata.normalize("NFKD", "mnemonic" + passphrase)
|
||
|
|
return hashlib.pbkdf2_hmac("sha512", m.encode(), s.encode(), 2048, 64)
|
||
|
|
|
||
|
|
# ── BIP-32 / addresses ────────────────────────────────────────────────
|
||
|
|
def _ckd(k: int, c: bytes, i: int):
|
||
|
|
if i >= 0x80000000:
|
||
|
|
data = b"\x00" + k.to_bytes(32, "big") + i.to_bytes(4, "big")
|
||
|
|
else:
|
||
|
|
data = _compress(_pt_mul(k)) + i.to_bytes(4, "big")
|
||
|
|
I = hmac.new(c, data, hashlib.sha512).digest()
|
||
|
|
il = int.from_bytes(I[:32], "big")
|
||
|
|
child = (il + k) % N
|
||
|
|
if il >= N or child == 0:
|
||
|
|
raise ValueError("invalid child key (astronomically unlikely) — reroll")
|
||
|
|
return child, I[32:]
|
||
|
|
|
||
|
|
def derive_addresses(mnemonic: str, count: int, start: int = 0):
|
||
|
|
mnemonic_to_entropy(mnemonic) # validates words + checksum
|
||
|
|
seed = mnemonic_to_seed(mnemonic)
|
||
|
|
I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()
|
||
|
|
k, c = int.from_bytes(I[:32], "big"), I[32:]
|
||
|
|
H = 0x80000000
|
||
|
|
for step in (44 + H, 60 + H, 0 + H, 0): # m/44'/60'/0'/0
|
||
|
|
k, c = _ckd(k, c, step)
|
||
|
|
out = []
|
||
|
|
# Indices at or above 2^31 are the HARDENED half of the path. _ckd would
|
||
|
|
# derive them happily, but they are a different key space: another wallet
|
||
|
|
# asked for m/44'/60'/0'/0/i with that i would disagree, so a pool derived
|
||
|
|
# there could not be recovered from the paper words. Unreachable from the
|
||
|
|
# CLI today — a refusal so it stays that way if --start ever appears.
|
||
|
|
if start < 0 or count < 1 or start + count > 0x80000000:
|
||
|
|
raise ValueError("address index outside the non-hardened range")
|
||
|
|
for i in range(start, start + count):
|
||
|
|
ck, _ = _ckd(k, c, i)
|
||
|
|
pt = _pt_mul(ck)
|
||
|
|
raw = keccak256(pt[0].to_bytes(32, "big") + pt[1].to_bytes(32, "big"))[12:]
|
||
|
|
out.append(to_eip55(raw))
|
||
|
|
return out
|
||
|
|
|
||
|
|
def to_eip55(raw20: bytes) -> str:
|
||
|
|
h = keccak256(raw20.hex().encode()).hex()
|
||
|
|
return "0x" + "".join(ch.upper() if int(h[i], 16) >= 8 else ch
|
||
|
|
for i, ch in enumerate(raw20.hex()))
|
||
|
|
|
||
|
|
# ── self-test: refuse to run if any primitive disagrees with the record ──
|
||
|
|
def self_test():
|
||
|
|
assert keccak256(b"").hex() == \
|
||
|
|
"c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", "keccak('')"
|
||
|
|
assert keccak256(b"abc").hex() == \
|
||
|
|
"4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45", "keccak('abc')"
|
||
|
|
# The padding edges and the multi-block path, which the two vectors above
|
||
|
|
# never reach: 135 bytes is the single-0x81 pad collapse, 136 is exactly one
|
||
|
|
# rate block, 137 spills into a second. A wrong pad typically makes inputs
|
||
|
|
# across the boundary collide, so distinctness is the check that catches it;
|
||
|
|
# the values themselves were cross-checked against pycryptodome.
|
||
|
|
assert keccak256(b"a" * 135).hex() == \
|
||
|
|
"34367dc248bbd832f4e3e69dfaac2f92638bd0bbd18f2912ba4ef454919cf446", "keccak 135"
|
||
|
|
assert keccak256(b"a" * 136).hex() == \
|
||
|
|
"a6c4d403279fe3e0af03729caada8374b5ca54d8065329a3ebcaeb4b60aa386e", "keccak 136"
|
||
|
|
assert keccak256(b"a" * 137).hex() == \
|
||
|
|
"d869f639c7046b4929fc92a4d988a8b22c55fbadb802c0c66ebcd484f1915f39", "keccak 137"
|
||
|
|
wl = "\n".join(WORDS) + "\n"
|
||
|
|
assert hashlib.sha256(wl.encode()).hexdigest() == \
|
||
|
|
"2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda", "wordlist"
|
||
|
|
assert entropy_to_mnemonic(b"\x00" * 16) == "abandon " * 11 + "about", "bip39-12w"
|
||
|
|
assert entropy_to_mnemonic(b"\x00" * 32) == "abandon " * 23 + "art", "bip39-24w"
|
||
|
|
tm = "abandon " * 11 + "about"
|
||
|
|
assert mnemonic_to_entropy(tm) == b"\x00" * 16, "bip39 roundtrip"
|
||
|
|
assert mnemonic_to_seed(tm, "TREZOR").hex() == \
|
||
|
|
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" \
|
||
|
|
"1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", "bip39 seed"
|
||
|
|
assert to_eip55(bytes.fromhex("5aaeb6053f3e94c9b9a09f33669435e7ef1beaed")) == \
|
||
|
|
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "eip55"
|
||
|
|
addrs = derive_addresses(tm, 2)
|
||
|
|
assert addrs[0] == "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", "end-to-end index 0"
|
||
|
|
assert addrs[1] == "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0", "end-to-end index 1"
|
||
|
|
|
||
|
|
|
||
|
|
# ── operator flows ────────────────────────────────────────────────────
|
||
|
|
import os
|
||
|
|
|
||
|
|
def _die(msg):
|
||
|
|
print(f"gen-eurc-pool: {msg}", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Clear screen AND scrollback (the 3J is the scrollback half; most terminals
|
||
|
|
# honour it). Only meaningful on a terminal — callers that show secrets check
|
||
|
|
# isatty first.
|
||
|
|
def _wipe():
|
||
|
|
if sys.stdout.isatty():
|
||
|
|
print("\033[2J\033[3J\033[H", end="", flush=True)
|
||
|
|
|
||
|
|
def cmd_gen(count, out):
|
||
|
|
if os.path.exists(out):
|
||
|
|
_die(f"'{out}' already exists — refusing to overwrite an address file. "
|
||
|
|
"Choose --out, or if this is a top-up, generate to a new file and "
|
||
|
|
"feed it to enable-eurc.sh --append.")
|
||
|
|
# Prove the file can be written BEFORE any words exist. Discovering an
|
||
|
|
# unwritable directory after the screen is wiped strands the operator with
|
||
|
|
# paper this tool cannot turn back into addresses.
|
||
|
|
probe = out + ".probe"
|
||
|
|
try:
|
||
|
|
os.close(os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
|
||
|
|
os.unlink(probe)
|
||
|
|
except OSError as e:
|
||
|
|
_die(f"cannot write next to '{out}': {e.strerror}. Fix that before "
|
||
|
|
"generating — the words must not exist until they can be saved.")
|
||
|
|
entropy = secrets.token_bytes(32)
|
||
|
|
mnemonic = entropy_to_mnemonic(entropy)
|
||
|
|
words = mnemonic.split()
|
||
|
|
|
||
|
|
# A pipe, a redirect, tee, script(1), tmux pipe-pane: there the wipe escape
|
||
|
|
# below is inert text and the words persist in whatever captured them.
|
||
|
|
if not sys.stdout.isatty():
|
||
|
|
_die("stdout is not a terminal, so the 24 words would land in whatever "
|
||
|
|
"is capturing this — a file, a pipe, a tmux log — where the screen "
|
||
|
|
"wipe cannot reach them. Run this straight in a terminal, with no "
|
||
|
|
"redirect, tee or script(1).")
|
||
|
|
print()
|
||
|
|
print("These 24 words ARE the wallet. Every donation address derives from")
|
||
|
|
print("them, and anyone holding them holds the money. Write them on paper")
|
||
|
|
print("TWICE, in order, numbered. Never photograph them, never type them")
|
||
|
|
print("into anything that isn't recovering this wallet.")
|
||
|
|
print()
|
||
|
|
for row in range(6):
|
||
|
|
print(" " + "".join(f"{4*row+col+1:>3}. {words[4*row+col]:<12}"
|
||
|
|
for col in range(4)))
|
||
|
|
print()
|
||
|
|
input("Press Enter once BOTH paper copies are written... ")
|
||
|
|
|
||
|
|
# Quiz three positions from the paper copy — this catches the classic
|
||
|
|
# losses (skipped word, swapped neighbours, misread handwriting) while
|
||
|
|
# the screen copy still exists to fix them against.
|
||
|
|
rng = secrets.SystemRandom()
|
||
|
|
for pos in sorted(rng.sample(range(24), 3)):
|
||
|
|
while True:
|
||
|
|
got = input(f"From your PAPER copy, word #{pos+1}: ").strip().lower()
|
||
|
|
if got == words[pos]:
|
||
|
|
break
|
||
|
|
print(" That does not match — check the paper copy against the screen.")
|
||
|
|
|
||
|
|
# The words must not outlive this prompt in a buffer somebody scrolls back
|
||
|
|
# through tomorrow.
|
||
|
|
_wipe()
|
||
|
|
|
||
|
|
addrs = derive_addresses(mnemonic, count)
|
||
|
|
fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||
|
|
with os.fdopen(fd, "w") as f:
|
||
|
|
f.write(f"# EURC receiving pool — {count} addresses, path m/44'/60'/0'/0/i\n")
|
||
|
|
f.write("# Recoverable in any BIP-39 wallet from the 24 words on paper.\n")
|
||
|
|
f.write("# APPEND-ONLY once live; see tools/enable-eurc.sh.\n")
|
||
|
|
for a in addrs:
|
||
|
|
f.write(a + "\n")
|
||
|
|
print(f"gen-eurc-pool: wrote {count} addresses to {out} (mode 0600)")
|
||
|
|
print()
|
||
|
|
print("Next:")
|
||
|
|
print(f" 1. tools/gen-eurc-pool.sh verify {out} — retype the words from")
|
||
|
|
print(" PAPER (not memory, not the screen you just cleared) to prove the")
|
||
|
|
print(" backup actually reproduces these addresses. Do it for both copies.")
|
||
|
|
print(f" 2. tools/enable-eurc.sh {out} — take the rail live.")
|
||
|
|
|
||
|
|
def cmd_derive(count, out, start):
|
||
|
|
"""Re-derive addresses from a mnemonic the operator already holds.
|
||
|
|
|
||
|
|
Two jobs the tool could not do before. One: recover after a failure at or
|
||
|
|
after the screen wipe — the words are on paper but nothing could turn them
|
||
|
|
into a pool file, so the ceremony had to be redone with a NEW seed. Two:
|
||
|
|
top up the pool from the SAME seed, which is what the append-only pool
|
||
|
|
actually wants — appending a second seed's addresses works, but then two
|
||
|
|
seeds must be kept safe forever instead of one.
|
||
|
|
|
||
|
|
--start is what makes a top-up correct: pass the number of addresses the
|
||
|
|
existing pool already holds, so the new file continues the same derivation
|
||
|
|
path instead of re-emitting addresses that are already published.
|
||
|
|
"""
|
||
|
|
if os.path.exists(out):
|
||
|
|
_die(f"'{out}' already exists — choose --out; this never overwrites an "
|
||
|
|
"address file.")
|
||
|
|
probe = out + ".probe"
|
||
|
|
try:
|
||
|
|
os.close(os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
|
||
|
|
os.unlink(probe)
|
||
|
|
except OSError as e:
|
||
|
|
_die(f"cannot write next to '{out}': {e.strerror}")
|
||
|
|
import getpass
|
||
|
|
print("Retype the 24 words from PAPER, spaces between (typing is hidden).")
|
||
|
|
mnemonic = getpass.getpass("words: ")
|
||
|
|
try:
|
||
|
|
addrs = derive_addresses(mnemonic, count, start)
|
||
|
|
except ValueError as e:
|
||
|
|
kind = str(e)
|
||
|
|
safe = ("a word is not in the BIP-39 list" if "not a BIP-39 word" in kind
|
||
|
|
else "the checksum does not match — a word is wrong or out of order"
|
||
|
|
if "checksum" in kind
|
||
|
|
else "the word count is wrong (a mnemonic is 12/15/18/21/24 words)"
|
||
|
|
if "words" in kind
|
||
|
|
else "it does not parse as a mnemonic")
|
||
|
|
_wipe()
|
||
|
|
_die(f"that is not a valid mnemonic: {safe}. Nothing you typed is shown "
|
||
|
|
"or stored.")
|
||
|
|
_wipe()
|
||
|
|
fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||
|
|
with os.fdopen(fd, "w") as f:
|
||
|
|
f.write(f"# EURC receiving pool — {count} addresses from index {start}, "
|
||
|
|
f"path m/44'/60'/0'/0/i\n")
|
||
|
|
f.write("# Re-derived from the paper mnemonic; same wallet as the rest.\n")
|
||
|
|
f.write("# APPEND-ONLY once live; see tools/enable-eurc.sh.\n")
|
||
|
|
for a in addrs:
|
||
|
|
f.write(a + "\n")
|
||
|
|
print(f"gen-eurc-pool: wrote {count} addresses "
|
||
|
|
f"(indices {start}..{start + count - 1}) to {out}")
|
||
|
|
if start == 0:
|
||
|
|
print("Verify against the existing pool file if you have one — the first")
|
||
|
|
print("addresses must match it exactly, or this is a different seed.")
|
||
|
|
else:
|
||
|
|
print(f"Top-up: feed it to tools/enable-eurc.sh {out} --append")
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_verify(poolfile):
|
||
|
|
try:
|
||
|
|
lines = open(poolfile).read().splitlines()
|
||
|
|
except OSError as e:
|
||
|
|
_die(f"cannot read '{poolfile}': {e.strerror}")
|
||
|
|
addrs = []
|
||
|
|
for ln in lines:
|
||
|
|
ln = ln.split("#")[0].strip().lower()
|
||
|
|
if ln:
|
||
|
|
addrs.append(ln)
|
||
|
|
if not addrs:
|
||
|
|
_die(f"'{poolfile}' holds no addresses")
|
||
|
|
import getpass
|
||
|
|
print(f"Retype the 24 words from PAPER, spaces between (typing is hidden).")
|
||
|
|
mnemonic = getpass.getpass("words: ")
|
||
|
|
try:
|
||
|
|
derived = [a.lower() for a in derive_addresses(mnemonic, len(addrs))]
|
||
|
|
except ValueError as e:
|
||
|
|
# NEVER echo the exception text: mnemonic_to_entropy names the offending
|
||
|
|
# token, and a typo is usually a REAL word of the seed with a stray
|
||
|
|
# character ("unaware." for "unaware"). getpass hid the typing, so the
|
||
|
|
# operator has every reason to believe nothing they typed is displayed —
|
||
|
|
# printing it put a genuine seed word into permanent scrollback.
|
||
|
|
kind = str(e)
|
||
|
|
safe = ("a word is not in the BIP-39 list" if "not a BIP-39 word" in kind
|
||
|
|
else "the checksum does not match — a word is wrong or out of order"
|
||
|
|
if "checksum" in kind
|
||
|
|
else "the word count is wrong (a mnemonic is 12/15/18/21/24 words)"
|
||
|
|
if "words" in kind
|
||
|
|
else "it does not parse as a mnemonic")
|
||
|
|
_wipe()
|
||
|
|
_die(f"that is not a valid mnemonic: {safe}. Nothing about what you "
|
||
|
|
"typed is shown or stored; check the paper copy and try again.")
|
||
|
|
if derived == addrs:
|
||
|
|
# Wipe before reporting: the words were typed into this terminal, and
|
||
|
|
# while getpass kept them off the screen, a wipe here also clears
|
||
|
|
# anything the operator pasted or mistyped in view earlier.
|
||
|
|
_wipe()
|
||
|
|
print(f"MATCH — the paper backup reproduces all {len(addrs)} addresses.")
|
||
|
|
return
|
||
|
|
bad = [i for i, (d, a) in enumerate(zip(derived, addrs)) if d != a]
|
||
|
|
print(f"MISMATCH — {len(bad)} of {len(addrs)} addresses differ "
|
||
|
|
f"(first at line index {bad[0] if bad else '?'}).", file=sys.stderr)
|
||
|
|
print("Either a word was miscopied to paper, or this pool holds appended", file=sys.stderr)
|
||
|
|
print("addresses from a different seed (mismatches only in the tail).", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
try:
|
||
|
|
self_test()
|
||
|
|
except AssertionError as e:
|
||
|
|
print(f"gen-eurc-pool: SELF-TEST FAILED ({e}) — refusing to generate "
|
||
|
|
"anything. The maths must prove itself before it may touch money.",
|
||
|
|
file=sys.stderr)
|
||
|
|
sys.exit(3)
|
||
|
|
|
||
|
|
mode = sys.argv[1]
|
||
|
|
if mode == "selftest":
|
||
|
|
print("gen-eurc-pool: self-test OK")
|
||
|
|
elif mode == "gen":
|
||
|
|
cmd_gen(int(sys.argv[2]), sys.argv[3])
|
||
|
|
elif mode == "verify":
|
||
|
|
cmd_verify(sys.argv[3]) # argv layout is MODE COUNT FILE START
|
||
|
|
elif mode == "derive":
|
||
|
|
cmd_derive(int(sys.argv[2]), sys.argv[3], int(sys.argv[4]))
|
||
|
|
PYSRC
|
||
|
|
)
|
||
|
|
exec python3 -c "$PY" "$MODE" "$COUNT" "$OUT" "$START"
|