From 99e7057856cf8f1597488810e10e343cf639af40 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Thu, 30 Nov 2023 10:53:40 +0000 Subject: [PATCH] mmgen-tool: new command: `decrypt_keystore` command rename: `extract_key_from_geth_wallet` -> `decrypt_geth_keystore` --- alt-requirements.txt | 1 + mmgen/altcoin/util.py | 82 +++++++++++++++++++ mmgen/data/version | 2 +- mmgen/main_tool.py | 3 +- mmgen/proto/eth/misc.py | 39 ++------- mmgen/tool/fileutil.py | 21 +++-- test/cmdtest_py_d/ct_ethdev.py | 4 +- test/cmdtest_py_d/ct_tool.py | 27 ++++-- .../ref/altcoin/98831F3A-keystore-wallet.json | 1 + test/tooltest.py | 2 +- 10 files changed, 132 insertions(+), 50 deletions(-) create mode 100755 mmgen/altcoin/util.py create mode 100644 test/ref/altcoin/98831F3A-keystore-wallet.json diff --git a/alt-requirements.txt b/alt-requirements.txt index 1a668a67..8bd64e08 100644 --- a/alt-requirements.txt +++ b/alt-requirements.txt @@ -1 +1,2 @@ pycryptodomex +pbkdf2 diff --git a/mmgen/altcoin/util.py b/mmgen/altcoin/util.py new file mode 100755 index 00000000..d25e12e8 --- /dev/null +++ b/mmgen/altcoin/util.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2023 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +altcoin.util: various altcoin-related utilities +""" + +from mmgen.util import die + +def decrypt_keystore(data,passwd,mac_algo=None,mac_params={}): + """ + Decrypt the encrypted data in a cross-chain keystore + Returns the decrypted data as a bytestring + """ + + cdata = data['crypto'] + parms = cdata['kdfparams'] + + valid_kdfs = ['scrypt','pbkdf2'] + valid_ciphers = ['aes-128-ctr','aes-256-ctr'] + + if (kdf := cdata['kdf']) not in valid_kdfs: + die(1, f'unsupported key derivation function {kdf!r} (must be one of {valid_kdfs})') + + if (cipher := cdata['cipher']) not in valid_ciphers: + die(1,f'unsupported cipher {cipher!r} (must be one of {valid_ciphers})') + + # Derive encryption key from password: + if kdf == 'scrypt': + if not mac_algo: + die(1,f'the ‘mac_algo’ parameter is required for scrypt kdf') + from hashlib import scrypt + hashed_pw = scrypt( + password = passwd, + salt = bytes.fromhex(parms['salt']), + n = parms['n'], + r = parms['r'], + p = parms['p'], + maxmem = 0, + dklen = parms['dklen']) + elif kdf == 'pbkdf2': + if (prf := parms.get('prf')) != 'hmac-sha256': + die(1, f"unsupported hash function {prf!r} (must be 'hmac-sha256')") + from pbkdf2 import PBKDF2 + hashed_pw = PBKDF2( + passphrase = passwd, + salt = bytes.fromhex(parms['salt']), + iterations = parms['c'], + digestmodule = 'sha256', + ).read(parms['dklen']) + # see: + # https://github.com/xchainjs/xchainjs-lib.git + # https://github.com/xchainjs/foundry-primitives-js.git + from hashlib import blake2b + mac_algo = mac_algo or blake2b + mac_params = mac_params or {'digest_size': 32} + + mac = mac_algo( + hashed_pw[16:32] + bytes.fromhex(cdata['ciphertext']), + **mac_params + ).digest().hex() + + if mac != cdata['mac']: + die(1, 'incorrect password') + + # Decrypt data: + from cryptography.hazmat.primitives.ciphers import Cipher,algorithms,modes + from cryptography.hazmat.backends import default_backend + cipher_len = int(cipher.split('-')[1]) // 8 + c = Cipher( + algorithms.AES(hashed_pw[:cipher_len]), + modes.CTR(bytes.fromhex(cdata['cipherparams']['iv'])), + backend = default_backend()) + encryptor = c.encryptor() + return encryptor.update(bytes.fromhex(cdata['ciphertext'])) + encryptor.finalize() diff --git a/mmgen/data/version b/mmgen/data/version index 4b964e96..aba498b3 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -14.0.0 +14.1.dev0 diff --git a/mmgen/main_tool.py b/mmgen/main_tool.py index bd985212..bb1c3c80 100755 --- a/mmgen/main_tool.py +++ b/mmgen/main_tool.py @@ -145,7 +145,8 @@ mods = { 'encrypt', ), 'fileutil': ( - 'extract_key_from_geth_wallet', + 'decrypt_keystore', + 'decrypt_geth_keystore', 'find_incog_data', 'rand2file', ), diff --git a/mmgen/proto/eth/misc.py b/mmgen/proto/eth/misc.py index 84f183a2..2684288d 100755 --- a/mmgen/proto/eth/misc.py +++ b/mmgen/proto/eth/misc.py @@ -12,10 +12,9 @@ proto.eth.misc: miscellaneous utilities for Ethereum base protocol """ -from ...util import die from ...util2 import get_keccak -def extract_key_from_geth_keystore_wallet(cfg,wallet_fn,passwd,check_addr=True): +def decrypt_geth_keystore(cfg,wallet_fn,passwd,check_addr=True): """ Decrypt the encrypted private key in a Geth keystore wallet, returning the decrypted key """ @@ -24,37 +23,11 @@ def extract_key_from_geth_keystore_wallet(cfg,wallet_fn,passwd,check_addr=True): with open(wallet_fn) as fp: wallet_data = json.loads(fp.read()) - cdata = wallet_data['crypto'] - - assert cdata['cipher'] == 'aes-128-ctr', f'incorrect cipher: "{cdata["cipher"]}" != "aes-128-ctr"' - assert cdata['kdf'] == 'scrypt', f'incorrect KDF: "{cdata["kdf"]}" != "scrypt"' - - # Derive encryption key from password - from hashlib import scrypt - sp = cdata['kdfparams'] - hashed_pw = scrypt( - password = passwd, - salt = bytes.fromhex( sp['salt'] ), - n = sp['n'], - r = sp['r'], - p = sp['p'], - maxmem = 0, - dklen = sp['dklen'] ) - - # Check password by comparing generated MAC to stored MAC - mac_chk = get_keccak(cfg)(hashed_pw[16:32] + bytes.fromhex( cdata['ciphertext'] )).digest().hex() - if mac_chk != cdata['mac']: - die(1,'Incorrect passphrase') - - # Decrypt Ethereum private key - from cryptography.hazmat.primitives.ciphers import Cipher,algorithms,modes - from cryptography.hazmat.backends import default_backend - c = Cipher( - algorithms.AES(hashed_pw[:16]), - modes.CTR(bytes.fromhex( cdata['cipherparams']['iv'] )), - backend = default_backend() ) - encryptor = c.encryptor() - key = encryptor.update( bytes.fromhex(cdata['ciphertext']) ) + encryptor.finalize() + from ...altcoin.util import decrypt_keystore + key = decrypt_keystore( + wallet_data, + passwd, + mac_algo = get_keccak()) # Optionally check that Ethereum private key produces correct address if check_addr: diff --git a/mmgen/tool/fileutil.py b/mmgen/tool/fileutil.py index d9d05946..ae018e5b 100755 --- a/mmgen/tool/fileutil.py +++ b/mmgen/tool/fileutil.py @@ -148,9 +148,20 @@ class tool_cmd(tool_cmd_base): return True - def extract_key_from_geth_wallet( self, wallet_file:str, check_addr=True ): - "decrypt the encrypted private key in a Geth keystore wallet, returning the decrypted key" + def decrypt_keystore(self, wallet_file:str, output_hex=False): + "decrypt the data in a keystore wallet, returning the decrypted data in binary format" from ..ui import line_input - from ..proto.eth.misc import extract_key_from_geth_keystore_wallet - passwd = line_input( self.cfg, 'Enter passphrase: ', echo=self.cfg.echo_passphrase ).strip().encode() - return extract_key_from_geth_keystore_wallet( self.cfg, wallet_file, passwd, check_addr ).hex() + passwd = line_input(self.cfg, 'Enter passphrase: ', echo=self.cfg.echo_passphrase).strip().encode() + import json + with open(wallet_file) as fh: + data = json.loads(fh.read()) + from ..altcoin.util import decrypt_keystore + ret = decrypt_keystore(data[0]['keystore'], passwd) + return ret.hex() if output_hex else ret + + def decrypt_geth_keystore(self, wallet_file:str, check_addr=True): + "decrypt the private key in a Geth keystore wallet, returning the decrypted key in hex format" + from ..ui import line_input + passwd = line_input(self.cfg, 'Enter passphrase: ', echo=self.cfg.echo_passphrase).strip().encode() + from ..proto.eth.misc import decrypt_geth_keystore + return decrypt_geth_keystore(self.cfg, wallet_file, passwd, check_addr).hex() diff --git a/test/cmdtest_py_d/ct_ethdev.py b/test/cmdtest_py_d/ct_ethdev.py index 7645e3a3..bafd0989 100755 --- a/test/cmdtest_py_d/ct_ethdev.py +++ b/test/cmdtest_py_d/ct_ethdev.py @@ -482,8 +482,8 @@ class CmdTestEthdev(CmdTestBase,CmdTestShared): wallet_fn = os.path.join( self.keystore_dir, os.listdir(self.keystore_dir)[0] ) - from mmgen.proto.eth.misc import extract_key_from_geth_keystore_wallet - key = extract_key_from_geth_keystore_wallet( + from mmgen.proto.eth.misc import decrypt_geth_keystore + key = decrypt_geth_keystore( cfg = cfg, wallet_fn = wallet_fn, passwd = b'' ) diff --git a/test/cmdtest_py_d/ct_tool.py b/test/cmdtest_py_d/ct_tool.py index c96420d8..8f5be45a 100755 --- a/test/cmdtest_py_d/ct_tool.py +++ b/test/cmdtest_py_d/ct_tool.py @@ -40,7 +40,8 @@ class CmdTestTool(CmdTestMain,CmdTestBase): ('tool_encrypt', (9,"'mmgen-tool encrypt' (random data)", [])), ('tool_decrypt', (9,"'mmgen-tool decrypt' (random data)", [[[enc_infn+'.mmenc'],9]])), ('tool_twview_bad_comment',(9,"'mmgen-tool twview' (with bad comment)", [])), - ('tool_extract_key_from_geth_wallet',(9,"'mmgen-tool extract_key_from_geth_wallet'", [])), + ('tool_decrypt_keystore',(9,"'mmgen-tool decrypt_keystore'", [])), + ('tool_decrypt_geth_keystore',(9,"'mmgen-tool decrypt_geth_keystore'", [])), ('tool_api', (9,'tool API (initialization, config methods, wif2addr)',[])), # ('tool_encrypt_ref', (9,"'mmgen-tool encrypt' (reference text)", [])), ) @@ -99,16 +100,28 @@ class CmdTestTool(CmdTestMain,CmdTestBase): t.req_exit_val = 2 return t - def tool_extract_key_from_geth_wallet(self): + def _decrypt_keystore(self,cmd,fn,pw,chk): if cfg.no_altcoin: return 'skip' - fn = 'test/ref/ethereum/geth-wallet.json' - key = '9627ddb68354f5e0ff45fb2da49d7a20a013b7257a83ef4adbbbd87aeaccc75e' - t = self.spawn('mmgen-tool',['-d',self.tmpdir,'extract_key_from_geth_wallet',fn]) - t.expect('Enter passphrase: ','\n') - t.expect(key) + t = self.spawn('mmgen-tool',['-d',self.tmpdir,cmd,fn]) + t.expect('Enter passphrase: ',pw+'\n') + t.expect(chk) return t + def tool_decrypt_keystore(self): + return self._decrypt_keystore( + cmd = 'decrypt_keystore', + fn = 'test/ref/altcoin/98831F3A-keystore-wallet.json', + pw = 'abc', + chk = read_from_file('test/ref/98831F3A.bip39') ) + + def tool_decrypt_geth_keystore(self): + return self._decrypt_keystore( + cmd = 'decrypt_geth_keystore', + fn = 'test/ref/ethereum/geth-wallet.json', + pw = '', + chk = '9627ddb68354f5e0ff45fb2da49d7a20a013b7257a83ef4adbbbd87aeaccc75e') + def tool_api(self): t = self.spawn( 'tool_api_test.py', diff --git a/test/ref/altcoin/98831F3A-keystore-wallet.json b/test/ref/altcoin/98831F3A-keystore-wallet.json new file mode 100644 index 00000000..0f917dbe --- /dev/null +++ b/test/ref/altcoin/98831F3A-keystore-wallet.json @@ -0,0 +1 @@ +[{"id":1700757398916,"name":"fake-test-wallet","keystore":{"crypto":{"cipher":"aes-128-ctr","ciphertext":"05e0926498f428b0693c6d47b5d22ffb664f376143cca66fa144b4004063ec3a9ec5fae950d4232717b22a1981fd0d9fe6657cfd400625b4a489fd1b02f06ca0055a3b616ad46b02cdffe860b8eaf3d3fe38f69a0f9018da293a417aa54a9519f5c1a4bdde0486d5eb778cf548e1475c4900348db103ba101ba09f3a2aa6684ad05a779fc651eca2ee079e07","cipherparams":{"iv":"f8a1c1f53f59ab43a27b683d1e97221c"},"kdf":"pbkdf2","kdfparams":{"prf":"hmac-sha256","dklen":32,"salt":"8dfbb24067ffd72225570e33f54327050f0aff01a65b7b94cb4661d5cfeae441","c":26214},"mac":"2ad462309e453b7ed98472b8836c2b765bfbde0303ea8cc50cb830a43cf01d8c"},"id":"bccb2fc2-8af5-4309-9ebc-a0edcfc73882","version":1,"meta":"xchain-keystore"},"selected":true}] diff --git a/test/tooltest.py b/test/tooltest.py index e3b61298..9063b14a 100755 --- a/test/tooltest.py +++ b/test/tooltest.py @@ -180,7 +180,7 @@ if cfg.testing_status: 'addrfile_chksum','keyaddrfile_chksum','passwdfile_chksum', 'add_label','remove_label','remove_address','twview', 'getbalance','listaddresses','listaddress', - 'daemon_version','extract_key_from_geth_wallet', + 'daemon_version','decrypt_geth_keystore', 'mn2hex_interactive','rand2file', 'rescan_address','rescan_blockchain','resolve_address', 'twexport','twimport','txhist'