wallet.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2021 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. wallet.py: Wallet classes and methods for the MMGen suite
  20. """
  21. import os
  22. from .common import *
  23. from .obj import *
  24. from .crypto import *
  25. from .baseconv import *
  26. from .seed import Seed
  27. def check_usr_seed_len(seed_len):
  28. if opt.seed_len and opt.seed_len != seed_len:
  29. die(1,f'ERROR: requested seed length ({opt.seed_len}) doesn’t match seed length of source ({seed_len})')
  30. def _is_mnemonic(s,fmt):
  31. oq_save = bool(opt.quiet)
  32. opt.quiet = True
  33. try:
  34. Wallet(in_data=s,in_fmt=fmt)
  35. ret = True
  36. except:
  37. ret = False
  38. finally:
  39. opt.quiet = oq_save
  40. return ret
  41. def is_bip39_mnemonic(s): return _is_mnemonic(s,fmt='bip39')
  42. def is_mmgen_mnemonic(s): return _is_mnemonic(s,fmt='words')
  43. class WalletMeta(type):
  44. wallet_classes = set() # one-instance class, so store data in class attr
  45. def __init__(cls,name,bases,namespace):
  46. cls.wallet_classes.add(cls)
  47. cls.wallet_classes -= set(bases)
  48. class Wallet(MMGenObject,metaclass=WalletMeta):
  49. desc = g.proj_name + ' seed source'
  50. file_mode = 'text'
  51. stdin_ok = False
  52. ask_tty = True
  53. no_tty = False
  54. op = None
  55. _msg = {}
  56. class WalletData(MMGenObject): pass
  57. def __new__(cls,
  58. fn = None,
  59. ss = None,
  60. seed_bin = None,
  61. seed = None,
  62. passchg = False,
  63. in_data = None,
  64. ignore_in_fmt = False,
  65. in_fmt = None ):
  66. in_fmt = in_fmt or opt.in_fmt
  67. if hasattr(opt,'out_fmt') and opt.out_fmt:
  68. out_cls = cls.fmt_code_to_type(opt.out_fmt)
  69. if not out_cls:
  70. die(1,f'{opt.out_fmt!r}: unrecognized output format')
  71. else:
  72. out_cls = None
  73. def die_on_opt_mismatch(opt,sstype):
  74. compare_or_die(
  75. cls.fmt_code_to_type(opt).__name__, 'input format requested on command line',
  76. sstype.__name__, 'input file format' )
  77. if seed or seed_bin:
  78. me = super(cls,cls).__new__(out_cls or MMGenWallet) # default to MMGenWallet
  79. me.seed = seed or Seed(seed_bin=seed_bin)
  80. me.op = 'new'
  81. elif ss:
  82. me = super(cls,cls).__new__((ss.__class__ if passchg else out_cls) or MMGenWallet)
  83. me.seed = ss.seed
  84. me.ss_in = ss
  85. me.op = ('conv','pwchg_new')[bool(passchg)]
  86. elif fn or opt.hidden_incog_input_params:
  87. from .filename import Filename
  88. if fn:
  89. f = Filename(fn)
  90. else:
  91. # permit comma in filename
  92. fn = ','.join(opt.hidden_incog_input_params.split(',')[:-1])
  93. f = Filename(fn,ftype=IncogWalletHidden)
  94. if in_fmt and not ignore_in_fmt:
  95. die_on_opt_mismatch(in_fmt,f.ftype)
  96. me = super(cls,cls).__new__(f.ftype)
  97. me.infile = f
  98. me.op = ('old','pwchg_old')[bool(passchg)]
  99. elif in_fmt:
  100. me = super(cls,cls).__new__(cls.fmt_code_to_type(in_fmt))
  101. me.op = ('old','pwchg_old')[bool(passchg)]
  102. else: # called with no arguments: initialize with random seed
  103. me = super(cls,cls).__new__(out_cls or MMGenWallet)
  104. me.seed = Seed(None)
  105. me.op = 'new'
  106. return me
  107. def __init__(self,
  108. fn = None,
  109. ss = None,
  110. seed_bin = None,
  111. seed = None,
  112. passchg = False,
  113. in_data = None,
  114. ignore_in_fmt = False,
  115. in_fmt = None ):
  116. self.passwd_file = opt.passwd_file
  117. self.ssdata = self.WalletData()
  118. self.msg = {}
  119. self.in_data = in_data
  120. for c in reversed(self.__class__.__mro__):
  121. if hasattr(c,'_msg'):
  122. self.msg.update(c._msg)
  123. if hasattr(self,'seed'):
  124. self._encrypt()
  125. return
  126. elif hasattr(self,'infile') or self.in_data or not g.stdin_tty:
  127. self._deformat_once()
  128. self._decrypt_retry()
  129. else:
  130. if not self.stdin_ok:
  131. die(1,f'Reading from standard input not supported for {self.desc} format')
  132. self._deformat_retry()
  133. self._decrypt_retry()
  134. qmsg('Valid {} for Seed ID {}{}'.format(
  135. self.desc,
  136. self.seed.sid.hl(),
  137. (f', seed length {self.seed.bitlen}' if self.seed.bitlen != 256 else '')
  138. ))
  139. def _get_data(self):
  140. if hasattr(self,'infile'):
  141. self.fmt_data = get_data_from_file(self.infile.name,self.desc,binary=self.file_mode=='binary')
  142. elif self.in_data:
  143. self.fmt_data = self.in_data
  144. else:
  145. self.fmt_data = self._get_data_from_user(self.desc)
  146. def _get_data_from_user(self,desc):
  147. return get_data_from_user(desc)
  148. def _deformat_once(self):
  149. self._get_data()
  150. if not self._deformat():
  151. die(2,'Invalid format for input data')
  152. def _deformat_retry(self):
  153. while True:
  154. self._get_data()
  155. if self._deformat():
  156. break
  157. msg('Trying again...')
  158. def _decrypt_retry(self):
  159. while True:
  160. if self._decrypt():
  161. break
  162. if self.passwd_file:
  163. die(2,'Passphrase from password file, so exiting')
  164. msg('Trying again...')
  165. @classmethod
  166. def get_extensions(cls):
  167. return [c.ext for c in cls.wallet_classes if hasattr(c,'ext')]
  168. @classmethod
  169. def fmt_code_to_type(cls,fmt_code):
  170. if fmt_code:
  171. for c in cls.wallet_classes:
  172. if fmt_code in getattr(c,'fmt_codes',[]):
  173. return c
  174. return None
  175. @classmethod
  176. def ext_to_type(cls,ext):
  177. if ext:
  178. for c in cls.wallet_classes:
  179. if ext == getattr(c,'ext',None):
  180. return c
  181. return None
  182. @classmethod
  183. def format_fmt_codes(cls):
  184. d = [(c.__name__,('.'+c.ext if c.ext else str(c.ext)),','.join(c.fmt_codes))
  185. for c in cls.wallet_classes
  186. if hasattr(c,'fmt_codes')]
  187. w = max(len(i[0]) for i in d)
  188. ret = [f'{a:<{w}} {b:<9} {c}' for a,b,c in [
  189. ('Format','FileExt','Valid codes'),
  190. ('------','-------','-----------')
  191. ] + sorted(d)]
  192. return '\n'.join(ret) + ('','-α')[g.debug_utf8] + '\n'
  193. def get_fmt_data(self):
  194. self._format()
  195. return self.fmt_data
  196. def write_to_file(self,outdir='',desc=''):
  197. self._format()
  198. kwargs = {
  199. 'desc': desc or self.desc,
  200. 'ask_tty': self.ask_tty,
  201. 'no_tty': self.no_tty,
  202. 'binary': self.file_mode == 'binary'
  203. }
  204. # write_data_to_file(): outfile with absolute path overrides opt.outdir
  205. if outdir:
  206. of = os.path.abspath(os.path.join(outdir,self._filename()))
  207. write_data_to_file(of if outdir else self._filename(),self.fmt_data,**kwargs)
  208. class WalletUnenc(Wallet):
  209. def _decrypt_retry(self): pass
  210. def _encrypt(self): pass
  211. def _filename(self):
  212. s = self.seed
  213. return '{}[{}]{x}.{}'.format(
  214. s.fn_stem,
  215. s.bitlen,
  216. self.ext,
  217. x='-α' if g.debug_utf8 else '')
  218. def _choose_seedlen(self,desc,ok_lens,subtype):
  219. from .term import get_char
  220. def choose_len():
  221. prompt = self.choose_seedlen_prompt
  222. while True:
  223. r = get_char('\r'+prompt)
  224. if is_int(r) and 1 <= int(r) <= len(ok_lens):
  225. break
  226. msg_r(('\r','\n')[g.test_suite] + ' '*len(prompt) + '\r')
  227. return ok_lens[int(r)-1]
  228. msg('{} {}'.format(
  229. blue(f'{capfirst(desc)} type:'),
  230. yellow(subtype)
  231. ))
  232. while True:
  233. usr_len = choose_len()
  234. prompt = self.choose_seedlen_confirm.format(usr_len)
  235. if keypress_confirm(prompt,default_yes=True,no_nl=not g.test_suite):
  236. return usr_len
  237. class WalletEnc(Wallet):
  238. _msg = {
  239. 'choose_passphrase': """
  240. You must choose a passphrase to encrypt your new {} with.
  241. A key will be generated from your passphrase using a hash preset of '{}'.
  242. Please note that no strength checking of passphrases is performed.
  243. For an empty passphrase, just hit ENTER twice.
  244. """
  245. }
  246. def _get_hash_preset_from_user(self,hp,add_desc=''):
  247. prompt = 'Enter {}hash preset for {}{}{},\nor hit ENTER to {} value ({!r}): '.format(
  248. ('old ' if self.op=='pwchg_old' else 'new ' if self.op=='pwchg_new' else ''),
  249. ('','new ')[self.op=='new'],
  250. self.desc,
  251. ('',' '+add_desc)[bool(add_desc)],
  252. ('accept the default','reuse the old')[self.op=='pwchg_new'],
  253. hp )
  254. while True:
  255. ret = line_input(prompt)
  256. if ret:
  257. if ret in g.hash_presets:
  258. return ret
  259. else:
  260. msg('Invalid input. Valid choices are {}'.format(', '.join(g.hash_presets)))
  261. else:
  262. return hp
  263. def _get_hash_preset(self,add_desc=''):
  264. if hasattr(self,'ss_in') and hasattr(self.ss_in.ssdata,'hash_preset'):
  265. old_hp = self.ss_in.ssdata.hash_preset
  266. if opt.keep_hash_preset:
  267. hp = old_hp
  268. qmsg(f'Reusing hash preset {hp!r} at user request')
  269. elif opt.hash_preset:
  270. hp = opt.hash_preset
  271. qmsg(f'Using hash preset {hp!r} requested on command line')
  272. else: # Prompt, using old value as default
  273. hp = self._get_hash_preset_from_user(old_hp,add_desc)
  274. if (not opt.keep_hash_preset) and self.op == 'pwchg_new':
  275. qmsg('Hash preset {}'.format( 'unchanged' if hp == old_hp else f'changed to {hp!r}' ))
  276. elif opt.hash_preset:
  277. hp = opt.hash_preset
  278. qmsg(f'Using hash preset {hp!r} requested on command line')
  279. else:
  280. hp = self._get_hash_preset_from_user(g.dfl_hash_preset,add_desc)
  281. self.ssdata.hash_preset = hp
  282. def _get_new_passphrase(self):
  283. desc = '{}passphrase for {}{}'.format(
  284. ('','new ')[self.op=='pwchg_new'],
  285. ('','new ')[self.op in ('new','conv')],
  286. self.desc
  287. )
  288. if self.passwd_file:
  289. pw = ' '.join(get_words_from_file(
  290. self.passwd_file,
  291. desc,
  292. quiet = pwfile_reuse_warning(self.passwd_file).warning_shown ))
  293. else:
  294. qmsg('\n'+fmt(self.msg['choose_passphrase'].format(self.desc,self.ssdata.hash_preset),indent=' '))
  295. if opt.echo_passphrase:
  296. pw = ' '.join(get_words_from_user(f'Enter {desc}: '))
  297. else:
  298. for i in range(g.passwd_max_tries):
  299. pw = ' '.join(get_words_from_user(f'Enter {desc}: '))
  300. pw_chk = ' '.join(get_words_from_user('Repeat passphrase: '))
  301. dmsg(f'Passphrases: [{pw}] [{pw_chk}]')
  302. if pw == pw_chk:
  303. vmsg('Passphrases match')
  304. break
  305. else:
  306. msg('Passphrases do not match. Try again.')
  307. else:
  308. die(2,f'User failed to duplicate passphrase in {g.passwd_max_tries} attempts')
  309. if pw == '':
  310. qmsg('WARNING: Empty passphrase')
  311. self.ssdata.passwd = pw
  312. return pw
  313. def _get_passphrase(self,add_desc=''):
  314. desc = '{}passphrase for {}{}'.format(
  315. ('','old ')[self.op=='pwchg_old'],
  316. self.desc,
  317. ('',' '+add_desc)[bool(add_desc)]
  318. )
  319. if self.passwd_file:
  320. ret = ' '.join(get_words_from_file(
  321. self.passwd_file,
  322. desc,
  323. quiet = pwfile_reuse_warning(self.passwd_file).warning_shown ))
  324. else:
  325. ret = ' '.join(get_words_from_user(f'Enter {desc}: '))
  326. self.ssdata.passwd = ret
  327. def _get_first_pw_and_hp_and_encrypt_seed(self):
  328. d = self.ssdata
  329. self._get_hash_preset()
  330. if hasattr(self,'ss_in') and hasattr(self.ss_in.ssdata,'passwd'):
  331. old_pw = self.ss_in.ssdata.passwd
  332. if opt.keep_passphrase:
  333. d.passwd = old_pw
  334. qmsg('Reusing passphrase at user request')
  335. else:
  336. pw = self._get_new_passphrase()
  337. if self.op == 'pwchg_new':
  338. qmsg('Passphrase {}'.format( 'unchanged' if pw == old_pw else 'changed' ))
  339. else:
  340. self._get_new_passphrase()
  341. d.salt = sha256(get_random(128)).digest()[:g.salt_len]
  342. key = make_key(d.passwd, d.salt, d.hash_preset)
  343. d.key_id = make_chksum_8(key)
  344. d.enc_seed = encrypt_seed(self.seed.data,key)
  345. class Mnemonic(WalletUnenc):
  346. stdin_ok = True
  347. wclass = 'mnemonic'
  348. conv_cls = baseconv
  349. choose_seedlen_prompt = 'Choose a mnemonic length: 1) 12 words, 2) 18 words, 3) 24 words: '
  350. choose_seedlen_confirm = 'Mnemonic length of {} words chosen. OK?'
  351. @property
  352. def mn_lens(self):
  353. return sorted(self.conv_cls.seedlen_map_rev[self.wl_id])
  354. def _get_data_from_user(self,desc):
  355. if not g.stdin_tty:
  356. return get_data_from_user(desc)
  357. from .mn_entry import mn_entry # import here to catch cfg var errors
  358. mn_len = self._choose_seedlen(self.wclass,self.mn_lens,self.mn_type)
  359. return mn_entry(self.wl_id).get_mnemonic_from_user(mn_len)
  360. @staticmethod
  361. def _mn2hex_pad(mn): return len(mn) * 8 // 3
  362. @staticmethod
  363. def _hex2mn_pad(hexnum): return len(hexnum) * 3 // 8
  364. def _format(self):
  365. hexseed = self.seed.hexdata
  366. mn = self.conv_cls.fromhex(hexseed,self.wl_id,self._hex2mn_pad(hexseed))
  367. ret = self.conv_cls.tohex(mn,self.wl_id,self._mn2hex_pad(mn))
  368. # Internal error, so just die on fail
  369. compare_or_die(ret,'recomputed seed',hexseed,'original',e='Internal error')
  370. self.ssdata.mnemonic = mn
  371. self.fmt_data = ' '.join(mn) + '\n'
  372. def _deformat(self):
  373. self.conv_cls.init_mn(self.wl_id)
  374. mn = self.fmt_data.split()
  375. if len(mn) not in self.mn_lens:
  376. msg('Invalid mnemonic ({} words). Valid numbers of words: {}'.format(
  377. len(mn),
  378. ', '.join(map(str,self.mn_lens)) ))
  379. return False
  380. for n,w in enumerate(mn,1):
  381. if w not in self.conv_cls.digits[self.wl_id]:
  382. msg(f'Invalid mnemonic: word #{n} is not in the {self.wl_id.upper()} wordlist')
  383. return False
  384. hexseed = self.conv_cls.tohex(mn,self.wl_id,self._mn2hex_pad(mn))
  385. ret = self.conv_cls.fromhex(hexseed,self.wl_id,self._hex2mn_pad(hexseed))
  386. if len(hexseed) * 4 not in g.seed_lens:
  387. msg('Invalid mnemonic (produces too large a number)')
  388. return False
  389. # Internal error, so just die
  390. compare_or_die(' '.join(ret),'recomputed mnemonic',' '.join(mn),'original',e='Internal error')
  391. self.seed = Seed(bytes.fromhex(hexseed))
  392. self.ssdata.mnemonic = mn
  393. check_usr_seed_len(self.seed.bitlen)
  394. return True
  395. class MMGenMnemonic(Mnemonic):
  396. fmt_codes = ('mmwords','words','mnemonic','mnem','mn','m')
  397. desc = 'MMGen native mnemonic data'
  398. mn_type = 'MMGen native'
  399. ext = 'mmwords'
  400. wl_id = 'mmgen'
  401. class BIP39Mnemonic(Mnemonic):
  402. fmt_codes = ('bip39',)
  403. desc = 'BIP39 mnemonic data'
  404. mn_type = 'BIP39'
  405. ext = 'bip39'
  406. wl_id = 'bip39'
  407. def __init__(self,*args,**kwargs):
  408. from .bip39 import bip39
  409. self.conv_cls = bip39
  410. super().__init__(*args,**kwargs)
  411. class MMGenSeedFile(WalletUnenc):
  412. stdin_ok = True
  413. fmt_codes = ('mmseed','seed','s')
  414. desc = 'seed data'
  415. ext = 'mmseed'
  416. def _format(self):
  417. b58seed = baseconv.frombytes(self.seed.data,'b58',pad='seed',tostr=True)
  418. self.ssdata.chksum = make_chksum_6(b58seed)
  419. self.ssdata.b58seed = b58seed
  420. self.fmt_data = '{} {}\n'.format(
  421. self.ssdata.chksum,
  422. split_into_cols(4,b58seed) )
  423. def _deformat(self):
  424. desc = self.desc
  425. ld = self.fmt_data.split()
  426. if not (7 <= len(ld) <= 12): # 6 <= padded b58 data (ld[1:]) <= 11
  427. msg(f'Invalid data length ({len(ld)}) in {desc}')
  428. return False
  429. a,b = ld[0],''.join(ld[1:])
  430. if not is_chksum_6(a):
  431. msg(f'{a!r}: invalid checksum format in {desc}')
  432. return False
  433. if not is_b58_str(b):
  434. msg(f'{b!r}: not a base 58 string, in {desc}')
  435. return False
  436. vmsg_r(f'Validating {desc} checksum...')
  437. if not compare_chksums(a,'file',make_chksum_6(b),'computed',verbose=True):
  438. return False
  439. ret = baseconv.tobytes(b,'b58',pad='seed')
  440. if ret == False:
  441. msg(f'Invalid base-58 encoded seed: {val}')
  442. return False
  443. self.seed = Seed(ret)
  444. self.ssdata.chksum = a
  445. self.ssdata.b58seed = b
  446. check_usr_seed_len(self.seed.bitlen)
  447. return True
  448. class DieRollSeedFile(WalletUnenc):
  449. stdin_ok = True
  450. fmt_codes = ('b6d','die','dieroll')
  451. desc = 'base6d die roll seed data'
  452. ext = 'b6d'
  453. conv_cls = baseconv
  454. wclass = 'dieroll'
  455. wl_id = 'b6d'
  456. mn_type = 'base6d'
  457. choose_seedlen_prompt = 'Choose a seed length: 1) 128 bits, 2) 192 bits, 3) 256 bits: '
  458. choose_seedlen_confirm = 'Seed length of {} bits chosen. OK?'
  459. user_entropy_prompt = 'Would you like to provide some additional entropy from the keyboard?'
  460. interactive_input = False
  461. def _format(self):
  462. d = baseconv.frombytes(self.seed.data,'b6d',pad='seed',tostr=True) + '\n'
  463. self.fmt_data = block_format(d,gw=5,cols=5)
  464. def _deformat(self):
  465. d = remove_whitespace(self.fmt_data)
  466. rmap = self.conv_cls.seedlen_map_rev['b6d']
  467. if not len(d) in rmap:
  468. raise SeedLengthError('{!r}: invalid length for {} (must be one of {})'.format(
  469. len(d),
  470. self.desc,
  471. list(rmap) ))
  472. # truncate seed to correct length, discarding high bits
  473. seed_len = rmap[len(d)]
  474. seed_bytes = baseconv.tobytes(d,'b6d',pad='seed')[-seed_len:]
  475. if self.interactive_input and opt.usr_randchars:
  476. if keypress_confirm(self.user_entropy_prompt):
  477. seed_bytes = add_user_random(
  478. rand_bytes = seed_bytes,
  479. desc = 'gathered from your die rolls' )
  480. self.desc += ' plus user-supplied entropy'
  481. self.seed = Seed(seed_bytes)
  482. self.ssdata.hexseed = seed_bytes.hex()
  483. check_usr_seed_len(self.seed.bitlen)
  484. return True
  485. def _get_data_from_user(self,desc):
  486. if not g.stdin_tty:
  487. return get_data_from_user(desc)
  488. seed_bitlens = [n*8 for n in sorted(self.conv_cls.seedlen_map['b6d'])]
  489. seed_bitlen = self._choose_seedlen(self.wclass,seed_bitlens,self.mn_type)
  490. nDierolls = self.conv_cls.seedlen_map['b6d'][seed_bitlen // 8]
  491. m = """
  492. For a {sb}-bit seed you must roll the die {nd} times. After each die roll,
  493. enter the result on the keyboard as a digit. If you make an invalid entry,
  494. you'll be prompted to re-enter it.
  495. """
  496. msg('\n'+fmt(m.strip()).format(sb=seed_bitlen,nd=nDierolls)+'\n')
  497. b6d_digits = self.conv_cls.digits['b6d']
  498. cr = '\n' if g.test_suite else '\r'
  499. prompt_fs = f'\b\b\b {cr}Enter die roll #{{}}: {CUR_SHOW}'
  500. clear_line = '' if g.test_suite else '\r' + ' ' * 25
  501. invalid_msg = CUR_HIDE + cr + 'Invalid entry' + ' ' * 11
  502. from .term import get_char
  503. def get_digit(n):
  504. p = prompt_fs
  505. sleep = g.short_disp_timeout
  506. while True:
  507. ch = get_char(p.format(n),num_chars=1,sleep=sleep)
  508. if ch in b6d_digits:
  509. msg_r(CUR_HIDE + ' OK')
  510. return ch
  511. else:
  512. msg_r(invalid_msg)
  513. sleep = g.err_disp_timeout
  514. p = clear_line + prompt_fs
  515. dierolls,n = [],1
  516. while len(dierolls) < nDierolls:
  517. dierolls.append(get_digit(n))
  518. n += 1
  519. msg('Die rolls successfully entered' + CUR_SHOW)
  520. self.interactive_input = True
  521. return ''.join(dierolls)
  522. class PlainHexSeedFile(WalletUnenc):
  523. stdin_ok = True
  524. fmt_codes = ('hex','rawhex','plainhex')
  525. desc = 'plain hexadecimal seed data'
  526. ext = 'hex'
  527. def _format(self):
  528. self.fmt_data = self.seed.hexdata + '\n'
  529. def _deformat(self):
  530. desc = self.desc
  531. d = self.fmt_data.strip()
  532. if not is_hex_str_lc(d):
  533. msg(f'{d!r}: not a lowercase hexadecimal string, in {desc}')
  534. return False
  535. if not len(d)*4 in g.seed_lens:
  536. msg(f'Invalid data length ({len(d)}) in {desc}')
  537. return False
  538. self.seed = Seed(bytes.fromhex(d))
  539. self.ssdata.hexseed = d
  540. check_usr_seed_len(self.seed.bitlen)
  541. return True
  542. class MMGenHexSeedFile(WalletUnenc):
  543. stdin_ok = True
  544. fmt_codes = ('seedhex','hexseed','mmhex')
  545. desc = 'hexadecimal seed data with checksum'
  546. ext = 'mmhex'
  547. def _format(self):
  548. h = self.seed.hexdata
  549. self.ssdata.chksum = make_chksum_6(h)
  550. self.ssdata.hexseed = h
  551. self.fmt_data = '{} {}\n'.format(
  552. self.ssdata.chksum,
  553. split_into_cols(4,h) )
  554. def _deformat(self):
  555. desc = self.desc
  556. d = self.fmt_data.split()
  557. try:
  558. d[1]
  559. chk,hstr = d[0],''.join(d[1:])
  560. except:
  561. msg(f'{self.fmt_data.strip()!r}: invalid {desc}')
  562. return False
  563. if not len(hstr)*4 in g.seed_lens:
  564. msg(f'Invalid data length ({len(hstr)}) in {desc}')
  565. return False
  566. if not is_chksum_6(chk):
  567. msg(f'{chk!r}: invalid checksum format in {desc}')
  568. return False
  569. if not is_hex_str(hstr):
  570. msg(f'{hstr!r}: not a hexadecimal string, in {desc}')
  571. return False
  572. vmsg_r(f'Validating {desc} checksum...')
  573. if not compare_chksums(chk,'file',make_chksum_6(hstr),'computed',verbose=True):
  574. return False
  575. self.seed = Seed(bytes.fromhex(hstr))
  576. self.ssdata.chksum = chk
  577. self.ssdata.hexseed = hstr
  578. check_usr_seed_len(self.seed.bitlen)
  579. return True
  580. class MMGenWallet(WalletEnc):
  581. fmt_codes = ('wallet','w')
  582. desc = g.proj_name + ' wallet'
  583. ext = 'mmdat'
  584. def __init__(self,*args,**kwargs):
  585. if opt.label:
  586. self.label = MMGenWalletLabel(
  587. opt.label,
  588. msg = "Error in option '--label'" )
  589. else:
  590. self.label = None
  591. super().__init__(*args,**kwargs)
  592. # logic identical to _get_hash_preset_from_user()
  593. def _get_label_from_user(self,old_lbl=''):
  594. prompt = 'Enter a wallet label, or hit ENTER {}: '.format(
  595. 'to reuse the label {}'.format(old_lbl.hl(encl="''")) if old_lbl else
  596. 'for no label' )
  597. while True:
  598. ret = line_input(prompt)
  599. if ret:
  600. lbl = get_obj(MMGenWalletLabel,s=ret)
  601. if lbl:
  602. return lbl
  603. else:
  604. msg('Invalid label. Trying again...')
  605. else:
  606. return old_lbl or MMGenWalletLabel('No Label')
  607. # logic identical to _get_hash_preset()
  608. def _get_label(self):
  609. if hasattr(self,'ss_in') and hasattr(self.ss_in.ssdata,'label'):
  610. old_lbl = self.ss_in.ssdata.label
  611. if opt.keep_label:
  612. lbl = old_lbl
  613. qmsg('Reusing label {} at user request'.format( lbl.hl(encl="''") ))
  614. elif self.label:
  615. lbl = self.label
  616. qmsg('Using label {} requested on command line'.format( lbl.hl(encl="''") ))
  617. else: # Prompt, using old value as default
  618. lbl = self._get_label_from_user(old_lbl)
  619. if (not opt.keep_label) and self.op == 'pwchg_new':
  620. qmsg('Label {}'.format( 'unchanged' if lbl == old_lbl else f'changed to {lbl!r}' ))
  621. elif self.label:
  622. lbl = self.label
  623. qmsg('Using label {} requested on command line'.format( lbl.hl(encl="''") ))
  624. else:
  625. lbl = self._get_label_from_user()
  626. self.ssdata.label = lbl
  627. def _encrypt(self):
  628. self._get_first_pw_and_hp_and_encrypt_seed()
  629. self._get_label()
  630. d = self.ssdata
  631. d.pw_status = ('NE','E')[len(d.passwd)==0]
  632. d.timestamp = make_timestamp()
  633. def _format(self):
  634. d = self.ssdata
  635. s = self.seed
  636. slt_fmt = baseconv.frombytes(d.salt,'b58',pad='seed',tostr=True)
  637. es_fmt = baseconv.frombytes(d.enc_seed,'b58',pad='seed',tostr=True)
  638. lines = (
  639. d.label,
  640. '{} {} {} {} {}'.format( s.sid.lower(), d.key_id.lower(), s.bitlen, d.pw_status, d.timestamp ),
  641. '{}: {} {} {}'.format( d.hash_preset, *get_hash_params(d.hash_preset) ),
  642. '{} {}'.format( make_chksum_6(slt_fmt), split_into_cols(4,slt_fmt) ),
  643. '{} {}'.format( make_chksum_6(es_fmt), split_into_cols(4,es_fmt) )
  644. )
  645. chksum = make_chksum_6(' '.join(lines).encode())
  646. self.fmt_data = '\n'.join((chksum,)+lines) + '\n'
  647. def _deformat(self):
  648. def check_master_chksum(lines,desc):
  649. if len(lines) != 6:
  650. msg(f'Invalid number of lines ({len(lines)}) in {desc} data')
  651. return False
  652. if not is_chksum_6(lines[0]):
  653. msg(f'Incorrect master checksum ({lines[0]}) in {desc} data')
  654. return False
  655. chk = make_chksum_6(' '.join(lines[1:]))
  656. if not compare_chksums(lines[0],'master',chk,'computed',
  657. hdr='For wallet master checksum',verbose=True):
  658. return False
  659. return True
  660. lines = self.fmt_data.splitlines()
  661. if not check_master_chksum(lines,self.desc):
  662. return False
  663. d = self.ssdata
  664. d.label = MMGenWalletLabel(lines[1])
  665. d1,d2,d3,d4,d5 = lines[2].split()
  666. d.seed_id = d1.upper()
  667. d.key_id = d2.upper()
  668. check_usr_seed_len(int(d3))
  669. d.pw_status,d.timestamp = d4,d5
  670. hpdata = lines[3].split()
  671. d.hash_preset = hp = hpdata[0][:-1] # a string!
  672. qmsg(f'Hash preset of wallet: {hp!r}')
  673. if opt.hash_preset and opt.hash_preset != hp:
  674. qmsg('Warning: ignoring user-requested hash preset {opt.hash_preset}')
  675. hash_params = tuple(map(int,hpdata[1:]))
  676. if hash_params != get_hash_params(d.hash_preset):
  677. msg(f'Hash parameters {" ".join(hash_params)!r} don’t match hash preset {d.hash_preset!r}')
  678. return False
  679. lmin,foo,lmax = sorted(baseconv.seedlen_map_rev['b58']) # 22,33,44
  680. for i,key in (4,'salt'),(5,'enc_seed'):
  681. l = lines[i].split(' ')
  682. chk = l.pop(0)
  683. b58_val = ''.join(l)
  684. if len(b58_val) < lmin or len(b58_val) > lmax:
  685. msg(f'Invalid format for {key} in {self.desc}: {l}')
  686. return False
  687. if not compare_chksums(chk,key,
  688. make_chksum_6(b58_val),'computed checksum',verbose=True):
  689. return False
  690. val = baseconv.tobytes(b58_val,'b58',pad='seed')
  691. if val == False:
  692. msg(f'Invalid base 58 number: {b58_val}')
  693. return False
  694. setattr(d,key,val)
  695. return True
  696. def _decrypt(self):
  697. d = self.ssdata
  698. # Needed for multiple transactions with {}-txsign
  699. self._get_passphrase(
  700. add_desc = os.path.basename(self.infile.name) if opt.quiet else '' )
  701. key = make_key(d.passwd, d.salt, d.hash_preset)
  702. ret = decrypt_seed(d.enc_seed, key, d.seed_id, d.key_id)
  703. if ret:
  704. self.seed = Seed(ret)
  705. return True
  706. else:
  707. return False
  708. def _filename(self):
  709. s = self.seed
  710. d = self.ssdata
  711. return '{}-{}[{},{}]{x}.{}'.format(
  712. s.fn_stem,
  713. d.key_id,
  714. s.bitlen,
  715. d.hash_preset,
  716. self.ext,
  717. x='-α' if g.debug_utf8 else '')
  718. class Brainwallet(WalletEnc):
  719. stdin_ok = True
  720. fmt_codes = ('mmbrain','brainwallet','brain','bw','b')
  721. desc = 'brainwallet'
  722. ext = 'mmbrain'
  723. # brainwallet warning message? TODO
  724. def get_bw_params(self):
  725. # already checked
  726. a = opt.brain_params.split(',')
  727. return int(a[0]),a[1]
  728. def _deformat(self):
  729. self.brainpasswd = ' '.join(self.fmt_data.split())
  730. return True
  731. def _decrypt(self):
  732. d = self.ssdata
  733. if opt.brain_params:
  734. """
  735. Don't set opt.seed_len! When using multiple wallets, BW seed len might differ from others
  736. """
  737. bw_seed_len,d.hash_preset = self.get_bw_params()
  738. else:
  739. if not opt.seed_len:
  740. qmsg(f'Using default seed length of {yellow(str(g.dfl_seed_len))} bits\n'
  741. + 'If this is not what you want, use the --seed-len option' )
  742. self._get_hash_preset()
  743. bw_seed_len = opt.seed_len or g.dfl_seed_len
  744. qmsg_r('Hashing brainwallet data. Please wait...')
  745. # Use buflen arg of scrypt.hash() to get seed of desired length
  746. seed = scrypt_hash_passphrase(
  747. self.brainpasswd.encode(),
  748. b'',
  749. d.hash_preset,
  750. buflen = bw_seed_len // 8 )
  751. qmsg('Done')
  752. self.seed = Seed(seed)
  753. msg(f'Seed ID: {self.seed.sid}')
  754. qmsg('Check this value against your records')
  755. return True
  756. def _format(self):
  757. raise NotImplementedError('Brainwallet not supported as an output format')
  758. def _encrypt(self):
  759. raise NotImplementedError('Brainwallet not supported as an output format')
  760. class IncogWalletBase(WalletEnc):
  761. _msg = {
  762. 'check_incog_id': """
  763. Check the generated Incog ID above against your records. If it doesn't
  764. match, then your incognito data is incorrect or corrupted.
  765. """,
  766. 'record_incog_id': """
  767. Make a record of the Incog ID but keep it secret. You will use it to
  768. identify your incog wallet data in the future.
  769. """,
  770. 'incorrect_incog_passphrase_try_again': """
  771. Incorrect passphrase, hash preset, or maybe old-format incog wallet.
  772. Try again? (Y)es, (n)o, (m)ore information:
  773. """.strip(),
  774. 'confirm_seed_id': """
  775. If the Seed ID above is correct but you're seeing this message, then you need
  776. to exit and re-run the program with the '--old-incog-fmt' option.
  777. """.strip(),
  778. 'dec_chk': " {} hash preset"
  779. }
  780. def _make_iv_chksum(self,s): return sha256(s).hexdigest()[:8].upper()
  781. def _get_incog_data_len(self,seed_len):
  782. e = (g.hincog_chk_len,0)[bool(opt.old_incog_fmt)]
  783. return g.aesctr_iv_len + g.salt_len + e + seed_len//8
  784. def _incog_data_size_chk(self):
  785. # valid sizes: 56, 64, 72
  786. dlen = len(self.fmt_data)
  787. seed_len = opt.seed_len or g.dfl_seed_len
  788. valid_dlen = self._get_incog_data_len(seed_len)
  789. if dlen == valid_dlen:
  790. return True
  791. else:
  792. if opt.old_incog_fmt:
  793. msg('WARNING: old-style incognito format requested. Are you sure this is correct?')
  794. msg(f'Invalid incognito data size ({dlen} bytes) for this seed length ({seed_len} bits)')
  795. msg(f'Valid data size for this seed length: {valid_dlen} bytes')
  796. for sl in g.seed_lens:
  797. if dlen == self._get_incog_data_len(sl):
  798. die(1,f'Valid seed length for this data size: {sl} bits')
  799. msg(f'This data size ({dlen} bytes) is invalid for all available seed lengths')
  800. return False
  801. def _encrypt (self):
  802. self._get_first_pw_and_hp_and_encrypt_seed()
  803. if opt.old_incog_fmt:
  804. die(1,'Writing old-format incog wallets is unsupported')
  805. d = self.ssdata
  806. # IV is used BOTH to initialize counter and to salt password!
  807. d.iv = get_random(g.aesctr_iv_len)
  808. d.iv_id = self._make_iv_chksum(d.iv)
  809. msg(f'New Incog Wallet ID: {d.iv_id}')
  810. qmsg('Make a record of this value')
  811. vmsg(self.msg['record_incog_id'])
  812. d.salt = get_random(g.salt_len)
  813. key = make_key(d.passwd, d.salt, d.hash_preset, 'incog wallet key')
  814. chk = sha256(self.seed.data).digest()[:8]
  815. d.enc_seed = encrypt_data(chk+self.seed.data, key, g.aesctr_dfl_iv, 'seed')
  816. d.wrapper_key = make_key(d.passwd, d.iv, d.hash_preset, 'incog wrapper key')
  817. d.key_id = make_chksum_8(d.wrapper_key)
  818. vmsg(f'Key ID: {d.key_id}')
  819. d.target_data_len = self._get_incog_data_len(self.seed.bitlen)
  820. def _format(self):
  821. d = self.ssdata
  822. self.fmt_data = d.iv + encrypt_data(d.salt+d.enc_seed, d.wrapper_key, d.iv, self.desc)
  823. def _filename(self):
  824. s = self.seed
  825. d = self.ssdata
  826. return '{}-{}-{}[{},{}]{x}.{}'.format(
  827. s.fn_stem,
  828. d.key_id,
  829. d.iv_id,
  830. s.bitlen,
  831. d.hash_preset,
  832. self.ext,
  833. x='-α' if g.debug_utf8 else '')
  834. def _deformat(self):
  835. if not self._incog_data_size_chk():
  836. return False
  837. d = self.ssdata
  838. d.iv = self.fmt_data[0:g.aesctr_iv_len]
  839. d.incog_id = self._make_iv_chksum(d.iv)
  840. d.enc_incog_data = self.fmt_data[g.aesctr_iv_len:]
  841. msg(f'Incog Wallet ID: {d.incog_id}')
  842. qmsg('Check this value against your records')
  843. vmsg(self.msg['check_incog_id'])
  844. return True
  845. def _verify_seed_newfmt(self,data):
  846. chk,seed = data[:8],data[8:]
  847. if sha256(seed).digest()[:8] == chk:
  848. qmsg('Passphrase{} are correct'.format( self.msg['dec_chk'].format('and') ))
  849. return seed
  850. else:
  851. msg('Incorrect passphrase{}'.format( self.msg['dec_chk'].format('or') ))
  852. return False
  853. def _verify_seed_oldfmt(self,seed):
  854. m = f'Seed ID: {make_chksum_8(seed)}. Is the Seed ID correct?'
  855. if keypress_confirm(m, True):
  856. return seed
  857. else:
  858. return False
  859. def _decrypt(self):
  860. d = self.ssdata
  861. self._get_hash_preset(add_desc=d.incog_id)
  862. self._get_passphrase(add_desc=d.incog_id)
  863. # IV is used BOTH to initialize counter and to salt password!
  864. key = make_key(d.passwd, d.iv, d.hash_preset, 'wrapper key')
  865. dd = decrypt_data(d.enc_incog_data, key, d.iv, 'incog data')
  866. d.salt = dd[0:g.salt_len]
  867. d.enc_seed = dd[g.salt_len:]
  868. key = make_key(d.passwd, d.salt, d.hash_preset, 'main key')
  869. qmsg(f'Key ID: {make_chksum_8(key)}')
  870. verify_seed = getattr(self,'_verify_seed_'+
  871. ('newfmt','oldfmt')[bool(opt.old_incog_fmt)])
  872. seed = verify_seed(decrypt_seed(d.enc_seed, key, '', ''))
  873. if seed:
  874. self.seed = Seed(seed)
  875. msg(f'Seed ID: {self.seed.sid}')
  876. return True
  877. else:
  878. return False
  879. class IncogWallet(IncogWalletBase):
  880. desc = 'incognito data'
  881. fmt_codes = ('mmincog','incog','icg','i')
  882. ext = 'mmincog'
  883. file_mode = 'binary'
  884. no_tty = True
  885. class IncogWalletHex(IncogWalletBase):
  886. desc = 'hex incognito data'
  887. fmt_codes = ('mmincox','incox','incog_hex','xincog','ix','xi')
  888. ext = 'mmincox'
  889. file_mode = 'text'
  890. no_tty = False
  891. def _deformat(self):
  892. ret = decode_pretty_hexdump(self.fmt_data)
  893. if ret:
  894. self.fmt_data = ret
  895. return super()._deformat()
  896. else:
  897. return False
  898. def _format(self):
  899. super()._format()
  900. self.fmt_data = pretty_hexdump(self.fmt_data)
  901. class IncogWalletHidden(IncogWalletBase):
  902. desc = 'hidden incognito data'
  903. fmt_codes = ('incog_hidden','hincog','ih','hi')
  904. ext = None
  905. file_mode = 'binary'
  906. no_tty = True
  907. _msg = {
  908. 'choose_file_size': """
  909. You must choose a size for your new hidden incog data. The minimum size is
  910. {} bytes, which puts the incog data right at the end of the file. Since you
  911. probably want to hide your data somewhere in the middle of the file where it's
  912. harder to find, you're advised to choose a much larger file size than this.
  913. """.strip(),
  914. 'check_incog_id': """
  915. Check generated Incog ID above against your records. If it doesn't
  916. match, then your incognito data is incorrect or corrupted, or you
  917. may have specified an incorrect offset.
  918. """,
  919. 'record_incog_id': """
  920. Make a record of the Incog ID but keep it secret. You will used it to
  921. identify the incog wallet data in the future and to locate the offset
  922. where the data is hidden in the event you forget it.
  923. """,
  924. 'dec_chk': ', hash preset, offset {} seed length'
  925. }
  926. def _get_hincog_params(self,wtype):
  927. a = getattr(opt,'hidden_incog_'+ wtype +'_params').split(',')
  928. return ','.join(a[:-1]),int(a[-1]) # permit comma in filename
  929. def _check_valid_offset(self,fn,action):
  930. d = self.ssdata
  931. m = ('Input','Destination')[action=='write']
  932. if fn.size < d.hincog_offset + d.target_data_len:
  933. die(1,'{} file {!r} has length {}, too short to {} {} bytes of data at offset {}'.format(
  934. m,
  935. fn.name,
  936. fn.size,
  937. action,
  938. d.target_data_len,
  939. d.hincog_offset ))
  940. def _get_data(self):
  941. d = self.ssdata
  942. d.hincog_offset = self._get_hincog_params('input')[1]
  943. qmsg(f'Getting hidden incog data from file {self.infile.name!r}')
  944. # Already sanity-checked:
  945. d.target_data_len = self._get_incog_data_len(opt.seed_len or g.dfl_seed_len)
  946. self._check_valid_offset(self.infile,'read')
  947. flgs = os.O_RDONLY|os.O_BINARY if g.platform == 'win' else os.O_RDONLY
  948. fh = os.open(self.infile.name,flgs)
  949. os.lseek(fh,int(d.hincog_offset),os.SEEK_SET)
  950. self.fmt_data = os.read(fh,d.target_data_len)
  951. os.close(fh)
  952. qmsg(f'Data read from file {self.infile.name!r} at offset {d.hincog_offset}')
  953. # overrides method in Wallet
  954. def write_to_file(self):
  955. d = self.ssdata
  956. self._format()
  957. compare_or_die(d.target_data_len, 'target data length',
  958. len(self.fmt_data),'length of formatted ' + self.desc)
  959. k = ('output','input')[self.op=='pwchg_new']
  960. fn,d.hincog_offset = self._get_hincog_params(k)
  961. if opt.outdir and not os.path.dirname(fn):
  962. fn = os.path.join(opt.outdir,fn)
  963. check_offset = True
  964. try:
  965. os.stat(fn)
  966. except:
  967. if keypress_confirm(
  968. f'Requested file {fn!r} does not exist. Create?',
  969. default_yes = True ):
  970. min_fsize = d.target_data_len + d.hincog_offset
  971. msg(self.msg['choose_file_size'].format(min_fsize))
  972. while True:
  973. fsize = parse_bytespec(line_input('Enter file size: '))
  974. if fsize >= min_fsize:
  975. break
  976. msg(f'File size must be an integer no less than {min_fsize}')
  977. from .tool import MMGenToolCmdFileUtil
  978. MMGenToolCmdFileUtil().rand2file(fn,str(fsize))
  979. check_offset = False
  980. else:
  981. die(1,'Exiting at user request')
  982. from .filename import Filename
  983. f = Filename(fn,ftype=type(self),write=True)
  984. dmsg('{} data len {}, offset {}'.format(
  985. capfirst(self.desc),
  986. d.target_data_len,
  987. d.hincog_offset ))
  988. if check_offset:
  989. self._check_valid_offset(f,'write')
  990. if not opt.quiet:
  991. confirm_or_raise( '', f'alter file {f.name!r}' )
  992. flgs = os.O_RDWR|os.O_BINARY if g.platform == 'win' else os.O_RDWR
  993. fh = os.open(f.name,flgs)
  994. os.lseek(fh, int(d.hincog_offset), os.SEEK_SET)
  995. os.write(fh, self.fmt_data)
  996. os.close(fh)
  997. msg('{} written to file {!r} at offset {}'.format(
  998. capfirst(self.desc),
  999. f.name,
  1000. d.hincog_offset ))