Browse Source

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
The MMGen Project 5 years ago
parent
commit
cfa16418b3

+ 21 - 4
mmgen/addr.py

@@ -783,6 +783,9 @@ def is_bip39_str(s):
 	from mmgen.bip39 import bip39
 	return bool(bip39.tohex(s.split(),wl_id='bip39'))
 
+def is_xmrseed(s):
+	return bool(baseconv.tobytes(s.split(),wl_id='xmrseed'))
+
 from collections import namedtuple
 class PasswordList(AddrList):
 	msgs = {
@@ -824,6 +827,7 @@ Record this checksum: it will be used to verify the password file in the future
 		'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),
+		'xmrseed': pwinfo(25, 25, 25, [25],     'Monero new-style mnemonic', is_xmrseed),
 		'hex':   pwinfo(32, 64 ,64, [32,48,64], 'hexadecimal password',  is_hex_str),
 	}
 	chksum_rec_f = lambda foo,e: (str(e.idx), e.passwd)
@@ -855,7 +859,7 @@ Record this checksum: it will be used to verify the password file in the future
 			self.al_id = AddrListID(seed.sid,MMGenPasswordType('P'))
 			self.data = self.generate(seed,pw_idxs)
 
-		if self.pw_fmt == 'bip39':
+		if self.pw_fmt in ('bip39','xmrseed'):
 			self.msgs['file_header'] = self.msgs['file_header_mn'].format(self.pw_fmt.upper())
 
 		self.num_addrs = len(self.data)
@@ -916,7 +920,13 @@ Record this checksum: it will be used to verify the password file in the future
 		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)
+			good_pw_len = bip39.seedlen2nwords(seed.byte_len,in_bytes=True)
+		elif pf == 'xmrseed':
+			pw_bytes = baseconv.seedlen_map_rev['xmrseed'][self.pw_len]
+			try:
+				good_pw_len = baseconv.seedlen_map['xmrseed'][seed.byte_len]
+			except:
+				die(1,'{}: unsupported seed length for Monero new-style mnemonic'.format(seed.byte_len*8))
 		elif pf in ('b32','b58'):
 			pw_int = (32 if pf == 'b32' else 58) ** self.pw_len
 			pw_bytes = pw_int.bit_length() // 8
@@ -946,6 +956,13 @@ Record this checksum: it will be used to verify the password file in the future
 			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'))
+		elif self.pw_fmt == 'xmrseed':
+			pw_len_hex = baseconv.seedlen_map_rev['xmrseed'][self.pw_len] * 2
+			# take most significant part
+			bytes_trunc = bytes.fromhex(hex_sec[:pw_len_hex])
+			from mmgen.protocol import MoneroProtocol
+			bytes_preproc = MoneroProtocol.preprocess_key(bytes_trunc,None)
+			return ' '.join(baseconv.frombytes(bytes_preproc,wl_id='xmrseed'))
 		else:
 			# take least significant part
 			return baseconv.fromhex(hex_sec,self.pw_fmt,pad=self.pw_len,tostr=True)[-self.pw_len:]
@@ -953,7 +970,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.split()) if self.pw_fmt == 'bip39' else len(pw)
+		pwlen = len(pw.split()) if self.pw_fmt in ('bip39','xmrseed') else len(pw)
 		if pwlen != self.pw_len:
 			raise ValueError('Password has incorrect length ({} != {})'.format(pwlen,self.pw_len))
 		return True
@@ -975,7 +992,7 @@ Record this checksum: it will be used to verify the password file in the future
 
 	def get_line(self,lines):
 		self.line_ctr += 1
-		if self.pw_fmt == 'bip39':
+		if self.pw_fmt in ('bip39','xmrseed'):
 			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])

+ 52 - 7
mmgen/baseconv.py

@@ -22,6 +22,7 @@ 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'])
@@ -38,6 +39,7 @@ 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
@@ -52,6 +54,7 @@ class baseconv(object):
 	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'
 	}
@@ -59,11 +62,13 @@ 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 },
 	}
 
 	@classmethod
@@ -73,6 +78,9 @@ class baseconv(object):
 		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]
@@ -127,6 +135,12 @@ class baseconv(object):
 			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"
@@ -161,6 +175,21 @@ class baseconv(object):
 			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')
@@ -198,10 +227,26 @@ class baseconv(object):
 		wl = cls.digits[wl_id]
 		base = len(wl)
 
-		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 ''.join(o) if tostr else o
+		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

+ 1632 - 0
mmgen/mn_monero.py

