#!/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', 'cupboard', 'curious', 'current', 'curtain', 'curve', 'cushion', 'custom', 'cute', 'cycle', 'dad', 'damage', 'damp', 'dance', 'danger', 'daring', 'dash', 'daughter', 'dawn', 'day', 'deal', 'debate', 'debris', 'decade', 'december', 'decide', 'decline', 'decorate', 'decrease', 'deer', 'defense', 'define', 'defy', 'degree', 'delay', 'deliver', 'demand', 'demise', 'denial', 'dentist', 'deny', 'depart', 'depend', 'deposit', 'depth', 'deputy', 'derive', 'describe', 'desert', 'design', 'desk', 'despair', 'destroy', 'detail', 'detect', 'develop', 'device', 'devote', 'diagram', 'dial', 'diamond', 'diary', 'dice', 'diesel', 'diet', 'differ', 'digital', 'dignity', 'dilemma', 'dinner', 'dinosaur', 'direct', 'dirt', 'disagree', 'discover', 'disease', 'dish', 'dismiss', 'disorder', 'display', 'distance', 'divert', 'divide', 'divorce', 'dizzy', 'doctor', 'document', 'dog', 'doll', 'dolphin', 'domain', 'donate', 'donkey', 'donor', 'door', 'dose', 'double', 'dove', 'draft', 'dragon', 'drama', 'drastic', 'draw', 'dream', 'dress', 'drift', 'drill', 'drink', 'drip', 'drive', 'drop', 'drum', 'dry', 'duck', 'dumb', 'dune', 'during', 'dust', 'dutch', 'duty', 'dwarf', 'dynamic', 'eager', 'eagle', 'early', 'earn', 'earth', 'easily', 'east', 'easy', 'echo', 'ecology', 'economy', 'edge', 'edit', 'educate', 'effort', 'egg', 'eight', 'either', 'elbow', 'elder', 'electric', 'elegant', 'element', 'elephant', 'elevator', 'elite', 'else', 'embark', 'embody', 'embrace', 'emerge', 'emotion', 'employ', 'empower', 'empty', 'enable', 'enact', 'end', 'endless', 'endorse', 'enemy', 'energy', 'enforce', 'engage', 'engine', 'enhance', 'enjoy', 'enlist', 'enough', 'enrich', 'enroll', 'ensure', 'enter', 'entire', 'entry', 'envelope', 'episode', 'equal', 'equip', 'era', 'erase', 'erode', 'erosion', 'error', 'erupt', 'escape', 'essay', 'essence', 'estate', 'eternal', 'ethics', 'evidence', 'evil', 'evoke', 'evolve', 'exact', 'example', 'excess', 'exchange', 'excite', 'exclude', 'excuse', 'execute', 'exercise', 'exhaust', 'exhibit', 'exile', 'exist', 'exit', 'exotic', 'expand', 'expect', 'expire', 'explain', 'expose', 'express', 'extend', 'extra', 'eye', 'eyebrow', 'fabric', 'face', 'faculty', 'fade', 'faint', 'faith', 'fall', 'false', 'fame', 'family', 'famous', 'fan', 'fancy', 'fantasy', 'farm', 'fashion', 'fat', 'fatal', 'father', 'fatigue', 'fault', 'favorite', 'feature', 'february', 'federal', 'fee', 'feed', 'feel', 'female', 'fence', 'festival', 'fetch', 'fever', 'few', 'fiber', 'fiction', 'field', 'figure', 'file', 'film', 'filter', 'final', 'find', 'fine', 'finger', 'finish', 'fire', 'firm', 'first', 'fiscal', 'fish', 'fit', 'fitness', 'fix', 'flag', 'flame', 'flash', 'flat', 'flavor', 'flee', 'flight', 'flip', 'float', 'flock', 'floor', 'flower', 'fluid', 'flush', 'fly', 'foam', 'focus', 'fog', 'foil', 'fold', 'follow', 'food', 'foot', 'force', 'forest', 'forget', 'fork', 'fortune', 'forum', 'forward', 'fossil', 'foster', 'found', 'fox', 'fragile', 'frame', 'frequent', 'fresh', 'friend', 'fringe', 'frog', 'front', 'frost', 'frown', 'frozen', 'fruit', 'fuel', 'fun', 'funny', 'furnace', 'fury', 'future', 'gadget', 'gain', 'galaxy', 'gallery', 'game', 'gap', 'garage', 'garbage', 'garden', 'garlic', 'garment', 'gas', 'gasp', 'gate', 'gather', 'gauge', 'gaze', 'general', 'genius', 'genre', 'gentle', 'genuine', 'gesture', 'ghost', 'giant', 'gift', 'giggle', 'ginger', 'giraffe', 'girl', 'give', 'glad', 'glance', 'glare', 'glass', 'glide', 'glimpse', 'globe', 'gloom', 'glory', 'glove', 'glow', 'glue', 'goat', 'goddess', 'gold', 'good', 'goose', 'gorilla', 'gospel', 'gossip', 'govern', 'gown', 'grab', 'grace', 'grain', 'grant', 'grape', 'grass', 'gravity', 'great', 'green', 'grid', 'grief', 'grit', 'grocery', 'group', 'grow', 'grunt', 'guard', 'guess', 'guide', 'guilt', 'guitar', 'gun', 'gym', 'habit', 'hair', 'half', 'hammer', 'hamster', 'hand', 'happy', 'harbor', 'hard', 'harsh', 'harvest', 'hat', 'have', 'hawk', 'hazard', 'head', 'health', 'heart', 'heavy', 'hedgehog', 'height', 'hello', 'helmet', 'help', 'hen', 'hero', 'hidden', 'high', 'hill', 'hint', 'hip', 'hire', 'history', 'hobby', 'hockey', 'hold', 'hole', 'holiday', 'hollow', 'home', 'honey', 'hood', 'hope', 'horn', 'horror', 'horse', 'hospital', 'host', 'hotel', 'hour', 'hover', 'hub', 'huge', 'human', 'humble', 'humor', 'hundred', 'hungry', 'hunt', 'hurdle', 'hurry', 'hurt', 'husband', 'hybrid', 'ice', 'icon', 'idea', 'identify', 'idle', 'ignore', 'ill', 'illegal', 'illness', 'image', 'imitate', 'immense', 'immune', 'impact', 'impose', 'improve', 'impulse', 'inch', 'include', 'income', 'increase', 'index', 'indicate', 'indoor', 'industry', 'infant', 'inflict', 'inform', 'inhale', 'inherit', 'initial', 'inject', 'injury', 'inmate', 'inner', 'innocent', 'input', 'inquiry', 'insane', 'insect', 'inside', 'inspire', 'install', 'intact', 'interest', 'into', 'invest', 'invite', 'involve', 'iron', 'island', 'isolate', 'issue', 'item', 'ivory', 'jacket', 'jaguar', 'jar', 'jazz', 'jealous', 'jeans', 'jelly', 'jewel', 'job', 'join', 'joke', 'journey', 'joy', 'judge', 'juice', 'jump', 'jungle', 'junior', 'junk', 'just', 'kangaroo', 'keen', 'keep', 'ketchup', 'key', 'kick', 'kid', 'kidney', 'kind', 'kingdom', 'kiss', 'kit', 'kitchen', 'kite', 'kitten', 'kiwi', 'knee', 'knife', 'knock', 'know', 'lab', 'label', 'labor', 'ladder', 'lady', 'lake', 'lamp', 'language', 'laptop', 'large', 'later', 'latin', 'laugh', 'laundry', 'lava', 'law', 'lawn', 'lawsuit', 'layer', 'lazy', 'leader', 'leaf', 'learn', 'leave', 'lecture', 'left', 'leg', 'legal', 'legend', 'leisure', 'lemon', 'lend', 'length', 'lens', 'leopard', 'lesson', 'letter', 'level', 'liar', 'liberty', 'library', 'license', 'life', 'lift', 'light', 'like', 'limb', 'limit', 'link', 'lion', 'liquid', 'list', 'little', 'live', 'lizard', 'load', 'loan', 'lobster', 'local', 'lock', 'logic', 'lonely', 'long', 'loop', 'lottery', 'loud', 'lounge', 'love', 'loyal', 'lucky', 'luggage', 'lumber', 'lunar', 'lunch', 'luxury', 'lyrics', 'machine', 'mad', 'magic', 'magnet', 'maid', 'mail', 'main', 'major', 'make', 'mammal', 'man', 'manage', 'mandate', 'mango', 'mansion', 'manual', 'maple', 'marble', 'march', 'margin', 'marine', 'market', 'marriage', 'mask', 'mass', 'master', 'match', 'material', 'math', 'matrix', 'matter', 'maximum', 'maze', 'meadow', 'mean', 'measure', 'meat', 'mechanic', 'medal', 'media', 'melody', 'melt', 'member', 'memory', 'mention', 'menu', 'mercy', 'merge', 'merit', 'merry', 'mesh', 'message', 'metal', 'method', 'middle', 'midnight', 'milk', 'million', 'mimic', 'mind', 'minimum', 'minor', 'minute', 'miracle', 'mirror', 'misery', 'miss', 'mistake', 'mix', 'mixed', 'mixture', 'mobile', 'model', 'modify', 'mom', 'moment', 'monitor', 'monkey', 'monster', 'month', 'moon', 'moral', 'more', 'morning', 'mosquito', 'mother', 'motion', 'motor', 'mountain', 'mouse', 'move', 'movie', 'much', 'muffin', 'mule', 'multiply', 'muscle', 'museum', 'mushroom', 'music', 'must', 'mutual', 'myself', 'mystery', 'myth', 'naive', 'name', 'napkin', 'narrow', 'nasty', 'nation', 'nature', 'near', 'neck', 'need', 'negative', 'neglect', 'neither', 'nephew', 'nerve', 'nest', 'net', 'network', 'neutral', 'never', 'news', 'next', 'nice', 'night', 'noble', 'noise', 'nominee', 'noodle', 'normal', 'north', 'nose', 'notable', 'note', 'nothing', 'notice', 'novel', 'now', 'nuclear', 'number', 'nurse', 'nut', 'oak', 'obey', 'object', 'oblige', 'obscure', 'observe', 'obtain', 'obvious', 'occur', 'ocean', 'october', 'odor', 'off', 'offer', 'office', 'often', 'oil', 'okay', 'old', 'olive', 'olympic', 'omit', 'once', 'one', 'onion', 'online', 'only', 'open', 'opera', 'opinion', 'oppose', 'option', 'orange', 'orbit', 'orchard', 'order', 'ordinary', 'organ', 'orient', 'original', 'orphan', 'ostrich', 'other', 'outdoor', 'outer', 'output', 'outside', 'oval', 'oven', 'over', 'own', 'owner', 'oxygen', 'oyster', 'ozone', 'pact', 'paddle', 'page', 'pair', 'palace', 'palm', 'panda', 'panel', 'panic', 'panther', 'paper', 'parade', 'parent', 'park', 'parrot', 'party', 'pass', 'patch', 'path', 'patient', 'patrol', 'pattern', 'pause', 'pave', 'payment', 'peace', 'peanut', 'pear', 'peasant', 'pelican', 'pen', 'penalty', 'pencil', 'people', 'pepper', 'perfect', 'permit', 'person', 'pet', 'phone', 'photo', 'phrase', 'physical', 'piano', 'picnic', 'picture', 'piece', 'pig', 'pigeon', 'pill', 'pilot', 'pink', 'pioneer', 'pipe', 'pistol', 'pitch', 'pizza', 'place', 'planet', 'plastic', 'plate', 'play', 'please', 'pledge', 'pluck', 'plug', 'plunge', 'poem', 'poet', 'point', 'polar', 'pole', 'police', 'pond', 'pony', 'pool', 'popular', 'portion', 'position', 'possible', 'post', 'potato', 'pottery', 'poverty', 'powder', 'power', 'practice', 'praise', 'predict', 'prefer', 'prepare', 'present', 'pretty', 'prevent', 'price', 'pride', 'primary', 'print', 'priority', 'prison', 'private', 'prize', 'problem', 'process', 'produce', 'profit', 'program', 'project', 'promote', 'proof', 'property', 'prosper', 'protect', 'proud', 'provide', 'public', 'pudding', 'pull', 'pulp', 'pulse', 'pumpkin', 'punch', 'pupil', 'puppy', 'purchase', 'purity', 'purpose', 'purse', 'push', 'put', 'puzzle', 'pyramid', 'quality', 'quantum', 'quarter', 'question', 'quick', 'quit', 'quiz', 'quote', 'rabbit', 'raccoon', 'race', 'rack', 'radar', 'radio', 'rail', 'rain', 'raise', 'rally', 'ramp', 'ranch', 'random', 'range', 'rapid', 'rare', 'rate', 'rather', 'raven', 'raw', 'razor', 'ready', 'real', 'reason', 'rebel', 'rebuild', 'recall', 'receive', 'recipe', 'record', 'recycle', 'reduce', 'reflect', 'reform', 'refuse', 'region', 'regret', 'regular', 'reject', 'relax', 'release', 'relief', 'rely', 'remain', 'remember', 'remind', 'remove', 'render', 'renew', 'rent', 'reopen', 'repair', 'repeat', 'replace', 'report', 'require', 'rescue', 'resemble', 'resist', 'resource', 'response', 'result', 'retire', 'retreat', 'return', 'reunion', 'reveal', 'review', 'reward', 'rhythm', 'rib', 'ribbon', 'rice', 'rich', 'ride', 'ridge', 'rifle', 'right', 'rigid', 'ring', 'riot', 'ripple', 'risk', 'ritual', 'rival', 'river', 'road', 'roast', 'robot', 'robust', 'rocket', 'romance', 'roof', 'rookie', 'room', 'rose', 'rotate', 'rough', 'round', 'route', 'royal', 'rubber', 'rude', 'rug', 'rule', 'run', 'runway', 'rural', 'sad', 'saddle', 'sadness', 'safe', 'sail', 'salad', 'salmon', 'salon', 'salt', 'salute', 'same', 'sample', 'sand', 'satisfy', 'satoshi', 'sauce', 'sausage', 'save', 'say', 'scale', 'scan', 'scare', 'scatter', 'scene', 'scheme', 'school', 'science', 'scissors', 'scorpion', 'scout', 'scrap', 'screen', 'script', 'scrub', 'sea', 'search', 'season', 'seat', 'second', 'secret', 'section', 'security', 'seed', 'seek', 'segment', 'select', 'sell', 'seminar', 'senior', 'sense', 'sentence', 'series', 'service', 'session', 'settle', 'setup', 'seven', 'shadow', 'shaft', 'shallow', 'share', 'shed', 'shell', 'sheriff', 'shield', 'shift', 'shine', 'ship', 'shiver', 'shock', 'shoe', 'shoot', 'shop', 'short', 'shoulder', 'shove', 'shrimp', 'shrug', 'shuffle', 'shy', 'sibling', 'sick', 'side', 'siege', 'sight', 'sign', 'silent', 'silk', 'silly', 'silver', 'similar', 'simple', 'since', 'sing', 'siren', 'sister', 'situate', 'six', 'size', 'skate', 'sketch', 'ski', 'skill', 'skin', 'skirt', 'skull', 'slab', 'slam', 'sleep', 'slender', 'slice', 'slide', 'slight', 'slim', 'slogan', 'slot', 'slow', 'slush', 'small', 'smart', 'smile', 'smoke', 'smooth', 'snack', 'snake', 'snap', 'sniff', 'snow', 'soap', 'soccer', 'social', 'sock', 'soda', 'soft', 'solar', 'soldier', 'solid', 'solution', 'solve', 'someone', 'song', 'soon', 'sorry', 'sort', 'soul', 'sound', 'soup', 'source', 'south', 'space', 'spare', 'spatial', 'spawn', 'speak', 'special', 'speed', 'spell', 'spend', 'sphere', 'spice', 'spider', 'spike', 'spin', 'spirit', 'split', 'spoil', 'sponsor', 'spoon', 'sport', 'spot', 'spray', 'spread', 'spring', 'spy', 'square', 'squeeze', 'squirrel', 'stable', 'stadium', 'staff', 'stage', 'stairs', 'stamp', 'stand', 'start', 'state', 'stay', 'steak', 'steel', 'stem', 'step', 'stereo', 'stick', 'still', 'sting', 'stock', 'stomach', 'stone', 'stool', 'story', 'stove', 'strategy', 'street', 'strike', 'strong', 'struggle', 'student', 'stuff', 'stumble', 'style', 'subject', 'submit', 'subway', 'success', 'such', 'sudden', 'suffer', 'sugar', 'suggest', 'suit', 'summer', 'sun', 'sunny', 'sunset', 'super', 'supply', 'supreme', 'sure', 'surface', 'surge', 'surprise', 'surround', 'survey', 'suspect', 'sustain', 'swallow', 'swamp', 'swap', 'swarm', 'swear', 'sweet', 'swift', 'swim', 'swing', 'switch', 'sword', 'symbol', 'symptom', 'syrup', 'system', 'table', 'tackle', 'tag', 'tail', 'talent', 'talk', 'tank', 'tape', 'target', 'task', 'taste', 'tattoo', 'taxi', 'teach', 'team', 'tell', 'ten', 'tenant', 'tennis', 'tent', 'term', 'test', 'text', 'thank', 'that', 'theme', 'then', 'theory', 'there', 'they', 'thing', 'this', 'thought', 'three', 'thrive', 'throw', 'thumb', 'thunder', 'ticket', 'tide', 'tiger', 'tilt', 'timber', 'time', 'tiny', 'tip', 'tired', 'tissue', 'title', 'toast', 'tobacco', 'today', 'toddler', 'toe', 'together', 'toilet', 'token', 'tomato', 'tomorrow', 'tone', 'tongue', 'tonight', 'tool', 'tooth', 'top', 'topic', 'topple', 'torch', 'tornado', 'tortoise', 'toss', 'total', 'tourist', 'toward', 'tower', 'town', 'toy', 'track', 'trade', 'traffic', 'tragic', 'train', 'transfer', 'trap', 'trash', 'travel', 'tray', 'treat', 'tree', 'trend', 'trial', 'tribe', 'trick', 'trigger', 'trim', 'trip', 'trophy', 'trouble', 'truck', 'true', 'truly', 'trumpet', 'trust', 'truth', 'try', 'tube', 'tuition', 'tumble', 'tuna', 'tunnel', 'turkey', 'turn', 'turtle', 'twelve', 'twenty', 'twice', 'twin', 'twist', 'two', 'type', 'typical', 'ugly', 'umbrella', 'unable', 'unaware', 'uncle', 'uncover', 'under', 'undo', 'unfair', 'unfold', 'unhappy', 'uniform', 'unique', 'unit', 'universe', 'unknown', 'unlock', 'until', 'unusual', 'unveil', 'update', 'upgrade', 'uphold', 'upon', 'upper', 'upset', 'urban', 'urge', 'usage', 'use', 'used', 'useful', 'useless', 'usual', 'utility', 'vacant', 'vacuum', 'vague', 'valid', 'valley', 'valve', 'van', 'vanish', 'vapor', 'various', 'vast', 'vault', 'vehicle', 'velvet', 'vendor', 'venture', 'venue', 'verb', 'verify', 'version', 'very', 'vessel', 'veteran', 'viable', 'vibrant', 'vicious', 'victory', 'video', 'view', 'village', 'vintage', 'violin', 'virtual', 'virus', 'visa', 'visit', 'visual', 'vital', 'vivid', 'vocal', 'voice', 'void', 'volcano', 'volume', 'vote', 'voyage', 'wage', 'wagon', 'wait', 'walk', 'wall', 'walnut', 'want', 'warfare', 'warm', 'warrior', 'wash', 'wasp', 'waste', 'water', 'wave', 'way', 'wealth', 'weapon', 'wear', 'weasel', 'weather', 'web', 'wedding', 'weekend', 'weird', 'welcome', 'west', 'wet', 'whale', 'what', 'wheat', 'wheel', 'when', 'where', 'whip', 'whisper', 'wide', 'width', 'wife', 'wild', 'will', 'win', 'window', 'wine', 'wing', 'wink', 'winner', 'winter', 'wire', 'wisdom', 'wise', 'wish', 'witness', 'wolf', 'woman', 'wonder', 'wood', 'wool', 'word', 'work', 'world', 'worry', 'worth', 'wrap', 'wreck', 'wrestle', 'wrist', 'write', 'wrong', 'yard', 'year', 'yellow', 'you', 'young', 'youth', 'zebra', 'zero', 'zone', 'zoo'] _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"