mmgen-wallet/mmgen/baseconv.py
The MMGen Project cfa16418b3
limited Monero mnemonic seed phrase ('xmrseed') support
- only 256-bit (25-word) new-style mnemonics are supported

Testing:

  $ test/unit_tests.py baseconv
  $ test/tooltest2.py hex2mn mn2hex
  $ test/scrambletest.py pw
  $ test/test.py ref_xmrseed_25_passwdgen_3
  $ test/test.py ref_passwdfile_chk_xmrseed_25

The following operations are supported:

  Generate a random Monero mnemonic:

  $ mmgen-tool mn_rand256 fmt=xmrseed

  Generate a Monero mnemonic from hexadecimal data:

  $ mmgen-tool hex2mn deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef fmt=xmrseed

  Convert the resulting mnemonic back to hexadecimal data:

  $ mmgen-tool mn2hex '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' fmt=xmrseed

  Note that the result of the reversal does not match the original input.  This
  is because input data is reduced to a spendkey before conversion so that a
  canonical seed phrase is produced.  This is required because Monero seeds,
  unlike ordinary wallet seeds, are tied to a concrete key/address pair.  The
  spendkey can be generated directly using the `hex2wif` command:

  $ mmgen-tool --coin=xmr hex2wif deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef

  Generate a list of passwords in Monero mnemonic format with ID 'mymonero':

  $ mmgen-passgen -f xmrseed 'mymonero' 1-10
2020-02-12 10:38:11 +00:00

252 lines
8.4 KiB
Python
Executable file