@@ -0,0 +1,1632 @@
+#!/usr/bin/env python3
+#
+# Source: https://github.com/monero-project/monero/blob/master/src/mnemonics/english.h
+
+words = tuple("""
+abbey
+abducts
+ability
+ablaze
+abnormal
+abort
+abrasive
+absorb
+abyss
+academy
+aces
+aching
+acidic
+acoustic
+acquire
+across
+actress
+acumen
+adapt
+addicted
+adept
+adhesive
+adjust
+adopt
+adrenalin
+adult
+adventure
+aerial
+afar
+affair
+afield
+afloat
+afoot
+afraid
+after
+against
+agenda
+aggravate
+agile
+aglow
+agnostic
+agony
+agreed
+ahead
+aided
+ailments
+aimless
+airport
+aisle
+ajar
+akin
+alarms
+album
+alchemy
+alerts
+algebra
+alkaline
+alley
+almost
+aloof
+alpine
+already
+also
+altitude
+alumni
+always
+amaze
+ambush
+amended
+amidst
+ammo
+amnesty
+among
+amply
+amused
+anchor
+android
+anecdote
+angled
+ankle
+annoyed
+answers
+antics
+anvil
+anxiety
+anybody
+apart
+apex
+aphid
+aplomb
+apology
+apply
+apricot
+aptitude
+aquarium
+arbitrary
+archer
+ardent
+arena
+argue
+arises
+army
+around
+arrow
+arsenic
+artistic
+ascend
+ashtray
+aside
+asked
+asleep
+aspire
+assorted
+asylum
+athlete
+atlas
+atom
+atrium
+attire
+auburn
+auctions
+audio
+august
+aunt
+austere
+autumn
+avatar
+avidly
+avoid
+awakened
+awesome
+awful
+awkward
+awning
+awoken
+axes
+axis
+axle
+aztec
+azure
+baby
+bacon
+badge
+baffles
+bagpipe
+bailed
+bakery
+balding
+bamboo
+banjo
+baptism
+basin
+batch
+bawled
+bays
+because
+beer
+befit
+begun
+behind
+being
+below
+bemused
+benches
+berries
+bested
+betting
+bevel
+beware
+beyond
+bias
+bicycle
+bids
+bifocals
+biggest
+bikini
+bimonthly
+binocular
+biology
+biplane
+birth
+biscuit
+bite
+biweekly
+blender
+blip
+bluntly
+boat
+bobsled
+bodies
+bogeys
+boil
+boldly
+bomb
+border
+boss
+both
+bounced
+bovine
+bowling
+boxes
+boyfriend
+broken
+brunt
+bubble
+buckets
+budget
+buffet
+bugs
+building
+bulb
+bumper
+bunch
+business
+butter
+buying
+buzzer
+bygones
+byline
+bypass
+cabin
+cactus
+cadets
+cafe
+cage
+cajun
+cake
+calamity
+camp
+candy
+casket
+catch
+cause
+cavernous
+cease
+cedar
+ceiling
+cell
+cement
+cent
+certain
+chlorine
+chrome
+cider
+cigar
+cinema
+circle
+cistern
+citadel
+civilian
+claim
+click
+clue
+coal
+cobra
+cocoa
+code
+coexist
+coffee
+cogs
+cohesive
+coils
+colony
+comb
+cool
+copy
+corrode
+costume
+cottage
+cousin
+cowl
+criminal
+cube
+cucumber
+cuddled
+cuffs
+cuisine
+cunning
+cupcake
+custom
+cycling
+cylinder
+cynical
+dabbing
+dads
+daft
+dagger
+daily
+damp
+dangerous
+dapper
+darted
+dash
+dating
+dauntless
+dawn
+daytime
+dazed
+debut
+decay
+dedicated
+deepest
+deftly
+degrees
+dehydrate
+deity
+dejected
+delayed
+demonstrate
+dented
+deodorant
+depth
+desk
+devoid
+dewdrop
+dexterity
+dialect
+dice
+diet
+different
+digit
+dilute
+dime
+dinner
+diode
+diplomat
+directed
+distance
+ditch
+divers
+dizzy
+doctor
+dodge
+does
+dogs
+doing
+dolphin
+domestic
+donuts
+doorway
+dormant
+dosage
+dotted
+double
+dove
+down
+dozen
+dreams
+drinks
+drowning
+drunk
+drying
+dual
+dubbed
+duckling
+dude
+duets
+duke
+dullness
+dummy
+dunes
+duplex
+duration
+dusted
+duties
+dwarf
+dwelt
+dwindling
+dying
+dynamite
+dyslexic
+each
+eagle
+earth
+easy
+eating
+eavesdrop
+eccentric
+echo
+eclipse
+economics
+ecstatic
+eden
+edgy
+edited
+educated
+eels
+efficient
+eggs
+egotistic
+eight
+either
+eject
+elapse
+elbow
+eldest
+eleven
+elite
+elope
+else
+eluded
+emails
+ember
+emerge
+emit
+emotion
+empty
+emulate
+energy
+enforce
+enhanced
+enigma
+enjoy
+enlist
+enmity
+enough
+enraged
+ensign
+entrance
+envy
+epoxy
+equip
+erase
+erected
+erosion
+error
+eskimos
+espionage
+essential
+estate
+etched
+eternal
+ethics
+etiquette
+evaluate
+evenings
+evicted
+evolved
+examine
+excess
+exhale
+exit
+exotic
+exquisite
+extra
+exult
+fabrics
+factual
+fading
+fainted
+faked
+fall
+family
+fancy
+farming
+fatal
+faulty
+fawns
+faxed
+fazed
+feast
+february
+federal
+feel
+feline
+females
+fences
+ferry
+festival
+fetches
+fever
+fewest
+fiat
+fibula
+fictional
+fidget
+fierce
+fifteen
+fight
+films
+firm
+fishing
+fitting
+five
+fixate
+fizzle
+fleet
+flippant
+flying
+foamy
+focus
+foes
+foggy
+foiled
+folding
+fonts
+foolish
+fossil
+fountain
+fowls
+foxes
+foyer
+framed
+friendly
+frown
+fruit
+frying
+fudge
+fuel
+fugitive
+fully
+fuming
+fungal
+furnished
+fuselage
+future
+fuzzy
+gables
+gadget
+gags
+gained
+galaxy
+gambit
+gang
+gasp
+gather
+gauze
+gave
+gawk
+gaze
+gearbox
+gecko
+geek
+gels
+gemstone
+general
+geometry
+germs
+gesture
+getting
+geyser
+ghetto
+ghost
+giant
+giddy
+gifts
+gigantic
+gills
+gimmick
+ginger
+girth
+giving
+glass
+gleeful
+glide
+gnaw
+gnome
+goat
+goblet
+godfather
+goes
+goggles
+going
+goldfish
+gone
+goodbye
+gopher
+gorilla
+gossip
+gotten
+gourmet
+governing
+gown
+greater
+grunt
+guarded
+guest
+guide
+gulp
+gumball
+guru
+gusts
+gutter
+guys
+gymnast
+gypsy
+gyrate
+habitat
+hacksaw
+haggled
+hairy
+hamburger
+happens
+hashing
+hatchet
+haunted
+having
+hawk
+haystack
+hazard
+hectare
+hedgehog
+heels
+hefty
+height
+hemlock
+hence
+heron
+hesitate
+hexagon
+hickory
+hiding
+highway
+hijack
+hiker
+hills
+himself
+hinder
+hippo
+hire
+history
+hitched
+hive
+hoax
+hobby
+hockey
+hoisting
+hold
+honked
+hookup
+hope
+hornet
+hospital
+hotel
+hounded
+hover
+howls
+hubcaps
+huddle
+huge
+hull
+humid
+hunter
+hurried
+husband
+huts
+hybrid
+hydrogen
+hyper
+iceberg
+icing
+icon
+identity
+idiom
+idled
+idols
+igloo
+ignore
+iguana
+illness
+imagine
+imbalance
+imitate
+impel
+inactive
+inbound
+incur
+industrial
+inexact
+inflamed
+ingested
+initiate
+injury
+inkling
+inline
+inmate
+innocent
+inorganic
+input
+inquest
+inroads
+insult
+intended
+inundate
+invoke
+inwardly
+ionic
+irate
+iris
+irony
+irritate
+island
+isolated
+issued
+italics
+itches
+items
+itinerary
+itself
+ivory
+jabbed
+jackets
+jaded
+jagged
+jailed
+jamming
+january
+jargon
+jaunt
+javelin
+jaws
+jazz
+jeans
+jeers
+jellyfish
+jeopardy
+jerseys
+jester
+jetting
+jewels
+jigsaw
+jingle
+jittery
+jive
+jobs
+jockey
+jogger
+joining
+joking
+jolted
+jostle
+journal
+joyous
+jubilee
+judge
+juggled
+juicy
+jukebox
+july
+jump
+junk
+jury
+justice
+juvenile
+kangaroo
+karate
+keep
+kennel
+kept
+kernels
+kettle
+keyboard
+kickoff
+kidneys
+king
+kiosk
+kisses
+kitchens
+kiwi
+knapsack
+knee
+knife
+knowledge
+knuckle
+koala
+laboratory
+ladder
+lagoon
+lair
+lakes
+lamb
+language
+laptop
+large
+last
+later
+launching
+lava
+lawsuit
+layout
+lazy
+lectures
+ledge
+leech
+left
+legion
+leisure
+lemon
+lending
+leopard
+lesson
+lettuce
+lexicon
+liar
+library
+licks
+lids
+lied
+lifestyle
+light
+likewise
+lilac
+limits
+linen
+lion
+lipstick
+liquid
+listen
+lively
+loaded
+lobster
+locker
+lodge
+lofty
+logic
+loincloth
+long
+looking
+lopped
+lordship
+losing
+lottery
+loudly
+love
+lower
+loyal
+lucky
+luggage
+lukewarm
+lullaby
+lumber
+lunar
+lurk
+lush
+luxury
+lymph
+lynx
+lyrics
+macro
+madness
+magically
+mailed
+major
+makeup
+malady
+mammal
+maps
+masterful
+match
+maul
+maverick
+maximum
+mayor
+maze
+meant
+mechanic
+medicate
+meeting
+megabyte
+melting
+memoir
+menu
+merger
+mesh
+metro
+mews
+mice
+midst
+mighty
+mime
+mirror
+misery
+mittens
+mixture
+moat
+mobile
+mocked
+mohawk
+moisture
+molten
+moment
+money
+moon
+mops
+morsel
+mostly
+motherly
+mouth
+movement
+mowing
+much
+muddy
+muffin
+mugged
+mullet
+mumble
+mundane
+muppet
+mural
+musical
+muzzle
+myriad
+mystery
+myth
+nabbing
+nagged
+nail
+names
+nanny
+napkin
+narrate
+nasty
+natural
+nautical
+navy
+nearby
+necklace
+needed
+negative
+neither
+neon
+nephew
+nerves
+nestle
+network
+neutral
+never
+newt
+nexus
+nibs
+niche
+niece
+nifty
+nightly
+nimbly
+nineteen
+nirvana
+nitrogen
+nobody
+nocturnal
+nodes
+noises
+nomad
+noodles
+northern
+nostril
+noted
+nouns
+novelty
+nowhere
+nozzle
+nuance
+nucleus
+nudged
+nugget
+nuisance
+null
+number
+nuns
+nurse
+nutshell
+nylon
+oaks
+oars
+oasis
+oatmeal
+obedient
+object
+obliged
+obnoxious
+observant
+obtains
+obvious
+occur
+ocean
+october
+odds
+odometer
+offend
+often
+oilfield
+ointment
+okay
+older
+olive
+olympics
+omega
+omission
+omnibus
+onboard
+oncoming
+oneself
+ongoing
+onion
+online
+onslaught
+onto
+onward
+oozed
+opacity
+opened
+opposite
+optical
+opus
+orange
+orbit
+orchid
+orders
+organs
+origin
+ornament
+orphans
+oscar
+ostrich
+otherwise
+otter
+ouch
+ought
+ounce
+ourselves
+oust
+outbreak
+oval
+oven
+owed
+owls
+owner
+oxidant
+oxygen
+oyster
+ozone
+pact
+paddles
+pager
+pairing
+palace
+pamphlet
+pancakes
+paper
+paradise
+pastry
+patio
+pause
+pavements
+pawnshop
+payment
+peaches
+pebbles
+peculiar
+pedantic
+peeled
+pegs
+pelican
+pencil
+people
+pepper
+perfect
+pests
+petals
+phase
+pheasants
+phone
+phrases
+physics
+piano
+picked
+pierce
+pigment
+piloted
+pimple
+pinched
+pioneer
+pipeline
+pirate
+pistons
+pitched
+pivot
+pixels
+pizza
+playful
+pledge
+pliers
+plotting
+plus
+plywood
+poaching
+pockets
+podcast
+poetry
+point
+poker
+polar
+ponies
+pool
+popular
+portents
+possible
+potato
+pouch
+poverty
+powder
+pram
+present
+pride
+problems
+pruned
+prying
+psychic
+public
+puck
+puddle
+puffin
+pulp
+pumpkins
+punch
+puppy
+purged
+push
+putty
+puzzled
+pylons
+pyramid
+python
+queen
+quick
+quote
+rabbits
+racetrack
+radar
+rafts
+rage
+railway
+raking
+rally
+ramped
+randomly
+rapid
+rarest
+rash
+rated
+ravine
+rays
+razor
+react
+rebel
+recipe
+reduce
+reef
+refer
+regular
+reheat
+reinvest
+rejoices
+rekindle
+relic
+remedy
+renting
+reorder
+repent
+request
+reruns
+rest
+return
+reunion
+revamp
+rewind
+rhino
+rhythm
+ribbon
+richly
+ridges
+rift
+rigid
+rims
+ringing
+riots
+ripped
+rising
+ritual
+river
+roared
+robot
+rockets
+rodent
+rogue
+roles
+romance
+roomy
+roped
+roster
+rotate
+rounded
+rover
+rowboat
+royal
+ruby
+rudely
+ruffled
+rugged
+ruined
+ruling
+rumble
+runway
+rural
+rustled
+ruthless
+sabotage
+sack
+sadness
+safety
+saga
+sailor
+sake
+salads
+sample
+sanity
+sapling
+sarcasm
+sash
+satin
+saucepan
+saved
+sawmill
+saxophone
+sayings
+scamper
+scenic
+school
+science
+scoop
+scrub
+scuba
+seasons
+second
+sedan
+seeded
+segments
+seismic
+selfish
+semifinal
+sensible
+september
+sequence
+serving
+session
+setup
+seventh
+sewage
+shackles
+shelter
+shipped
+shocking
+shrugged
+shuffled
+shyness
+siblings
+sickness
+sidekick
+sieve
+sifting
+sighting
+silk
+simplest
+sincerely
+sipped
+siren
+situated
+sixteen
+sizes
+skater
+skew
+skirting
+skulls
+skydive
+slackens
+sleepless
+slid
+slower
+slug
+smash
+smelting
+smidgen
+smog
+smuggled
+snake
+sneeze
+sniff
+snout
+snug
+soapy
+sober
+soccer
+soda
+software
+soggy
+soil
+solved
+somewhere
+sonic
+soothe
+soprano
+sorry
+southern
+sovereign
+sowed
+soya
+space
+speedy
+sphere
+spiders
+splendid
+spout
+sprig
+spud
+spying
+square
+stacking
+stellar
+stick
+stockpile
+strained
+stunning
+stylishly
+subtly
+succeed
+suddenly
+suede
+suffice
+sugar
+suitcase
+sulking
+summon
+sunken
+superior
+surfer
+sushi
+suture
+swagger
+swept
+swiftly
+sword
+swung
+syllabus
+symptoms
+syndrome
+syringe
+system
+taboo
+tacit
+tadpoles
+tagged
+tail
+taken
+talent
+tamper
+tanks
+tapestry
+tarnished
+tasked
+tattoo
+taunts
+tavern
+tawny
+taxi
+teardrop
+technical
+tedious
+teeming
+tell
+template
+tender
+tepid
+tequila
+terminal
+testing
+tether
+textbook
+thaw
+theatrics
+thirsty
+thorn
+threaten
+thumbs
+thwart
+ticket
+tidy
+tiers
+tiger
+tilt
+timber
+tinted
+tipsy
+tirade
+tissue
+titans
+toaster
+tobacco
+today
+toenail
+toffee
+together
+toilet
+token
+tolerant
+tomorrow
+tonic
+toolbox
+topic
+torch
+tossed
+total
+touchy
+towel
+toxic
+toyed
+trash
+trendy
+tribal
+trolling
+truth
+trying
+tsunami
+tubes
+tucks
+tudor
+tuesday
+tufts
+tugs
+tuition
+tulips
+tumbling
+tunnel
+turnip
+tusks
+tutor
+tuxedo
+twang
+tweezers
+twice
+twofold
+tycoon
+typist
+tyrant
+ugly
+ulcers
+ultimate
+umbrella
+umpire
+unafraid
+unbending
+uncle
+under
+uneven
+unfit
+ungainly
+unhappy
+union
+unjustly
+unknown
+unlikely
+unmask
+unnoticed
+unopened
+unplugs
+unquoted
+unrest
+unsafe
+until
+unusual
+unveil
+unwind
+unzip
+upbeat
+upcoming
+update
+upgrade
+uphill
+upkeep
+upload
+upon
+upper
+upright
+upstairs
+uptight
+upwards
+urban
+urchins
+urgent
+usage
+useful
+usher
+using
+usual
+utensils
+utility
+utmost
+utopia
+uttered
+vacation
+vague
+vain
+value
+vampire
+vane
+vapidly
+vary
+vastness
+vats
+vaults
+vector
+veered
+vegan
+vehicle
+vein
+velvet
+venomous
+verification
+vessel
+veteran
+vexed
+vials
+vibrate
+victim
+video
+viewpoint
+vigilant
+viking
+village
+vinegar
+violin
+vipers
+virtual
+visited
+vitals
+vivid
+vixen
+vocal
+vogue
+voice
+volcano
+vortex
+voted
+voucher
+vowels
+voyage
+vulture
+wade
+waffle
+wagtail
+waist
+waking
+wallets
+wanted
+warped
+washing
+water
+waveform
+waxing
+wayside
+weavers
+website
+wedge
+weekday
+weird
+welders
+went
+wept
+were
+western
+wetsuit
+whale
+when
+whipped
+whole
+wickets
+width
+wield
+wife
+wiggle
+wildly
+winter
+wipeout
+wiring
+wise
+withdrawn
+wives
+wizard
+wobbly
+woes
+woken
+wolf
+womanly
+wonders
+woozy
+worry
+wounded
+woven
+wrap
+wrist
+wrong
+yacht
+yahoo
+yanks
+yard
+yawning
+yearbook
+yellow
+yesterday
+yeti
+yields
+yodel
+yoga
+younger
+yoyo
+zapped
+zeal
+zebra
+zero
+zesty
+zigzags
+zinger
+zippers
+zodiac
+zombie
+zones
+zoom
+""".split())

