wallet-rpc: restore from multisig seed

This commit is contained in:
jeffro256 2023-06-22 09:15:12 +02:00
parent 94e67bf96b
commit 45b52de28e
No known key found for this signature in database
GPG key ID: 6F79797A6E392442
7 changed files with 217 additions and 35 deletions

View file

@ -147,6 +147,16 @@ namespace epee
return {reinterpret_cast<const std::uint8_t*>(src.data()), src.size_bytes()};
}
//! \return `span<std::uint8_t>` from a STL compatible `src`.
template<typename T>
constexpr span<std::uint8_t> to_mut_byte_span(T& src)
{
using value_type = typename T::value_type;
static_assert(!std::is_empty<value_type>(), "empty value types will not work -> sizeof == 1");
static_assert(!has_padding<value_type>(), "source value type may have padding");
return {reinterpret_cast<std::uint8_t*>(src.data()), src.size() * sizeof(value_type)};
}
//! \return `span<const std::uint8_t>` which represents the bytes at `&src`.
template<typename T>
span<const std::uint8_t> as_byte_span(const T& src) noexcept

View file

@ -986,6 +986,21 @@ bool get_pruned_tx(const cryptonote::COMMAND_RPC_GET_TRANSACTIONS::entry &entry,
return false;
}
// Given M (threshold) and N (total), calculate the number of private multisig keys each
// signer should have. This value is equal to (N - 1) choose (N - M)
// Prereq: M >= 1 && N >= M && N <= 16
uint64_t num_priv_multisig_keys_post_setup(uint64_t threshold, uint64_t total)
{
THROW_WALLET_EXCEPTION_IF(threshold < 1 || total < threshold || threshold > 16,
tools::error::wallet_internal_error, "Invalid arguments to num_priv_multisig_keys_post_setup");
uint64_t n_multisig_keys = 1;
for (uint64_t i = 2; i <= total - 1; ++i) n_multisig_keys *= i; // multiply by (N - 1)!
for (uint64_t i = 2; i <= total - threshold; ++i) n_multisig_keys /= i; // divide by (N - M)!
for (uint64_t i = 2; i <= threshold - 1; ++i) n_multisig_keys /= i; // divide by ((N - 1) - (N - M))!
return n_multisig_keys;
}
//-----------------------------------------------------------------
} //namespace
@ -1394,7 +1409,7 @@ bool wallet2::get_seed(epee::wipeable_string& electrum_words, const epee::wipeab
return true;
}
//----------------------------------------------------------------------------------------------------
bool wallet2::get_multisig_seed(epee::wipeable_string& seed, const epee::wipeable_string &passphrase, bool raw) const
bool wallet2::get_multisig_seed(epee::wipeable_string& seed, const epee::wipeable_string &passphrase) const
{
bool ready;
uint32_t threshold, total;
@ -1408,15 +1423,14 @@ bool wallet2::get_multisig_seed(epee::wipeable_string& seed, const epee::wipeabl
std::cout << "This multisig wallet is not yet finalized" << std::endl;
return false;
}
if (!raw && seed_language.empty())
{
std::cout << "seed_language not set" << std::endl;
return false;
}
const uint64_t num_expected_ms_keys = num_priv_multisig_keys_post_setup(threshold, total);
crypto::secret_key skey;
crypto::public_key pkey;
const account_keys &keys = get_account().get_keys();
THROW_WALLET_EXCEPTION_IF(num_expected_ms_keys != keys.m_multisig_keys.size(),
error::wallet_internal_error, "Unexpected number of private multisig keys")
epee::wipeable_string data;
data.append((const char*)&threshold, sizeof(uint32_t));
data.append((const char*)&total, sizeof(uint32_t));
@ -1441,18 +1455,7 @@ bool wallet2::get_multisig_seed(epee::wipeable_string& seed, const epee::wipeabl
data = encrypt(data, key, true);
}
if (raw)
{
seed = epee::to_hex::wipeable_string({(const unsigned char*)data.data(), data.size()});
}
else
{
if (!crypto::ElectrumWords::bytes_to_words(data.data(), data.size(), seed, seed_language))
{
std::cout << "Failed to encode seed";
return false;
}
}
return true;
}
@ -4905,9 +4908,11 @@ void wallet2::generate(const std::string& wallet_, const epee::wipeable_string&
offset += sizeof(uint32_t);
uint32_t total = *(uint32_t*)(multisig_data.data() + offset);
offset += sizeof(uint32_t);
THROW_WALLET_EXCEPTION_IF(threshold < 2, error::invalid_multisig_seed);
THROW_WALLET_EXCEPTION_IF(total != threshold && total != threshold + 1, error::invalid_multisig_seed);
const size_t n_multisig_keys = total == threshold ? 1 : threshold;
THROW_WALLET_EXCEPTION_IF(threshold < 1, error::invalid_multisig_seed);
THROW_WALLET_EXCEPTION_IF(total < threshold, error::invalid_multisig_seed);
THROW_WALLET_EXCEPTION_IF(threshold > 16, error::invalid_multisig_seed); // doing N choose (N - M + 1) might overflow
const uint64_t n_multisig_keys = num_priv_multisig_keys_post_setup(threshold, total);
THROW_WALLET_EXCEPTION_IF(multisig_data.size() != 8 + 32 * (4 + n_multisig_keys + total), error::invalid_multisig_seed);
std::vector<crypto::secret_key> multisig_keys;

View file

@ -1020,7 +1020,7 @@ private:
bool multisig(bool *ready = NULL, uint32_t *threshold = NULL, uint32_t *total = NULL) const;
bool has_multisig_partial_key_images() const;
bool has_unknown_key_images() const;
bool get_multisig_seed(epee::wipeable_string& seed, const epee::wipeable_string &passphrase = std::string(), bool raw = true) const;
bool get_multisig_seed(epee::wipeable_string& seed, const epee::wipeable_string &passphrase = std::string()) const;
bool key_on_device() const { return get_device_type() != hw::device::device_type::SOFTWARE; }
hw::device::device_type get_device_type() const { return m_key_device_type; }
bool reconnect_device();

View file

@ -3807,7 +3807,7 @@ namespace tools
std::string old_language;
// check the given seed
{
if (!req.enable_multisig_experimental) {
if (!crypto::ElectrumWords::words_to_bytes(req.seed, recovery_key, old_language))
{
er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
@ -3830,6 +3830,13 @@ namespace tools
// process seed_offset if given
{
if (req.enable_multisig_experimental && !req.seed_offset.empty())
{
er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
er.message = "Multisig seeds are not compatible with seed offsets";
return false;
}
if (!req.seed_offset.empty())
{
recovery_key = cryptonote::decrypt_key(recovery_key, req.seed_offset);
@ -3893,7 +3900,27 @@ namespace tools
crypto::secret_key recovery_val;
try
{
if (req.enable_multisig_experimental)
{
// Parse multisig seed into raw multisig data
epee::wipeable_string multisig_data;
multisig_data.resize(req.seed.size() / 2);
if (!epee::from_hex::to_buffer(epee::to_mut_byte_span(multisig_data), req.seed))
{
er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
er.message = "Multisig seed not represented as hexadecimal string";
return false;
}
// Generate multisig wallet
wal->generate(wallet_file, std::move(rc.second).password(), multisig_data, false);
wal->enable_multisig(true);
}
else
{
// Generate normal wallet
recovery_val = wal->generate(wallet_file, std::move(rc.second).password(), recovery_key, true, false, false);
}
MINFO("Wallet has been restored.\n");
}
catch (const std::exception &e)
@ -3904,7 +3931,7 @@ namespace tools
// // Convert the secret key back to seed
epee::wipeable_string electrum_words;
if (!crypto::ElectrumWords::bytes_to_words(recovery_val, electrum_words, mnemonic_language))
if (!req.enable_multisig_experimental && !crypto::ElectrumWords::bytes_to_words(recovery_val, electrum_words, mnemonic_language))
{
er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
er.message = "Failed to encode seed";

View file

@ -2360,6 +2360,7 @@ namespace wallet_rpc
std::string password;
std::string language;
bool autosave_current;
bool enable_multisig_experimental;
BEGIN_KV_SERIALIZE_MAP()
KV_SERIALIZE_OPT(restore_height, (uint64_t)0)
@ -2369,6 +2370,7 @@ namespace wallet_rpc
KV_SERIALIZE(password)
KV_SERIALIZE(language)
KV_SERIALIZE_OPT(autosave_current, true)
KV_SERIALIZE_OPT(enable_multisig_experimental, false)
END_KV_SERIALIZE_MAP()
};
typedef epee::misc_utils::struct_init<request_t> request;

View file

@ -29,6 +29,7 @@
# THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from __future__ import print_function
import random
"""Test multisig transfers
"""
@ -36,48 +37,98 @@ from __future__ import print_function
from framework.daemon import Daemon
from framework.wallet import Wallet
MULTISIG_PUB_ADDRS = [
'45J58b7PmKJFSiNPFFrTdtfMcFGnruP7V4CMuRpX7NsH4j3jGHKAjo3YJP2RePX6HMaSkbvTbrWUFhDNcNcHgtNmQ3gr7sG', # 2/2
'44G2TQNfsiURKkvxp7gbgaJY8WynZvANnhmyMAwv6WeEbAvyAWMfKXRhh3uBXT2UAKhAsUJ7Fg5zjjF2U1iGciFk5duN94i', # 2/3
'41mro238grj56GnrWkakAKTkBy2yDcXYsUZ2iXCM9pe5Ueajd2RRc6Fhh3uBXT2UAKhAsUJ7Fg5zjjF2U1iGciFk5ief4ZP', # 3/3
'44vZSprQKJQRFe6t1VHgU4ESvq2dv7TjBLVGE7QscKxMdFSiyyPCEV64NnKUQssFPyWxc2meyt7j63F2S2qtCTRL6dakeff', # 3/4
'47puypSwsV1gvUDratmX4y58fSwikXVehEiBhVLxJA1gRCxHyrRgTDr4NnKUQssFPyWxc2meyt7j63F2S2qtCTRL6aRPj5U', # 2/4
'4A8RnBQixry4VXkqeWhmg8L7vWJVDJj4FN9PV4E7Mgad5ZZ6LKQdn8dYJP2RePX6HMaSkbvTbrWUFhDNcNcHgtNmQ4S8RSB' # 1/2
]
class MultisigTest():
def run_test(self):
self.reset()
self.mine('45J58b7PmKJFSiNPFFrTdtfMcFGnruP7V4CMuRpX7NsH4j3jGHKAjo3YJP2RePX6HMaSkbvTbrWUFhDNcNcHgtNmQ3gr7sG', 5)
self.mine('44G2TQNfsiURKkvxp7gbgaJY8WynZvANnhmyMAwv6WeEbAvyAWMfKXRhh3uBXT2UAKhAsUJ7Fg5zjjF2U1iGciFk5duN94i', 5)
self.mine('41mro238grj56GnrWkakAKTkBy2yDcXYsUZ2iXCM9pe5Ueajd2RRc6Fhh3uBXT2UAKhAsUJ7Fg5zjjF2U1iGciFk5ief4ZP', 5)
self.mine('44vZSprQKJQRFe6t1VHgU4ESvq2dv7TjBLVGE7QscKxMdFSiyyPCEV64NnKUQssFPyWxc2meyt7j63F2S2qtCTRL6dakeff', 5)
self.mine('47puypSwsV1gvUDratmX4y58fSwikXVehEiBhVLxJA1gRCxHyrRgTDr4NnKUQssFPyWxc2meyt7j63F2S2qtCTRL6aRPj5U', 5)
for pub_addr in MULTISIG_PUB_ADDRS:
self.mine(pub_addr, 4)
self.mine('42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm', 80)
self.test_states()
self.fund_addrs_with_normal_wallet(MULTISIG_PUB_ADDRS)
self.create_multisig_wallets(2, 2, '45J58b7PmKJFSiNPFFrTdtfMcFGnruP7V4CMuRpX7NsH4j3jGHKAjo3YJP2RePX6HMaSkbvTbrWUFhDNcNcHgtNmQ3gr7sG')
self.import_multisig_info([1, 0], 5)
txid = self.transfer([1, 0])
self.import_multisig_info([0, 1], 6)
self.check_transaction(txid)
self.remake_some_multisig_wallets_by_multsig_seed(2)
self.import_multisig_info([0, 1], 6) # six outputs, same as before
txid = self.transfer([0, 1])
self.import_multisig_info([0, 1], 7) # seven outputs b/c we're dest plus change
self.check_transaction(txid)
self.create_multisig_wallets(2, 3, '44G2TQNfsiURKkvxp7gbgaJY8WynZvANnhmyMAwv6WeEbAvyAWMfKXRhh3uBXT2UAKhAsUJ7Fg5zjjF2U1iGciFk5duN94i')
self.import_multisig_info([0, 2], 5)
txid = self.transfer([0, 2])
self.import_multisig_info([0, 1, 2], 6)
self.check_transaction(txid)
self.remake_some_multisig_wallets_by_multsig_seed(2)
self.import_multisig_info([0, 2], 6) # six outputs, same as before
txid = self.transfer([0, 2])
self.import_multisig_info([0, 1, 2], 7) # seven outputs b/c we're dest plus change
self.check_transaction(txid)
self.create_multisig_wallets(3, 3, '41mro238grj56GnrWkakAKTkBy2yDcXYsUZ2iXCM9pe5Ueajd2RRc6Fhh3uBXT2UAKhAsUJ7Fg5zjjF2U1iGciFk5ief4ZP')
self.import_multisig_info([2, 0, 1], 5)
txid = self.transfer([2, 1, 0])
self.import_multisig_info([0, 2, 1], 6)
self.check_transaction(txid)
self.remake_some_multisig_wallets_by_multsig_seed(3)
self.import_multisig_info([2, 0, 1], 6) # six outputs, same as before
txid = self.transfer([2, 1, 0])
self.import_multisig_info([0, 2, 1], 7) # seven outputs b/c we're dest plus change
self.check_transaction(txid)
self.create_multisig_wallets(3, 4, '44vZSprQKJQRFe6t1VHgU4ESvq2dv7TjBLVGE7QscKxMdFSiyyPCEV64NnKUQssFPyWxc2meyt7j63F2S2qtCTRL6dakeff')
self.import_multisig_info([0, 2, 3], 5)
txid = self.transfer([0, 2, 3])
self.import_multisig_info([0, 1, 2, 3], 6)
self.check_transaction(txid)
self.remake_some_multisig_wallets_by_multsig_seed(3)
self.import_multisig_info([0, 2, 3], 6) # six outputs, same as before
txid = self.transfer([0, 2, 3])
self.import_multisig_info([0, 1, 2, 3], 7) # seven outputs b/c we're dest plus change
self.check_transaction(txid)
self.create_multisig_wallets(2, 4, '47puypSwsV1gvUDratmX4y58fSwikXVehEiBhVLxJA1gRCxHyrRgTDr4NnKUQssFPyWxc2meyt7j63F2S2qtCTRL6aRPj5U')
self.import_multisig_info([1, 2], 5)
txid = self.transfer([1, 2])
self.import_multisig_info([0, 1, 2, 3], 6)
self.check_transaction(txid)
self.remake_some_multisig_wallets_by_multsig_seed(2)
self.import_multisig_info([0, 1, 2, 3], 6) # six outputs, same as before
txid = self.transfer([2, 3])
self.import_multisig_info([0, 1, 2, 3], 7) # seven outputs b/c we're dest plus change
self.check_transaction(txid)
self.create_multisig_wallets(1, 2, '4A8RnBQixry4VXkqeWhmg8L7vWJVDJj4FN9PV4E7Mgad5ZZ6LKQdn8dYJP2RePX6HMaSkbvTbrWUFhDNcNcHgtNmQ4S8RSB')
self.import_multisig_info([0, 1], 5)
txid = self.transfer([0])
self.import_multisig_info([0, 1], 6)
self.check_transaction(txid)
self.remake_some_multisig_wallets_by_multsig_seed(1)
self.import_multisig_info([0, 1], 6) # six outputs, same as before
txid = self.transfer([1])
self.import_multisig_info([0, 1], 7) # seven outputs b/c we're dest plus change
self.check_transaction(txid)
def reset(self):
print('Resetting blockchain')
daemon = Daemon()
@ -90,6 +141,11 @@ class MultisigTest():
daemon = Daemon()
daemon.generateblocks(address, blocks)
# This method sets up N_total wallets with a threshold of M_threshold doing the following steps:
# * restore_deterministic_wallet(w/ hardcoded seeds)
# * prepare_multisig(enable_multisig_experimental = True)
# * make_multisig()
# * exchange_multisig_keys()
def create_multisig_wallets(self, M_threshold, N_total, expected_address):
print('Creating ' + str(M_threshold) + '/' + str(N_total) + ' multisig wallet')
seeds = [
@ -100,6 +156,8 @@ class MultisigTest():
]
assert M_threshold <= N_total
assert N_total <= len(seeds)
# restore_deterministic_wallet() & prepare_multisig()
self.wallet = [None] * N_total
info = []
for i in range(N_total):
@ -111,10 +169,12 @@ class MultisigTest():
assert len(res.multisig_info) > 0
info.append(res.multisig_info)
# Assert that all wallets are multisig
for i in range(N_total):
res = self.wallet[i].is_multisig()
assert res.multisig == False
# make_multisig() with each other's info
addresses = []
next_stage = []
for i in range(N_total):
@ -122,6 +182,7 @@ class MultisigTest():
addresses.append(res.address)
next_stage.append(res.multisig_info)
# Assert multisig paramaters M/N for each wallet
for i in range(N_total):
res = self.wallet[i].is_multisig()
assert res.multisig == True
@ -129,13 +190,15 @@ class MultisigTest():
assert res.threshold == M_threshold
assert res.total == N_total
while True:
# exchange_multisig_keys()
num_exchange_multisig_keys_stages = 0
while True: # while not all wallets are ready
n_ready = 0
for i in range(N_total):
res = self.wallet[i].is_multisig()
if res.ready == True:
n_ready += 1
assert n_ready == 0 or n_ready == N_total
assert n_ready == 0 or n_ready == N_total # No partial readiness
if n_ready == N_total:
break
info = next_stage
@ -145,10 +208,17 @@ class MultisigTest():
res = self.wallet[i].exchange_multisig_keys(info)
next_stage.append(res.multisig_info)
addresses.append(res.address)
num_exchange_multisig_keys_stages += 1
# We should only need N - M + 1 key exchange rounds
assert num_exchange_multisig_keys_stages == N_total - M_threshold + 1
# Assert that the all wallets have expected public address
for i in range(N_total):
assert addresses[i] == expected_address
assert addresses[i] == expected_address, addresses[i]
self.wallet_address = expected_address
# Assert multisig paramaters M/N and "ready" for each wallet
for i in range(N_total):
res = self.wallet[i].is_multisig()
assert res.multisig == True
@ -156,6 +226,73 @@ class MultisigTest():
assert res.threshold == M_threshold
assert res.total == N_total
# We want to test if multisig wallets can receive normal transfers as well and mining transfers
def fund_addrs_with_normal_wallet(self, addrs):
print("Funding multisig wallets with normal wallet-to-wallet transfers")
# Generate normal deterministic wallet
normal_seed = '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'
assert not hasattr(self, 'wallet') or not self.wallet
self.wallet = [Wallet(idx = 0)]
res = self.wallet[0].restore_deterministic_wallet(seed = normal_seed)
assert res.address == '42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm'
self.wallet[0].refresh()
# Check that we own enough spendable enotes
res = self.wallet[0].incoming_transfers(transfer_type = 'available')
assert 'transfers' in res
num_outs_spendable = 0
min_out_amount = None
for t in res.transfers:
if not t.spent:
num_outs_spendable += 1
min_out_amount = min(min_out_amount, t.amount) if min_out_amount is not None else t.amount
assert num_outs_spendable >= 2 * len(addrs)
# Transfer to addrs and mine to confirm tx
dsts = [{'address': addr, 'amount': int(min_out_amount * 0.95)} for addr in addrs]
res = self.wallet[0].transfer(dsts, get_tx_metadata = True)
tx_hex = res.tx_metadata
res = self.wallet[0].relay_tx(tx_hex)
self.mine('42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm', 10)
def remake_some_multisig_wallets_by_multsig_seed(self, threshold):
N = len(self.wallet)
signers_to_remake = set()
num_signers_to_remake = random.randint(1, N) # Do at least one
while len(signers_to_remake) < num_signers_to_remake:
signers_to_remake.add(random.randint(0, N - 1))
for i in signers_to_remake:
print("Remaking {}/{} multsig wallet from multisig seed: #{}".format(threshold, N, i+1))
otherwise_unused_seed = \
'factual wiggle awakened maul sash biscuit pause reinvest fonts sleepless knowledge tossed jewels request gusts dagger gumball onward dotted amended powder cynical strained topic request'
# Get information about wallet, will compare against later
old_viewkey = self.wallet[i].query_key('view_key').key
old_spendkey = self.wallet[i].query_key('spend_key').key
old_multisig_seed = self.wallet[i].query_key('mnemonic').key
# Close old wallet and restore w/ random seed so we know that restoring actually did something
self.wallet[i].close_wallet()
self.wallet[i].restore_deterministic_wallet(seed=otherwise_unused_seed)
mid_viewkey = self.wallet[i].query_key('view_key').key
assert mid_viewkey != old_viewkey
# Now restore w/ old multisig seed and check against original
self.wallet[i].close_wallet()
self.wallet[i].restore_deterministic_wallet(seed=old_multisig_seed, enable_multisig_experimental=True)
new_viewkey = self.wallet[i].query_key('view_key').key
new_spendkey = self.wallet[i].query_key('spend_key').key
new_multisig_seed = self.wallet[i].query_key('mnemonic').key
assert new_viewkey == old_viewkey
assert new_spendkey == old_spendkey
assert new_multisig_seed == old_multisig_seed
self.wallet[i].refresh()
def test_states(self):
print('Testing multisig states')
seeds = [
@ -248,7 +385,7 @@ class MultisigTest():
assert res.n_outputs == expected_outputs
def transfer(self, signers):
assert len(signers) >= 2
assert len(signers) >= 1
daemon = Daemon()

View file

@ -297,7 +297,7 @@ class Wallet(object):
}
return self.rpc.send_json_rpc_request(query_key)
def restore_deterministic_wallet(self, seed = '', seed_offset = '', filename = '', restore_height = 0, password = '', language = '', autosave_current = True):
def restore_deterministic_wallet(self, seed = '', seed_offset = '', filename = '', restore_height = 0, password = '', language = '', autosave_current = True, enable_multisig_experimental = False):
restore_deterministic_wallet = {
'method': 'restore_deterministic_wallet',
'params' : {
@ -308,6 +308,7 @@ class Wallet(object):
'password': password,
'language': language,
'autosave_current': autosave_current,
'enable_multisig_experimental': enable_multisig_experimental
},
'jsonrpc': '2.0',
'id': '0'