From 6b6fc4ae5d159a96f94cbf2c3a12037b30fafd26 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Thu, 28 May 2020 00:32:02 +0100 Subject: [PATCH] Handle fidelity bonds in client-server protocol Parse incoming and announce outgoing fidelity bond messages Fidelity bond proof messages will be checked and added to the internal database just like offers. Such messages are not announced in public but only directly to takers who ask for them, this is because the signature proofs must commmit to the maker's and taker's IRC nicknames in order to avoid replay attacks. --- jmbase/jmbase/commands.py | 8 +- jmbase/test/test_commands.py | 14 +- jmclient/jmclient/client_protocol.py | 10 +- jmclient/jmclient/fidelity_bond.py | 136 ++++++++++ jmclient/jmclient/wallet.py | 17 ++ jmclient/test/test_client_protocol.py | 4 +- jmdaemon/jmdaemon/__init__.py | 1 + jmdaemon/jmdaemon/daemon_protocol.py | 21 +- .../jmdaemon/fidelity_bond_sanity_check.py | 12 + jmdaemon/jmdaemon/message_channel.py | 36 ++- jmdaemon/jmdaemon/orderbookwatch.py | 25 +- jmdaemon/jmdaemon/protocol.py | 2 + jmdaemon/test/test_daemon_protocol.py | 5 +- jmdaemon/test/test_message_channel.py | 8 +- jmdaemon/test/test_orderbookwatch.py | 235 +++++++++++++++++- scripts/obwatch/ob-watcher.py | 2 +- 16 files changed, 504 insertions(+), 32 deletions(-) create mode 100644 jmclient/jmclient/fidelity_bond.py create mode 100644 jmdaemon/jmdaemon/fidelity_bond_sanity_check.py diff --git a/jmbase/jmbase/commands.py b/jmbase/jmbase/commands.py index b7138e3..5f1f2bb 100644 --- a/jmbase/jmbase/commands.py +++ b/jmbase/jmbase/commands.py @@ -3,9 +3,10 @@ Commands defining client-server (daemon) messaging protocol (*not* Joinmarket p2p protocol). Used for AMP asynchronous messages. """ -from twisted.protocols.amp import Boolean, Command, Integer, Unicode +from twisted.protocols.amp import Boolean, Command, Integer, Unicode, String from .bigstring import BigUnicode + class DaemonNotReady(Exception): pass @@ -45,7 +46,8 @@ class JMSetup(JMCommand): role, passes initial offers for announcement (for TAKER, this data is "none") """ arguments = [(b'role', Unicode()), - (b'initdata', Unicode())] + (b'offers', Unicode()), + (b'fidelity_bond', String())] class JMMsgSignature(JMCommand): """A response to a request for a bitcoin signature @@ -376,4 +378,4 @@ class BIP78InfoMsg(JMCommand): from the daemon about current status at network level. """ - arguments = [(b'infomsg', Unicode())] \ No newline at end of file + arguments = [(b'infomsg', Unicode())] diff --git a/jmbase/test/test_commands.py b/jmbase/test/test_commands.py index 12bceca..231ba19 100644 --- a/jmbase/test/test_commands.py +++ b/jmbase/test/test_commands.py @@ -63,8 +63,8 @@ class JMTestServerProtocol(JMBaseProtocol): return {'accepted': True} @JMSetup.responder - def on_JM_SETUP(self, role, initdata): - show_receipt("JMSETUP", role, initdata) + def on_JM_SETUP(self, role, offers, use_fidelity_bond): + show_receipt("JMSETUP", role, offers, use_fidelity_bond) d = self.callRemote(JMSetupDone) self.defaultCallbacks(d) return {'accepted': True} @@ -75,7 +75,8 @@ class JMTestServerProtocol(JMBaseProtocol): #build a huge orderbook to test BigString Argument orderbook = ["aaaa" for _ in range(2**15)] d = self.callRemote(JMOffers, - orderbook=json.dumps(orderbook)) + orderbook=json.dumps(orderbook), + fidelitybonds="dummyfidelitybonds") self.defaultCallbacks(d) return {'accepted': True} @@ -156,7 +157,8 @@ class JMTestClientProtocol(JMBaseProtocol): show_receipt("JMUP") d = self.callRemote(JMSetup, role="TAKER", - initdata="none") + offers="{}", + use_fidelity_bond=False) self.defaultCallbacks(d) return {'accepted': True} @@ -177,8 +179,8 @@ class JMTestClientProtocol(JMBaseProtocol): return {'accepted': True} @JMOffers.responder - def on_JM_OFFERS(self, orderbook): - show_receipt("JMOFFERS", orderbook) + def on_JM_OFFERS(self, orderbook, fidelitybonds): + show_receipt("JMOFFERS", orderbook, fidelitybonds) d = self.callRemote(JMFill, amount=100, commitment="dummycommitment", diff --git a/jmclient/jmclient/client_protocol.py b/jmclient/jmclient/client_protocol.py index 822345e..07bac06 100644 --- a/jmclient/jmclient/client_protocol.py +++ b/jmclient/jmclient/client_protocol.py @@ -392,9 +392,14 @@ class JMMakerClientProtocol(JMClientProtocol): if not self.client.offerlist: return self.offers_ready_loop.stop() + if self.client.fidelity_bond: + fidelity_bond_data = self.client.fidelity_bond.serialize() + else: + fidelity_bond_data = b'' d = self.callRemote(commands.JMSetup, role="MAKER", - initdata=json.dumps(self.client.offerlist)) + offers=json.dumps(self.client.offerlist), + fidelity_bond=fidelity_bond_data) self.defaultCallbacks(d) @commands.JMSetupDone.responder @@ -627,7 +632,8 @@ class JMTakerClientProtocol(JMClientProtocol): def on_JM_UP(self): d = self.callRemote(commands.JMSetup, role="TAKER", - initdata="none") + offers="{}", + fidelity_bond=b'') self.defaultCallbacks(d) return {'accepted': True} diff --git a/jmclient/jmclient/fidelity_bond.py b/jmclient/jmclient/fidelity_bond.py new file mode 100644 index 0000000..2340f55 --- /dev/null +++ b/jmclient/jmclient/fidelity_bond.py @@ -0,0 +1,136 @@ +import struct +import base64 +import json +from jmbitcoin import ecdsa_sign, ecdsa_verify +from jmdaemon import import fidelity_bond_sanity_check + + +def assert_is_utxo(utxo): + assert len(utxo) == 2 + assert isinstance(utxo[0], bytes) + assert len(utxo[0]) == 32 + assert isinstance(utxo[1], int) + assert utxo[1] >= 0 + + +def get_cert_msg(cert_pub, cert_expiry): + return b'fidelity-bond-cert|' + cert_pub + b'|' + str(cert_expiry).encode('ascii') + + +class FidelityBond: + def __init__(self, utxo, utxo_pubkey, locktime, cert_expiry, + cert_privkey, cert_pubkey, cert_signature): + assert_is_utxo(utxo) + assert isinstance(utxo_pubkey, bytes) + assert isinstance(locktime, int) + assert isinstance(cert_expiry, int) + assert isinstance(cert_privkey, bytes) + assert isinstance(cert_pubkey, bytes) + assert isinstance(cert_signature, bytes) + self.utxo = utxo + self.utxo_pubkey = utxo_pubkey + self.locktime = locktime + self.cert_expiry = cert_expiry + self.cert_privkey = cert_privkey + self.cert_pubkey = cert_pubkey + self.cert_signature = cert_signature + + def create_proof(self, maker_nick, taker_nick): + return FidelityBondProof( + maker_nick, taker_nick, self.cert_pubkey, self.cert_expiry, + self.cert_signature, self.utxo, self.utxo_pubkey, self.locktime) + + def serialize(self): + return json.dumps([ + self.utxo, + self.utxo_pubkey, + self.locktime, + self.cert_expiry, + self.cert_privkey, + self.cert_pubkey, + self.cert_signature, + ]) + + @classmethod + def deserialize(cls, data): + return cls(*json.loads(data)) + + +class FidelityBondProof: + # nick_sig + cert_sig + cert_pubkey + cert_expiry + utxo_pubkey + txid + vout + timelock + # 72 + 72 + 33 + 2 + 33 + 32 + 4 + 4 = 252 bytes + SER_STUCT_FMT = '<72s72s33sH33s32sII' + + def __init__(self, maker_nick, taker_nick, cert_pub, cert_expiry, + cert_sig, utxo, utxo_pub, locktime): + assert isinstance(maker_nick, str) + assert isinstance(taker_nick, str) + assert isinstance(cert_pub, bytes) + assert isinstance(cert_sig, bytes) + assert isinstance(utxo_pub, bytes) + assert isinstance(locktime, int) + assert_is_utxo(utxo) + self.maker_nick = maker_nick + self.taker_nick = taker_nick + self.cert_pub = cert_pub + self.cert_expiry = cert_expiry + self.cert_sig = cert_sig + self.utxo = utxo + self.utxo_pub = utxo_pub + self.locktime = locktime + + @property + def nick_msg(self): + return (self.taker_nick + '|' + self.maker_nick).encode('ascii') + + def create_proof_msg(self, cert_priv): + nick_sig = ecdsa_sign(self.nick_msg, cert_priv) + # FIXME: remove stupid base64 + nick_sig = base64.b64decode(nick_sig) + return self._serialize_proof_msg(nick_sig) + + def _serialize_proof_msg(self, msg_signature): + msg_signature = msg_signature.rjust(72, b'\xff') + cert_sig = self.cert_sig.rjust(72, b'\xff') + fidelity_bond_data = struct.pack( + self.SER_STUCT_FMT, + msg_signature, + cert_sig, + self.cert_pub, + self.cert_expiry, + self.utxo_pub, + self.utxo[0], + self.utxo[1], + self.locktime + ) + return base64.b64encode(fidelity_bond_data).decode('ascii') + + @staticmethod + def _verify_signature(message, signature, pubkey): + # FIXME: remove stupid base64 + return ecdsa_verify(message, base64.b64encode(signature), pubkey) + + @classmethod + def parse_and_verify_proof_msg(cls, maker_nick, taker_nick, data): + if not fidelity_bond_sanity_check.fidelity_bond_sanity_check(data): + raise ValueError("sanity check failed") + decoded_data = base64.b64decode(data) + + unpacked_data = struct.unpack(cls.SER_STUCT_FMT, decoded_data) + try: + signature = unpacked_data[0][unpacked_data[0].index(b'\x30'):] + cert_sig = unpacked_data[1][unpacked_data[1].index(b'\x30'):] + except ValueError: + #raised if index() doesnt find the position + raise ValueError("der signature header not found") + proof = cls(maker_nick, taker_nick, unpacked_data[2], unpacked_data[3], + cert_sig, (unpacked_data[5], unpacked_data[6]), + unpacked_data[4], unpacked_data[7]) + cert_msg = get_cert_msg(proof.cert_pub, proof.cert_expiry) + + if not cls._verify_signature(proof.nick_msg, signature, proof.cert_pub): + raise ValueError("nick sig does not verify") + if not cls._verify_signature(cert_msg, proof.cert_sig, proof.utxo_pub): + raise ValueError("cert sig does not verify") + + return proof diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 1b2bec8..4312e00 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -2400,6 +2400,23 @@ class FidelityBondMixin(object): a = max(0, min(1, exp(r*T) - 1) - min(1, exp(r*max(0, t-L)) - 1)) return utxo_value*utxo_value*a*a + @classmethod + def get_validated_timelocked_fidelity_bond_utxo(cls, utxo, utxo_pubkey, locktime, + cert_expiry, current_block_height): + + utxo_data = jm_single().bc_interface.query_utxo_set(utxo, includeconf=True) + if utxo_data[0] == None: + return None + if utxo_data[0]["confirms"] <= 0: + return None + RETARGET_INTERVAL = 2016 + if current_block_height > cert_expiry*RETARGET_INTERVAL: + return None + implied_spk = btc.redeem_script_to_p2wsh_script(btc.mk_freeze_script(utxo_pubkey, locktime)) + if utxo_data[0]["script"] != implied_spk: + return None + return utxo_data[0] + class BIP49Wallet(BIP32PurposedWallet): _PURPOSE = 2**31 + 49 _ENGINE = ENGINES[TYPE_P2SH_P2WPKH] diff --git a/jmclient/test/test_client_protocol.py b/jmclient/test/test_client_protocol.py index d16f8df..b89c158 100644 --- a/jmclient/test/test_client_protocol.py +++ b/jmclient/test/test_client_protocol.py @@ -184,8 +184,8 @@ class JMTestServerProtocol(JMBaseProtocol): return {'accepted': True} @JMSetup.responder - def on_JM_SETUP(self, role, initdata): - show_receipt("JMSETUP", role, initdata) + def on_JM_SETUP(self, role, offers, fidelity_bond): + show_receipt("JMSETUP", role, offers, fidelity_bond) d = self.callRemote(JMSetupDone) self.defaultCallbacks(d) return {'accepted': True} diff --git a/jmdaemon/jmdaemon/__init__.py b/jmdaemon/jmdaemon/__init__.py index 0e854ba..384b5f7 100644 --- a/jmdaemon/jmdaemon/__init__.py +++ b/jmdaemon/jmdaemon/__init__.py @@ -14,6 +14,7 @@ from .daemon_protocol import (JMDaemonServerProtocolFactory, JMDaemonServerProto from .protocol import (COMMAND_PREFIX, ORDER_KEYS, NICK_HASH_LENGTH, NICK_MAX_ENCODED, JM_VERSION, JOINMARKET_NICK_HEADER) from .message_channel import MessageChannelCollection + # Set default logging handler to avoid "No handler found" warnings. try: from logging import NullHandler diff --git a/jmdaemon/jmdaemon/daemon_protocol.py b/jmdaemon/jmdaemon/daemon_protocol.py index 954293c..b3eadbb 100644 --- a/jmdaemon/jmdaemon/daemon_protocol.py +++ b/jmdaemon/jmdaemon/daemon_protocol.py @@ -13,6 +13,7 @@ from jmbase import (hextobin, is_hs_uri, get_tor_agent, JMHiddenService, get_nontor_agent, BytesProducer, wrapped_urlparse, bdict_sdict_convert, JMHTTPResource) from jmbase.commands import * +from jmbitcoin.fidelity_bond import FidelityBond from twisted.protocols import amp from twisted.internet import reactor, ssl, task from twisted.internet.protocol import ServerFactory @@ -474,6 +475,7 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): self.crypto_boxes = {} self.sig_lock = threading.Lock() self.active_orders = {} + self.fidelity_bond = None def checkClientResponse(self, response): """A generic check of client acceptance; any failure @@ -551,7 +553,7 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): return {'accepted': True} @JMSetup.responder - def on_JM_SETUP(self, role, initdata): + def on_JM_SETUP(self, role, offers, fidelity_bond): assert self.jm_state == 0 self.role = role self.crypto_boxes = {} @@ -565,8 +567,10 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): if self.role == "TAKER": self.mcc.pubmsg(COMMAND_PREFIX + "orderbook") elif self.role == "MAKER": - self.offerlist = json.loads(initdata) - self.mcc.announce_orders(self.offerlist) + self.offerlist = json.loads(offers) + if fidelity_bond: + self.fidelity_bond = FidelityBond.deserialize(fidelity_bond) + self.mcc.announce_orders(self.offerlist, None, None, None) self.jm_state = 1 return {'accepted': True} @@ -711,7 +715,15 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): def on_orderbook_requested(self, nick, mc=None): """Dealt with by daemon, assuming offerlist is up to date """ - self.mcc.announce_orders(self.offerlist, nick, mc) + if self.fidelity_bond: + taker_nick = nick + maker_nick = self.mcc.nick + proof = self.fidelity_bond.create_proof(maker_nick, taker_nick) + proof_msg = proof.create_proof_msg(self.fidelity_bond.cert_privkey) + else: + proof_msg = None + + self.mcc.announce_orders(self.offerlist, nick, proof_msg, mc) @maker_only def on_order_fill(self, nick, oid, amount, taker_pk, commit): @@ -1015,6 +1027,7 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): if self.mcc: self.mcc.shutdown() + class JMDaemonServerProtocolFactory(ServerFactory): protocol = JMDaemonServerProtocol diff --git a/jmdaemon/jmdaemon/fidelity_bond_sanity_check.py b/jmdaemon/jmdaemon/fidelity_bond_sanity_check.py new file mode 100644 index 0000000..44251f3 --- /dev/null +++ b/jmdaemon/jmdaemon/fidelity_bond_sanity_check.py @@ -0,0 +1,12 @@ + +import base64 + +def fidelity_bond_sanity_check(proof): + try: + decoded_data = base64.b64decode(proof, validate=True) + if len(decoded_data) != 252: + return False + except Exception: + return False + return True + diff --git a/jmdaemon/jmdaemon/message_channel.py b/jmdaemon/jmdaemon/message_channel.py index a5cbe6c..1105e88 100644 --- a/jmdaemon/jmdaemon/message_channel.py +++ b/jmdaemon/jmdaemon/message_channel.py @@ -6,7 +6,8 @@ import threading from twisted.internet import reactor from jmdaemon import encrypt_encode, decode_decrypt, COMMAND_PREFIX,\ NICK_HASH_LENGTH, NICK_MAX_ENCODED, plaintext_commands,\ - encrypted_commands, commitment_broadcast_list, offername_list + encrypted_commands, commitment_broadcast_list, offername_list,\ + fidelity_bond_cmd_list from jmbase.support import get_log from functools import wraps @@ -284,7 +285,7 @@ class MessageChannelCollection(object): "; cannot find on any message channel.") return - def announce_orders(self, orderlist, nick=None, new_mc=None): + def announce_orders(self, orderlist, nick, fidelity_bond_proof_msg, new_mc): """Send orders defined in list orderlist either to the shared public channel (pit), on all message channels, if nick=None, @@ -301,6 +302,7 @@ class MessageChannelCollection(object): "Tried to announce orders on an unavailable message channel.") return if nick is None: + assert fidelity_bond_proof_msg is None for mc in self.available_channels(): mc.announce_orders(orderlines) else: @@ -310,6 +312,9 @@ class MessageChannelCollection(object): cmd = orderlist[0]['ordertype'] msg = ' '.join(orderlines[0].split(' ')[1:]) msg += ''.join(orderlines[1:]) + if fidelity_bond_proof_msg: + msg += (COMMAND_PREFIX + fidelity_bond_cmd_list[0] + " " + + fidelity_bond_proof_msg) if new_mc: self.prepare_privmsg(nick, cmd, msg, mc=new_mc) else: @@ -563,7 +568,8 @@ class MessageChannelCollection(object): # orderbook watcher commands def register_orderbookwatch_callbacks(self, on_order_seen=None, - on_order_cancel=None): + on_order_cancel=None, + on_fidelity_bond_seen=None): """Special cases: on_order_seen: use it as a trigger for presence of nick. on_order_cancel: what happens if cancel/modify in one place @@ -572,7 +578,7 @@ class MessageChannelCollection(object): self.on_order_seen = on_order_seen for mc in self.mchannels: mc.register_orderbookwatch_callbacks(self.on_order_seen_trigger, - on_order_cancel) + on_order_cancel, on_fidelity_bond_seen) def on_orderbook_requested_trigger(self, nick, mc): """Update nicks_seen state to reflect presence of @@ -647,6 +653,7 @@ class MessageChannel(object): # orderbook watch functions self.on_order_seen = None self.on_order_cancel = None + self.on_fidelity_bond_seen = None # taker functions self.on_error = None self.on_pubkey = None @@ -730,9 +737,11 @@ class MessageChannel(object): # orderbook watcher commands def register_orderbookwatch_callbacks(self, on_order_seen=None, - on_order_cancel=None): + on_order_cancel=None, + on_fidelity_bond_seen=None): self.on_order_seen = on_order_seen self.on_order_cancel = on_order_cancel + self.on_fidelity_bond_seen = on_fidelity_bond_seen # taker commands def register_taker_callbacks(self, @@ -813,6 +822,21 @@ class MessageChannel(object): return True return False + def check_for_fidelity_bond(self, nick, _chunks): + if _chunks[0] in fidelity_bond_cmd_list: + try: + fidelity_bond_proof_msg = _chunks[1] + if self.on_fidelity_bond_seen: + self.on_fidelity_bond_seen(nick, _chunks[0], fidelity_bond_proof_msg) + except IndexError as e: + log.debug(e) + log.debug('index error parsing chunks, possibly malformed ' + 'offer by other party. No user action required. ' + 'Triggered by: ' + str(nick)) + finally: + return True + return False + def cancel_orders(self, oid_list): clines = [COMMAND_PREFIX + 'cancel ' + str(oid) for oid in oid_list] self.pubmsg(''.join(clines)) @@ -952,6 +976,8 @@ class MessageChannel(object): # orderbook watch commands if self.check_for_orders(nick, _chunks): pass + elif self.check_for_fidelity_bond(nick, _chunks): + pass # taker commands elif _chunks[0] == 'error': error = " ".join(_chunks[1:]) diff --git a/jmdaemon/jmdaemon/orderbookwatch.py b/jmdaemon/jmdaemon/orderbookwatch.py index 62edcb7..abd8b57 100644 --- a/jmdaemon/jmdaemon/orderbookwatch.py +++ b/jmdaemon/jmdaemon/orderbookwatch.py @@ -7,6 +7,7 @@ from decimal import InvalidOperation, Decimal from numbers import Integral from jmdaemon.protocol import JM_VERSION +from jmdaemon import fidelity_bond_sanity_check from jmbase.support import get_log, joinmarket_alert, DUST_THRESHOLD log = get_log() @@ -21,13 +22,12 @@ def dict_factory(cursor, row): class JMTakerError(Exception): pass - class OrderbookWatch(object): def set_msgchan(self, msgchan): self.msgchan = msgchan self.msgchan.register_orderbookwatch_callbacks(self.on_order_seen, - self.on_order_cancel) + self.on_order_cancel, self.on_fidelity_bond_seen) self.msgchan.register_channel_callbacks( self.on_welcome, self.on_set_topic, None, self.on_disconnect, self.on_nick_leave, None) @@ -41,6 +41,8 @@ class OrderbookWatch(object): self.db.execute("CREATE TABLE orderbook(counterparty TEXT, " "oid INTEGER, ordertype TEXT, minsize INTEGER, " "maxsize INTEGER, txfee INTEGER, cjfee TEXT);") + self.db.execute("CREATE TABLE fidelitybonds(counterparty TEXT, " + "takernick TEXT, proof TEXT);"); finally: self.dblock.release() @@ -134,11 +136,29 @@ class OrderbookWatch(object): finally: self.dblock.release() + def on_fidelity_bond_seen(self, nick, bond_type, fidelity_bond_proof_msg): + taker_nick = self.msgchan.nick + maker_nick = nick + if not fidelity_bond_sanity_check.fidelity_bond_sanity_check(fidelity_bond_proof_msg): + log.debug("Failed to verify fidelity bond for {}, skipping." + .format(maker_nick)) + return + try: + self.dblock.acquire(True) + self.db.execute("DELETE FROM fidelitybonds WHERE counterparty=?;", + (nick, )) + self.db.execute("INSERT INTO fidelitybonds VALUES(?, ?, ?);", + (nick, taker_nick, fidelity_bond_proof_msg)) + finally: + self.dblock.release() + def on_nick_leave(self, nick): try: self.dblock.acquire(True) self.db.execute('DELETE FROM orderbook WHERE counterparty=?;', (nick,)) + self.db.execute('DELETE FROM fidelitybonds WHERE counterparty=?;', + (nick,)) finally: self.dblock.release() @@ -146,5 +166,6 @@ class OrderbookWatch(object): try: self.dblock.acquire(True) self.db.execute('DELETE FROM orderbook;') + self.db.execute('DELETE FROM fidelitybonds;') finally: self.dblock.release() diff --git a/jmdaemon/jmdaemon/protocol.py b/jmdaemon/jmdaemon/protocol.py index 7d531aa..eefe72d 100644 --- a/jmdaemon/jmdaemon/protocol.py +++ b/jmdaemon/jmdaemon/protocol.py @@ -20,6 +20,8 @@ offertypes = {"reloffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"), offername_list = list(offertypes.keys()) +fidelity_bond_cmd_list = ["tbond"] + ORDER_KEYS = ['counterparty', 'oid', 'ordertype', 'minsize', 'maxsize', 'txfee', 'cjfee'] diff --git a/jmdaemon/test/test_daemon_protocol.py b/jmdaemon/test/test_daemon_protocol.py index 26083e8..40a2415 100644 --- a/jmdaemon/test/test_daemon_protocol.py +++ b/jmdaemon/test/test_daemon_protocol.py @@ -84,7 +84,8 @@ class JMTestClientProtocol(JMBaseProtocol): show_receipt("JMUP") d = self.callRemote(JMSetup, role="TAKER", - initdata="none") + offers="{}", + fidelity_bond=b'') self.defaultCallbacks(d) return {'accepted': True} @@ -110,7 +111,7 @@ class JMTestClientProtocol(JMBaseProtocol): self.defaultCallbacks(d) @JMOffers.responder - def on_JM_OFFERS(self, orderbook): + def on_JM_OFFERS(self, orderbook, fidelitybonds): if end_early: return {'accepted': True} jlog.debug("JMOFFERS" + str(orderbook)) diff --git a/jmdaemon/test/test_message_channel.py b/jmdaemon/test/test_message_channel.py index 9ca3891..684128e 100644 --- a/jmdaemon/test/test_message_channel.py +++ b/jmdaemon/test/test_message_channel.py @@ -194,13 +194,13 @@ def test_setup_mc(): del mcc.active_channels[cp1] mcc.prepare_privmsg(cp1, "auth", "a b c") #try announcing orders; first public - mcc.announce_orders(t_orderbook) + mcc.announce_orders(t_orderbook, nick=None, fidelity_bond_proof_msg=None, new_mc=None) #try on fake mc - mcc.announce_orders(t_orderbook, new_mc="fakemc") + mcc.announce_orders(t_orderbook, nick=None, fidelity_bond_proof_msg=None, new_mc="fakemc") #direct to one cp - mcc.announce_orders(t_orderbook, nick=cp1) + mcc.announce_orders(t_orderbook, nick=cp1, fidelity_bond_proof_msg=None, new_mc=None) #direct to one cp on one mc - mcc.announce_orders(t_orderbook, nick=cp1, new_mc=dmcs[0]) + mcc.announce_orders(t_orderbook, nick=cp1, fidelity_bond_proof_msg=None, new_mc=dmcs[0]) #Next, set up 6 counterparties and fill their offers, #send txs to them cps = [make_valid_nick(i) for i in range(1, 7)] diff --git a/jmdaemon/test/test_orderbookwatch.py b/jmdaemon/test/test_orderbookwatch.py index d02be08..e07ff23 100644 --- a/jmdaemon/test/test_orderbookwatch.py +++ b/jmdaemon/test/test_orderbookwatch.py @@ -6,6 +6,9 @@ from jmdaemon.orderbookwatch import OrderbookWatch from jmdaemon import IRCMessageChannel from jmclient import get_irc_mchannels, load_test_config from jmdaemon.protocol import JM_VERSION, ORDER_KEYS +from jmbase.support import hextobin +from jmbitcoin.fidelity_bond import FidelityBondProof + class DummyDaemon(object): def request_signature_verify(self, a, b, c, d, e, f, g, h): @@ -125,4 +128,234 @@ def test_disconnect_leave(): - +@pytest.mark.parametrize( + "valid, fidelity_bond_proof, maker_nick, taker_nick", + [ + ( + True, + { + #nicksig len = 71, certsig len = 71 + "nick-signature": (b'0E\x02!\x00\xdbb\x15\x96\xa0\x87\xb8\x1d\xe05\xddV\xa1\x1bn\x8f' + + b'q\x90&\x8cG@\x89"2\xb2\x81\x9b\xc00\xa5\xb6\x02 \x03\x14l\xd7BR\xba\x8c:\x88(' + + b'\x8e3l\xac\xf5`T\x87\xfa\xf5\xa9\x1f\x19\xc0\xb6\xe9\xbb\xdc\xc7y\x99'), + "certificate-signature": ("3045022100eb512af938113badb4d7b29e0c22061c51dadb113a9395e" + + "9ed81a46103391213022029170de414964f07228c4f0d404b1386272bae337f0133f1329d948a" + + "252fa2a0"), + "certificate-pubkey": "0258efb077960d6848f001904857f062fa453de26c1ad8736f55497254f56e8a74", + "certificate-expiry": 1, + "utxo-pubkey": "02f54f027377e84171296453828aa863c23fc4489453025f49bd3addfb3a359b3d", + "txid": "84c88fafe0bb75f507fe3bfb29a93d10b2e80c15a63b2943c1a5fecb5a55cba2", + "vout": 0, + "locktime": 1640995200 + }, + "J5A4k9ecQzRRDfBx", + "J55VZ6U6ZyFDNeuv" + ), + ( + True, + { + #nicksig len = 71, certsig len = 70 + "nick-signature": (b'0E\x02!\x00\x80\xc6$\x0c\xa1\x15YS\xacHB\xb33\xfa~\x9f\xb9`\xb3' + + b'\xfe\xed0\xadHq\xc1~\x03.B\xbb#\x02 #y~]\xd9\xbbX2\xc0\x1b\xe57\xf4\x0f\x1f' + + b'\xd6$\x01\xf9\x15Z\xc9X\xa5\x18\xbe\x83\x1a&4Y\xd4'), + "certificate-signature": ("304402205669ea394f7381e9abf0b3c013fac2b79d24c02feb86ff153" + + "cff83c658d7cf7402200b295ace655687f80738f3733c1dc5f1e2b8f351c017a05b8bd31983dd" + + "4d723f"), + "certificate-pubkey": "031d1c006a6310dbdf57341efc19c3a43c402379d7ccd2480416cadc7579f973f7", + "certificate-expiry": 1, + "utxo-pubkey": "02616c56412eb738a9eacfb0550b43a5a2e77e5d5205ea9e2ca8dfac34e50c9754", + "txid": "84c88fafe0bb75f507fe3bfb29a93d10b2e80c15a63b2943c1a5fecb5a55cba2", + "vout": 1, + "locktime": 1893456000 + }, + "J54LS6YyJPoseqFS", + "J55VZ6U6ZyFDNeuv" + ), + ( + True, + { #nicksig len = 70, certsig len = 71 + "nick-signature": (b'0D\x02 K)\xe9\x17d\x0b\xc0\x82(\xd1\xa2*l\xd8\x0eJ\xc7\x01NV\xbf' + + b'\xcb\x02O]\xc0\x11\x01\x01B"\xed\x02 ob\xa1\xf8>\x80U)\xc8\x96\x86\x1b \x0e' + + b'\x00.\xf8\x86}\xcd\xf8\x82T\xa2\xb5\x8a4\xdb4\xbe\xf3{'), + "certificate-signature": ("3045022100d3beb5660bef33d095f92a3023bbbab15ece48ab2f211fa" + + "935b62fe8b764c8c002204892deffb4c9aa0d734aa3f55cc8e2baae4a03fc5a9e571b4f671493" + + "f1254df9"), + "certificate-pubkey": "03a2d1d15290d6d21204d1153c062970b4ff757a675e47a451fd0ba5c084127807", + "certificate-expiry": 1, + "utxo-pubkey": "03b9c12c9c31286772349b986653d07232327b284bd0787ad5829a04ac68f59b89", + "txid": "70c2995b283db086813d97817264f10b8823b870298d30ab09cb43c6bf2670cf", + "vout": 0, + "locktime": 1735689600 + }, + "J59PRzM6ZsdA5uyJ", + "J55VZ6U6ZyFDNeuv" + ), + ( + False, + { #nick signature with no DER header + "nick-signature": (b'ZD\x02 K)\xe9\x17d\x0b\xc0\x82(\xd1\xa2*l\xd8\x0eJ\xc7\x01NV\xbf' + + b'\xcb\x02O]\xc0\x11\x01\x01B"\xed\x02 ob\xa1\xf8>\x80U)\xc8\x96\x86\x1b \x0e' + + b'\x00.\xf8\x86}\xcd\xf8\x82T\xa2\xb5\x8a4\xdb4\xbe\xf3{'), + "certificate-signature": ("3045022100d3beb5660bef33d095f92a3023bbbab15ece48ab2f211fa" + + "935b62fe8b764c8c002204892deffb4c9aa0d734aa3f55cc8e2baae4a03fc5a9e571b4f671493" + + "f1254df9"), + "certificate-pubkey": "03a2d1d15290d6d21204d1153c062970b4ff757a675e47a451fd0ba5c084127807", + "certificate-expiry": 1, + "utxo-pubkey": "03b9c12c9c31286772349b986653d07232327b284bd0787ad5829a04ac68f59b89", + "txid": "70c2995b283db086813d97817264f10b8823b870298d30ab09cb43c6bf2670cf", + "vout": 0, + "locktime": 1735689600 + }, + "J59PRzM6ZsdA5uyJ", + "J55VZ6U6ZyFDNeuv" + ), + ( + False, + { #nick signature which fails ecdsa_verify + "nick-signature": (b'0E\x02 K)\xe9\x17d\x0b\xc0\x82(\xd1\xa2*l\xd8\x0eJ\xc7\x01NV\xbf' + + b'\xcb\x02O]\xc0\x11\x01\x01B"\xed\x02 ob\xa1\xf8>\x80U)\xc8\x96\x86\x1b \x0e' + + b'\x00.\xf8\x86}\xcd\xf8\x82T\xa2\xb5\x8a4\xdb4\xbe\xf3{'), + "certificate-signature": ("3045022100d3beb5660bef33d095f92a3023bbbab15ece48ab2f211fa" + + "935b62fe8b764c8c002204892deffb4c9aa0d734aa3f55cc8e2baae4a03fc5a9e571b4f671493" + + "f1254df9"), + "certificate-pubkey": "03a2d1d15290d6d21204d1153c062970b4ff757a675e47a451fd0ba5c084127807", + "certificate-expiry": 1, + "utxo-pubkey": "03b9c12c9c31286772349b986653d07232327b284bd0787ad5829a04ac68f59b89", + "txid": "70c2995b283db086813d97817264f10b8823b870298d30ab09cb43c6bf2670cf", + "vout": 0, + "locktime": 1735689600 + }, + "J59PRzM6ZsdA5uyJ", + "J55VZ6U6ZyFDNeuv" + ), + ( + False, + { #cert signature which fails ecdsa_verify + "nick-signature": (b'0D\x02 K)\xe9\x17d\x0b\xc0\x82(\xd1\xa2*l\xd8\x0eJ\xc7\x01NV\xbf' + + b'\xcb\x02O]\xc0\x11\x01\x01B"\xed\x02 ob\xa1\xf8>\x80U)\xc8\x96\x86\x1b \x0e' + + b'\x00.\xf8\x86}\xcd\xf8\x82T\xa2\xb5\x8a4\xdb4\xbe\xf3{'), + "certificate-signature": ("3055022100d3beb5660bef33d095f92a3023bbbab15ece48ab2f211fa" + + "935b62fe8b764c8c002204892deffb4c9aa0d734aa3f55cc8e2baae4a03fc5a9e571b4f671493" + + "f1254df9"), + "certificate-pubkey": "03a2d1d15290d6d21204d1153c062970b4ff757a675e47a451fd0ba5c084127807", + "certificate-expiry": 1, + "utxo-pubkey": "03b9c12c9c31286772349b986653d07232327b284bd0787ad5829a04ac68f59b89", + "txid": "70c2995b283db086813d97817264f10b8823b870298d30ab09cb43c6bf2670cf", + "vout": 0, + "locktime": 1735689600 + }, + "J59PRzM6ZsdA5uyJ", + "J55VZ6U6ZyFDNeuv" + ) + ]) +def test_fidelity_bond_seen(valid, fidelity_bond_proof, maker_nick, taker_nick): + proof = FidelityBondProof( + maker_nick, taker_nick, hextobin(fidelity_bond_proof['certificate-pubkey']), + fidelity_bond_proof['certificate-expiry'], + hextobin(fidelity_bond_proof['certificate-signature']), + (hextobin(fidelity_bond_proof['txid']), fidelity_bond_proof['vout']), + hextobin(fidelity_bond_proof['utxo-pubkey']), fidelity_bond_proof['locktime'] + ) + serialized = proof._serialize_proof_msg(fidelity_bond_proof['nick-signature']) + + ob = get_ob() + ob.msgchan.nick = taker_nick + ob.on_fidelity_bond_seen(maker_nick, fidelity_bond_cmd_list[0], serialized) + rows = ob.db.execute("SELECT * FROM fidelitybonds;").fetchall() + assert len(rows) == 1 + assert rows[0]["counterparty"] == maker_nick + assert rows[0]["takernick"] == taker_nick + try: + parsed_proof = FidelityBondProof.parse_and_verify_proof_msg(rows[0]["counterparty"], + rows[0]["takernick"], rows[0]["proof"]) + except ValueError: + parsed_proof = None + if valid: + assert len(rows) == 1 + assert rows[0]["counterparty"] == maker_nick + assert rows[0]["vout"] == fidelity_bond_proof["vout"] + assert rows[0]["locktime"] == fidelity_bond_proof["locktime"] + assert rows[0]["certexpiry"] == fidelity_bond_proof["certificate-expiry"] + assert rows[0]["txid"] == hextobin(fidelity_bond_proof["txid"]) + assert rows[0]["utxopubkey"] == hextobin(fidelity_bond_proof["utxo-pubkey"]) + else: + assert len(rows) == 0 + +def test_duplicate_fidelity_bond_rejected(): + + fidelity_bond_info = ( + ( + { + "nick-signature": (b'0E\x02!\x00\xdbb\x15\x96\xa0\x87\xb8\x1d\xe05\xddV\xa1\x1bn\x8f' + + b'q\x90&\x8cG@\x89"2\xb2\x81\x9b\xc00\xa5\xb6\x02 \x03\x14l\xd7BR\xba\x8c:\x88(' + + b'\x8e3l\xac\xf5`T\x87\xfa\xf5\xa9\x1f\x19\xc0\xb6\xe9\xbb\xdc\xc7y\x99'), + "certificate-signature": ("3045022100eb512af938113badb4d7b29e0c22061c51dadb113a9395e" + + "9ed81a46103391213022029170de414964f07228c4f0d404b1386272bae337f0133f1329d948a" + + "252fa2a0"), + "certificate-pubkey": "0258efb077960d6848f001904857f062fa453de26c1ad8736f55497254f56e8a74", + "certificate-expiry": 1, + "utxo-pubkey": "02f54f027377e84171296453828aa863c23fc4489453025f49bd3addfb3a359b3d", + "txid": "84c88fafe0bb75f507fe3bfb29a93d10b2e80c15a63b2943c1a5fecb5a55cba2", + "vout": 0, + "locktime": 1640995200 + }, + "J5A4k9ecQzRRDfBx", + "J55VZ6U6ZyFDNeuv" + ), + ( + { + "nick-signature": (b'0E\x02!\x00\x80\xc6$\x0c\xa1\x15YS\xacHB\xb33\xfa~\x9f\xb9`\xb3' + + b'\xfe\xed0\xadHq\xc1~\x03.B\xbb#\x02 #y~]\xd9\xbbX2\xc0\x1b\xe57\xf4\x0f\x1f' + + b'\xd6$\x01\xf9\x15Z\xc9X\xa5\x18\xbe\x83\x1a&4Y\xd4'), + "certificate-signature": ("304402205669ea394f7381e9abf0b3c013fac2b79d24c02feb86ff153" + + "cff83c658d7cf7402200b295ace655687f80738f3733c1dc5f1e2b8f351c017a05b8bd31983dd" + + "4d723f"), + "certificate-pubkey": "031d1c006a6310dbdf57341efc19c3a43c402379d7ccd2480416cadc7579f973f7", + "certificate-expiry": 1, + "utxo-pubkey": "02616c56412eb738a9eacfb0550b43a5a2e77e5d5205ea9e2ca8dfac34e50c9754", + "txid": "84c88fafe0bb75f507fe3bfb29a93d10b2e80c15a63b2943c1a5fecb5a55cba2", + "vout": 1, + "locktime": 1893456000 + }, + "J54LS6YyJPoseqFS", + "J55VZ6U6ZyFDNeuv" + ) + ) + + ob = get_ob() + + fidelity_bond_proof1, maker_nick1, taker_nick1 = fidelity_bond_info[0] + proof = FidelityBondProof( + maker_nick1, taker_nick1, hextobin(fidelity_bond_proof1['certificate-pubkey']), + fidelity_bond_proof1['certificate-expiry'], + hextobin(fidelity_bond_proof1['certificate-signature']), + (hextobin(fidelity_bond_proof1['txid']), fidelity_bond_proof1['vout']), + hextobin(fidelity_bond_proof1['utxo-pubkey']), fidelity_bond_proof1['locktime'] + ) + serialized1 = proof._serialize_proof_msg(fidelity_bond_proof1['nick-signature']) + ob.msgchan.nick = taker_nick1 + + ob.on_fidelity_bond_seen(maker_nick1, fidelity_bond_cmd_list[0], serialized1) + rows = ob.db.execute("SELECT * FROM fidelitybonds;").fetchall() + assert len(rows) == 1 + + #show the same fidelity bond message again, check it gets rejected as duplicate + ob.on_fidelity_bond_seen(maker_nick1, fidelity_bond_cmd_list[0], serialized1) + rows = ob.db.execute("SELECT * FROM fidelitybonds;").fetchall() + assert len(rows) == 1 + + #show a different fidelity bond and check it does get accepted + fidelity_bond_proof2, maker_nick2, taker_nick2 = fidelity_bond_info[1] + proof2 = FidelityBondProof( + maker_nick1, taker_nick1, hextobin(fidelity_bond_proof2['certificate-pubkey']), + fidelity_bond_proof2['certificate-expiry'], + hextobin(fidelity_bond_proof2['certificate-signature']), + (hextobin(fidelity_bond_proof2['txid']), fidelity_bond_proof2['vout']), + hextobin(fidelity_bond_proof2['utxo-pubkey']), fidelity_bond_proof2['locktime'] + ) + serialized2 = proof2._serialize_proof_msg(fidelity_bond_proof2['nick-signature']) + ob.msgchan.nick = taker_nick2 + + ob.on_fidelity_bond_seen(maker_nick2, fidelity_bond_cmd_list[0], serialized2) + rows = ob.db.execute("SELECT * FROM fidelitybonds;").fetchall() + assert len(rows) == 2 diff --git a/scripts/obwatch/ob-watcher.py b/scripts/obwatch/ob-watcher.py index f5339ab..b0cdc84 100755 --- a/scripts/obwatch/ob-watcher.py +++ b/scripts/obwatch/ob-watcher.py @@ -128,7 +128,6 @@ def create_choose_units_form(selected_btc, selected_rel): '