+ 18 - 1
mmgen/tool.py

@@ -234,6 +234,7 @@ dfl_mnemonic_fmt = 'mmgen'
 mnemonic_fmts = {
 	'mmgen': { 'fmt': 'words', 'conv_cls': lambda: baseconv },
 	'bip39': { 'fmt': 'bip39', 'conv_cls': conv_cls_bip39 },
+	'xmrseed': { 'fmt': 'xmrseed','conv_cls': lambda: baseconv },
 }
 mn_opts_disp = "(valid options: '{}')".format("', '".join(mnemonic_fmts))
 
@@ -473,7 +474,7 @@ class MMGenToolCmdCoin(MMGenToolCmdBase):
 
 class MMGenToolCmdMnemonic(MMGenToolCmdBase):
 	"""
-	seed phrase utilities (valid formats: 'mmgen' (default), 'bip39')
+	seed phrase utilities (valid formats: 'mmgen' (default), 'bip39', 'xmrseed')
 
 		IMPORTANT NOTE: MMGen's default seed phrase format uses the Electrum
 		wordlist, however seed phrases are computed using a different algorithm
@@ -484,10 +485,24 @@ class MMGenToolCmdMnemonic(MMGenToolCmdBase):
 		users should be aware that BIP39 support does not imply BIP32 support!
 		MMGen uses its own key derivation scheme differing from the one described
 		by the BIP32 protocol.
+
+		For Monero ('xmrseed') seed phrases, 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.  To manually generate a Monero spendkey,
+		use the 'hex2wif' command.
 	"""
