baseconv: move Monero mnemonic code to new xmrseed class

- a new unit test has also been added

Testing:

    $ test/unit_tests.py mn_entry xmrseed
    $ test/tooltest2.py hex2mn mn2hex
    $ test/test.py ref ref3_addr
This commit is contained in:
The MMGen Project 2022-01-18 09:10:59 +00:00
commit d6f82fb6c9
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
10 changed files with 251 additions and 82 deletions

View file

@ -30,9 +30,6 @@ def is_b58_str(s):
def is_b32_str(s):
return set(list(s)) <= set(baseconv.digits['b32'])
def is_xmrseed(s):
return bool( baseconv('xmrseed').tobytes(s.split()) )
class baseconv(object):
desc = {
@ -45,7 +42,6 @@ class baseconv(object):
# 'tirosh':('Tirosh mnemonic', 'base1626 mnemonic using truncated Tirosh wordlist'), # not used by wallet
'mmgen': ('MMGen native mnemonic',
'MMGen native mnemonic seed phrase created using old Electrum wordlist and simple base conversion'),
'xmrseed': ('Monero mnemonic', 'Monero new-style mnemonic seed phrase'),
}
# https://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet
# https://tools.ietf.org/html/rfc4648
@ -60,7 +56,6 @@ class baseconv(object):
mn_base = 1626
wl_chksums = {
'mmgen': '5ca31424',
'xmrseed':'3c381ebb',
# 'tirosh': '48f05e1f', # tirosh truncated to mn_base
# 'tirosh1633': '1a5faeff' # tirosh list is 1633 words long!
}
@ -68,13 +63,11 @@ class baseconv(object):
'b58': { 16:22, 24:33, 32:44 },
'b6d': { 16:50, 24:75, 32:100 },
'mmgen': { 16:12, 24:18, 32:24 },
'xmrseed': { 32:25 },
}
seedlen_map_rev = {
'b58': { 22:16, 33:24, 44:32 },
'b6d': { 50:16, 75:24, 100:32 },
'mmgen': { 12:16, 18:24, 24:32 },
'xmrseed': { 25:32 },
}
def __init__(self,wl_id):
@ -82,9 +75,6 @@ class baseconv(object):
if wl_id == 'mmgen':
from .mn_electrum import words
self.digits[wl_id] = words
elif wl_id == 'xmrseed':
from .mn_monero import words
self.digits[wl_id] = words
elif wl_id not in self.digits:
raise ValueError(f'{wl_id}: unrecognized mnemonic ID')
@ -132,12 +122,6 @@ class baseconv(object):
else:
raise BaseConversionPadError(f"{pad!r}: illegal value for 'pad' (must be None,'seed' or int)")
@staticmethod
def monero_mn_checksum(words):
from binascii import crc32
wstr = ''.join(word[:3] for word in words)
return words[crc32(wstr.encode()) % len(words)]
def tohex(self,words_arg,pad=None):
"convert string or list data of instance base to hex string"
return self.tobytes(words_arg,pad//2 if type(pad)==int else pad).hex()
@ -168,21 +152,6 @@ class baseconv(object):
( 'seed data' if pad == 'seed' else f'{words_arg!r}:' ) +
f' not in {desc} format' )
if self.wl_id == 'xmrseed':
if len(words) not in self.seedlen_map_rev['xmrseed']:
die(2,f'{len(words)}: invalid length for Monero mnemonic')
z = self.monero_mn_checksum(words[:-1])
assert z == words[-1],'invalid Monero mnemonic checksum'
words = tuple(words[:-1])
ret = b''
for i in range(len(words)//3):
w1,w2,w3 = [wl.index(w) for w in words[3*i:3*i+3]]
x = w1 + base*((w2-w1)%base) + base*base*((w3-w2)%base)
ret += x.to_bytes(4,'big')[::-1]
return ret
ret = sum([wl.index(words[::-1][i])*(base**i) for i in range(len(words))])
bl = ret.bit_length()
return ret.to_bytes(max(pad_val,bl//8+bool(bl%8)),'big')
@ -214,28 +183,15 @@ class baseconv(object):
pad = max(self.get_pad(pad,get_seed_pad),1)
wl = self.digits[self.wl_id]
base = len(wl)
if self.wl_id == 'xmrseed':
if len(bytestr) not in self.seedlen_map['xmrseed']:
die(2, f'{len(bytestr)}: invalid seed byte length for Monero mnemonic')
def num2base_monero(num):
w1 = num % base
w2 = (num//base + w1) % base
w3 = (num//base//base + w2) % base
return [wl[w1], wl[w2], wl[w3]]
o = []
for i in range(len(bytestr)//4):
o += num2base_monero(int.from_bytes(bytestr[i*4:i*4+4][::-1],'big'))
o.append(self.monero_mn_checksum(o))
else:
def gen():
num = int.from_bytes(bytestr,'big')
ret = []
base = len(wl)
while num:
ret.append(num % base)
yield num % base
num //= base
o = [wl[n] for n in [0] * (pad-len(ret)) + ret[::-1]]
return (' ' if self.wl_id in ('mmgen','xmrseed') else '').join(o) if tostr else o
ret = list(gen())
o = [wl[n] for n in [0] * (pad-len(ret)) + ret[::-1]]
return (' ' if self.wl_id == 'mmgen' else '').join(o) if tostr else o

View file

@ -420,7 +420,7 @@ class MnemonicEntryBIP39(MnemonicEntry):
class MnemonicEntryMonero(MnemonicEntry):
wl_id = 'xmrseed'
modname = 'baseconv'
modname = 'xmrseed'
entry_modes = ('full','short')
dfl_entry_mode = 'short'
has_chksum = True

View file

@ -25,8 +25,9 @@ from collections import namedtuple
from .exception import InvalidPasswdFormat
from .util import ymsg,is_hex_str,is_int,keypress_confirm
from .obj import ImmutableAttr,ListItemAttr,MMGenPWIDString
from .baseconv import baseconv,is_b32_str,is_b58_str,is_xmrseed
from .baseconv import baseconv,is_b32_str,is_b58_str
from .bip39 import is_bip39_str
from .xmrseed import is_xmrseed
from .key import PrivKey
from .addr import MMGenPasswordType,AddrIdx,AddrListID
from .addrlist import (
@ -158,9 +159,10 @@ class PasswordList(AddrList):
pw_bytes = bip39.nwords2seedlen(self.pw_len,in_bytes=True)
good_pw_len = bip39.seedlen2nwords(seed.byte_len,in_bytes=True)
elif pf == 'xmrseed':
pw_bytes = baseconv.seedlen_map_rev['xmrseed'][self.pw_len]
from .xmrseed import xmrseed
pw_bytes = xmrseed.seedlen_map_rev['xmrseed'][self.pw_len]
try:
good_pw_len = baseconv.seedlen_map['xmrseed'][seed.byte_len]
good_pw_len = xmrseed.seedlen_map['xmrseed'][seed.byte_len]
except:
die(1,f'{seed.byte_len*8}: unsupported seed length for Monero new-style mnemonic')
elif pf in ('b32','b58'):
@ -196,12 +198,13 @@ class PasswordList(AddrList):
# take most significant part
return ' '.join( bip39().fromhex(secbytes[:pw_len_bytes].hex()) )
elif self.pw_fmt == 'xmrseed':
pw_len_bytes = baseconv.seedlen_map_rev['xmrseed'][self.pw_len]
from .xmrseed import xmrseed
pw_len_bytes = xmrseed.seedlen_map_rev['xmrseed'][self.pw_len]
from .protocol import init_proto
bytes_preproc = init_proto('xmr').preprocess_key(
secbytes[:pw_len_bytes], # take most significant part
None )
return ' '.join( baseconv('xmrseed').frombytes(bytes_preproc) )
return ' '.join( xmrseed().frombytes(bytes_preproc) )
else:
# take least significant part
return baseconv(self.pw_fmt).frombytes(

View file

@ -29,6 +29,7 @@ from .addr import *
from .addrlist import AddrList,KeyAddrList
from .passwdlist import PasswordList
from .baseconv import baseconv
from .xmrseed import xmrseed
from .bip39 import bip39
NL = ('\n','\r\n')[g.platform=='win']
@ -238,7 +239,7 @@ mft = namedtuple('mnemonic_format',['fmt','pad','conv_cls'])
mnemonic_fmts = {
'mmgen': mft( 'words', 'seed', baseconv ),
'bip39': mft( 'bip39', None, bip39 ),
'xmrseed': mft( 'xmrseed', None, baseconv ),
'xmrseed': mft( 'xmrseed', None, xmrseed ),
}
mn_opts_disp = _options_annot_str(mnemonic_fmts)

101
mmgen/xmrseed.py Executable file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
xmrseed.py: Monero mnemonic conversion class for the MMGen suite
"""
from .exception import *
from .baseconv import baseconv
from .util import die
def is_xmrseed(s):
return bool( xmrseed().tobytes(s.split()) )
# implements a subset of the baseconv API
class xmrseed(baseconv):
desc = { 'xmrseed': ('Monero mnemonic', 'Monero new-style mnemonic seed phrase') }
wl_chksums = { 'xmrseed': '3c381ebb' }
seedlen_map = { 'xmrseed': { 32:25 } }
seedlen_map_rev = { 'xmrseed': { 25:32 } }
def __init__(self,wl_id='xmrseed'):
assert wl_id == 'xmrseed', "initialize with 'xmrseed' for compatibility with baseconv API"
from .mn_monero import words
self.digits = { 'xmrseed': words }
self.wl_id = 'xmrseed'
@staticmethod
def monero_mn_checksum(words):
from binascii import crc32
wstr = ''.join(word[:3] for word in words)
return words[crc32(wstr.encode()) % len(words)]
def tobytes(self,words,pad=None):
assert isinstance(words,(list,tuple)),'words must be list or tuple'
assert pad == None, f"{pad}: invalid 'pad' argument (must be None)"
desc = self.desc[self.wl_id][0]
wl = self.digits[self.wl_id]
base = len(wl)
if not set(words) <= set(wl):
raise MnemonicError( f'{words!r}: not in {desc} format' )
if len(words) not in self.seedlen_map_rev['xmrseed']:
raise MnemonicError( f'{len(words)}: invalid seed phrase length for {desc}' )
z = self.monero_mn_checksum(words[:-1])
if z != words[-1]:
raise MnemonicError(f'invalid {desc} checksum')
words = tuple(words[:-1])
def gen():
for i in range(len(words)//3):
w1,w2,w3 = [wl.index(w) for w in words[3*i:3*i+3]]
x = w1 + base*((w2-w1)%base) + base*base*((w3-w2)%base)
yield x.to_bytes(4,'big')[::-1]
return b''.join(gen())
def frombytes(self,bytestr,pad=None,tostr=False):
assert pad == None, f"{pad}: invalid 'pad' argument (must be None)"
desc = self.desc[self.wl_id][0]
wl = self.digits[self.wl_id]
base = len(wl)
if len(bytestr) not in self.seedlen_map['xmrseed']:
raise SeedLengthError(f'{len(bytestr)}: invalid seed byte length for {desc}')
def num2base_monero(num):
w1 = num % base
w2 = (num//base + w1) % base
w3 = (num//base//base + w2) % base
return ( wl[w1], wl[w2], wl[w3] )
def gen():
for i in range(len(bytestr)//4):
for e in num2base_monero( int.from_bytes( bytestr[i*4:i*4+4][::-1], 'big' ) ):
yield e
o = list(gen())
o.append( self.monero_mn_checksum(o) )
return ' '.join(o) if tostr else tuple(o)

View file

@ -579,12 +579,12 @@ class MoneroWalletOps:
async def process_wallet(self,d,fn,last):
msg_r('') # for pexpect
from .baseconv import baseconv
from .xmrseed import xmrseed
ret = await self.c.call(
'restore_deterministic_wallet',
filename = os.path.basename(fn),
password = d.wallet_passwd,
seed = baseconv('xmrseed').fromhex(d.sec.wif,tostr=True),
seed = xmrseed().fromhex(d.sec.wif,tostr=True),
restore_height = uopt.restore_height,
language = 'English' )

View file

@ -34,6 +34,7 @@ sys.path.insert(0,overlay_setup(repo_root))
from mmgen.common import *
from test.include.common import *
from mmgen.wallet import is_bip39_mnemonic,is_mmgen_mnemonic
from mmgen.xmrseed import is_xmrseed
from mmgen.baseconv import *
skipped_tests = ['mn2hex_interactive']

View file

@ -9,23 +9,6 @@ from mmgen.exception import *
class unit_test(object):
vectors = {
'xmrseed': (
# 42nsXK8WbVGTNayQ6Kjw5UdgqbQY5KCCufdxdCgF7NgTfjC69Mna7DJSYyie77hZTQ8H92G2HwgFhgEUYnDzrnLnQdF28r3
(('0000000000000000000000000000000000000000000000000000000000000001','seed'), # 0x1
'abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey bamboo jaws jerseys abbey'),
# 49voQEbjouUQSDikRWKUt1PGbS47TBde4hiGyftN46CvTDd8LXCaimjHRGtofCJwY5Ed5QhYwc12P15AH5w7SxUAMCz1nr1
(('1c95988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0f','seed'), # 0xffffffff * 8
'powder directed sayings enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying soil software foamy solved soggy foamy solved soggy jury yawning ankle solved'),
# 41i7saPWA53EoHenmJVRt34dubPxsXwoWMnw8AdMyx4mTD1svf7qYzcVjxxRfteLNdYrAxWUMmiPegFW9EfoNgXx7vDMExv
(('e8164dda6d42bd1e261a3406b2038dcbddadbeefdeadbeefdeadbeefdeadbe0f','seed'), # 0xdeadbeef * 8
'viewpoint donuts ardent template unveil agile meant unafraid urgent athlete rustled mime azure jaded hawk baby jagged haystack baby jagged haystack ramped oncoming point template'),
# 42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm
(('148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e','seed'), # Monero repo
'velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted'),
),
'b58': (
(('00',None),'1'),
(('00',1),'1'),
@ -180,9 +163,7 @@ class unit_test(object):
for (hexstr,pad),ret_chk in data:
if type(pad) == int:
pad = len(hexstr)
ret = baseconv(base).tohex(
ret_chk.split() if base == 'xmrseed' else ret_chk,
pad=pad)
ret = baseconv(base).tohex( ret_chk, pad=pad )
if pad == None:
assert int(ret,16) == int(hexstr,16), rerr.format(int(ret,16),int(hexstr,16))
else:

View file

@ -139,7 +139,7 @@ class unit_tests:
'restore_deterministic_wallet',
filename = fn,
password = 'foo',
seed = baseconv('xmrseed').fromhex('beadface'*8,tostr=True) )
seed = xmrseed().fromhex('beadface'*8,tostr=True) )
qmsg(f'Opening {wd.network} wallet')
await c.call( 'open_wallet', filename=fn, password='foo' )
@ -152,7 +152,7 @@ class unit_tests:
gmsg('OK')
from mmgen.baseconv import baseconv
from mmgen.xmrseed import xmrseed
import shutil
shutil.rmtree('test/trash2',ignore_errors=True)
os.makedirs('test/trash2')

126
test/unit_tests_d/ut_xmrseed.py Executable file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
test/unit_tests_d/ut_xmrseed: Monero mnemonic unit test for the MMGen suite
"""
from mmgen.common import *
from mmgen.exception import *
class unit_test(object):
vectors = ( # private keys are reduced
( '148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e', # Monero repo
'velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches ' +
'lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted',
),
( 'e8164dda6d42bd1e261a3406b2038dcbddadbeefdeadbeefdeadbeefdeadbe0f',
'viewpoint donuts ardent template unveil agile meant unafraid urgent athlete rustled mime azure ' +
'jaded hawk baby jagged haystack baby jagged haystack ramped oncoming point template'
),
( '6900dea9753f5c7ced87b53bdcfb109a8417bca6a2797a708194157b227fb60b',
'criminal bamboo scamper gnaw limits womanly wrong tuition birth mundane donuts square cohesive ' +
'dolphin titans narrate fuel saved wrap aloof magically mirror together update wrap'
),
( '0000000000000000000000000000000000000000000000000000000000000001',
'abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey ' +
'abbey abbey abbey abbey abbey bamboo jaws jerseys abbey'
),
( '1c95988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0f',
'powder directed sayings enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying ' +
'soil software foamy solved soggy foamy solved soggy jury yawning ankle solved'
),
( '2c94988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0f',
'memoir apart olive enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying soil ' +
'software foamy solved soggy foamy solved soggy jury yawning ankle foamy'
),
( '4bb0288c9673b69fa68c2174851884abbaaedce6af48a03bbfd25e8cd0364102',
'rated bicycle pheasants dejected pouch fizzle shipped rash citadel queen avatar sample muzzle mews ' +
'jagged origin yeti dunes obtains godfather unbending pastry vortex washing citadel'
),
( '4bb0288c9673b69fa68c2174851884abbaaedce6af48a03bbfd25e8cd0364100',
'rated bicycle pheasants dejected pouch fizzle shipped rash citadel queen avatar sample muzzle mews ' +
'jagged origin yeti dunes obtains godfather unbending kangaroo auctions audio citadel'
),
( '1d95988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0e',
'pram distance scamper enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying ' +
'soil software foamy solved soggy foamy solved soggy hashing mullet onboard solved'
),
( 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0f',
'foamy solved soggy foamy solved soggy foamy solved soggy foamy solved soggy foamy solved soggy ' +
'foamy solved soggy foamy solved soggy jury yawning ankle soggy'
),
)
def run_test(self,name,ut):
def test_fromhex(b):
vmsg('')
qmsg('Checking seed to mnemonic conversion:')
for privhex,chk in self.vectors:
vmsg(f' {chk}')
chk = tuple(chk.split())
res = b.fromhex(privhex)
if use_moneropy:
mp_chk = tuple( mnemonic.mn_encode(privhex) )
assert res[:24] == mp_chk, f'check failed:\nres: {res[:24]}\nchk: {chk}'
assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
def test_tohex(b):
vmsg('')
qmsg('Checking mnemonic to seed conversion:')
for chk,words in self.vectors:
vmsg(f' {chk}')
res = b.tohex( words.split() )
if use_moneropy:
mp_chk = mnemonic.mn_decode( words.split() )
assert res == mp_chk, f'check failed:\nres: {res}\nchk: {mp_chk}'
assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
msg_r('Testing xmrseed conversion routines...')
qmsg('')
from mmgen.xmrseed import xmrseed
b = xmrseed()
b.check_wordlist()
try:
from moneropy import mnemonic
except ImportError:
use_moneropy = False
ymsg('Warning: unable to import moneropy, skipping external library checks')
else:
use_moneropy = True
test_fromhex(b)
test_tohex(b)
vmsg('')
qmsg('Checking error handling:')
bad_chksum_mn = ('abbey ' * 21 + 'bamboo jaws jerseys donuts').split()
bad_word_mn = "admire zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo".split()
bad_seed = 'deadbeef'
good_mn = self.vectors[0][1].split()
good_hex = self.vectors[0][0]
bad_len_mn = good_mn[:22]
th = b.tohex
fh = b.fromhex
bad_data = (
('hex', 'HexadecimalStringError', 'not a hexadecimal', lambda:fh('xx')),
('seed len', 'SeedLengthError', 'invalid seed byte len', lambda:fh(bad_seed)),
('mnemonic type', 'AssertionError', 'must be list', lambda:th('string')),
('pad arg (fromhex)', 'AssertionError', "invalid 'pad' arg", lambda:fh(good_hex,pad=23)),
('pad arg (tohex)', 'AssertionError', "invalid 'pad' arg", lambda:th(good_mn,pad=23)),
('word', 'MnemonicError', "not in Monero", lambda:th(bad_word_mn)),
('checksum', 'MnemonicError', "checksum", lambda:th(bad_chksum_mn)),
('seed phrase len', 'MnemonicError', "phrase len", lambda:th(bad_len_mn)),
)
ut.process_bad_data(bad_data)
vmsg('')
msg('OK')
return True