You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1070 lines
43 KiB
1070 lines
43 KiB
#! /usr/bin/env python |
|
|
|
from .message_channel import MessageChannelCollection |
|
from .orderbookwatch import OrderbookWatch |
|
from .enc_wrapper import (as_init_encryption, init_keypair, init_pubkey, |
|
NaclError) |
|
from .protocol import (COMMAND_PREFIX, ORDER_KEYS, NICK_HASH_LENGTH, |
|
NICK_MAX_ENCODED, JM_VERSION, JOINMARKET_NICK_HEADER, |
|
COMMITMENT_PREFIXES) |
|
|
|
from .irc import IRCMessageChannel |
|
from .onionmc import OnionMessageChannel |
|
from jmbase import (is_hs_uri, get_tor_agent, JMHiddenService, |
|
get_nontor_agent, BytesProducer, wrapped_urlparse, |
|
bdict_sdict_convert, JMHTTPResource) |
|
from jmbase.commands import * |
|
from twisted.protocols import amp |
|
from twisted.internet import reactor, ssl, task |
|
from twisted.internet.protocol import ServerFactory |
|
from twisted.internet.error import (ConnectionLost, ConnectionAborted, |
|
ConnectionClosed, ConnectionDone, |
|
ConnectionRefusedError) |
|
from twisted.web.http_headers import Headers |
|
from twisted.web.client import ResponseFailed, readBody |
|
from twisted.web import server |
|
from txtorcon.socks import HostUnreachableError |
|
from twisted.python import log |
|
import urllib.parse as urlparse |
|
from urllib.parse import urlencode |
|
import json |
|
import threading |
|
import os |
|
from io import BytesIO |
|
import copy |
|
from functools import wraps |
|
|
|
"""Joinmarket application protocol control flow. |
|
For documentation on protocol (formats, message sequence) see |
|
https://github.com/JoinMarket-Org/JoinMarket-Docs/blob/master/ |
|
Joinmarket-messaging-protocol.md |
|
""" |
|
""" |
|
*** |
|
API |
|
*** |
|
The client-daemon two-way communication is documented in jmbase.commands.py |
|
""" |
|
|
|
"""Decorators for limiting which |
|
inbound callbacks trigger in the DaemonProtocol |
|
object. |
|
""" |
|
|
|
def maker_only(func): |
|
@wraps(func) |
|
def func_wrapper(inst, *args, **kwargs): |
|
if inst.role == "MAKER": |
|
return func(inst, *args, **kwargs) |
|
return None |
|
return func_wrapper |
|
|
|
def taker_only(func): |
|
@wraps(func) |
|
def func_wrapper(inst, *args, **kwargs): |
|
if inst.role == "TAKER": |
|
return func(inst, *args, **kwargs) |
|
return None |
|
return func_wrapper |
|
|
|
# the location of the file |
|
blacklist_location = None |
|
def set_blacklist_location(location): |
|
global blacklist_location |
|
blacklist_location = location |
|
|
|
def check_utxo_blacklist(commitment, persist=False): |
|
"""Compare a given commitment with the persisted blacklist log file, |
|
which is hardcoded to this directory and name 'commitmentlist' (no |
|
security or privacy issue here). |
|
If the commitment has been used before, return False (disallowed), |
|
else return True. |
|
If flagged, persist the usage of this commitment to the above file. |
|
""" |
|
#TODO format error checking? |
|
fname = blacklist_location |
|
if os.path.isfile(fname): |
|
with open(fname, "rb") as f: |
|
blacklisted_commitments = [x.decode('ascii').strip() for x in f.readlines()] |
|
else: |
|
blacklisted_commitments = [] |
|
if commitment in blacklisted_commitments: |
|
return False |
|
elif persist: |
|
blacklisted_commitments += [commitment] |
|
with open(fname, "wb") as f: |
|
f.write('\n'.join(blacklisted_commitments).encode('ascii')) |
|
f.flush() |
|
#If the commitment is new and we are *not* persisting, nothing to do |
|
#(we only add it to the list on sending io_auth, which represents actual |
|
#usage). |
|
return True |
|
|
|
class JMProtocolError(Exception): |
|
pass |
|
|
|
class BIP78ReceiverResource(JMHTTPResource): |
|
|
|
def __init__(self, info_callback, shutdown_callback, post_request_handler): |
|
""" The POST request handling callback has function signature: |
|
args: (request-body-content-in-bytes,) |
|
returns: (errormsg, errcode, httpcode, response-in-bytes) |
|
If the request was successful, errormsg should be true and response |
|
should be in bytes, to be sent in the return value of render_POST(). |
|
""" |
|
self.post_request_handler = post_request_handler |
|
super().__init__(info_callback, shutdown_callback) |
|
|
|
def bip78_error(self, request, error_meaning, |
|
error_code="unavailable", http_code=400): |
|
""" |
|
See https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-well-known-errors |
|
|
|
We return, to the sender, stringified json in the body as per the above. |
|
""" |
|
request.setResponseCode(http_code) |
|
request.setHeader(b"content-type", b"text/html; charset=utf-8") |
|
print("Returning an error: " + str( |
|
error_code) + ": " + str(error_meaning)) |
|
if error_code in ["original-psbt-rejected", "version-unsupported"]: |
|
# if there is a negotiation failure in the first step, we cannot |
|
# know whether the sender client sent a valid non-payjoin or not, |
|
# hence the warning below is somewhat ambiguous: |
|
print("Negotiation failure. Payment has not yet been made," |
|
" check wallet.") |
|
# shutdown now but wait until response is sent. |
|
task.deferLater(reactor, 2.0, self.end_failure) |
|
return json.dumps({"errorCode": error_code, |
|
"message": error_meaning}).encode("utf-8") |
|
|
|
def render_POST(self, request): |
|
""" The sender will use POST to send the initial |
|
payment transaction. |
|
""" |
|
print("The server got this POST request: ") |
|
# unfortunately the twisted Request object is not |
|
# easily serialized: |
|
print(request) |
|
print(request.method) |
|
print(request.uri) |
|
print(request.args) |
|
sender_parameters = request.args |
|
print(request.path) |
|
# defer logging of raw request content: |
|
proposed_tx = request.content |
|
if not isinstance(proposed_tx, BytesIO): |
|
return self.bip78_error(request, "invalid psbt format", |
|
"original-psbt-rejected") |
|
payment_psbt_base64 = proposed_tx.read().decode("utf-8") |
|
reactor.callLater(0.0, self.post_request_handler, request, |
|
payment_psbt_base64, sender_parameters) |
|
return server.NOT_DONE_YET |
|
|
|
def end_failure(self): |
|
self.info_callback("Shutting down, payjoin negotiation failed.") |
|
self.shutdown_callback() |
|
|
|
|
|
class HTTPPassThrough(amp.AMP): |
|
""" This class supports passing through |
|
requests over HTTPS or over a socks proxy to a remote |
|
onion service, or multiple. |
|
""" |
|
|
|
def on_INIT(self, netconfig): |
|
""" The network config must be passed in json |
|
and contains these fields: |
|
socks5_host |
|
socks5_proxy |
|
servers (comma separated list) |
|
tls_whitelist (comma separated list) |
|
filterconfig (not yet defined) |
|
credentials (not yet defined) |
|
""" |
|
self.socks5_host = netconfig["socks5_host"] |
|
self.socks5_port = int(netconfig["socks5_port"]) |
|
self.servers = [a for a in netconfig["servers"] if a != ""] |
|
self.tls_whitelist = [a for a in netconfig["tls_whitelist"].split( |
|
",") if a != ""] |
|
|
|
def getAgentDestination(self, server, params=None): |
|
tor_url_data = is_hs_uri(server) |
|
if tor_url_data: |
|
# note: SSL over Tor not supported at the moment: |
|
agent = get_tor_agent(self.socks5_host, self.socks5_port) |
|
else: |
|
agent = get_nontor_agent(self.tls_whitelist) |
|
|
|
destination_url = server.encode("utf-8") |
|
url_parts = list(wrapped_urlparse(destination_url)) |
|
if params: |
|
url_parts[4] = urlencode(params).encode("utf-8") |
|
destination_url = urlparse.urlunparse(url_parts) |
|
return (agent, destination_url) |
|
|
|
def getDefaultHeaders(self): |
|
# Deliberately sending NO headers other than |
|
# Content-Type by default; |
|
# this could be a tricky point for anonymity of users, |
|
# as much boilerplate code will not create |
|
# requests that look like this. |
|
return Headers({"Content-Type": ["text/plain"]}) |
|
|
|
def getRequest(self, server, success_callback, url=None, headers=None): |
|
""" Make GET request to server server, if response received OK, |
|
passed to success_callback, which must have function signature |
|
(response, server). |
|
""" |
|
agent, destination_url = self.getAgentDestination(server) |
|
if url: |
|
destination_url = destination_url + url |
|
# Deliberately sending NO headers; this could be a tricky point |
|
# for anonymity of users, as much boilerplate code will not create |
|
# requests that look like this. |
|
headers = self.getDefaultHeaders() if not headers else headers |
|
d = agent.request(b"GET", destination_url, headers) |
|
d.addCallback(success_callback, server) |
|
# note that the errback (here "noResponse") is *not* triggered |
|
# by a server rejection (which is accompanied by a non-200 |
|
# status code returned), but by failure to communicate. |
|
def noResponse(failure): |
|
failure.trap(ResponseFailed, ConnectionRefusedError, |
|
HostUnreachableError, ConnectionLost) |
|
log.msg(failure.value) |
|
d.addErrback(noResponse) |
|
|
|
def postRequest(self, body, server, success_callback, |
|
url=None, params=None, headers=None): |
|
""" Pass body of post request as string, will be encoded here. |
|
""" |
|
agent, destination_url = self.getAgentDestination(server, |
|
params=params) |
|
if url: |
|
destination_url = destination_url + url |
|
body = BytesProducer(body.encode("utf-8")) |
|
headers = self.getDefaultHeaders() if not headers else headers |
|
d = agent.request(b"POST", destination_url, |
|
headers, bodyProducer=body) |
|
d.addCallback(success_callback, server) |
|
# note that the errback (here "noResponse") is *not* triggered |
|
# by a server rejection (which is accompanied by a non-200 |
|
# status code returned), but by failure to communicate. |
|
def noResponse(failure): |
|
failure.trap(ResponseFailed, ConnectionRefusedError, |
|
HostUnreachableError, ConnectionLost) |
|
log.msg(failure.value) |
|
self.callRemote(BIP78SenderReceiveError, |
|
errormsg="failure to connect", |
|
errorcode=10000) |
|
d.addErrback(noResponse) |
|
|
|
def checkClientResponse(self, response): |
|
"""A generic check of client acceptance; any failure |
|
is considered criticial. |
|
""" |
|
if 'accepted' not in response or not response['accepted']: |
|
reactor.stop() #pragma: no cover |
|
|
|
def defaultErrback(self, failure): |
|
"""TODO better network error handling. |
|
""" |
|
failure.trap(ConnectionAborted, ConnectionClosed, |
|
ConnectionDone, ConnectionLost) |
|
|
|
def defaultCallbacks(self, d): |
|
d.addCallback(self.checkClientResponse) |
|
d.addErrback(self.defaultErrback) |
|
|
|
class BIP78ServerProtocol(HTTPPassThrough): |
|
@BIP78ReceiverInit.responder |
|
def on_BIP78_RECEIVER_INIT(self, netconfig): |
|
self.serving_port = int(netconfig["port"]) |
|
self.tor_control_host = netconfig["tor_control_host"] |
|
self.tor_control_port = int(netconfig["tor_control_port"]) |
|
self.onion_serving_host=netconfig["onion_serving_host"] |
|
self.onion_serving_port=int(netconfig["onion_serving_port"]) |
|
self.bip78_rr = BIP78ReceiverResource(self.info_callback, |
|
self.shutdown_callback, |
|
self.post_request_handler) |
|
self.hs = JMHiddenService(self.bip78_rr, |
|
self.info_callback, |
|
self.setup_error_callback, |
|
self.onion_hostname_callback, |
|
self.tor_control_host, |
|
self.tor_control_port, |
|
self.onion_serving_host, |
|
self.onion_serving_port, |
|
shutdown_callback=self.shutdown_callback) |
|
# this call will start bringing up the HS; when it's finished, |
|
# it will fire the `onion_hostname_callback`, or if it fails, |
|
# it'll fire the `setup_error_callback`. |
|
self.hs.start_tor() |
|
return {"accepted": True} |
|
|
|
def setup_error_callback(self, errormsg): |
|
d = self.callRemote(BIP78ReceiverOnionSetupFailed, |
|
reason=errormsg) |
|
self.defaultCallbacks(d) |
|
|
|
def shutdown_callback(self): |
|
d = self.callRemote(BIP78ReceiverHiddenServiceShutdown) |
|
self.defaultCallbacks(d) |
|
|
|
def info_callback(self, msg): |
|
""" Informational messages are all passed |
|
to the client. TODO makes sense to log locally |
|
too, in case daemon is isolated?. |
|
""" |
|
d = self.callRemote(BIP78InfoMsg, infomsg=msg) |
|
self.defaultCallbacks(d) |
|
|
|
def onion_hostname_callback(self, hostname): |
|
""" On successful start of HS, we pass hostname |
|
to client, who can use this to build the full URI. |
|
""" |
|
d = self.callRemote(BIP78ReceiverUp, |
|
hostname=hostname) |
|
self.defaultCallbacks(d) |
|
|
|
def post_request_handler(self, request, body, params): |
|
""" Fired when a sender has sent a POST request |
|
to our hidden service. Argument `body` should be a base64 |
|
string and params should be a dict. |
|
""" |
|
self.post_request = request |
|
d = self.callRemote(BIP78ReceiverOriginalPSBT, body=body, |
|
params=bdict_sdict_convert(params)) |
|
self.defaultCallbacks(d) |
|
|
|
@BIP78ReceiverSendProposal.responder |
|
def on_BIP78_RECEIVER_SEND_PROPOSAL(self, psbt): |
|
content = psbt.encode("utf-8") |
|
self.post_request.setHeader(b"content-length", |
|
("%d" % len(content))) |
|
self.post_request.write(content) |
|
self.post_request.finish() |
|
return {"accepted": True} |
|
|
|
@BIP78ReceiverSendError.responder |
|
def on_BIP78_RECEIVER_SEND_ERROR(self, errormsg, errorcode): |
|
self.post_request.write(self.bip78_rr.bip78_error( |
|
self.post_request, errormsg, errorcode)) |
|
self.post_request.finish() |
|
return {"accepted": True} |
|
|
|
@BIP78SenderInit.responder |
|
def on_BIP78_SENDER_INIT(self, netconfig): |
|
self.on_INIT(netconfig) |
|
d = self.callRemote(BIP78SenderUp) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@BIP78SenderOriginalPSBT.responder |
|
def on_BIP78_SENDER_ORIGINAL_PSBT(self, body, params): |
|
self.postRequest(body, self.servers[0], |
|
self.bip78_receiver_response, |
|
params=params, |
|
headers=Headers({"Content-Type": ["text/plain"]})) |
|
return {"accepted": True} |
|
|
|
def bip78_receiver_response(self, response, server): |
|
d = readBody(response) |
|
# if the response code is not 200 OK, we must assume payjoin |
|
# attempt has failed, and revert to standard payment. |
|
if int(response.code) != 200: |
|
d.addCallback(self.process_receiver_errormsg, response.code) |
|
return |
|
d.addCallback(self.process_receiver_psbt) |
|
|
|
def process_receiver_errormsg(self, response, errorcode): |
|
d = self.callRemote(BIP78SenderReceiveError, |
|
errormsg=response.decode("utf-8"), |
|
errorcode=errorcode) |
|
self.defaultCallbacks(d) |
|
|
|
def process_receiver_psbt(self, response): |
|
d = self.callRemote(BIP78SenderReceiveProposal, |
|
psbt=response.decode("utf-8")) |
|
self.defaultCallbacks(d) |
|
|
|
class SNICKERDaemonServerProtocol(HTTPPassThrough): |
|
|
|
@SNICKERProposerPostProposals.responder |
|
def on_SNICKER_PROPOSER_POST_PROPOSALS(self, proposals, server): |
|
""" Receives a list of proposals to be posted to a specific |
|
server. |
|
""" |
|
self.postRequest(proposals, server, self.receive_proposals_response) |
|
return {"accepted": True} |
|
|
|
def receive_proposals_response(self, response, server): |
|
d = readBody(response) |
|
if int(response.code) != 200: |
|
log.msg("Server returned error code: " + str(response.code)) |
|
d = self.callRemote(SNICKERServerError, server=server, |
|
errorcode=response.code) |
|
self.defaultCallbacks(d) |
|
return |
|
d.addCallback(self.process_proposals_response_from_server, server) |
|
|
|
@SNICKERReceiverInit.responder |
|
def on_SNICKER_RECEIVER_INIT(self, netconfig): |
|
self.on_INIT(netconfig) |
|
d = self.callRemote(SNICKERReceiverUp) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
@SNICKERProposerInit.responder |
|
def on_SNICKER_PROPOSER_INIT(self, netconfig): |
|
self.on_INIT(netconfig) |
|
d = self.callRemote(SNICKERProposerUp) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@SNICKERReceiverGetProposals.responder |
|
def on_SNICKER_RECEIVER_GET_PROPOSALS(self): |
|
for srv in self.servers: |
|
self.getRequest(srv, self.receive_proposals_from_server) |
|
return {'accepted': True} |
|
|
|
def receive_proposals_from_server(self, response, server): |
|
""" Parses the response from one server. |
|
""" |
|
# if the response code is not 200 OK, we must let the client |
|
# know that this server is not responding as expected. |
|
if int(response.code) != 200: |
|
d = self.callRemote(SNICKERServerError, |
|
server=server, |
|
errorcode = response.code) |
|
self.defaultCallbacks(d) |
|
return |
|
d = readBody(response) |
|
d.addCallback(self.process_proposals_from_server, server) |
|
|
|
@SNICKERRequestPowTarget.responder |
|
def on_SNICKER_REQUEST_POW_TARGET(self, server): |
|
self.getRequest(server, self.receive_pow_target, |
|
url=b"/target") |
|
return {"accepted": True} |
|
|
|
def receive_pow_target(self, response, server): |
|
d = readBody(response) |
|
d.addCallback(self.process_pow_target, server) |
|
|
|
def process_pow_target(self, response_body, server): |
|
d = self.callRemote(SNICKERReceivePowTarget, |
|
server=server, |
|
targetbits=int(response_body.decode("utf-8"))) |
|
self.defaultCallbacks(d) |
|
|
|
def process_proposals_from_server(self, response, server): |
|
d = self.callRemote(SNICKERReceiverProposals, |
|
proposals=response.decode("utf-8"), |
|
server=server) |
|
self.defaultCallbacks(d) |
|
|
|
def process_proposals_response_from_server(self, response_body, server): |
|
d = self.callRemote(SNICKERProposalsServerResponse, |
|
response=response_body.decode("utf-8"), |
|
server=server) |
|
self.defaultCallbacks(d) |
|
|
|
class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): |
|
|
|
def __init__(self, factory): |
|
self.factory = factory |
|
self.jm_state = 0 |
|
self.restart_mc_required = False |
|
self.chan_configs = None |
|
self.mcc = None |
|
#Default role is TAKER; must be overriden to MAKER in JMSetup message. |
|
self.role = "TAKER" |
|
self.crypto_boxes = {} |
|
self.sig_lock = threading.Lock() |
|
self.active_orders = {} |
|
self.use_fidelity_bond = False |
|
self.offerlist = None |
|
self.kp = None |
|
|
|
def checkClientResponse(self, response): |
|
"""A generic check of client acceptance; any failure |
|
is considered criticial. |
|
""" |
|
if 'accepted' not in response or not response['accepted']: |
|
reactor.stop() #pragma: no cover |
|
|
|
def defaultErrback(self, failure): |
|
"""TODO better network error handling. |
|
""" |
|
failure.trap(ConnectionAborted, ConnectionClosed, |
|
ConnectionDone, ConnectionLost) |
|
|
|
def defaultCallbacks(self, d): |
|
d.addCallback(self.checkClientResponse) |
|
d.addErrback(self.defaultErrback) |
|
|
|
@JMInit.responder |
|
def on_JM_INIT(self, bcsource, network, chan_configs, minmakers, |
|
maker_timeout_sec, dust_threshold, blacklist_location): |
|
"""Reads in required configuration from client for a new |
|
session; feeds back joinmarket messaging protocol constants |
|
(required for nick creation). |
|
If a new message channel configuration is required, the current |
|
one is shutdown in preparation. |
|
""" |
|
self.maker_timeout_sec = maker_timeout_sec |
|
self.minmakers = minmakers |
|
set_blacklist_location(blacklist_location) |
|
self.dust_threshold = int(dust_threshold) |
|
#(bitcoin) network only referenced in channel name construction |
|
self.network = network |
|
if chan_configs == self.chan_configs: |
|
self.restart_mc_required = False |
|
log.msg("New init received did not require a new message channel" |
|
" setup.") |
|
else: |
|
if self.chan_configs: |
|
#close the existing connections |
|
self.mc_shutdown() |
|
self.chan_configs = chan_configs |
|
self.restart_mc_required = True |
|
mcs = [] |
|
for c in self.chan_configs: |
|
if "type" in c and c["type"] == "onion": |
|
mcs.append(OnionMessageChannel(c, daemon=self)) |
|
else: |
|
# default is IRC; TODO allow others |
|
mcs.append(IRCMessageChannel(c, |
|
daemon=self, |
|
realname='btcint=' + bcsource)) |
|
self.mcc = MessageChannelCollection(mcs) |
|
OrderbookWatch.set_msgchan(self, self.mcc) |
|
#register taker-specific msgchan callbacks here |
|
self.mcc.register_taker_callbacks(self.on_error, self.on_pubkey, |
|
self.on_ioauth, self.on_sig) |
|
self.mcc.register_maker_callbacks(self.on_orderbook_requested, |
|
self.on_order_fill, |
|
self.on_seen_auth, |
|
self.on_seen_tx, |
|
self.on_push_tx, |
|
self.on_commitment_seen, |
|
self.on_commitment_transferred) |
|
self.mcc.set_daemon(self) |
|
d = self.callRemote(JMInitProto, |
|
nick_hash_length=NICK_HASH_LENGTH, |
|
nick_max_encoded=NICK_MAX_ENCODED, |
|
joinmarket_nick_header=JOINMARKET_NICK_HEADER, |
|
joinmarket_version=JM_VERSION) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
@JMStartMC.responder |
|
def on_JM_START_MC(self, nick): |
|
"""Starts message channel threads, if we are working with |
|
a new message channel configuration. Sets new nick if required. |
|
JM_UP will be called when the welcome messages are received. |
|
""" |
|
self.init_connections(nick) |
|
return {'accepted': True} |
|
|
|
@JMSetup.responder |
|
def on_JM_SETUP(self, role, initdata, use_fidelity_bond): |
|
assert self.jm_state == 0 |
|
self.role = role |
|
self.crypto_boxes = {} |
|
self.kp = init_keypair() |
|
d = self.callRemote(JMSetupDone) |
|
self.defaultCallbacks(d) |
|
#Request orderbook here, on explicit setup request from client, |
|
#assumes messagechannels are in "up" state. Orders are read |
|
#in the callback on_order_seen in OrderbookWatch. |
|
#TODO: pubmsg should not (usually?) fire if already up from previous run. |
|
if self.role == "TAKER": |
|
self.mcc.pubmsg(COMMAND_PREFIX + "orderbook") |
|
elif self.role == "MAKER": |
|
self.offerlist = initdata |
|
self.use_fidelity_bond = use_fidelity_bond |
|
self.mcc.announce_orders(self.offerlist, None, None, None) |
|
self.jm_state = 1 |
|
return {'accepted': True} |
|
|
|
@JMMsgSignature.responder |
|
def on_JM_MSGSIGNATURE(self, nick, cmd, msg_to_return, hostid): |
|
self.mcc.privmsg(nick, cmd, msg_to_return, mc=hostid) |
|
return {'accepted': True} |
|
|
|
@JMMsgSignatureVerify.responder |
|
def on_JM_MSGSIGNATURE_VERIFY(self, verif_result, nick, fullmsg, hostid): |
|
if not verif_result: |
|
log.msg("Verification failed for nick: " + str(nick)) |
|
else: |
|
self.mcc.on_verified_privmsg(nick, fullmsg, hostid) |
|
return {'accepted': True} |
|
|
|
@JMShutdown.responder |
|
def on_JM_SHUTDOWN(self): |
|
self.mc_shutdown() |
|
self.jm_state = 0 |
|
return {'accepted': True} |
|
|
|
"""Taker specific responders |
|
""" |
|
|
|
@JMRequestOffers.responder |
|
def on_JM_REQUEST_OFFERS(self): |
|
"""Reports the current state of the orderbook. |
|
This call is stateless.""" |
|
rows = self.db.execute('SELECT * FROM orderbook;').fetchall() |
|
self.orderbook = [dict([(k, o[k]) for k in ORDER_KEYS]) for o in rows] |
|
string_orderbook = json.dumps(self.orderbook) |
|
|
|
fbond_rows = self.db.execute("SELECT * FROM fidelitybonds;").fetchall() |
|
fidelitybonds = [fb for fb in fbond_rows] |
|
string_fidelitybonds = json.dumps(fidelitybonds) |
|
|
|
log.msg("About to send orderbook (size=" + str(len(self.orderbook)) |
|
+ " with fidelity bonds (size=" + str(len(fidelitybonds))) |
|
d = self.callRemote(JMOffers, orderbook=string_orderbook, |
|
fidelitybonds=string_fidelitybonds) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
@JMFill.responder |
|
def on_JM_FILL(self, amount, commitment, revelation, filled_offers): |
|
"""Takes the necessary data from the Taker and initiates the Stage 1 |
|
interaction with the Makers. |
|
""" |
|
if self.jm_state != 1 or amount < 0: |
|
return {'accepted': False} |
|
self.cjamount = amount |
|
self.commitment = commitment |
|
self.revelation = revelation |
|
#Reset utxo data to null for this new transaction |
|
self.ioauth_data = {} |
|
self.active_orders = filled_offers |
|
for nick, offer_dict in self.active_orders.items(): |
|
offer_fill_msg = " ".join([str(offer_dict["oid"]), str(amount), |
|
self.kp.hex_pk().decode('ascii'), str(commitment)]) |
|
self.mcc.prepare_privmsg(nick, "fill", offer_fill_msg) |
|
reactor.callLater(self.maker_timeout_sec, self.completeStage1) |
|
self.jm_state = 2 |
|
return {'accepted': True} |
|
|
|
@JMMakeTx.responder |
|
def on_JM_MAKE_TX(self, nick_list, tx): |
|
"""Taker sends the prepared unsigned transaction |
|
to all the Makers in nick_list |
|
""" |
|
if not self.jm_state == 4: |
|
log.msg("Make tx was called in wrong state, rejecting") |
|
return {'accepted': False} |
|
self.mcc.send_tx(nick_list, tx) |
|
return {'accepted': True} |
|
|
|
@JMPushTx.responder |
|
def on_JM_PushTx(self, nick, tx): |
|
self.mcc.push_tx(nick, tx) |
|
return {'accepted': True} |
|
|
|
"""Maker specific responders |
|
""" |
|
|
|
@JMAnnounceOffers.responder |
|
def on_JM_ANNOUNCE_OFFERS(self, to_announce, to_cancel, offerlist): |
|
"""Called by Maker to reset his current offerlist; |
|
Daemon decides what messages (cancel, announce) to |
|
send to the message channel. |
|
""" |
|
if self.role != "MAKER": |
|
return |
|
self.offerlist = offerlist |
|
if len(to_cancel) > 0: |
|
self.mcc.cancel_orders(to_cancel) |
|
if len(to_announce) > 0: |
|
self.mcc.announce_orders(to_announce, None, None, None) |
|
return {"accepted": True} |
|
|
|
@JMFidelityBondProof.responder |
|
def on_JM_FIDELITY_BOND_PROOF(self, nick, proof): |
|
"""Called by maker client as a reply to a request of a fidelity bond proof""" |
|
self.mcc.announce_orders(self.offerlist, nick, proof, new_mc=None) |
|
return {"accepted": True} |
|
|
|
@JMIOAuth.responder |
|
def on_JM_IOAUTH(self, nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig): |
|
"""Daemon constructs full !ioauth message to be sent on message |
|
channel based on data from Maker. Relevant data (utxos, addresses) |
|
are stored in the active_orders dict keyed by the nick of the Taker. |
|
""" |
|
if not self.role == "MAKER": |
|
return |
|
if nick not in self.active_orders: |
|
return |
|
#completed population of order/offer object |
|
self.active_orders[nick]["cjaddr"] = cjaddr |
|
self.active_orders[nick]["changeaddr"] = changeaddr |
|
self.active_orders[nick]["utxos"] = utxolist |
|
msg = str(",".join(utxolist)) + " " + " ".join( |
|
[pubkey, cjaddr, changeaddr, pubkeysig]) |
|
self.mcc.prepare_privmsg(nick, "ioauth", msg) |
|
#In case of *blacklisted (ie already used) commitments, we already |
|
#broadcasted them on receipt; in case of valid, and now used commitments, |
|
#we broadcast them here, and not early - to avoid accidentally |
|
#blacklisting commitments that are broadcast between makers in real time |
|
#for the same transaction. |
|
self.transfer_commitment(self.active_orders[nick]["commit"]) |
|
#now persist the fact that the commitment is actually used. |
|
check_utxo_blacklist(self.active_orders[nick]["commit"], persist=True) |
|
return {"accepted": True} |
|
|
|
@JMTXSigs.responder |
|
def on_JM_TX_SIGS(self, nick, sigs): |
|
"""Signatures that the Maker has produced |
|
are passed here to the daemon as a list and |
|
broadcast one by one. TODO: could shorten this, |
|
have more than one sig per message. |
|
""" |
|
for sig in sigs: |
|
self.mcc.prepare_privmsg(nick, "sig", sig) |
|
return {"accepted": True} |
|
|
|
"""Message channel callbacks |
|
""" |
|
|
|
def on_welcome(self): |
|
"""Fired when channel indicated state readiness |
|
""" |
|
d = self.callRemote(JMUp) |
|
self.defaultCallbacks(d) |
|
|
|
@maker_only |
|
def on_orderbook_requested(self, nick, mc=None): |
|
"""Dealt with by daemon, assuming offerlist is up to date |
|
""" |
|
if self.use_fidelity_bond: |
|
taker_nick = nick |
|
maker_nick = self.mcc.nick |
|
d = self.callRemote(JMFidelityBondProofRequest, |
|
takernick=taker_nick, |
|
makernick=maker_nick) |
|
self.defaultCallbacks(d) |
|
else: |
|
self.mcc.announce_orders(self.offerlist, nick, fidelity_bond_proof_msg=None, |
|
new_mc=mc) |
|
|
|
@maker_only |
|
def on_order_fill(self, nick, oid, amount, taker_pk, commit): |
|
"""Handled locally in daemon. This is the start of |
|
communication with the Taker. Does the following: |
|
|
|
* Immediately rejects if commitment is invalid or already used. |
|
* Checks that the fill is against a valid offer. |
|
* Establishes encryption with a new ephemeral keypair |
|
* Creates the amount, commitment and keypair fields in |
|
active_orders[nick] (or resets if already existing). |
|
|
|
Processing will only return to the Maker once the conversation |
|
up to !ioauth is complete. |
|
""" |
|
if nick in self.active_orders: |
|
log.msg("Restarting transaction for nick: " + nick) |
|
if not commit[0] in COMMITMENT_PREFIXES: |
|
self.mcc.send_error(nick, |
|
"Unsupported commitment type: " + str(commit[0])) |
|
return |
|
scommit = commit[1:] |
|
if not check_utxo_blacklist(scommit): |
|
log.msg("Taker utxo commitment is blacklisted, rejecting.") |
|
self.mcc.send_error(nick, "Commitment is blacklisted: " + str(scommit)) |
|
#Note that broadcast is happening here to reflect an already |
|
#consumed commitment; it can also be broadcast separately (earlier) on |
|
#valid usage |
|
self.transfer_commitment(scommit) |
|
return |
|
offer_s = [o for o in self.offerlist if o['oid'] == oid] |
|
if len(offer_s) == 0: |
|
self.mcc.send_error(nick, 'oid not found') |
|
return |
|
offer = offer_s[0] |
|
if amount < offer['minsize'] or amount > offer['maxsize']: |
|
self.mcc.send_error(nick, 'amount out of range') |
|
return |
|
#prepare a pubkey for this valid transaction |
|
kp = init_keypair() |
|
try: |
|
crypto_box = as_init_encryption(kp, init_pubkey(taker_pk)) |
|
except NaclError as e: |
|
log.msg("Unable to set up cryptobox with counterparty: " + repr(e)) |
|
self.mcc.send_error(nick, "Invalid nacl pubkey: " + taker_pk) |
|
return |
|
#Note this sets the *whole* dict, old entries (e.g. changeaddr) |
|
#are removed, so we can't have a conflict between old and new |
|
#versions of active_orders[nick] |
|
self.active_orders[nick] = {"crypto_box": crypto_box, |
|
"kp": kp, |
|
"offer": offer, |
|
"amount": amount, |
|
"commit": scommit} |
|
self.mcc.prepare_privmsg(nick, "pubkey", kp.hex_pk().decode('ascii')) |
|
|
|
@maker_only |
|
def on_seen_auth(self, nick, commitment_revelation): |
|
"""Passes to Maker the !auth message from the Taker, |
|
for processing. This will include validating the PoDLE |
|
commitment revelation against the existing commitment, |
|
which was already stored in active_orders[nick]. |
|
""" |
|
if nick not in self.active_orders: |
|
return |
|
ao = self.active_orders[nick] |
|
#ask the client to validate the commitment and prepare the utxo data |
|
d = self.callRemote(JMAuthReceived, |
|
nick=nick, |
|
offer=ao["offer"], |
|
commitment=ao["commit"], |
|
revelation=commitment_revelation, |
|
amount=ao["amount"], |
|
kphex=ao["kp"].hex_pk().decode('ascii')) |
|
self.defaultCallbacks(d) |
|
|
|
@maker_only |
|
def on_commitment_seen(self, nick, commitment): |
|
"""Triggered when we see a commitment for blacklisting |
|
appear in the public pit channel. |
|
""" |
|
#just add if necessary, ignore return value. |
|
check_utxo_blacklist(commitment, persist=True) |
|
log.msg("Received commitment broadcast by other maker: " + str( |
|
commitment) + ", now blacklisted.") |
|
|
|
@maker_only |
|
def on_commitment_transferred(self, nick, commitment): |
|
"""Triggered when a privmsg is received from another maker |
|
with a commitment to announce in public (obfuscation of source). |
|
We simply post it in public (not affected by whether we ourselves |
|
are *accepting* commitment broadcasts. |
|
""" |
|
self.mcc.pubmsg("!hp2 " + commitment) |
|
|
|
@maker_only |
|
def on_push_tx(self, nick, tx): |
|
"""Broadcast unquestioningly |
|
""" |
|
d = self.callRemote(JMTXBroadcast, tx=tx) |
|
self.defaultCallbacks(d) |
|
|
|
@maker_only |
|
def on_seen_tx(self, nick, tx): |
|
"""Passes the txhex to the Maker for verification |
|
and signing. Note the security checks occur in Maker. |
|
""" |
|
if nick not in self.active_orders: |
|
return |
|
#we send a copy of the entire "active_orders" entry except the cryptobox, |
|
#so make a temporary copy |
|
ao = copy.deepcopy(self.active_orders[nick]) |
|
del ao["crypto_box"] |
|
del ao["kp"] |
|
d = self.callRemote(JMTXReceived, nick=nick, tx=tx, offer=ao) |
|
self.defaultCallbacks(d) |
|
|
|
@taker_only |
|
def on_pubkey(self, nick, maker_pk): |
|
"""This is handled locally in the daemon; set up e2e |
|
encrypted messaging with this counterparty |
|
""" |
|
if nick not in self.active_orders.keys(): |
|
log.msg("Counterparty not part of this transaction. Ignoring") |
|
return |
|
try: |
|
self.crypto_boxes[nick] = [maker_pk, as_init_encryption( |
|
self.kp, init_pubkey(maker_pk))] |
|
except NaclError as e: |
|
print("Unable to setup crypto box with " + nick + ": " + repr(e)) |
|
self.mcc.send_error(nick, "invalid nacl pubkey: " + maker_pk) |
|
return |
|
self.mcc.prepare_privmsg(nick, "auth", str(self.revelation)) |
|
|
|
@taker_only |
|
def on_ioauth(self, nick, utxo_list, auth_pub, cj_addr, change_addr, |
|
btc_sig): |
|
"""Passes through to Taker the information from counterparties once |
|
they've all been received; note that we must also pass back the maker_pk |
|
so it can be verified against the btc-sigs for anti-MITM |
|
""" |
|
if nick not in self.active_orders.keys(): |
|
print("Got an unexpected ioauth from nick: " + str(nick)) |
|
return |
|
self.ioauth_data[nick] = [utxo_list, auth_pub, cj_addr, change_addr, |
|
btc_sig, self.crypto_boxes[nick][0]] |
|
if self.ioauth_data.keys() == self.active_orders.keys(): |
|
#Finish early if we got all |
|
self.respondToIoauths(True) |
|
|
|
@taker_only |
|
def on_sig(self, nick, sig): |
|
"""Pass signature through to Taker. |
|
""" |
|
d = self.callRemote(JMSigReceived, nick=nick, sig=sig) |
|
self.defaultCallbacks(d) |
|
|
|
def on_error(self, msg): |
|
log.msg("Received error: " + str(msg)) |
|
|
|
"""The following 2 functions handle requests and responses |
|
from client for messaging signing and verifying. |
|
""" |
|
|
|
def request_signed_message(self, nick, cmd, msg, msg_to_be_signed, hostid): |
|
"""The daemon passes the nick and cmd fields |
|
to the client so it can be echoed back to the privmsg |
|
after return (with signature); note that the cmd is already |
|
inside "msg" after having been parsed in MessageChannel; this |
|
duplication is so that the client does not need to know the |
|
message syntax. |
|
""" |
|
with self.sig_lock: |
|
d = self.callRemote(JMRequestMsgSig, |
|
nick=str(nick), |
|
cmd=str(cmd), |
|
msg=str(msg), |
|
msg_to_be_signed=str(msg_to_be_signed), |
|
hostid=str(hostid)) |
|
self.defaultCallbacks(d) |
|
|
|
def request_signature_verify(self, msg, fullmsg, sig, pubkey, nick, hashlen, |
|
max_encoded, hostid): |
|
with self.sig_lock: |
|
d = self.callRemote(JMRequestMsgSigVerify, |
|
msg=msg, |
|
fullmsg=fullmsg, |
|
sig=sig, |
|
pubkey=pubkey, |
|
nick=nick, |
|
hashlen=hashlen, |
|
max_encoded=max_encoded, |
|
hostid=hostid) |
|
self.defaultCallbacks(d) |
|
|
|
def init_connections(self, nick): |
|
"""Sets up message channel connections |
|
if they are not already up; re-sets joinmarket state to 0 |
|
for a new transaction; effectively means any previous |
|
incomplete transaction is wiped. |
|
""" |
|
self.jm_state = 0 |
|
self.mcc.set_nick(nick) |
|
if self.restart_mc_required: |
|
self.mcc.run() |
|
self.restart_mc_required = False |
|
else: |
|
#if we are not restarting the MC, |
|
#we must simulate the on_welcome message: |
|
self.on_welcome() |
|
|
|
def transfer_commitment(self, commit): |
|
"""Send this commitment via privmsg to one (random) |
|
other maker. |
|
""" |
|
crow = self.db.execute( |
|
'SELECT DISTINCT counterparty FROM orderbook ORDER BY ' + |
|
'RANDOM() LIMIT 1;' |
|
).fetchone() |
|
if crow is None: |
|
return |
|
counterparty = crow['counterparty'] |
|
#TODO de-hardcode hp2 |
|
log.msg("Sending commitment to: " + str(counterparty)) |
|
self.mcc.prepare_privmsg(counterparty, 'hp2', commit) |
|
|
|
def respondToIoauths(self, accepted): |
|
"""Sends the full set of data from the Makers to the |
|
Taker after processing of first stage is completed, |
|
using the JMFillResponse command. But if the responses |
|
were not accepted (including, not sufficient number |
|
of responses), we send the list of Makers who did not |
|
respond to the Taker, instead of the ioauth data, |
|
so that the Taker can keep track of non-responders |
|
(although note this code is not yet quite ideal, see |
|
comments below). |
|
""" |
|
if self.jm_state != 2: |
|
#this can be called a second time on timeout, in which case we |
|
#do nothing |
|
return |
|
self.jm_state = 3 |
|
if not accepted: |
|
#use ioauth data field to return the list of non-responsive makers |
|
nonresponders = [x for x in self.active_orders |
|
if x not in self.ioauth_data] |
|
ioauth_data = self.ioauth_data if accepted else nonresponders |
|
d = self.callRemote(JMFillResponse, |
|
success=accepted, |
|
ioauth_data=ioauth_data) |
|
if not accepted: |
|
#Client simply accepts failure TODO |
|
self.defaultCallbacks(d) |
|
else: |
|
#Act differently if *we* provided utxos, but |
|
#client does not accept for some reason |
|
d.addCallback(self.checkUtxosAccepted) |
|
d.addErrback(self.defaultErrback) |
|
|
|
def completeStage1(self): |
|
"""Timeout of stage 1 requests; |
|
either send success + ioauth data if enough makers, |
|
else send failure to client. |
|
""" |
|
response = True if len(self.ioauth_data) >= self.minmakers else False |
|
self.respondToIoauths(response) |
|
|
|
def checkUtxosAccepted(self, accepted): |
|
if not accepted: |
|
log.msg("Taker rejected utxos provided; resetting.") |
|
#TODO create re-set function to start again |
|
else: |
|
#only update state if client accepted |
|
self.jm_state = 4 |
|
|
|
def get_crypto_box_from_nick(self, nick): |
|
"""Retrieve the libsodium box object for the counterparty; |
|
stored differently for Taker and Maker |
|
""" |
|
if nick in self.crypto_boxes and self.crypto_boxes[nick] != None: |
|
return self.crypto_boxes[nick][1] |
|
elif nick in self.active_orders and self.active_orders[nick] != None \ |
|
and "crypto_box" in self.active_orders[nick]: |
|
return self.active_orders[nick]["crypto_box"] |
|
else: |
|
log.msg('something wrong, no crypto object, nick=' + nick + |
|
', message will be dropped') |
|
return None |
|
|
|
def mc_shutdown(self): |
|
log.msg("Message channels being shutdown by daemon") |
|
if self.mcc: |
|
self.mcc.shutdown() |
|
|
|
|
|
class JMDaemonServerProtocolFactory(ServerFactory): |
|
protocol = JMDaemonServerProtocol |
|
|
|
def buildProtocol(self, addr): |
|
return JMDaemonServerProtocol(self) |
|
|
|
class SNICKERDaemonServerProtocolFactory(ServerFactory): |
|
protocol = SNICKERDaemonServerProtocol |
|
|
|
class BIP78ServerProtocolFactory(ServerFactory): |
|
protocol = BIP78ServerProtocol |
|
|
|
def start_daemon(host, port, factory, usessl=False, sslkey=None, sslcert=None): |
|
if usessl: |
|
assert sslkey |
|
assert sslcert |
|
serverconn = reactor.listenSSL( |
|
port, factory, ssl.DefaultOpenSSLContextFactory(sslkey, sslcert), |
|
interface=host) |
|
else: |
|
serverconn = reactor.listenTCP(port, factory, interface=host) |
|
return serverconn
|
|
|