+
+	@staticmethod
+	def _xmr_reduce(bytestr):
+		from mmgen.protocol import MoneroProtocol
+		return MoneroProtocol.preprocess_key(bytestr,None)
+
 	def _do_random_mn(self,nbytes:int,fmt:str):
 		assert nbytes in (16,24,32), 'nbytes must be 16, 24 or 32'
 		randbytes = get_random(nbytes)
+		if fmt == 'xmrseed':
+			randbytes = self._xmr_reduce(randbytes)
 		if opt.verbose:
 			msg('Seed: {}'.format(randbytes.hex()))
 		return self.hex2mn(randbytes.hex(),fmt=fmt)
@@ -518,6 +533,8 @@ class MMGenToolCmdMnemonic(MMGenToolCmdBase):
 			return ' '.join(bip39.fromhex(hexstr,fmt))
 		else:
 			bytestr = bytes.fromhex(hexstr)
+			if fmt == 'xmrseed':
+				bytestr = self._xmr_reduce(bytestr)
 			return baseconv.frombytes(bytestr,fmt,'seed',tostr=True)
 
 	def mn2hex( self, seed_mnemonic:'sstr', fmt:mn_opts_disp = dfl_mnemonic_fmt ):

+ 1 - 0
setup.py

@@ -113,6 +113,7 @@ setup(
 			'mmgen.keccak',
 			'mmgen.license',
 			'mmgen.mn_electrum',
+			'mmgen.mn_monero',
 			'mmgen.mn_tirosh',
 			'mmgen.obj',
 			'mmgen.opts',

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

@@ -0,0 +1,6 @@
+# B488 21D3 4539 968D
+98831F3A фубар@crypto.org xmrseed:25 {
+  1     amended jailed extra apart hinder upbeat dating onboard yesterday aided irony guide cogs apricot aggravate twofold tepid nuance ripped vessel soothe woven vials when aided
+  4     godfather arrow hobby mailed educated abnormal boss cavernous skirting alumni voted hunter vessel unsafe mohawk pylons amused rift jury code assorted oscar himself inquest oscar
+  1100  molten icon lectures amaze foes lurk camp divers goldfish zombie neutral drunk topic abort boldly recipe natural verification circle polar woozy biplane yoyo adapt abort
+}

+ 1 - 0
test/scrambletest.py

@@ -88,6 +88,7 @@ passwd_data = {
 'bip39_dfl_αω':td('95b383d5092a55df', 'bip39:24:αω','-αω-bip39-24','αω bip39:24','treat athlete brand top beauty poverty senior unhappy vacant domain yellow scale fossil aim lonely fatal sun nuclear such ancient stage require stool similar'),
 'bip39_18_αω': td('29e5a605ffa36142', 'bip39:18:αω','-αω-bip39-18','αω bip39:18','better legal various ketchup then range festival either tomato cradle say absorb solar earth alter pattern canyon liar'),
 'bip39_12_αω': td('efa13cb309d7fc1d', 'bip39:12:αω','-αω-bip39-12','αω bip39:12','lady oppose theme fit position merry reopen acquire tuna dentist young chunk'),
+'xmrseed_dfl_αω':td('62f5b72a5ca89cab', 'xmrseed:25:αω','-αω-xmrseed-25','αω xmrseed:25','tequila eden skulls giving jester hospital dreams bakery adjust nanny cactus inwardly films amply nanny soggy vials muppet yellow woken ashtray organs exhale foes eden'),
 }
 
 cvr_opts = ' -m trace --count --coverdir={} --file={}'.format(*init_coverage()) if opt.coverage else ''

+ 4 - 0
test/test_py_d/ts_ref.py

@@ -53,6 +53,7 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		'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_xmrseed_25': '98831F3A-фубар@crypto.org-xmrseed-25[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',
@@ -99,6 +100,7 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		'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_xmrseed_25_chksum': 'B488 21D3 4539 968D',
 		'ref_passwdfile_hex2bip39_12_chksum': '93AD 4AE2 03D1 8A0A',
 	}
 	cmd_group = ( # TODO: move to tooltest2
@@ -123,6 +125,7 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		('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_xmrseed_25','saved reference password file (Monero new-style mnemonic, 25 words)'),
 		('ref_passwdfile_chk_hex2bip39_12','saved reference password file (hex-to-BIP39, 12 words)'),
 
 #	Create the fake inputs:
@@ -252,6 +255,7 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 	def ref_passwdfile_chk_bip39_12(self): return self.ref_passwdfile_chk(key='bip39_12',pat=r'BIP39.*len.* 12\b')
 	def ref_passwdfile_chk_bip39_18(self): return self.ref_passwdfile_chk(key='bip39_18',pat=r'BIP39.*len.* 18\b')
 	def ref_passwdfile_chk_bip39_24(self): return self.ref_passwdfile_chk(key='bip39_24',pat=r'BIP39.*len.* 24\b')
+	def ref_passwdfile_chk_xmrseed_25(self): return self.ref_passwdfile_chk(key='xmrseed_25',pat=r'Mon.*len.* 25\b')
 	def ref_passwdfile_chk_hex2bip39_12(self): return self.ref_passwdfile_chk(key='hex2bip39_12',pat=r'BIP39.*len.* 12\b')
 
 	def ref_tx_chk(self):

+ 9 - 0
test/test_py_d/ts_ref_3seed.py

@@ -42,6 +42,10 @@ class TestSuiteRef3Seed(TestSuiteBase,TestSuiteShared):
 		'sids': ('FE3C6545', '1378FC64', '98831F3A'),
 	}
 	shared_deps = ['mmdat',pwfile]
