# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.
"""Electrum entropy / mnemonic / seed functions.
Electrum mnemonic is versioned, conveying BIP32 derivation rule too.
What is implemented here is Electrum's scheme, not an approximation of
it: the same entropy yields the mnemonic Electrum yields, and a mnemonic
Electrum accepts is accepted here. Four consequences, none of them
BIP39's behaviour, so none of them can be guessed from `bip39.py`:
- the words run least-significant first, the reverse of BIP39's order
- the entropy is an integer and the search starts at entropy + 1, so the
value supplied is a starting point and never itself the answer
- a candidate that is a pre-2.0 Electrum seed, or that is also a valid
BIP39 mnemonic, is skipped rather than returned
- a mnemonic is normalized before it is hashed or stretched -- NFKD,
lower-case, accents dropped, whitespace collapsed -- so an upper-cased
or accented sentence is read, not rejected
Electrum reads five word-lists -- en, es, ja, pt, zh -- and its own
registry here holds all of BIP39's beside them, so a language
Electrum does not read is still a language this module writes: an
"electrum" mnemonic in Italian is btclib's extension, and Electrum
cannot read it.
Most are BIP39's file after NFKD normalization, byte for byte, so
nothing above depends on which of the two schemes loaded them.
Portuguese is the exception and the reason this module has a registry of
its own rather than sharing WORDLISTS: Electrum's Portuguese is Monero's
word-list, 1626 words rather than 2048, and "pt" therefore names one
word-list in bip39.py and another here. Two consequences, both of them
visible in the code below: 1626 is not a power of two, so an index into
it is not eleven bits and the entropy is a base conversion and nothing
more; and a Portuguese sentence carrying Electrum's default entropy is
thirteen words, which is why a "2fa" mnemonic cannot be generated in
Portuguese at all -- that version wants twelve words or twenty, and
Electrum raises there too.
The pre-2.0 scheme is here too, and it is a different thing wearing the
same words. A wallet created before Electrum 2.0 has a twelve- or
twenty-four-word mnemonic over a word-list of its own, 1626 words long;
it decodes to a hex master seed rather than to entropy, three words to
each 32-bit group; the master private key is that seed stretched by a
hundred thousand rounds of SHA-256 rather than by PBKDF2; and it has no
passphrase, so one supplied is refused rather than defaulted away. There
is no specification to follow -- the scheme predates the BIPs and never
had one -- so Electrum's implementation is what correct means, and every
vector for it comes from Electrum's own tests.
"""
from __future__ import annotations
import hmac
import math
import secrets
import string
import unicodedata
from functools import cache
from hashlib import pbkdf2_hmac, sha256, sha512
from pathlib import Path
from btclib.curves.curve import mult, secp256k1
from btclib.curves.sec_point import bytes_from_point, scalar_from_prv_key
from btclib.exceptions import BTClibValueError
from btclib.network import network_from_name
from btclib.utils import assert_type
from btclib_wallet.bip32 import derive, rootxprv_from_seed
from btclib_wallet.bip32.der_path import _HARDENED_OFFSET
from btclib_wallet.mnemonic.entropy import (
BinStr,
Entropy,
bin_str_entropy_from_entropy,
bin_str_entropy_from_wordlist_indexes,
)
from btclib_wallet.mnemonic.mnemonic import (
BIP39_LANGUAGE_FILES,
Mnemonic,
WordLists,
data_file,
indexes_from_mnemonic,
mnemonic_from_indexes,
)
__all__ = [
"ELECTRUM_WORDLISTS",
"entropy_from_mnemonic",
"hex_seed_from_old_mnemonic",
"lang_from_mnemonic",
"mnemonic_from_entropy",
"mxprv_from_mnemonic",
"old_master_prv_key_from_mnemonic",
"old_master_pub_key_from_mnemonic",
"old_mnemonic_from_hex_seed",
"version_from_mnemonic",
]
_MNEMONIC_VERSIONS = {
"standard": "01", # p2pkh and p2ms-p2sh wallets
"segwit": "100", # p2wpkh and p2wsh wallets
"2fa": "101", # Two-factor authenticated wallets
"2fa_segwit": "102", # Two-factor authenticated wallets, using segwit
}
# the entropy electrum draws when the caller supplies none; 132 and not
# 128 because 132 is twelve whole words of eleven bits
_RANDOM_ENTROPY_BITS = 132
# the ranges electrum calls CJK, from the table its mnemonic.py cites,
# http://www.asahi-net.or.jp/~ax2s-kmtn/ref/unicode/e_asia.html.
# Vendored rather than derived from unicodedata: the set is an editorial
# choice -- it takes in Hangul, Bopomofo, Lisu, Miao, Yi and the
# halfwidth/fullwidth forms, which no one Unicode property groups -- and
# normalization has to agree with electrum's on every character, not
# nearly
_CJK_INTERVALS = (
(0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
(0x2F800, 0x2FA1D), # CJK Compatibility Ideographs Supplement
(0x3190, 0x319F), # Kanbun
(0x2E80, 0x2EFF), # CJK Radicals Supplement
(0x2F00, 0x2FDF), # CJK Radicals
(0x31C0, 0x31EF), # CJK Strokes
(0x2FF0, 0x2FFF), # Ideographic Description Characters
(0xE0100, 0xE01EF), # Variation Selectors Supplement
(0x3100, 0x312F), # Bopomofo
(0x31A0, 0x31BF), # Bopomofo Extended
(0xFF00, 0xFFEF), # Halfwidth and Fullwidth Forms
(0x3040, 0x309F), # Hiragana
(0x30A0, 0x30FF), # Katakana
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
(0x1B000, 0x1B0FF), # Kana Supplement
(0xAC00, 0xD7AF), # Hangul Syllables
(0x1100, 0x11FF), # Hangul Jamo
(0xA960, 0xA97F), # Hangul Jamo Extended A
(0xD7B0, 0xD7FF), # Hangul Jamo Extended B
(0x3130, 0x318F), # Hangul Compatibility Jamo
(0xA4D0, 0xA4FF), # Lisu
(0x16F00, 0x16F9F), # Miao
(0xA000, 0xA48F), # Yi Syllables
(0xA490, 0xA4CF), # Yi Radicals
)
# the pre-2.0 word-list, transcribed from the _words tuple of
# spesmilo/electrum's electrum/old_mnemonic.py -- MIT, "Copyright (C)
# 2011 thomasv@gitorious" -- one word per line, in its order, all 1626 of
# them. Pinned at commit 6be5bf96a89a72ac5b553493f7db385a0519ddb5
# (2025-07-18), whose blob for that path is
# c86634378e62935d4a68966ef19e2f6413066283. The order is load-bearing
# and is not the alphabet's -- the list is a frequency list of
# contemporary poetry -- so a re-sorted copy decodes every seed to
# something else. There is no upstream file to compare bytes against,
# the words living inside a python module rather than in a .txt
_OLD_WORDLIST_FILE = Path(__file__).parent / "_data" / "electrum_old_english.txt"
# the rounds of Old_KeyStore.stretch_key, which is what a pre-2.0 seed
# has instead of the versioned scheme's 2048 PBKDF2 iterations
_OLD_STRETCH_ROUNDS = 100_000
# Electrum's own word-lists, which are BIP39's but for Portuguese: that
# one is Monero's list, vendored as electrum_portuguese.txt
# (electrum/wordlist/portuguese.txt, BSD-3-Clause, "Copyright (c) 2014,
# The Monero Project", the licence header kept in the file because the
# loader reads '#' as a comment). power_of_two off for it: 1626 words is
# a base to convert to, not eleven bits per word
ELECTRUM_WORDLISTS = WordLists(
{**BIP39_LANGUAGE_FILES, "pt": data_file("electrum_portuguese.txt")},
power_of_two=False,
)
def _is_cjk(char: str) -> bool:
return any(imin <= ord(char) <= imax for imin, imax in _CJK_INTERVALS)
def _normalize(text: str) -> str:
"""Return the text as electrum hashes and stretches it.
NFKD, lower-case, combining characters dropped, whitespace collapsed,
and whitespace between two CJK characters removed. It applies to the
passphrase as well as to the mnemonic, which is why it takes a plain
string rather than a Mnemonic.
Not mnemonic.py's normalize_mnemonic, which is BIP39's reading of the
same question and agrees with this one on the whitespace alone. The
two cannot be merged: dropping the combining characters undoes the
decomposition BIP39 requires, and joining the words either side of a
CJK space would hand PBKDF2 one long word where BIP39's japanese
vectors expect twelve.
"""
text = unicodedata.normalize("NFKD", text)
text = text.lower()
text = "".join(char for char in text if not unicodedata.combining(char))
text = " ".join(text.split())
# the collapse above leaves no leading or trailing whitespace, so the
# neighbour lookups cannot run off either end: at i == 0 and at
# i == len - 1 the character is not whitespace, and the "and" stops
# before text[i - 1] or text[i + 1] is asked for
return "".join(
text[i]
for i in range(len(text))
if not (
text[i] in string.whitespace
and _is_cjk(text[i - 1])
and _is_cjk(text[i + 1])
)
)
@cache
def _old_wordlist() -> tuple[str, ...]:
"""Return electrum's pre-2.0 word-list, read once, in its own order.
Not a language of ELECTRUM_WORDLISTS, which would take it -- 1626
words is what its Portuguese has -- because the pre-2.0 scheme is not
a base conversion at all: three words carry four bytes, so an index
into this list is not a digit, and none of the entropy machinery a
registered language reaches applies to it.
"""
with _OLD_WORDLIST_FILE.open(encoding="ascii") as file_:
return tuple(line.rstrip("\n") for line in file_)
@cache
def _old_word_indexes() -> dict[str, int]:
"""Return the word-to-index map of electrum's pre-2.0 word-list.
Electrum's Wordlist keeps the same map for the same reason: the
decoder asks for an index once per word and the recognizer asks for
membership once per word, and a 1626-tuple answers either by walking
itself.
"""
return {word: index for index, word in enumerate(_old_wordlist())}
def _is_hex_str(text: str) -> bool:
"""Return True for a string of hex characters and nothing else.
Electrum's is_hex_str, and neither of the two shorter spellings:
bytes.fromhex skips ASCII whitespace between pairs, so "0123 4567"
would pass as four octets, and int(text, 16) takes a sign and
underscore separators besides. The length check is what closes both.
"""
try:
octets = bytes.fromhex(text)
except ValueError:
return False
return len(text) == 2 * len(octets)
def _is_old_mnemonic(mnemonic: Mnemonic) -> bool:
"""Return True for a pre-2.0 Electrum seed.
Deliberately weak, and electrum says so of its own is_old_seed
(spesmilo/electrum#3149): twelve or twenty-four words that all happen
to be in the old list are enough, as is any 16- or 32-byte hex
string. Copied at that strength on purpose -- a stricter test here
would accept a seed electrum refuses, which is the divergence this
module exists to avoid.
"""
mnemonic = _normalize(mnemonic)
words = mnemonic.split()
# electrum runs the old decoder and asks only whether it raised, and
# an unknown word is its one failure mode: it walks len(words) // 3
# triples, so a count that is not a multiple of three drops the tail
# silently rather than complaining. Testing every word gives the same
# answer wherever the answer is used, the count there being 12 or 24
uses_old_words = all(word in _old_word_indexes() for word in words)
try:
is_hex = len(bytes.fromhex(mnemonic)) in {16, 32}
except ValueError:
is_hex = False
return is_hex or (uses_old_words and len(words) in {12, 24})
def _seed_version(mnemonic: Mnemonic) -> str:
return hmac.new(b"Seed version", _normalize(mnemonic).encode(), sha512).hexdigest()
def _is_bip39_mnemonic(mnemonic: Mnemonic, lang: str) -> bool:
"""Return True if the mnemonic is also a valid BIP39 mnemonic.
The word-list is the same file for both schemes, so a sentence can
satisfy both: one 12-word mnemonic in sixteen has a valid BIP39
checksum by chance, which makes this the skip that counts most
towards generating what electrum generates.
Electrum's bip39_is_checksum_valid, arithmetic included, rather than a
call into bip39.py -- which is the same answer for the 2048-word
lists, and a test pins that, but not for Portuguese. Electrum hands
that function its own word-list whatever the language, so for
Portuguese it is 1626 words being read with eleven bits per word:
nonsense as a checksum, and still what decides which candidates
electrum passes over, so a sentence generated here without it would
not be the sentence electrum generates.
"""
words = mnemonic.split()
# electrum answers "checksum invalid" rather than raising for a length
# outside this set, so the length is a question of its own and not
# something to read out of an exception
if len(words) not in {12, 15, 18, 21, 24}:
return False
try:
indexes = indexes_from_mnemonic(mnemonic, lang, ELECTRUM_WORDLISTS)
except BTClibValueError:
return False
base = ELECTRUM_WORDLISTS.language_length(lang)
int_entropy = 0
for index in indexes:
int_entropy = int_entropy * base + index
checksum_bits = 11 * len(words) // 33
# the entropy is 32 bits per checksum bit, so four bytes: with a
# 2048-word list the shift leaves exactly that many, and with a
# 1626-word one it leaves fewer, never more, at every allowed length
bytes_entropy = (int_entropy >> checksum_bits).to_bytes(4 * checksum_bits, "big")
hashed = int.from_bytes(sha256(bytes_entropy).digest(), byteorder="big")
return int_entropy % (1 << checksum_bits) == hashed >> (256 - checksum_bits)
def _mnemonic_type(mnemonic: Mnemonic) -> str:
"""Return the Electrum seed type of the mnemonic, "" if it has none.
Electrum's calc_seed_type. "old" is one of the answers and is not a
key of _MNEMONIC_VERSIONS: the pre-2.0 scheme has no version prefix,
it is recognized by its word-list, and it is tested first because an
old seed can match one of the four prefixes by chance -- reporting it
as "standard" would hand a caller the wrong derivation in silence.
"""
# the count is taken before normalization, as electrum takes it:
# dropping the whitespace between CJK characters joins words
nwords = len(mnemonic.split())
if _is_old_mnemonic(mnemonic):
return "old"
seed_version = _seed_version(mnemonic)
for mnemonic_type, version in _MNEMONIC_VERSIONS.items():
if not seed_version.startswith(version):
continue
# electrum 2.7 changed how "2fa" seeds derive keys and reused the
# prefix, so the word count is all that tells the two apart:
# twelve words is post-2.7, twenty or more pre-2.7, and "101" at
# any other length is not a seed at all -- the loop goes on to
# the remaining prefixes exactly as electrum's elif chain does
if mnemonic_type == "2fa" and nwords != 12 and nwords < 20:
continue
return mnemonic_type
return ""
[docs]
def version_from_mnemonic(mnemonic: Mnemonic) -> tuple[str, str]:
"""Return the (Electrum version, normalized mnemonic) tuple.
The version is one of the four in _MNEMONIC_VERSIONS, or "old" for
the pre-2.0 scheme, which is recognized so that it is never mistaken
for one of the four; deriving keys from it is not supported.
The normalized mnemonic is the one electrum hashes and stretches:
NFKD, lower-case, accents dropped, whitespace collapsed.
"""
assert_type(mnemonic, str, "mnemonic")
mnemonic_type = _mnemonic_type(mnemonic)
if not mnemonic_type:
seed_version = _seed_version(mnemonic)
err_msg = f"unknown electrum mnemonic version: '{seed_version[:3]}'; "
err_msg += f"not in {list(_MNEMONIC_VERSIONS.keys())}"
raise BTClibValueError(err_msg)
return mnemonic_type, _normalize(mnemonic)
def _mnemonic_from_int_entropy(int_entropy: int, lang: str) -> Mnemonic:
"""Return the mnemonic of an integer, least-significant word first.
Electrum's mnemonic_encode. entropy.py's shared helpers end in
list(reversed(indexes)) for bip39.py, where that order is right and
the BIP39 vectors prove it, so they are not what this needs; working
on the integer directly also keeps the leading-zero question out of
it, an integer having no leading zeros to lose.
"""
base = ELECTRUM_WORDLISTS.language_length(lang)
indexes = []
while int_entropy:
int_entropy, index = divmod(int_entropy, base)
indexes.append(index)
return mnemonic_from_indexes(indexes, lang, ELECTRUM_WORDLISTS)
def _decodable(mnemonic: Mnemonic) -> Mnemonic:
"""Return the sentence in the spelling its words are looked up in.
Not _normalize: that one drops the accents of a spanish word and the
spaces between two chinese ones, and neither survives a word-list
lookup -- what it returns is what electrum *hashes*, and electrum's
own mnemonic_decode is handed the sentence untouched. Case is folded
all the same, an upper-cased sentence being one version_from_mnemonic
recognizes, so refusing to decode what was just recognized would be
the worse answer. NFKD because the word-lists are NFKD.
"""
return unicodedata.normalize("NFKD", mnemonic).lower()
def _bin_str_entropy_from_mnemonic(mnemonic: Mnemonic, lang: str) -> BinStr:
"""Return the entropy of a mnemonic whose first word is least significant.
Electrum's mnemonic_decode, which pops from the tail. The index list
is reversed before entropy.py's helper sees it, that helper being
written for BIP39's order.
"""
indexes = indexes_from_mnemonic(_decodable(mnemonic), lang, ELECTRUM_WORDLISTS)
base = ELECTRUM_WORDLISTS.language_length(lang)
return bin_str_entropy_from_wordlist_indexes(indexes[::-1], base)
def _random_int_entropy(lang: str) -> int:
"""Return the entropy electrum's make_seed draws when given none.
132 bits rounded up to whole words, with anything below one word less
drawn again: that is electrum's guard against a short sentence.
secrets.randbits(128) instead leaves the word count to follow the bit
length, and the word count is the one thing about the answer a caller
cannot correct afterwards.
Bits per word is a float, as it is in electrum: for a 2048-word list
it is 11.0 and the rounding is exact. Portuguese has 1626 words and
10.667 bits, so the lower bound is below the first integer that needs
thirteen words. A small share of accepted draws therefore has twelve
words, exactly as it does in electrum.
"""
bits_per_word = math.log2(ELECTRUM_WORDLISTS.language_length(lang))
nbits = int(math.ceil(_RANDOM_ENTROPY_BITS / bits_per_word) * bits_per_word)
int_entropy = 1
while int_entropy < 2 ** (nbits - bits_per_word):
# electrum's randrange: 1 <= r < 2**nbits, zero excluded
int_entropy = secrets.randbelow((1 << nbits) - 1) + 1
return int_entropy
def _search_mnemonic(int_entropy: int, version: str, lang: str) -> Mnemonic:
"""Return the first mnemonic of that version at or after entropy + 1."""
nonce = 0
while True:
# the first candidate is int_entropy + 1: electrum increments
# before it encodes, so the value handed in is never itself tried
nonce += 1
candidate = int_entropy + nonce
mnemonic = _mnemonic_from_int_entropy(candidate, lang)
if candidate != int(_bin_str_entropy_from_mnemonic(mnemonic, lang), 2):
err_msg = f"cannot extract the same entropy from mnemonic: {mnemonic}"
raise BTClibValueError(err_msg)
# a pre-2.0 seed and a valid BIP39 mnemonic are both skipped, not
# returned: electrum would read back what it had just written as
# an old seed, or as a BIP39 one, and derive other keys from it
if _is_old_mnemonic(mnemonic) or _is_bip39_mnemonic(mnemonic, lang):
continue
if _seed_version(mnemonic).startswith(version):
return mnemonic
[docs]
def mnemonic_from_entropy(
mnemonic_type: str = "standard", entropy: Entropy | None = None, lang: str = "en"
) -> Mnemonic:
"""Convert input entropy to Electrum versioned mnemonic sentence.
Input entropy can be expressed as binary 0/1 string, bytes-like, or
integer.
In the case of binary 0/1 string and bytes-like, leading zeros are
considered redundant padding.
The entropy is where the search for a mnemonic of the requested
version starts, not what the mnemonic encodes: the first candidate
tried is entropy + 1, and candidates that are pre-2.0 Electrum seeds
or valid BIP39 mnemonics are passed over. The search is Electrum's,
so the mnemonic is the one Electrum returns for that entropy.
"""
if mnemonic_type not in _MNEMONIC_VERSIONS:
err_msg = f"unknown electrum mnemonic version: '{mnemonic_type}'; "
err_msg += f"not in {list(_MNEMONIC_VERSIONS.keys())}"
raise BTClibValueError(err_msg)
int_entropy = (
_random_int_entropy(lang)
# not `not entropy`: entropy can be an int, and int 0 is a value
# to search from, not a missing one -- bip39.py's version has why
if entropy is None or entropy == ""
else int(bin_str_entropy_from_entropy(entropy), 2)
)
mnemonic = _search_mnemonic(int_entropy, _MNEMONIC_VERSIONS[mnemonic_type], lang)
# the prefix is necessary and not sufficient, so electrum closes by
# asking what the sentence it has just built would be read as. "2fa"
# is the case that can fail: it wants twelve words or twenty, and an
# entropy worth thirteen words gives neither
found = _mnemonic_type(mnemonic)
if found != mnemonic_type:
err_msg = f"electrum mnemonic version: '{found}'; expected: '{mnemonic_type}'"
raise BTClibValueError(err_msg)
return mnemonic
[docs]
def lang_from_mnemonic(mnemonic: Mnemonic) -> str:
"""Return the language of the Electrum mnemonic sentence.
The word-lists that hold every word of the sentence are the
candidates, and more than one is refused rather than resolved. BIP39's
tie-break is not available here: an Electrum mnemonic has no checksum
over its entropy -- the version prefix is a hash of the sentence and
says nothing about which word-list spelled it -- so every candidate
decodes to an entropy that is as valid as the others, and only the
caller knows which one was meant. It is Chinese that reaches this,
Simplified and Traditional sharing 1275 of their 2048 words.
"""
assert_type(mnemonic, str, "mnemonic")
candidates = ELECTRUM_WORDLISTS.langs_of_words(_decodable(mnemonic).split())
if not candidates:
raise BTClibValueError(f"unknown language for mnemonic: '{mnemonic}'")
if len(candidates) > 1:
err_msg = f"ambiguous language for mnemonic: {candidates}"
raise BTClibValueError(err_msg)
return candidates[0]
[docs]
def entropy_from_mnemonic(mnemonic: Mnemonic, lang: str | None = None) -> BinStr:
"""Return the entropy from the Electrum versioned mnemonic sentence.
This is the entropy the mnemonic encodes, which is one more than the
smallest entropy mnemonic_from_entropy would produce it from.
The language is read off the words if it is not provided.
"""
# the version first, and the language second: the version is a hash of
# the sentence and needs no word-list, while a pre-2.0 seed is spelled
# in a word-list no language here holds -- asking the language first
# would report it as an unknown language rather than as what it is
mnemonic_type, _ = version_from_mnemonic(mnemonic)
if mnemonic_type == "old":
err_msg = "pre-2.0 electrum mnemonic: entropy is not a base conversion; "
err_msg += "use hex_seed_from_old_mnemonic"
raise BTClibValueError(err_msg)
lang = lang or lang_from_mnemonic(mnemonic)
# the sentence handed in, and not the normalized one version_from_mnemonic
# returns beside the version: _decodable says why
return _bin_str_entropy_from_mnemonic(mnemonic, lang)
def _seed_from_mnemonic(mnemonic: Mnemonic, passphrase: str) -> tuple[str, bytes]:
"""Return (version, seed) from the provided Electrum mnemonic."""
version, mnemonic = version_from_mnemonic(mnemonic)
hf_name = "sha512"
password = mnemonic.encode()
# the passphrase is normalized as the mnemonic is, electrum putting
# both through the same function before it stretches them together
salt = f"electrum{_normalize(passphrase)}".encode()
iterations = 2048
dksize = 64
return version, pbkdf2_hmac(hf_name, password, salt, iterations, dksize)
[docs]
def mxprv_from_mnemonic(
mnemonic: Mnemonic, passphrase: str | None = None, network: str = "mainnet"
) -> str:
"""Return BIP32 master extended private key from Electrum mnemonic.
The derivation path is "m" for a "standard" mnemonic and "m/0h"
for a "segwit" one.
"""
version, seed = _seed_from_mnemonic(mnemonic, passphrase or "")
if version == "standard":
xversion = network_from_name(network).bip32_prv
return rootxprv_from_seed(seed, xversion)
if version == "segwit":
xversion = network_from_name(network).slip132_p2wpkh_prv
rootxprv = rootxprv_from_seed(seed, xversion)
return derive(rootxprv, _HARDENED_OFFSET) # "m/0h"
err_msg = f"unmanaged electrum mnemonic version: {version}"
if version == "old":
# the pre-2.0 scheme has no BIP32 master key to return: its keys
# hang off the master public key by an addition of its own, not
# by a chain code, so there is no xprv this could answer with
err_msg += "; use old_master_prv_key_from_mnemonic"
raise BTClibValueError(err_msg)
[docs]
def old_mnemonic_from_hex_seed(hex_seed: str) -> Mnemonic:
"""Return the pre-2.0 Electrum mnemonic of a hex master seed.
Electrum's old_mnemonic.mn_encode. Each group of eight hex characters
-- one 32-bit word -- becomes three words of the 1626-word list, and
the second and third are offsets from the one before rather than
digits of their own. Electrum's file names the reason: US patent
5892470 claims a scheme in which a word stands for a fixed digit, and
in this one the digit a word carries depends on the word before it.
Three words to a group is also why 1626 need not be a power of two,
and so why the list is not a WORDLISTS language.
"""
if not _is_hex_str(hex_seed):
raise BTClibValueError("pre-2.0 electrum seed is not a hex string")
if len(hex_seed) % 8:
err_msg = f"invalid pre-2.0 electrum seed: {len(hex_seed)} hex "
err_msg += "characters, not a multiple of eight"
raise BTClibValueError(err_msg)
wordlist = _old_wordlist()
base = len(wordlist)
words = []
for i in range(len(hex_seed) // 8):
group = int(hex_seed[8 * i : 8 * i + 8], 16)
first = group % base
second = (group // base + first) % base
third = (group // base // base + second) % base
words += [wordlist[first], wordlist[second], wordlist[third]]
return " ".join(words)
[docs]
def hex_seed_from_old_mnemonic(mnemonic: Mnemonic) -> str:
"""Return the hex master seed of a pre-2.0 Electrum mnemonic.
Electrum's Old_KeyStore.format_seed wrapped around
old_mnemonic.mn_decode: the sentence is normalized, a seed already
written as hex passes through -- pre-2.0 Electrum stored the seed
that way, so that is what a user may be holding -- and anything else
is decoded three words at a time.
The hex test here is the loose one, bytes.fromhex alone, because that
is the one format_seed uses; the strict test of _is_hex_str is the
one Electrum applies a step later, in stretch_key, and the gap
between them is upstream's rather than something to close here.
Whether the answer is a seed at all is a question this cannot answer,
and Electrum cannot either: three words can carry a group above
2**32, so twelve words can decode to 33 or 34 hex characters instead
of 32, and neither decoder notices. Deriving is where that fails, if
it fails at all -- 34 characters are still octets, and one of
Electrum's own published seeds is exactly that.
"""
assert_type(mnemonic, str, "mnemonic")
if not _is_old_mnemonic(mnemonic):
raise BTClibValueError("not a pre-2.0 electrum mnemonic")
mnemonic = _normalize(mnemonic)
try:
bytes.fromhex(mnemonic)
except ValueError:
pass
else:
return mnemonic
indexes = _old_word_indexes()
words = mnemonic.split()
base = len(_old_wordlist())
hex_seed = ""
# _is_old_mnemonic passed and the string is not hex, so every word is
# in the list and there are 12 or 24 of them: the lookups below
# cannot fail and no triple is left over
for i in range(len(words) // 3):
first, second, third = (indexes[word] for word in words[3 * i : 3 * i + 3])
group = first
group += base * ((second - first) % base)
group += base * base * ((third - second) % base)
hex_seed += f"{group:08x}"
return hex_seed
[docs]
def old_master_prv_key_from_mnemonic(
mnemonic: Mnemonic, passphrase: str | None = None
) -> int:
"""Return the pre-2.0 Electrum master private key, as an integer.
Electrum's Old_KeyStore.stretch_key: a hundred thousand rounds of
sha256 over the digest so far followed by the seed, where the seed is
the hex *characters* and not the octets they spell, and the last
digest read as a big-endian integer. Iterated sha256, not PBKDF2, and
not the versioned scheme's 2048 iterations of it: that is the fact
only a vector pins, and the vectors here are Electrum's own.
The passphrase is refused rather than defaulted. Nothing but the seed
enters the stretch, so there is nowhere for one to go, and accepting
it silently would hand back the wallet of a seed the caller did not
ask for. Electrum refuses it the same way in keystore.from_seed --
"'old'-type electrum seed cannot have passphrase" -- and its
can_seed_have_passphrase answers False for this scheme alone; None
and the empty string are "no passphrase" there and here.
"""
if passphrase:
raise BTClibValueError("pre-2.0 electrum mnemonic cannot have a passphrase")
hex_seed = hex_seed_from_old_mnemonic(mnemonic)
if not _is_hex_str(hex_seed):
# electrum asserts is_hex_str here and this is the assertion that
# fires: a decode that overflowed 32 bits leaves an odd number of
# hex characters, which bytes.fromhex refuses. Neither
# implementation can derive from such a seed, and refusing is
# what agreeing with electrum means
err_msg = f"pre-2.0 electrum mnemonic decodes to {len(hex_seed)} hex "
err_msg += "characters, which are not octets"
raise BTClibValueError(err_msg)
encoded = hex_seed.encode("ascii")
digest = encoded
for _ in range(_OLD_STRETCH_ROUNDS):
digest = sha256(digest + encoded).digest()
return int.from_bytes(digest, "big")
[docs]
def old_master_pub_key_from_mnemonic(
mnemonic: Mnemonic, passphrase: str | None = None
) -> str:
"""Return the pre-2.0 Electrum master public key, as Electrum writes it.
Electrum's Old_KeyStore.mpk_from_seed: the uncompressed SEC point of
the stretched key with its 04 prefix cut off, so 128 hex characters
of x and then y. That string is what a pre-2.0 wallet file holds
under "master_public_key", which is what makes it the value a vector
can be taken from.
"""
prv_key = old_master_prv_key_from_mnemonic(mnemonic, passphrase)
# scalar_from_prv_key and not mult alone: mult reduces the scalar mod n
# and would answer for a stretch that landed outside 1..n-1, where
# electrum's ECPrivkey raises. A 2**-128 disagreement, and refusing
# is the side that costs nothing
point = mult(scalar_from_prv_key(prv_key, secp256k1), secp256k1.G, secp256k1)
return bytes_from_point(point, secp256k1, compressed=False)[1:].hex()