Browse Source

mmgen-passgen: support BIP39 passwords

Examples:

  Generate three 24-word BIP39 mnemonic seed phrases from your default wallet
  for your Trezor device:

  $ mmgen-passgen --passwd-fmt=bip39 mytrezor 1-3

  Same, but generate 12-word seed phrases:

  $ mmgen-passgen --passwd-fmt=bip39 --passwd-len=12 mytrezor 1-3

Relevant tests:

  $ test/test.py ref ref3
MMGen 5 years ago
parent
commit
8705e57b8f

+ 65 - 7
mmgen/addr.py

@@ -594,7 +594,7 @@ Removed {{}} duplicate WIF key{{}} from keylist (also in {pnm} key-address file
 			out.append('# Record this value to a secure location.\n')
 
 		if type(self) == PasswordList:
-			lbl = '{} {} {}:{}'.format(self.al_id.sid,self.pw_id_str,self.pw_fmt,self.pw_len)
+			lbl = '{} {} {}:{}'.format(self.al_id.sid,self.pw_id_str,self.pw_fmt_disp,self.pw_len)
 		else:
 			bc,mt = g.proto.base_coin,self.al_id.mmtype
 			l_coin = [] if bc == 'BTC' else [g.coin] if bc == 'ETH' else [bc]
@@ -780,6 +780,10 @@ class KeyList(AddrList):
 	ext      = 'keys'
 	chksum_rec_f = lambda foo,e: (str(e.idx), e.addr, e.sec.wif)
 
+def is_bip39_str(s):
+	from mmgen.bip39 import bip39
+	return bool(bip39.tohex(s.split(),wl_id='bip39'))
+
 from collections import namedtuple
 class PasswordList(AddrList):
 	msgs = {
@@ -792,6 +796,13 @@ class PasswordList(AddrList):
 # password.  The label may contain any printable ASCII symbol.
 #
 """.strip().format(n=TwComment.max_screen_width,pnm=pnm),
+	'file_header_bip39': """
+# {pnm} BIP39 password file
+#
+# This file is editable.
+# Everything following a hash symbol '#' is a comment and ignored by {pnm}.
+#
+""".strip().format(pnm=pnm),
 	'record_chksum': """
 Record this checksum: it will be used to verify the password file in the future
 """.strip()
@@ -813,10 +824,14 @@ Record this checksum: it will be used to verify the password file in the future
 	pw_info     = {
 		'b32':   pwinfo(10, 42 ,24, None,       'base32 password',       is_b32_str),   # 32**24 < 2**128
 		'b58':   pwinfo(8,  36 ,20, None,       'base58 password',       is_b58_str),   # 58**20 < 2**128
+		'bip39': pwinfo(12, 24 ,24, [12,18,24], 'BIP39 mnemonic',        is_bip39_str),
 		'hex':   pwinfo(32, 64 ,64, [32,48,64], 'hexadecimal password',  is_hex_str),
 	}
 	chksum_rec_f = lambda foo,e: (str(e.idx), e.passwd)
 
+	feature_warn_fs = 'WARNING: {!r} is a potentially dangerous feature.  Use at your own risk!'
+	hex2bip39 = False
+
 	def __init__(   self,infile=None,seed=None,
 					pw_idxs=None,pw_id_str=None,pw_len=None,pw_fmt=None,
 					chk_params_only=False):
@@ -835,24 +850,35 @@ Record this checksum: it will be used to verify the password file in the future
 			self.set_pw_len(pw_len)
 			if chk_params_only:
 				return
+			if self.hex2bip39:
+				ymsg(self.feature_warn_fs.format(pw_fmt))
 			self.set_pw_len_vs_seed_len(pw_len,seed)
 			self.al_id = AddrListID(seed.sid,MMGenPasswordType('P'))
 			self.data = self.generate(seed,pw_idxs)
 
+		if self.pw_fmt == 'bip39':
+			self.msgs['file_header'] = self.msgs['file_header_bip39']
+
 		self.num_addrs = len(self.data)
 		self.fmt_data = ''
 		self.chksum = AddrListChksum(self)
 
-		fs = '{}-{}-{}-{}[{{}}]'.format(self.al_id.sid,self.pw_id_str,self.pw_fmt,self.pw_len)
+		fs = '{}-{}-{}-{}[{{}}]'.format(self.al_id.sid,self.pw_id_str,self.pw_fmt_disp,self.pw_len)
 		self.id_str = AddrListIDStr(self,fs)
 		qmsg('Checksum for {} data {}: {}'.format(self.data_desc,self.id_str.hl(),self.chksum.hl()))
 		qmsg(self.msgs[('record_chksum','check_chksum')[bool(infile)]])
 
 	def set_pw_fmt(self,pw_fmt):
-		if pw_fmt not in self.pw_info:
+		if pw_fmt == 'hex2bip39':
+			self.hex2bip39 = True
+			self.pw_fmt = 'bip39'
+			self.pw_fmt_disp = 'hex2bip39'
+		else:
+			self.pw_fmt = pw_fmt
+			self.pw_fmt_disp = pw_fmt
+		if self.pw_fmt not in self.pw_info:
 			m = '{!r}: invalid password format.  Valid formats: {}'
-			raise InvalidPasswdFormat(m.format(pw_fmt,', '.join(sorted(self.pw_info))))
-		self.pw_fmt = pw_fmt
+			raise InvalidPasswdFormat(m.format(self.pw_fmt,', '.join(sorted(self.pw_info))))
 
 	def chk_pw_len(self,passwd=None):
 		if passwd is None:
@@ -888,6 +914,10 @@ Record this checksum: it will be used to verify the password file in the future
 		if pf == 'hex':
 			pw_bytes = self.pw_len // 2
 			good_pw_len = seed.byte_len * 2
+		elif pf == 'bip39':
+			from mmgen.bip39 import bip39
+			pw_bytes = bip39.nwords2seedlen(self.pw_len,in_bytes=True)
+			good_pw_len = bip39.seedlen2nwords(len(seed.data),in_bytes=True)
 		elif pf in ('b32','b58'):
 			pw_int = (32 if pf == 'b32' else 58) ** self.pw_len
 			pw_bytes = pw_int.bit_length() // 8
@@ -901,7 +931,7 @@ Record this checksum: it will be used to verify the password file in the future
 					'Re-run the command, specifying a password length of {} or less' )
 			die(1,(m1+'\n'+m2).format(len(seed.data) * 8,good_pw_len))
 
-		if pf == 'hex' and pw_bytes < seed.byte_len:
+		if pf in ('bip39','hex') and pw_bytes < seed.byte_len:
 			m1 = 'WARNING: requested {} length has less entropy than underlying seed!'
 			m2 = 'Is this what you want?'
 			if not keypress_confirm((m1+'\n'+m2).format(self.pw_info[pf].desc),default_yes=True):
@@ -912,6 +942,11 @@ Record this checksum: it will be used to verify the password file in the future
 		if self.pw_fmt == 'hex':
 			# take most significant part
 			return hex_sec[:self.pw_len]
+		elif self.pw_fmt == 'bip39':
+			from mmgen.bip39 import bip39
+			pw_len_hex = bip39.nwords2seedlen(self.pw_len,in_hex=True)
+			# take most significant part
+			return ' '.join(bip39.fromhex(hex_sec[:pw_len_hex],wl_id='bip39'))
 		else:
 			# take least significant part
 			return baseconv.fromhex(hex_sec,self.pw_fmt,pad=self.pw_len,tostr=True)[-self.pw_len:]
@@ -919,7 +954,7 @@ Record this checksum: it will be used to verify the password file in the future
 	def check_format(self,pw):
 		if not self.pw_info[self.pw_fmt].chk_func(pw):
 			raise ValueError('Password is not valid {} data'.format(self.pw_info[self.pw_fmt].desc))
-		pwlen = len(pw)
+		pwlen = len(pw.split()) if self.pw_fmt == 'bip39' else len(pw)
 		if pwlen != self.pw_len:
 			raise ValueError('Password has incorrect length ({} != {})'.format(pwlen,self.pw_len))
 		return True
@@ -929,9 +964,32 @@ Record this checksum: it will be used to verify the password file in the future
 		# set of passwords to be generated: this is what we want.
 		# NB: In original implementation, pw_id_str was 'baseN', not 'bN'
 		scramble_key = '{}:{}:{}'.format(self.pw_fmt,self.pw_len,self.pw_id_str)
+
+		if self.hex2bip39:
+			from mmgen.bip39 import bip39
+			pwlen = bip39.nwords2seedlen(self.pw_len,in_hex=True)
+			scramble_key = '{}:{}:{}'.format('hex',pwlen,self.pw_id_str)
+
 		from mmgen.crypto import scramble_seed
 		return scramble_seed(seed,scramble_key.encode())
 
+	def get_line(self,lines):
+		self.line_ctr += 1
+		if self.pw_fmt == 'bip39':
+			ret = lines.pop(0).split(None,self.pw_len+1)
+			if len(ret) > self.pw_len+1:
+				m1 = 'extraneous text {!r} found after password'.format(ret[self.pw_len+1])
+				m2 = '[bare comments not allowed in BIP39 password files]'
+				m = m1+' '+m2
+			elif len(ret) < self.pw_len+1:
+				m = 'invalid password length {}'.format(len(ret)-1)
+			else:
+				return (ret[0],' '.join(ret[1:self.pw_len+1]),'')
+			raise ValueError(m)
+		else:
+			ret = lines.pop(0).split(None,2)
+			return ret if len(ret) == 3 else ret + ['']
+
 class AddrData(MMGenObject):
 	msgs = {
 	'too_many_acct_addresses': """

+ 15 - 0
mmgen/bip39.py

@@ -2092,6 +2092,21 @@ zoo
 	}
 	digits = { 'bip39': words }
 
+	@classmethod
+	def nwords2seedlen(cls,nwords,in_bytes=False,in_hex=False):
+		for k,v in cls.constants.items():
+			if v[1] == nwords:
+				return int(k)//8 if in_bytes else int(k)//4 if in_hex else int(k)
+		raise MnemonicError('{!r}: invalid word length for BIP39 mnemonic'.format(nwords))
+
+	@classmethod
+	def seedlen2nwords(cls,seed_len,in_bytes=False,in_hex=False):
+		seed_bits = seed_len * 8 if in_bytes else seed_len * 4 if in_hex else seed_len
+		try:
+			return cls.constants[str(seed_bits)][1]
+		except:
+			raise ValueError('{!r}: invalid seed length for BIP39 mnemonic'.format(seed_bits))
+
 	@classmethod
 	def tohex(cls,words,wl_id,pad=None):
 		assert isinstance(words,(list,tuple)),'words must be list or tuple'

+ 5 - 1
mmgen/main_passgen.py

@@ -93,6 +93,10 @@ EXAMPLES:
   Generate ten base32 passwords of length {i32.dfl_len} for Alice's email account:
   {g.prog_name} --passwd-fmt=b32 alice@nowhere.com 1-10
 
+  Generate three BIP39 mnemonic seed phrases of length {i39.dfl_len} for Alice's
+  Trezor device:
+  {g.prog_name} --passwd-fmt=bip39 mytrezor 1-3
+
   All passwords are cryptographically unlinkable with each other, including
   passwords with the same format but different length, so Alice needn't worry
   about inadvertent reuse of private data.
@@ -117,7 +121,7 @@ FMT CODES:
 			kgs=' '.join(['{}:{}'.format(n,k) for n,k in enumerate(g.key_generators,1)])
 		),
 		'notes': lambda s: s.format(
-				o=opts,g=g,i58=pwi['b58'],i32=pwi['b32'],
+				o=opts,g=g,i58=pwi['b58'],i32=pwi['b32'],i39=pwi['bip39'],
 				ml=MMGenPWIDString.max_len,
 				fs="', '".join(MMGenPWIDString.forbidden),
 				n_pw=help_notes('passwd'),

+ 6 - 0
test/ref/98831F3A-фубар@crypto.org-bip39-12[1,4,1100].pws

@@ -0,0 +1,6 @@
+# checksum: BF57 02A3 5229 CF18
+98831F3A фубар@crypto.org bip39:12 {
+  1     end later squirrel find token lens spring bubble solution museum uncover cat
+  4     auto nice blood insane buffalo client slender warrior thumb glass sing sunset
+  1100  swing rude gas illness silly cherry differ payment retreat whip sausage quiz
+}

+ 6 - 0
test/ref/98831F3A-фубар@crypto.org-bip39-18[1,4,1100].pws

@@ -0,0 +1,6 @@
+# checksum: 31D3 1656 B7DC 27CF
+98831F3A фубар@crypto.org bip39:18 {
+  1     dirt broom flag tragic wool grape image window paddle wrap enough negative jewel tiger theory have relief nephew
+  4     shrug catch neutral artwork zebra circle defy cream wild resist east custom recycle ability enact cable office inhale
+  1100  kitchen visa way various scrap odor syrup armed crater expire cushion table work better design afford umbrella market
+}

+ 6 - 0
test/ref/98831F3A-фубар@crypto.org-bip39-24[1,4,1100].pws

@@ -0,0 +1,6 @@
+# checksum: E565 3A59 7D91 4671
+98831F3A фубар@crypto.org bip39:24 {
+  1     letter sphere indoor maze surprise orange much state clinic sibling demise suggest tobacco economy resist lawsuit flock napkin report air rule catch cheap toilet
+  4     perfect squeeze jump term critic elegant satisfy apology chair soap they visual chimney keep absurd goddess time move party blade crawl enhance token uncover
+  1100  accident dad dilemma fault uphold dutch pony scout luxury puppy wave response gossip chronic denial spoon energy ugly uncover enlist regular ceiling found gesture
+}

+ 6 - 0
test/ref/98831F3A-фубар@crypto.org-hex2bip39-12[1,4,1100].pws

@@ -0,0 +1,6 @@
+# checksum: 93AD 4AE2 03D1 8A0A
+98831F3A фубар@crypto.org hex2bip39:12 {
+  1     session mobile spirit butter hand hip people rebuild flight there tower armed
+  4     cycle humor able term august budget lend cover usual grocery mountain journey
+  1100  reward tongue emotion kid humor sight lunar bread remove clinic fun valve
+}

+ 16 - 0
test/test_py_d/ts_ref.py

@@ -48,6 +48,10 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		'ref_passwdfile_hex_32': '98831F3A-фубар@crypto.org-hex-32[1,4,1100].pws',
 		'ref_passwdfile_hex_48': '98831F3A-фубар@crypto.org-hex-48[1,4,1100].pws',
 		'ref_passwdfile_hex_64': '98831F3A-фубар@crypto.org-hex-64[1,4,1100].pws',
+		'ref_passwdfile_bip39_12': '98831F3A-фубар@crypto.org-bip39-12[1,4,1100].pws',
+		'ref_passwdfile_bip39_18': '98831F3A-фубар@crypto.org-bip39-18[1,4,1100].pws',
+		'ref_passwdfile_bip39_24': '98831F3A-фубар@crypto.org-bip39-24[1,4,1100].pws',
+		'ref_passwdfile_hex2bip39_12': '98831F3A-фубар@crypto.org-hex2bip39-12[1,4,1100].pws',
 		'ref_tx_file': { # data shared with ref_altcoin, autosign
 			'btc': ('0B8D5A[15.31789,14,tl=1320969600].rawtx',
 					'0C7115[15.86255,14,tl=1320969600].testnet.rawtx'),
@@ -90,6 +94,10 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		'ref_passwdfile_hex_32_chksum': '05C7 3678 E25E BC32',
 		'ref_passwdfile_hex_48_chksum': '7DBB FFD0 633E DE6F',
 		'ref_passwdfile_hex_64_chksum': 'F11D CB0A 8AE3 4D21',
+		'ref_passwdfile_bip39_12_chksum': 'BF57 02A3 5229 CF18',
+		'ref_passwdfile_bip39_18_chksum': '31D3 1656 B7DC 27CF',
+		'ref_passwdfile_bip39_24_chksum': 'E565 3A59 7D91 4671',
+		'ref_passwdfile_hex2bip39_12_chksum': '93AD 4AE2 03D1 8A0A',
 	}
 	cmd_group = ( # TODO: move to tooltest2
 		('ref_words_to_subwallet_chk1','subwallet generation from reference words file (long subseed)'),
@@ -110,6 +118,10 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		('ref_passwdfile_chk_hex_32','saved reference password file (hexadecimal, 32 chars)'),
 		('ref_passwdfile_chk_hex_48','saved reference password file (hexadecimal, 48 chars)'),
 		('ref_passwdfile_chk_hex_64','saved reference password file (hexadecimal, 64 chars)'),
+		('ref_passwdfile_chk_bip39_12','saved reference password file (BIP39, 12 words)'),
+		('ref_passwdfile_chk_bip39_18','saved reference password file (BIP39, 18 words)'),
+		('ref_passwdfile_chk_bip39_24','saved reference password file (BIP39, 24 words)'),
+		('ref_passwdfile_chk_hex2bip39_12','saved reference password file (hex-to-BIP39, 12 words)'),
 
 #	Create the fake inputs:
 #	('txcreate8',          'transaction creation (8)'),
@@ -233,6 +245,10 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 	def ref_passwdfile_chk_hex_32(self): return self.ref_passwdfile_chk(key='hex_32',pat='Hexadec.*len.* 32\n')
 	def ref_passwdfile_chk_hex_48(self): return self.ref_passwdfile_chk(key='hex_48',pat='Hexadec.*len.* 48\n')
 	def ref_passwdfile_chk_hex_64(self): return self.ref_passwdfile_chk(key='hex_64',pat='Hexadec.*len.* 64\n')
+	def ref_passwdfile_chk_bip39_12(self): return self.ref_passwdfile_chk(key='bip39_12',pat='BIP39.*len.* 12\n')
+	def ref_passwdfile_chk_bip39_18(self): return self.ref_passwdfile_chk(key='bip39_18',pat='BIP39.*len.* 18\n')
+	def ref_passwdfile_chk_bip39_24(self): return self.ref_passwdfile_chk(key='bip39_24',pat='BIP39.*len.* 24\n')
+	def ref_passwdfile_chk_hex2bip39_12(self): return self.ref_passwdfile_chk(key='hex2bip39_12',pat='BIP39.*len.* 12\n')
 
 	def ref_tx_chk(self):
 		fn = self.sources['ref_tx_file'][g.coin.lower()][bool(self.tn_ext)]

+ 28 - 0
test/test_py_d/ts_ref_3seed.py

@@ -77,6 +77,10 @@ class TestSuiteRef3Seed(TestSuiteBase,TestSuiteShared):
 		'ref_b32passwdgen_1': '37B6 C218 2ABC 7508',
 		'ref_hexpasswdgen_1': '8E99 E696 84CE E7D5',
 		'ref_hexpasswdgen_half_1': '8E99 E696 84CE E7D5',
+		'ref_bip39_12_passwdgen_1': '834F CF45 0B33 8AF0',
+		'ref_bip39_18_passwdgen_1': '834F CF45 0B33 8AF0',
+		'ref_bip39_24_passwdgen_1': '834F CF45 0B33 8AF0',
+		'ref_hex2bip39_24_passwdgen_1': '91AF E735 A31D 72A0',
 		'refaddrgen_legacy_2': {
 			'btc': ('8C17 A5FA 0470 6E89','764C 66F9 7502 AAEA'),
 			'ltc': ('2B77 A009 D5D0 22AD','51D1 979D 0A35 F24B'),
@@ -114,6 +118,10 @@ class TestSuiteRef3Seed(TestSuiteBase,TestSuiteShared):
 		'ref_b32passwdgen_2': '2A28 C5C7 36EC 217A',
 		'ref_hexpasswdgen_2': '88F9 0D48 3A7E 7CC2',
 		'ref_hexpasswdgen_half_2': '59F3 8F48 861E 1186',
+		'ref_bip39_12_passwdgen_2': 'D32D B8D7 A840 250B',
+		'ref_bip39_18_passwdgen_2': '0FAA 78DD A6BA 31AD',
+		'ref_bip39_24_passwdgen_2': '0FAA 78DD A6BA 31AD',
+		'ref_hex2bip39_24_passwdgen_2': '0E8E 23C9 923F 7C2D',
 		'refaddrgen_legacy_3': {
 			'btc': ('6FEF 6FB9 7B13 5D91','424E 4326 CFFE 5F51'),
 			'ltc': ('AD52 C3FE 8924 AAF0','4EBE 2E85 E969 1B30'),
@@ -151,6 +159,10 @@ class TestSuiteRef3Seed(TestSuiteBase,TestSuiteShared):
 		'ref_b32passwdgen_3': 'F6C1 CDFB 97D9 FCAE',
 		'ref_hexpasswdgen_3': 'BD4F A0AC 8628 4BE4',
 		'ref_hexpasswdgen_half_3': 'FBDD F733 FFB9 21C1',
+		'ref_bip39_12_passwdgen_3': 'A86E EA14 974A 1B0E',
+		'ref_bip39_18_passwdgen_3': 'EF87 9904 88E2 5884',
+		'ref_bip39_24_passwdgen_3': 'EBE8 2A8F 8F8C 7DBD',
+		'ref_hex2bip39_24_passwdgen_3': '93FA 5EFD 33F3 760E',
 	}
 	cmd_group = (
 		# reading
@@ -182,6 +194,10 @@ class TestSuiteRef3Seed(TestSuiteBase,TestSuiteShared):
 		('ref_b32passwdgen',     (['mmdat',pwfile],'new refwallet passwd file chksum (base32)')),
 		('ref_hexpasswdgen',     (['mmdat',pwfile],'new refwallet passwd file chksum (hex)')),
 		('ref_hexpasswdgen_half',(['mmdat',pwfile],'new refwallet passwd file chksum (hex, half-length)')),
+		('ref_bip39_12_passwdgen',(['mmdat',pwfile],'new refwallet passwd file chksum (BIP39, 12 words)')),
+		('ref_bip39_18_passwdgen',(['mmdat',pwfile],'new refwallet passwd file chksum (BIP39, up to 18 words)')),
+		('ref_bip39_24_passwdgen',(['mmdat',pwfile],'new refwallet passwd file chksum (BIP39, up to 24 words)')),
+		('ref_hex2bip39_24_passwdgen',(['mmdat',pwfile],'new refwallet passwd file chksum (hex-to-BIP39, up to 24 words)')),
 	)
 
 	def __init__(self,trunner,cfgs,spawn):
@@ -339,3 +355,15 @@ class TestSuiteRef3Seed(TestSuiteBase,TestSuiteShared):
 	def ref_hexpasswdgen_half(self,wf,pf):
 		ea = ['--passwd-fmt=hex','--passwd-len=h','--accept-defaults']
 		return self.addrgen(wf,pf,check_ref=True,ftype='passhex',id_str='фубар@crypto.org',extra_args=ea,stdout=1)
+
+	def ref_bip39_passwdgen(self,wf,pf,req_pw_len,pw_fmt='bip39',stdout=False):
+		pw_len = min(req_pw_len,{'1':12,'2':18,'3':24}[self.test_name[-1]])
+		ea = ['--passwd-fmt='+pw_fmt,'--passwd-len={}'.format(pw_len),'--accept-defaults']
+		return self.addrgen(
+			wf,pf,check_ref=True,ftype='passbip39',id_str='фубар@crypto.org',extra_args=ea,stdout=stdout)
+
+	def ref_bip39_12_passwdgen(self,wf,pf): return self.ref_bip39_passwdgen(wf,pf,12,stdout=True)
+	def ref_bip39_18_passwdgen(self,wf,pf): return self.ref_bip39_passwdgen(wf,pf,18,stdout=True)
+	def ref_bip39_24_passwdgen(self,wf,pf): return self.ref_bip39_passwdgen(wf,pf,24)
+
+	def ref_hex2bip39_24_passwdgen(self,wf,pf): return self.ref_bip39_passwdgen(wf,pf,24,'hex2bip39')