#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
# Copyright (C)2013-2019 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/>.
"""
baseconv.py: base conversion class for the MMGen suite
"""
from hashlib import sha256
from mmgen.exception import *
from mmgen.util import die
def is_b58_str(s): return set(list(s)) <= set(baseconv.digits['b58'])
def is_b32_str(s): return set(list(s)) <= set(baseconv.digits['b32'])
class baseconv(object):
desc = {
'b58': ('base58', 'base58-encoded data'),
'b32': ('MMGen base32', 'MMGen base32-encoded data created using simple base conversion'),
'b16': ('hexadecimal string','base16 (hexadecimal) string data'),
'b10': ('base10 string', 'base10 (decimal) string data'),
'b8': ('base8 string', 'base8 (octal) string data'),
'b6d': ('base6d (die roll)', 'base6 data using the digits from one to six'),
'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
digits = {
'b58': tuple('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'),
'b32': tuple('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'), # RFC 4648 alphabet
'b16': tuple('0123456789abcdef'),
'b10': tuple('0123456789'),
'b8': tuple('01234567'),
'b6d': tuple('123456'),
}
mn_base = 1626 # tirosh list is 1633 words long!
wl_chksums = {
'mmgen': '5ca31424',
'xmrseed':'3c381ebb',
'tirosh': '48f05e1f', # tirosh truncated to mn_base (1626)
# 'tirosh1633': '1a5faeff'
}
seedlen_map = {
'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 },
}
@classmethod
def init_mn(cls,mn_id):
if mn_id in cls.digits:
return
if mn_id == 'mmgen':
from mmgen.mn_electrum import words
cls.digits[mn_id] = words
elif mn_id == 'xmrseed':
from mmgen.mn_monero import words
cls.digits[mn_id] = words
elif mn_id == 'tirosh':
from mmgen.mn_tirosh import words
cls.digits[mn_id] = words[:cls.mn_base]
else:
raise ValueError('{}: unrecognized mnemonic ID'.format(mn_id))
@classmethod
def get_wordlist(cls,wl_id):
cls.init_mn(wl_id)
return cls.digits[wl_id]
@classmethod
def get_wordlist_chksum(cls,wl_id):
cls.init_mn(wl_id)
return sha256(' '.join(cls.digits[wl_id]).encode()).hexdigest()[:8]
@classmethod
def check_wordlists(cls):
for k,v in list(cls.wl_chksums.items()):
res = cls.get_wordlist_chksum(k)
assert res == v,'{}: checksum mismatch for {} (should be {})'.format(res,k,v)
@classmethod
def check_wordlist(cls,wl_id):
cls.init_mn(wl_id)
wl = cls.digits[wl_id]
from mmgen.util import qmsg,compare_chksums
qmsg('Wordlist: {}\nLength: {} words'.format(wl_id,len(wl)))
new_chksum = cls.get_wordlist_chksum(wl_id)
a,b = 'generated','saved'
compare_chksums(new_chksum,a,cls.wl_chksums[wl_id],b,die_on_fail=True)
qmsg('List is sorted') if tuple(sorted(wl)) == wl else die(3,'ERROR: List is not sorted!')
@classmethod
def get_pad(cls,pad,seed_pad_func):
"""
'pad' argument to baseconv conversion methods must be either None, 'seed' or an integer.
If None, output of minimum (but never zero) length will be produced.
If 'seed', output length will be mapped from input length using data in seedlen_map.
If an integer, the string, hex string or byte output will be padded to this length.
"""
if pad == None:
return 0
elif type(pad) == int:
return pad
elif pad == 'seed':
return seed_pad_func()
else:
m = "{!r}: illegal value for 'pad' (must be None,'seed' or int)"
raise BaseConversionPadError(m.format(pad))
@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)]
@classmethod
def tohex(cls,words_arg,wl_id,pad=None):
"convert string or list data of base 'wl_id' to hex string"
return cls.tobytes(words_arg,wl_id,pad//2 if type(pad)==int else pad).hex()
@classmethod
def tobytes(cls,words_arg,wl_id,pad=None):
"convert string or list data of base 'wl_id' to byte string"
if wl_id not in cls.digits:
cls.init_mn(wl_id)
words = words_arg if isinstance(words_arg,(list,tuple)) else tuple(words_arg.strip())
desc = cls.desc[wl_id][0]
if len(words) == 0:
raise BaseConversionError('empty {} data'.format(desc))
def get_seed_pad():
assert wl_id in cls.seedlen_map_rev,'seed padding not supported for base {!r}'.format(wl_id)
d = cls.seedlen_map_rev[wl_id]
if not len(words) in d:
m = '{}: invalid length for seed-padded {} data in base conversion'
raise BaseConversionError(m.format(len(words),desc))
return d[len(words)]
pad_val = max(cls.get_pad(pad,get_seed_pad),1)
wl = cls.digits[wl_id]
base = len(wl)
if not set(words) <= set(wl):
m = ('{w!r}:','seed data')[pad=='seed'] + ' not in {d} format'
raise BaseConversionError(m.format(w=words_arg,d=desc))
if wl_id == 'xmrseed':
if len(words) not in cls.seedlen_map_rev['xmrseed']:
die(2,'{}: invalid length for Monero mnemonic'.format(len(words)))
z = cls.monero_mn_checksum(words[:-1])
assert z == words[-1],'{!r}: invalid Monero checksum (should be {!r})'.format(words[-1],z)
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')
@classmethod
def fromhex(cls,hexstr,wl_id,pad=None,tostr=False):
"convert hex string to list or string data of base 'wl_id'"
from mmgen.util import is_hex_str
if not is_hex_str(hexstr):
m = ('{h!r}:','seed data')[pad=='seed'] + ' not a hexadecimal string'
raise HexadecimalStringError(m.format(h=hexstr))
return cls.frombytes(bytes.fromhex(hexstr),wl_id,pad,tostr)
@classmethod
def frombytes(cls,bytestr,wl_id,pad=None,tostr=False):
"convert byte string to list or string data of base 'wl_id'"
if wl_id not in cls.digits:
cls.init_mn(wl_id)
if not bytestr:
raise BaseConversionError('empty data not allowed in base conversion')
def get_seed_pad():
assert wl_id in cls.seedlen_map,'seed padding not supported for base {!r}'.format(wl_id)
d = cls.seedlen_map[wl_id]
if not len(bytestr) in d:
m = '{}: invalid byte length for seed data in seed-padded base conversion'
raise SeedLengthError(m.format(len(bytestr)))
return d[len(bytestr)]
pad = max(cls.get_pad(pad,get_seed_pad),1)
wl = cls.digits[wl_id]
base = len(wl)
if wl_id == 'xmrseed':
if len(bytestr) not in cls.seedlen_map['xmrseed']:
die(2,'{}: invalid seed byte length for Monero mnemonic'.format(len(bytestr)))
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(cls.monero_mn_checksum(o))
else:
num = int.from_bytes(bytestr,'big')
ret = []
while num:
ret.append(num % base)
num //= base
o = [wl[n] for n in [0] * (pad-len(ret)) + ret[::-1]]
return (' ' if wl_id in ('mmgen','xmrseed') else '').join(o) if tostr else o