+	skip_cmds = (
+		'ref_xmrseed_25_passwdgen_1',
+		'ref_xmrseed_25_passwdgen_2',
+	)
 	cmd_group = (
 		# reading saved reference wallets
 		('ref_wallet_chk',  ([],'saved reference wallet')),
@@ -319,6 +323,7 @@ class TestSuiteRef3Addr(TestSuiteRef3Seed):
 		'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',
+		'ref_xmrseed_25_passwdgen_3': '91AE E76A 2827 C8CC',
 	}
 
 	cmd_group = (
@@ -339,6 +344,7 @@ class TestSuiteRef3Addr(TestSuiteRef3Seed):
 		('ref_bip39_12_passwdgen',    ([],'new refwallet passwd file chksum (BIP39, 12 words)')),
 		('ref_bip39_18_passwdgen',    ([],'new refwallet passwd file chksum (BIP39, up to 18 words)')),
 		('ref_bip39_24_passwdgen',    ([],'new refwallet passwd file chksum (BIP39, up to 24 words)')),
+		('ref_xmrseed_25_passwdgen',  ([],'new refwallet passwd file chksum (Monero new-style mnemonic, 25 words)')),
 		('ref_hex2bip39_24_passwdgen',([],'new refwallet passwd file chksum (hex-to-BIP39, up to 24 words)')),
 	)
 
