mirror of
https://github.com/monero-project/monero.git
synced 2024-12-23 03:59:33 +00:00
b0bf49a65a
Read more about k-anonymity [here](https://en.wikipedia.org/wiki/K-anonymity). We implement this feature in the monero daemon for transactions by providing a "Txid Template", which is simply a txid with all but `num_matching_bits` bits zeroed out, and the number `num_matching_bits`. We add an operation to `BlockchainLMDB` called `get_txids_loose` which takes a txid template and returns all txids in the database (chain and mempool) that satisfy that template. Thus, a client can ask about a specific transaction from a daemon without revealing the exact transaction they are inquiring about. The client can control the statistical chance that other TXIDs (besides the one in question) match the txid template sent to the daemon up to a power of 2. For example, if a client sets their `num_matching_bits` to 5, then statistically any txid has a 1/(2^5) chance to match. With `num_matching_bits`=10, there is a 1/(2^10) chance, so on and so forth. Co-authored-by: ACK-J <60232273+ACK-J@users.noreply.github.com>
314 lines
13 KiB
Python
Executable file
314 lines
13 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
# Copyright (c) 2023, The Monero Project
|
|
#
|
|
# All rights reserved.
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without modification, are
|
|
# permitted provided that the following conditions are met:
|
|
#
|
|
# 1. Redistributions of source code must retain the above copyright notice, this list of
|
|
# conditions and the following disclaimer.
|
|
#
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice, this list
|
|
# of conditions and the following disclaimer in the documentation and/or other
|
|
# materials provided with the distribution.
|
|
#
|
|
# 3. Neither the name of the copyright holder nor the names of its contributors may be
|
|
# used to endorse or promote products derived from this software without specific
|
|
# prior written permission.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
|
|
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
|
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
|
|
# THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
|
|
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
|
|
# THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
from __future__ import print_function
|
|
import math
|
|
import random
|
|
|
|
"""
|
|
Test the k-anonymity daemon RPC features:
|
|
* txid fetching by prefix
|
|
"""
|
|
|
|
from framework.daemon import Daemon
|
|
from framework.wallet import Wallet
|
|
|
|
seeds = [
|
|
'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',
|
|
'peeled mixture ionic radar utopia puddle buying illness nuns gadget river spout cavernous bounced paradise drunk looking cottage jump tequila melting went winter adjust spout',
|
|
'tadpoles shrugged ritual exquisite deepest rest people musical farming otherwise shelter fabrics altitude seventh request tidy ivory diet vapidly syllabus logic espionage oozed opened people',
|
|
'ocio charla pomelo humilde maduro geranio bruto moño admitir mil difícil diva lucir cuatro odisea riego bebida mueble cáncer puchero carbón poeta flor fruta fruta'
|
|
]
|
|
|
|
pub_addrs = [
|
|
'42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm',
|
|
'44Kbx4sJ7JDRDV5aAhLJzQCjDz2ViLRduE3ijDZu3osWKBjMGkV1XPk4pfDUMqt1Aiezvephdqm6YD19GKFD9ZcXVUTp6BW',
|
|
'45uQD4jzWwPazqr9QJx8CmFPN7a9RaEE8T4kULg6r8GzfcrcgKXshfYf8cezLWwmENHC9pDN2fGAUFmmdFxjeZSs3n671rz',
|
|
'48hKTTTMfuiW2gDkmsibERHCjTCpqyCCh57WcU4KBeqDSAw7dG7Ad1h7v8iJF4q59aDqBATg315MuZqVmkF89E3cLPrBWsi'
|
|
]
|
|
|
|
CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW = 60
|
|
CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE = 10
|
|
RESTRICTED_SPENT_KEY_IMAGES_COUNT = 5000
|
|
|
|
def make_hash32_loose_template(txid, nbits):
|
|
txid_bytes = list(bytes.fromhex(txid))
|
|
for i in reversed(range(32)):
|
|
mask_nbits = min(8, nbits)
|
|
mask = 256 - (1 << (8 - mask_nbits))
|
|
nbits -= mask_nbits
|
|
txid_bytes[i] &= mask
|
|
return bytes(txid_bytes).hex()
|
|
|
|
def txid_list_is_sorted_in_template_order(txids):
|
|
reversed_txid_bytes = [bytes(reversed(bytes.fromhex(txid))) for txid in txids]
|
|
return sorted(reversed_txid_bytes) == reversed_txid_bytes
|
|
|
|
def txid_matches_template(txid, template, nbits):
|
|
txid_bytes = bytes.fromhex(txid)
|
|
template_bytes = bytes.fromhex(template)
|
|
for i in reversed(range(32)):
|
|
mask_nbits = min(8, nbits)
|
|
mask = 256 - (1 << (8 - mask_nbits))
|
|
nbits -= mask_nbits
|
|
if 0 != ((txid_bytes[i] ^ template_bytes[i]) & mask):
|
|
return False
|
|
return True
|
|
|
|
class KAnonymityTest:
|
|
def run_test(self):
|
|
self.reset()
|
|
self.create_wallets()
|
|
|
|
# If each of the N wallets is making N-1 transfers the first round, each N wallets needs
|
|
# N-1 unlocked coinbase outputs
|
|
N = len(seeds)
|
|
self.mine_and_refresh(2 * N * (N - 1))
|
|
self.mine_and_refresh(CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW)
|
|
|
|
# Generate a bunch of transactions
|
|
NUM_ROUNDS = 10
|
|
intermediate_mining_period = int(math.ceil(CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE / N)) * N
|
|
for i in range(NUM_ROUNDS):
|
|
self.transfer_around()
|
|
self.mine_and_refresh(intermediate_mining_period)
|
|
print("Wallets created {} transactions in {} rounds".format(len(self.wallet_txids), NUM_ROUNDS))
|
|
|
|
self.test_all_chain_txids() # Also gathers miner_txids
|
|
|
|
self.test_get_txids_loose_chain_suite()
|
|
|
|
self.test_get_txids_loose_pool_suite()
|
|
|
|
self.test_bad_txid_templates()
|
|
|
|
def reset(self):
|
|
print('Resetting blockchain')
|
|
daemon = Daemon()
|
|
res = daemon.get_height()
|
|
daemon.pop_blocks(res.height - 1)
|
|
daemon.flush_txpool()
|
|
self.wallet_txids = set()
|
|
self.total_blocks_mined = 0
|
|
self.miner_txids = set()
|
|
self.pool_txids = set()
|
|
|
|
def create_wallets(self):
|
|
print('Creating wallets')
|
|
assert len(seeds) == len(pub_addrs)
|
|
self.wallet = [None] * len(seeds)
|
|
for i in range(len(seeds)):
|
|
self.wallet[i] = Wallet(idx = i)
|
|
# close the wallet if any, will throw if none is loaded
|
|
try: self.wallet[i].close_wallet()
|
|
except: pass
|
|
res = self.wallet[i].restore_deterministic_wallet(seed = seeds[i])
|
|
|
|
def mine_and_refresh(self, num_blocks):
|
|
print("Mining {} blocks".format(num_blocks))
|
|
daemon = Daemon()
|
|
|
|
res = daemon.get_info()
|
|
old_height = res.height
|
|
|
|
assert num_blocks % len(self.wallet) == 0
|
|
assert len(self.wallet) == len(pub_addrs)
|
|
|
|
for i in range(len(self.wallet)):
|
|
daemon.generateblocks(pub_addrs[i], num_blocks // len(self.wallet))
|
|
|
|
res = daemon.get_info()
|
|
new_height = res.height
|
|
assert new_height == old_height + num_blocks, "height {} -> {}".format(old_height, new_height)
|
|
|
|
for i in range(len(self.wallet)):
|
|
self.wallet[i].refresh()
|
|
res = self.wallet[i].get_height()
|
|
assert res.height == new_height, "{} vs {}".format(res.height, new_height)
|
|
|
|
self.wallet_txids.update(self.pool_txids)
|
|
self.pool_txids.clear()
|
|
self.total_blocks_mined += num_blocks
|
|
|
|
def transfer_around(self):
|
|
N = len(self.wallet)
|
|
assert N == len(pub_addrs)
|
|
|
|
print("Creating transfers b/t wallets")
|
|
|
|
num_successful_transfers = 0
|
|
fee_margin = 0.05 # 5%
|
|
for sender in range(N):
|
|
receivers = list((r for r in range(N) if r != sender))
|
|
random.shuffle(receivers)
|
|
assert len(receivers) == N - 1
|
|
for j, receiver in enumerate(receivers):
|
|
unlocked_balance = self.wallet[sender].get_balance().unlocked_balance
|
|
if 0 == unlocked_balance:
|
|
assert j != 0 # we want all wallets to start out with at least some funds
|
|
break
|
|
imperfect_starting_balance = unlocked_balance * (N - 1) / (N - 1 - j) * (1 - fee_margin)
|
|
transfer_amount = int(imperfect_starting_balance / (N - 1))
|
|
assert transfer_amount < unlocked_balance
|
|
dst = {'address': pub_addrs[receiver], 'amount': transfer_amount}
|
|
res = self.wallet[sender].transfer([dst], get_tx_metadata = True)
|
|
tx_hex = res.tx_metadata
|
|
self.pool_txids.add(res.tx_hash)
|
|
res = self.wallet[sender].relay_tx(tx_hex)
|
|
self.wallet[sender].refresh()
|
|
num_successful_transfers += 1
|
|
|
|
print("Transferred {} times".format(num_successful_transfers))
|
|
|
|
def test_all_chain_txids(self):
|
|
daemon = Daemon()
|
|
|
|
print("Grabbing all txids from the daemon and testing against known txids")
|
|
|
|
# If assert stmt below fails, this test case needs to be rewritten to chunk the requests;
|
|
# there are simply too many txids on-chain to gather at once
|
|
expected_total_num_txids = len(self.wallet_txids) + self.total_blocks_mined + 1 # +1 for genesis coinbase tx
|
|
assert expected_total_num_txids <= RESTRICTED_SPENT_KEY_IMAGES_COUNT
|
|
|
|
res = daemon.get_txids_loose('0' * 64, 0)
|
|
all_txids = res.txids
|
|
assert 'c88ce9783b4f11190d7b9c17a69c1c52200f9faaee8e98dd07e6811175177139' in all_txids # genesis coinbase tx
|
|
assert len(all_txids) == expected_total_num_txids, "{} {}".format(len(all_txids), expected_total_num_txids)
|
|
|
|
assert txid_list_is_sorted_in_template_order(all_txids)
|
|
|
|
for txid in self.wallet_txids:
|
|
assert txid in all_txids
|
|
|
|
self.miner_txids = set(all_txids) - self.wallet_txids
|
|
|
|
def test_get_txids_loose_success(self, txid, num_matching_bits):
|
|
daemon = Daemon()
|
|
|
|
txid_template = make_hash32_loose_template(txid, num_matching_bits)
|
|
|
|
res = daemon.get_txids_loose(txid_template, num_matching_bits)
|
|
assert 'txids' in res
|
|
txids = res.txids
|
|
|
|
first_pool_index = 0
|
|
while first_pool_index < len(txids):
|
|
if txids[first_pool_index] in self.pool_txids:
|
|
break
|
|
else:
|
|
first_pool_index += 1
|
|
|
|
chain_txids = txids[:first_pool_index]
|
|
pool_txids = txids[first_pool_index:]
|
|
|
|
assert txid_list_is_sorted_in_template_order(chain_txids)
|
|
assert txid_list_is_sorted_in_template_order(pool_txids)
|
|
|
|
# Assert we know where txids came from
|
|
for txid in chain_txids:
|
|
assert (txid in self.wallet_txids) or (txid in self.miner_txids)
|
|
for txid in pool_txids:
|
|
assert txid in self.pool_txids
|
|
|
|
# Assert that all known txids were matched as they should've been
|
|
for txid in self.wallet_txids:
|
|
assert txid_matches_template(txid, txid_template, num_matching_bits) == (txid in chain_txids)
|
|
for txid in self.miner_txids:
|
|
assert txid_matches_template(txid, txid_template, num_matching_bits) == (txid in chain_txids)
|
|
for txid in self.pool_txids:
|
|
assert txid_matches_template(txid, txid_template, num_matching_bits) == (txid in pool_txids)
|
|
|
|
def test_get_txids_loose_chain_suite(self):
|
|
daemon = Daemon()
|
|
|
|
print("Testing grabbing on-chain txids loosely with all different bit sizes")
|
|
|
|
# Assert pool empty
|
|
assert len(self.pool_txids) == 0
|
|
res = daemon.get_transaction_pool_hashes()
|
|
assert not 'tx_hashes' in res or len(res.tx_hashes) == 0
|
|
|
|
assert len(self.wallet_txids)
|
|
|
|
current_chain_txids = list(self.wallet_txids.union(self.miner_txids))
|
|
for nbits in range(0, 256):
|
|
random_txid = random.choice(current_chain_txids)
|
|
self.test_get_txids_loose_success(random_txid, nbits)
|
|
|
|
def test_get_txids_loose_pool_suite(self):
|
|
daemon = Daemon()
|
|
|
|
print("Testing grabbing pool txids loosely with all different bit sizes")
|
|
|
|
# Create transactions to pool
|
|
self.transfer_around()
|
|
|
|
# Assert pool not empty
|
|
assert len(self.pool_txids) != 0
|
|
res = daemon.get_transaction_pool_hashes()
|
|
assert 'tx_hashes' in res and set(res.tx_hashes) == self.pool_txids
|
|
|
|
current_pool_txids = list(self.pool_txids)
|
|
for nbits in range(0, 256):
|
|
random_txid = random.choice(current_pool_txids)
|
|
self.test_get_txids_loose_success(random_txid, nbits)
|
|
|
|
def test_bad_txid_templates(self):
|
|
daemon = Daemon()
|
|
|
|
print("Making sure the daemon catches bad txid templates")
|
|
|
|
test_cases = [
|
|
['q', 256],
|
|
['a', 128],
|
|
['69' * 32, 257],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 0],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 1],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 2],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 4],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 8],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 16],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 32],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 64],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 128],
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 193],
|
|
['0000000000000000000000000000000000000000000000000000000000000080', 0],
|
|
['0000000000000000000000000000000000000000000000000000000000000007', 5],
|
|
['00000000000000000000000000000000000000000000000000000000000000f7', 5],
|
|
]
|
|
|
|
for txid_template, num_matching_bits in test_cases:
|
|
ok = False
|
|
try: res = daemon.get_txids_loose(txid_template, num_matching_bits)
|
|
except: ok = True
|
|
assert ok, 'bad template didnt error: {} {}'.format(txid_template, num_matching_bits)
|
|
|
|
if __name__ == '__main__':
|
|
KAnonymityTest().run_test()
|