#!/usr/bin/env python3 """ A rudimentary implementation of a server, allowing POST of proposals in base64 format, with POW attached required, and GET of all current proposals, for SNICKER. Serves only over Tor onion service. For persistent onion services, specify public port, local port and hidden service directory: `python snicker-server.py 80 7080 /my/hiddenservicedir` ... and (a) make sure these settings match those in your Tor config, and also (b) note that the hidden service hostname may not be displayed if the running user, understandably, do not have permissions to read that directory. If you only want an ephemeral onion service, for testing, just run without arguments: `python snicker-server.py` """ from twisted.internet import reactor from twisted.internet.defer import Deferred from twisted.web.server import Site from twisted.web.resource import Resource from twisted.internet.endpoints import TCP4ClientEndpoint, UNIXClientEndpoint, serverFromString import txtorcon import sys import base64 import json import sqlite3 import threading from io import BytesIO from jmbase import jmprint, hextobin, verify_pow from jmclient import process_shutdown, jm_single, load_program_config, check_and_start_tor from jmclient.configure import get_log # Note: this is actually a duplication of the # string in jmbitcoin.secp256k1_ecies, but this is deliberate, # as we want this tool to have no dependency on jmbitcoin. ECIES_MAGIC_BYTES = b'BIE1' log = get_log() database_file_name = "proposals.db" database_table_name = "proposals" class SNICKERServer(Resource): # rudimentary: flat file, TODO location of file DATABASE = "snicker-proposals.txt" def __init__(self): self.dblock = threading.Lock() self.conn = sqlite3.connect(database_file_name, check_same_thread=False) # TODO: ? #con.row_factory = dict_factory self.cursor = self.conn.cursor() try: self.dblock.acquire(True) # note the pubkey is *NOT* a primary key, by # design; we need to be able to create multiple # proposals against one key. self.cursor.execute("CREATE TABLE IF NOT EXISTS {}(" "pubkey TEXT NOT NULL, proposal TEXT NOT NULL, " "unique (pubkey, proposal));".format(database_table_name)) finally: self.dblock.release() # initial PoW setting; todo, change this: self.set_pow_target_bits(8) self.nonce_length = 10 super().__init__() isLeaf = True def set_pow_target_bits(self, nbits): self.nbits = nbits def get_pow_target_bits(self): return self.nbits def return_error(self, request, error_meaning, error_code="unavailable", http_code=400): """ 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") log.debug("Returning an error: " + str( error_code) + ": " + str(error_meaning)) return json.dumps({"errorCode": error_code, "message": error_meaning}).encode("utf-8") def render_GET(self, request): """GET request to "/" retrieves the entire current data set. GET "/target" retrieves the current nbits target for PoW. It's intended that proposers request the target in real time before each submission, so that the server can dynamically update it at any time. """ log.debug("GET request, path: {}".format(request.path)) if request.path == b"/target": return self.serve_pow_target(request) if request.path != b"/": return self.return_error(request, "Invalid request path", "invalid-request-path") proposals = self.get_all_current_proposals() request.setHeader(b"content-length", ("%d" % len(proposals)).encode("ascii")) return proposals.encode("ascii") def serve_pow_target(self, request): targetbits = ("%d" % self.nbits).encode("ascii") request.setHeader(b"content-length", ("%d" % len(targetbits)).encode("ascii")) return targetbits def render_POST(self, request): """ An individual proposal may be submitted in base64, with key appended after newline separator in hex. """ log.debug("The server got this POST request: ") # unfortunately the twisted Request object is not # easily serialized: log.debug(request) log.debug(request.method) log.debug(request.uri) log.debug(request.args) sender_parameters = request.args log.debug(request.path) # defer logging of raw request content: proposals = request.content if not isinstance(proposals, BytesIO): return self.return_error(request, "Invalid request format", "invalid-request-format") proposals = proposals.read() # for now, only allowing proposals of form "base64ciphertext,hexkey", #newline separated: proposals = proposals.split(b"\n") log.debug("Client send proposal list of length: " + str( len(proposals))) accepted_proposals = [] for proposal in proposals: if len(proposal) == 0: continue try: encryptedtx, key, nonce = proposal.split(b",") bin_key = hextobin(key.decode('utf-8')) bin_nonce = hextobin(nonce.decode('utf-8')) base64.b64decode(encryptedtx) except: log.warn("This proposal was not accepted: " + proposal.decode( "utf-8")) # give up immediately in case of format error: return self.return_error(request, "Invalid request format", "invalid-request-format") if not verify_pow(proposal, nbits=self.nbits, truncate=32): return self.return_error(request, "Insufficient PoW", "insufficient proof of work") accepted_proposals.append((key, encryptedtx)) # the proposals are valid format-wise; add them to the database for p in accepted_proposals: # note we will ignore errors here and continue; # warning will be shown in logs from called fn. self.add_proposal(p) content = "{} proposals accepted".format(len(accepted_proposals)) request.setHeader(b"content-length", ("%d" % len(content)).encode( "ascii")) return content.encode("ascii") def add_proposal(self, p): proposal_to_add = tuple(x.decode("utf-8") for x in p) try: self.cursor.execute('INSERT INTO {} VALUES(?, ?);'.format( database_table_name),proposal_to_add) except sqlite3.Error as e: log.warn("Error inserting data into table: {}".format( " ".join(e.args))) return False self.conn.commit() return True def dbquery(self, querystr, params, return_results=False): try: self.dblock.acquire(True) if return_results: return self.cursor.execute( querystr, params).fetchall() self.cursor.execute(querystr, params) finally: self.dblock.release() def get_all_keys(self): rows = self.dbquery('SELECT DISTINCT pubkey FROM {};'.format( database_table_name), (), True) if not rows: return [] return list([x[0] for x in rows]) @classmethod def db_row_to_proposal_string(cls, row): assert len(row) == 2 key, proposal = row return proposal + "," + key def get_all_current_proposals(self): rows = self.dbquery('SELECT * from {};'.format( database_table_name), (), True) return "\n".join([self.db_row_to_proposal_string(x) for x in rows]) def get_proposals_for_key(self, key): rows = self.dbquery('SELECT proposal FROM {} WHERE pubkey=?'.format( database_table_name), (key,), True) if not rows: return [] return rows class SNICKERServerManager(object): def __init__(self, port, local_port=None, hsdir=None, control_port=9051, uri_created_callback=None, info_callback=None, shutdown_callback=None): # port is the *public* port, default 80 # if local_port is None, we follow the process # to create an ephemeral hidden service. # if local_port is a valid port, we start the # hidden service configured at directory hsdir. # In the latter case, note the patch described at # https://github.com/meejah/txtorcon/issues/347 is required. self.port = port self.local_port = local_port if self.local_port is not None: assert hsdir is not None self.hsdir = hsdir self.control_port = control_port if not uri_created_callback: self.uri_created_callback = self.default_info_callback else: self.uri_created_callback = uri_created_callback if not info_callback: self.info_callback = self.default_info_callback else: self.info_callback = info_callback self.shutdown_callback =shutdown_callback def default_info_callback(self, msg): jmprint(msg) def start_snicker_server_and_tor(self): """ Packages the startup of the receiver side. """ self.server = SNICKERServer() self.site = Site(self.server) self.site.displayTracebacks = False jmprint("Attempting to start onion service on port: " + str( self.port) + " ...") self.start_tor() def setup_failed(self, arg): errmsg = "Setup failed: " + str(arg) log.error(errmsg) self.info_callback(errmsg) process_shutdown() def create_onion_ep(self, t): if self.local_port: endpointString = "onion:{}:controlPort={}:localPort={}:hiddenServiceDir={}".format( self.port, self.control_port,self.local_port, self.hsdir) return serverFromString(reactor, endpointString) else: # ephemeral onion: self.tor_connection = t return t.create_onion_endpoint(self.port, version=3) def onion_listen(self, onion_ep): return onion_ep.listen(self.site) def print_host(self, ep): """ Callback fired once the HS is available; receiver user needs a BIP21 URI to pass to the sender: """ self.info_callback("Your hidden service is available: ") # Note that ep,getHost().onion_port must return the same # port as we chose in self.port; if not there is an error. assert ep.getHost().onion_port == self.port self.uri_created_callback(str(ep.getHost().onion_uri)) def start_tor(self): """ This function executes the workflow of starting the hidden service. """ if not self.local_port: control_host = jm_single().config.get("PAYJOIN", "tor_control_host") control_port = int(jm_single().config.get("PAYJOIN", "tor_control_port")) if str(control_host).startswith('unix:'): control_endpoint = UNIXClientEndpoint(reactor, control_host[5:]) else: control_endpoint = TCP4ClientEndpoint(reactor, control_host, control_port) d = txtorcon.connect(reactor, control_endpoint) d.addCallback(self.create_onion_ep) d.addErrback(self.setup_failed) else: d = Deferred() d.callback(None) d.addCallback(self.create_onion_ep) # TODO: add errbacks to the next two calls in # the chain: d.addCallback(self.onion_listen) d.addCallback(self.print_host) def shutdown(self): self.tor_connection.protocol.transport.loseConnection() process_shutdown(self.mode) self.info_callback("Hidden service shutdown complete") if self.shutdown_callback: self.shutdown_callback() def snicker_server_start(port, local_port=None, hsdir=None): ssm = SNICKERServerManager(port, local_port=local_port, hsdir=hsdir) ssm.start_snicker_server_and_tor() if __name__ == "__main__": load_program_config(bs="no-blockchain") check_and_start_tor() # in testing, we can optionally use ephemeral; # in testing or prod we can use persistent: if len(sys.argv) < 2: snicker_server_start(80) else: port = int(sys.argv[1]) local_port = int(sys.argv[2]) hsdir = sys.argv[3] snicker_server_start(port, local_port, hsdir) reactor.run()