@@ -383,6 +389,8 @@ class TestSuiteRef3Addr(TestSuiteRef3Seed):
 
 	def mn_pwgen(self,req_pw_len,pwfmt,ftype='passbip39',stdout=False):
 		pwlen = min(req_pw_len,{'1':12,'2':18,'3':24}[self.test_name[-1]])
+		if pwfmt == 'xmrseed':
+			pwlen += 1
 		ea = ['--accept-defaults']
 		return self.pwgen(ftype,'фубар@crypto.org',pwfmt,pwlen,ea,stdout=stdout)
 
@@ -390,3 +398,4 @@ class TestSuiteRef3Addr(TestSuiteRef3Seed):
 	def ref_bip39_18_passwdgen(self):     return self.mn_pwgen(18,'bip39',stdout=True)
 	def ref_bip39_24_passwdgen(self):     return self.mn_pwgen(24,'bip39')
 	def ref_hex2bip39_24_passwdgen(self): return self.mn_pwgen(24,'hex2bip39')
+	def ref_xmrseed_25_passwdgen(self):   return self.mn_pwgen(24,'xmrseed',ftype='passxmrseed')

+ 11 - 0
test/tooltest2.py

@@ -32,6 +32,7 @@ from mmgen.common import *
 from test.common import *
 from mmgen.obj import is_wif,is_coin_addr
 from mmgen.seed import is_bip39_mnemonic,is_mmgen_mnemonic
+from mmgen.addr import is_xmrseed
 from mmgen.baseconv import *
 
 NL = ('\n','\r\n')[g.platform=='win']
@@ -112,6 +113,9 @@ tests = {
 			( ['0000000000000000000000000000000000000000000000000000000000000001'],
 			('able able able able able able able able able able able able ' +
 			'able able able able able able able able able able able about') ),
+			( ['e8164dda6d42bd1e261a3406b2038dcbddadbeefdeadbeefdeadbeefdeadbe0f','fmt=xmrseed'],
+			('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') ),
 		] + [([a,'fmt=bip39'],b) for a,b in bip39.vectors],
 		'mn2hex': [
 			( ['table cast forgive master funny gaze sadness ripple million paint moral match','fmt=mmgen'],
@@ -133,6 +137,10 @@ tests = {
 			( ['able able able able able able able able able able able able ' +
 				'able able able able able able able able able able able about'],
 				'0000000000000000000000000000000000000000000000000000000000000001'),
+			( ['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'],
+				'e8164dda6d42bd1e261a3406b2038dcbddadbeefdeadbeefdeadbeefdeadbe0f'),
 		] + [([b,'fmt=bip39'],a) for a,b in bip39.vectors],
 		'mn_rand128': [
 			( [], is_mmgen_mnemonic, ['-r0']),
@@ -146,16 +154,19 @@ tests = {
 		'mn_rand256': [
 			( ['fmt=mmgen'], is_mmgen_mnemonic, ['-r0']),
 			( ['fmt=bip39'], is_bip39_mnemonic, ['-r0']),
+			( ['fmt=xmrseed'], is_xmrseed, ['-r0']),
 		],
 		'mn_stats': [
 			( [], is_str ),
 			( ['fmt=mmgen'], is_str ),
 			( ['fmt=bip39'], is_str ),
+			( ['fmt=xmrseed'], is_str ),
 		],
 		'mn_printlist': [
 			( [], is_str ),
 			( ['fmt=mmgen'], is_str ),
 			( ['fmt=bip39'], is_str ),
+			( ['fmt=xmrseed','enum=true'], is_str ),
 		],
 	},
 	'Util': {

+ 21 - 1
test/unit_tests_d/ut_baseconv.py

@@ -9,6 +9,23 @@ 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'),
@@ -161,7 +178,10 @@ class unit_test(object):
 			for (hexstr,pad),ret_chk in data:
 				if type(pad) == int:
 					pad = len(hexstr)
-				ret = baseconv.tohex(ret_chk,wl_id=base,pad=pad)
+				ret = baseconv.tohex(
+					ret_chk.split() if base == 'xmrseed' else ret_chk,
+					wl_id=base,
+					pad=pad)
 				if pad == None:
 					assert int(ret,16) == int(hexstr,16), rerr.format(int(ret,16),int(hexstr,16))
 				else: