# Copyright (c) 2012-2016 Jonathan Warren # Copyright (c) 2012-2023 The Bitmessage developers """ This is not what you run to start the Bitmessage API. Instead, `enable the API `_ and optionally `enable daemon mode `_ then run the PyBitmessage. The PyBitmessage API is provided either as `XML-RPC `_ or `JSON-RPC `_ like in bitcoin. It's selected according to 'apivariant' setting in config file. Special value ``apivariant=legacy`` is to mimic the old pre 0.6.3 behaviour when any results are returned as strings of json. .. list-table:: All config settings related to API: :header-rows: 0 * - apienabled = true - if 'false' the `singleAPI` wont start * - apiinterface = 127.0.0.1 - this is the recommended default * - apiport = 8442 - the API listens apiinterface:apiport if apiport is not used, random in range (32767, 65535) otherwice * - apivariant = xml - current default for backward compatibility, 'json' is recommended * - apiusername = username - set the username * - apipassword = password - and the password * - apinotifypath = - not really the API setting, this sets a path for the executable to be ran when certain internal event happens To use the API concider such simple example: .. code-block:: python from jsonrpclib import jsonrpc from pybitmessage import helper_startup from pybitmessage.bmconfigparser import config helper_startup.loadConfig() # find and load local config file api_uri = "http://%s:%s@127.0.0.1:%s/" % ( config.safeGet('bitmessagesettings', 'apiusername'), config.safeGet('bitmessagesettings', 'apipassword'), config.safeGet('bitmessagesettings', 'apiport') ) api = jsonrpc.ServerProxy(api_uri) print(api.clientStatus()) For further examples please reference `.tests.test_api`. """ import base64 import errno import hashlib import json import random import socket import subprocess # nosec B404 import time from binascii import hexlify, unhexlify from struct import pack, unpack import six from six.moves import configparser, http_client, xmlrpc_server import helper_inbox import helper_sent import protocol import proofofwork import queues import shared import shutdown import state from addresses import ( addBMIfNotPresent, decodeAddress, decodeVarint, varintDecodeError ) from bmconfigparser import config from debug import logger from defaults import ( networkDefaultProofOfWorkNonceTrialsPerByte, networkDefaultPayloadLengthExtraBytes) from helper_sql import ( SqlBulkExecute, sqlExecute, sqlQuery, sqlStoredProcedure, sql_ready) from highlevelcrypto import calculateInventoryHash try: from network import connectionpool except ImportError: connectionpool = None from network import stats, StoppableThread, invQueue from version import softwareVersion try: # TODO: write tests for XML vulnerabilities from defusedxml.xmlrpc import monkey_patch except ImportError: logger.warning( 'defusedxml not available, only use API on a secure, closed network.') else: monkey_patch() str_chan = '[chan]' str_broadcast_subscribers = '[Broadcast subscribers]' class ErrorCodes(type): """Metaclass for :class:`APIError` documenting error codes.""" _CODES = { 0: 'Invalid command parameters number', 1: 'The specified passphrase is blank.', 2: 'The address version number currently must be 3, 4, or 0' ' (which means auto-select).', 3: 'The stream number must be 1 (or 0 which means' ' auto-select). Others aren\'t supported.', 4: 'Why would you ask me to generate 0 addresses for you?', 5: 'You have (accidentally?) specified too many addresses to' ' make. Maximum 999. This check only exists to prevent' ' mischief; if you really want to create more addresses than' ' this, contact the Bitmessage developers and we can modify' ' the check or you can do it yourself by searching the source' ' code for this message.', 6: 'The encoding type must be 2 or 3.', 7: 'Could not decode address', 8: 'Checksum failed for address', 9: 'Invalid characters in address', 10: 'Address version number too high (or zero)', 11: 'The address version number currently must be 2, 3 or 4.' ' Others aren\'t supported. Check the address.', 12: 'The stream number must be 1. Others aren\'t supported.' ' Check the address.', 13: 'Could not find this address in your keys.dat file.', 14: 'Your fromAddress is disabled. Cannot send.', 15: 'Invalid ackData object size.', 16: 'You are already subscribed to that address.', 17: 'Label is not valid UTF-8 data.', 18: 'Chan name does not match address.', 19: 'The length of hash should be 32 bytes (encoded in hex' ' thus 64 characters).', 20: 'Invalid method:', 21: 'Unexpected API Failure', 22: 'Decode error', 23: 'Bool expected in eighteenByteRipe', 24: 'Chan address is already present.', 25: 'Specified address is not a chan address.' ' Use deleteAddress API call instead.', 26: 'Malformed varint in address: ', 27: 'Message is too long.' } def __new__(mcs, name, bases, namespace): result = super(ErrorCodes, mcs).__new__(mcs, name, bases, namespace) for code in six.iteritems(mcs._CODES): # beware: the formatting is adjusted for list-table result.__doc__ += """ * - %04i - %s """ % code return result class APIError(xmlrpc_server.Fault): """ APIError exception class .. list-table:: Possible error values :header-rows: 1 :widths: auto * - Error Number - Message """ __metaclass__ = ErrorCodes def __str__(self): return "API Error %04i: %s" % (self.faultCode, self.faultString) # This thread, of which there is only one, runs the API. class singleAPI(StoppableThread): """API thread""" name = "singleAPI" def stopThread(self): super(singleAPI, self).stopThread() s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.connect(( config.get('bitmessagesettings', 'apiinterface'), config.getint('bitmessagesettings', 'apiport') )) s.shutdown(socket.SHUT_RDWR) s.close() except BaseException: pass def run(self): """ The instance of `SimpleXMLRPCServer.SimpleXMLRPCServer` or :class:`jsonrpclib.SimpleJSONRPCServer` is created and started here with `BMRPCDispatcher` dispatcher. """ port = config.getint('bitmessagesettings', 'apiport') try: getattr(errno, 'WSAEADDRINUSE') except AttributeError: errno.WSAEADDRINUSE = errno.EADDRINUSE RPCServerBase = xmlrpc_server.SimpleXMLRPCServer ct = 'text/xml' if config.safeGet( 'bitmessagesettings', 'apivariant') == 'json': try: from jsonrpclib.SimpleJSONRPCServer import ( SimpleJSONRPCServer as RPCServerBase) except ImportError: logger.warning( 'jsonrpclib not available, failing back to XML-RPC') else: ct = 'application/json-rpc' # Nested class. FIXME not found a better solution. class StoppableRPCServer(RPCServerBase): """A SimpleXMLRPCServer that honours state.shutdown""" allow_reuse_address = True content_type = ct def serve_forever(self, poll_interval=None): """Start the RPCServer""" sql_ready.wait() while state.shutdown == 0: self.handle_request() for attempt in range(50): try: if attempt > 0: logger.warning( 'Failed to start API listener on port %s', port) port = random.randint(32767, 65535) # nosec B311 se = StoppableRPCServer( (config.get( 'bitmessagesettings', 'apiinterface'), port), BMXMLRPCRequestHandler, True, encoding='UTF-8') except socket.error as e: if e.errno in (errno.EADDRINUSE, errno.WSAEADDRINUSE): continue else: if attempt > 0: logger.warning('Setting apiport to %s', port) config.set( 'bitmessagesettings', 'apiport', str(port)) config.save() break se.register_instance(BMRPCDispatcher()) se.register_introspection_functions() apiNotifyPath = config.safeGet( 'bitmessagesettings', 'apinotifypath') if apiNotifyPath: logger.info('Trying to call %s', apiNotifyPath) try: subprocess.call([apiNotifyPath, "startingUp"]) # nosec B603 except OSError: logger.warning( 'Failed to call %s, removing apinotifypath setting', apiNotifyPath) config.remove_option( 'bitmessagesettings', 'apinotifypath') se.serve_forever() class CommandHandler(type): """ The metaclass for `BMRPCDispatcher` which fills _handlers dict by methods decorated with @command """ def __new__(mcs, name, bases, namespace): # pylint: disable=protected-access result = super(CommandHandler, mcs).__new__( mcs, name, bases, namespace) result.config = config result._handlers = {} apivariant = result.config.safeGet('bitmessagesettings', 'apivariant') for func in namespace.values(): try: for alias in getattr(func, '_cmd'): try: prefix, alias = alias.split(':') if apivariant != prefix: continue except ValueError: pass result._handlers[alias] = func except AttributeError: pass return result class testmode(object): # pylint: disable=too-few-public-methods """Decorator to check testmode & route to command decorator""" def __init__(self, *aliases): self.aliases = aliases def __call__(self, func): """Testmode call method""" if not state.testmode: return None return command(self.aliases[0]).__call__(func) class command(object): # pylint: disable=too-few-public-methods """Decorator for API command method""" def __init__(self, *aliases): self.aliases = aliases def __call__(self, func): if config.safeGet( 'bitmessagesettings', 'apivariant') == 'legacy': def wrapper(*args): """ A wrapper for legacy apivariant which dumps the result into string of json """ result = func(*args) return result if isinstance(result, (int, str)) \ else json.dumps(result, indent=4) wrapper.__doc__ = func.__doc__ else: wrapper = func # pylint: disable=protected-access wrapper._cmd = self.aliases wrapper.__doc__ = """Commands: *%s* """ % ', '.join(self.aliases) + wrapper.__doc__.lstrip() return wrapper # This is one of several classes that constitute the API # This class was written by Vaibhav Bhatia. # Modified by Jonathan Warren (Atheros). # Further modified by the Bitmessage developers # http://code.activestate.com/recipes/501148 class BMXMLRPCRequestHandler(xmlrpc_server.SimpleXMLRPCRequestHandler): """The main API handler""" # pylint: disable=protected-access def do_POST(self): """ Handles the HTTP POST request. Attempts to interpret all HTTP POST requests as XML-RPC calls, which are forwarded to the server's _dispatch method for handling. .. note:: this method is the same as in `SimpleXMLRPCServer.SimpleXMLRPCRequestHandler`, just hacked to handle cookies """ # Check that the path is legal if not self.is_rpc_path_valid(): self.report_404() return try: # Get arguments by reading body of request. # We read this in chunks to avoid straining # socket.read(); around the 10 or 15Mb mark, some platforms # begin to have problems (bug #792570). max_chunk_size = 10 * 1024 * 1024 size_remaining = int(self.headers["content-length"]) L = [] while size_remaining: chunk_size = min(size_remaining, max_chunk_size) chunk = self.rfile.read(chunk_size) if not chunk: break L.append(chunk) size_remaining -= len(L[-1]) data = b''.join(L) # data = self.decode_request_content(data) # pylint: disable=attribute-defined-outside-init self.cookies = [] validuser = self.APIAuthenticateClient() if not validuser: time.sleep(2) self.send_response(http_client.UNAUTHORIZED) self.end_headers() return # "RPC Username or password incorrect or HTTP header" # " lacks authentication at all." else: # In previous versions of SimpleXMLRPCServer, _dispatch # could be overridden in this class, instead of in # SimpleXMLRPCDispatcher. To maintain backwards compatibility, # check to see if a subclass implements _dispatch and dispatch # using that method if present. response = self.server._marshaled_dispatch( data, getattr(self, '_dispatch', None) ) except Exception: # This should only happen if the module is buggy # internal error, report as HTTP server error self.send_response(http_client.INTERNAL_SERVER_ERROR) self.end_headers() else: # got a valid XML RPC response self.send_response(http_client.OK) self.send_header("Content-type", self.server.content_type) self.send_header("Content-length", str(len(response))) # HACK :start -> sends cookies here if self.cookies: for cookie in self.cookies: self.send_header('Set-Cookie', cookie.output(header='')) # HACK :end self.end_headers() self.wfile.write(response) # shut down the connection self.wfile.flush() self.connection.shutdown(1) # actually handle shutdown command after sending response if state.shutdown is False: shutdown.doCleanShutdown() def APIAuthenticateClient(self): """ Predicate to check for valid API credentials in the request header """ if 'Authorization' in self.headers: # handle Basic authentication encstr = self.headers.get('Authorization').split()[1] emailid, password = base64.b64decode( encstr).decode('utf-8').split(':') return ( emailid == config.get( 'bitmessagesettings', 'apiusername' ) and password == config.get( 'bitmessagesettings', 'apipassword')) else: logger.warning( 'Authentication failed because header lacks' ' Authentication field') time.sleep(2) return False # pylint: disable=no-self-use,no-member,too-many-public-methods @six.add_metaclass(CommandHandler) class BMRPCDispatcher(object): """This class is used to dispatch API commands""" @staticmethod def _decode(text, decode_type): try: if decode_type == 'hex': return unhexlify(text) elif decode_type == 'base64': return base64.b64decode(text) except Exception as e: raise APIError( 22, 'Decode error - %s. Had trouble while decoding string: %r' % (e, text) ) def _verifyAddress(self, address): status, addressVersionNumber, streamNumber, ripe = \ decodeAddress(address) if status != 'success': if status == 'checksumfailed': raise APIError(8, 'Checksum failed for address: ' + address) if status == 'invalidcharacters': raise APIError(9, 'Invalid characters in address: ' + address) if status == 'versiontoohigh': raise APIError( 10, 'Address version number too high (or zero) in address: ' + address) if status == 'varintmalformed': raise APIError(26, 'Malformed varint in address: ' + address) raise APIError( 7, 'Could not decode address: %s : %s' % (address, status)) if addressVersionNumber < 2 or addressVersionNumber > 4: raise APIError( 11, 'The address version number currently must be 2, 3 or 4.' ' Others aren\'t supported. Check the address.' ) if streamNumber != 1: raise APIError( 12, 'The stream number must be 1. Others aren\'t supported.' ' Check the address.' ) return { 'status': status, 'addressVersion': addressVersionNumber, 'streamNumber': streamNumber, 'ripe': base64.b64encode(ripe) } if self._method == 'decodeAddress' else ( status, addressVersionNumber, streamNumber, ripe) @staticmethod def _dump_inbox_message( # pylint: disable=too-many-arguments msgid, toAddress, fromAddress, subject, received, message, encodingtype, read): subject = shared.fixPotentiallyInvalidUTF8Data(subject) message = shared.fixPotentiallyInvalidUTF8Data(message) return { 'msgid': hexlify(msgid), 'toAddress': toAddress, 'fromAddress': fromAddress, 'subject': base64.b64encode(subject), 'message': base64.b64encode(message), 'encodingType': encodingtype, 'receivedTime': received, 'read': read } @staticmethod def _dump_sent_message( # pylint: disable=too-many-arguments msgid, toAddress, fromAddress, subject, lastactiontime, message, encodingtype, status, ackdata): subject = shared.fixPotentiallyInvalidUTF8Data(subject) message = shared.fixPotentiallyInvalidUTF8Data(message) return { 'msgid': hexlify(msgid), 'toAddress': toAddress, 'fromAddress': fromAddress, 'subject': base64.b64encode(subject), 'message': base64.b64encode(message), 'encodingType': encodingtype, 'lastActionTime': lastactiontime, 'status': status, 'ackData': hexlify(ackdata) } # Request Handlers @command('decodeAddress') def HandleDecodeAddress(self, address): """ Decode given address and return dict with status, addressVersion, streamNumber and ripe keys """ return self._verifyAddress(address) @command('listAddresses', 'listAddresses2') def HandleListAddresses(self): """ Returns dict with a list of all used addresses with their properties in the *addresses* key. """ data = [] for address in self.config.addresses(): streamNumber = decodeAddress(address)[2] label = self.config.get(address, 'label') if self._method == 'listAddresses2': label = base64.b64encode(label) data.append({ 'label': label, 'address': address, 'stream': streamNumber, 'enabled': self.config.safeGetBoolean(address, 'enabled'), 'chan': self.config.safeGetBoolean(address, 'chan') }) return {'addresses': data} # the listAddressbook alias should be removed eventually. @command('listAddressBookEntries', 'legacy:listAddressbook') def HandleListAddressBookEntries(self, label=None): """ Returns dict with a list of all address book entries (address and label) in the *addresses* key. """ queryreturn = sqlQuery( "SELECT label, address from addressbook WHERE label = ?", label ) if label else sqlQuery("SELECT label, address from addressbook") data = [] for label, address in queryreturn: label = shared.fixPotentiallyInvalidUTF8Data(label) data.append({ 'label': base64.b64encode(label), 'address': address }) return {'addresses': data} # the addAddressbook alias should be deleted eventually. @command('addAddressBookEntry', 'legacy:addAddressbook') def HandleAddAddressBookEntry(self, address, label): """Add an entry to address book. label must be base64 encoded.""" label = self._decode(label, "base64") address = addBMIfNotPresent(address) self._verifyAddress(address) # TODO: add unique together constraint in the table queryreturn = sqlQuery( "SELECT address FROM addressbook WHERE address=?", address) if queryreturn != []: raise APIError( 16, 'You already have this address in your address book.') sqlExecute("INSERT INTO addressbook VALUES(?,?)", label, address) queues.UISignalQueue.put(('rerenderMessagelistFromLabels', '')) queues.UISignalQueue.put(('rerenderMessagelistToLabels', '')) queues.UISignalQueue.put(('rerenderAddressBook', '')) return "Added address %s to address book" % address # the deleteAddressbook alias should be deleted eventually. @command('deleteAddressBookEntry', 'legacy:deleteAddressbook') def HandleDeleteAddressBookEntry(self, address): """Delete an entry from address book.""" address = addBMIfNotPresent(address) self._verifyAddress(address) sqlExecute('DELETE FROM addressbook WHERE address=?', address) queues.UISignalQueue.put(('rerenderMessagelistFromLabels', '')) queues.UISignalQueue.put(('rerenderMessagelistToLabels', '')) queues.UISignalQueue.put(('rerenderAddressBook', '')) return "Deleted address book entry for %s if it existed" % address @command('createRandomAddress') def HandleCreateRandomAddress( self, label, eighteenByteRipe=False, totalDifficulty=0, smallMessageDifficulty=0 ): """ Create one address using the random number generator. :param str label: base64 encoded label for the address :param bool eighteenByteRipe: is telling Bitmessage whether to generate an address with an 18 byte RIPE hash (as opposed to a 19 byte hash). """ nonceTrialsPerByte = self.config.get( 'bitmessagesettings', 'defaultnoncetrialsperbyte' ) if not totalDifficulty else int( networkDefaultProofOfWorkNonceTrialsPerByte * totalDifficulty) payloadLengthExtraBytes = self.config.get( 'bitmessagesettings', 'defaultpayloadlengthextrabytes' ) if not smallMessageDifficulty else int( networkDefaultPayloadLengthExtraBytes * smallMessageDifficulty) if not isinstance(eighteenByteRipe, bool): raise APIError( 23, 'Bool expected in eighteenByteRipe, saw %s instead' % type(eighteenByteRipe)) label = self._decode(label, "base64") try: label.decode('utf-8') except UnicodeDecodeError: raise APIError(17, 'Label is not valid UTF-8 data.') queues.apiAddressGeneratorReturnQueue.queue.clear() # FIXME hard coded stream no streamNumberForAddress = 1 queues.addressGeneratorQueue.put(( 'createRandomAddress', 4, streamNumberForAddress, label, 1, "", eighteenByteRipe, nonceTrialsPerByte, payloadLengthExtraBytes )) return queues.apiAddressGeneratorReturnQueue.get() # pylint: disable=too-many-arguments @command('createDeterministicAddresses') def HandleCreateDeterministicAddresses( self, passphrase, numberOfAddresses=1, addressVersionNumber=0, streamNumber=0, eighteenByteRipe=False, totalDifficulty=0, smallMessageDifficulty=0 ): """ Create many addresses deterministically using the passphrase. :param str passphrase: base64 encoded passphrase :param int numberOfAddresses: number of addresses to create, up to 999 *addressVersionNumber* and *streamNumber* may be set to 0 which will tell Bitmessage to use the most up-to-date address version and the most available stream. """ nonceTrialsPerByte = self.config.get( 'bitmessagesettings', 'defaultnoncetrialsperbyte' ) if not totalDifficulty else int( networkDefaultProofOfWorkNonceTrialsPerByte * totalDifficulty) payloadLengthExtraBytes = self.config.get( 'bitmessagesettings', 'defaultpayloadlengthextrabytes' ) if not smallMessageDifficulty else int( networkDefaultPayloadLengthExtraBytes * smallMessageDifficulty) if not passphrase: raise APIError(1, 'The specified passphrase is blank.') if not isinstance(eighteenByteRipe, bool): raise APIError( 23, 'Bool expected in eighteenByteRipe, saw %s instead' % type(eighteenByteRipe)) passphrase = self._decode(passphrase, "base64") # 0 means "just use the proper addressVersionNumber" if addressVersionNumber == 0: addressVersionNumber = 4 if addressVersionNumber not in (3, 4): raise APIError( 2, 'The address version number currently must be 3, 4, or 0' ' (which means auto-select). %i isn\'t supported.' % addressVersionNumber) if streamNumber == 0: # 0 means "just use the most available stream" streamNumber = 1 # FIXME hard coded stream no if streamNumber != 1: raise APIError( 3, 'The stream number must be 1 (or 0 which means' ' auto-select). Others aren\'t supported.') if numberOfAddresses == 0: raise APIError( 4, 'Why would you ask me to generate 0 addresses for you?') if numberOfAddresses > 999: raise APIError( 5, 'You have (accidentally?) specified too many addresses to' ' make. Maximum 999. This check only exists to prevent' ' mischief; if you really want to create more addresses than' ' this, contact the Bitmessage developers and we can modify' ' the check or you can do it yourself by searching the source' ' code for this message.') queues.apiAddressGeneratorReturnQueue.queue.clear() logger.debug( 'Requesting that the addressGenerator create %s addresses.', numberOfAddresses) queues.addressGeneratorQueue.put(( 'createDeterministicAddresses', addressVersionNumber, streamNumber, 'unused API address', numberOfAddresses, passphrase, eighteenByteRipe, nonceTrialsPerByte, payloadLengthExtraBytes )) return {'addresses': queues.apiAddressGeneratorReturnQueue.get()} @command('getDeterministicAddress') def HandleGetDeterministicAddress( self, passphrase, addressVersionNumber, streamNumber): """ Similar to *createDeterministicAddresses* except that the one address that is returned will not be added to the Bitmessage user interface or the keys.dat file. """ numberOfAddresses = 1 eighteenByteRipe = False if not passphrase: raise APIError(1, 'The specified passphrase is blank.') passphrase = self._decode(passphrase, "base64") if addressVersionNumber not in (3, 4): raise APIError( 2, 'The address version number currently must be 3 or 4. %i' ' isn\'t supported.' % addressVersionNumber) if streamNumber != 1: raise APIError( 3, ' The stream number must be 1. Others aren\'t supported.') queues.apiAddressGeneratorReturnQueue.queue.clear() logger.debug( 'Requesting that the addressGenerator create %s addresses.', numberOfAddresses) queues.addressGeneratorQueue.put(( 'getDeterministicAddress', addressVersionNumber, streamNumber, 'unused API address', numberOfAddresses, passphrase, eighteenByteRipe )) return queues.apiAddressGeneratorReturnQueue.get() @command('createChan') def HandleCreateChan(self, passphrase): """ Creates a new chan. passphrase must be base64 encoded. Returns the corresponding Bitmessage address. """ passphrase = self._decode(passphrase, "base64") if not passphrase: raise APIError(1, 'The specified passphrase is blank.') # It would be nice to make the label the passphrase but it is # possible that the passphrase contains non-utf-8 characters. try: passphrase.decode('utf-8') label = str_chan + ' ' + passphrase except UnicodeDecodeError: label = str_chan + ' ' + repr(passphrase) addressVersionNumber = 4 streamNumber = 1 queues.apiAddressGeneratorReturnQueue.queue.clear() logger.debug( 'Requesting that the addressGenerator create chan %s.', passphrase) queues.addressGeneratorQueue.put(( 'createChan', addressVersionNumber, streamNumber, label, passphrase, True )) queueReturn = queues.apiAddressGeneratorReturnQueue.get() try: return queueReturn[0] except IndexError: raise APIError(24, 'Chan address is already present.') @command('joinChan') def HandleJoinChan(self, passphrase, suppliedAddress): """ Join a chan. passphrase must be base64 encoded. Returns 'success'. """ passphrase = self._decode(passphrase, "base64") if not passphrase: raise APIError(1, 'The specified passphrase is blank.') # It would be nice to make the label the passphrase but it is # possible that the passphrase contains non-utf-8 characters. try: passphrase.decode('utf-8') label = str_chan + ' ' + passphrase except UnicodeDecodeError: label = str_chan + ' ' + repr(passphrase) self._verifyAddress(suppliedAddress) suppliedAddress = addBMIfNotPresent(suppliedAddress) queues.apiAddressGeneratorReturnQueue.queue.clear() queues.addressGeneratorQueue.put(( 'joinChan', suppliedAddress, label, passphrase, True )) queueReturn = queues.apiAddressGeneratorReturnQueue.get() try: if queueReturn[0] == 'chan name does not match address': raise APIError(18, 'Chan name does not match address.') except IndexError: raise APIError(24, 'Chan address is already present.') return "success" @command('leaveChan') def HandleLeaveChan(self, address): """ Leave a chan. Returns 'success'. .. note:: at this time, the address is still shown in the UI until a restart. """ self._verifyAddress(address) address = addBMIfNotPresent(address) if not self.config.safeGetBoolean(address, 'chan'): raise APIError( 25, 'Specified address is not a chan address.' ' Use deleteAddress API call instead.') try: self.config.remove_section(address) except configparser.NoSectionError: raise APIError( 13, 'Could not find this address in your keys.dat file.') self.config.save() queues.UISignalQueue.put(('rerenderMessagelistFromLabels', '')) queues.UISignalQueue.put(('rerenderMessagelistToLabels', '')) return "success" @command('deleteAddress') def HandleDeleteAddress(self, address): """ Permanently delete the address from keys.dat file. Returns 'success'. """ self._verifyAddress(address) address = addBMIfNotPresent(address) try: self.config.remove_section(address) except configparser.NoSectionError: raise APIError( 13, 'Could not find this address in your keys.dat file.') self.config.save() queues.UISignalQueue.put(('writeNewAddressToTable', ('', '', ''))) shared.reloadMyAddressHashes() return "success" @command('enableAddress') def HandleEnableAddress(self, address, enable=True): """Enable or disable the address depending on the *enable* value""" self._verifyAddress(address) address = addBMIfNotPresent(address) config.set(address, 'enabled', str(enable)) self.config.save() shared.reloadMyAddressHashes() return "success" @command('getAllInboxMessages') def HandleGetAllInboxMessages(self): """ Returns a dict with all inbox messages in the *inboxMessages* key. The message is a dict with such keys: *msgid*, *toAddress*, *fromAddress*, *subject*, *message*, *encodingType*, *receivedTime*, *read*. *msgid* is hex encoded string. *subject* and *message* are base64 encoded. """ queryreturn = sqlQuery( "SELECT msgid, toaddress, fromaddress, subject, received, message," " encodingtype, read FROM inbox WHERE folder='inbox'" " ORDER BY received" ) return {"inboxMessages": [ self._dump_inbox_message(*data) for data in queryreturn ]} @command('getAllInboxMessageIds', 'getAllInboxMessageIDs') def HandleGetAllInboxMessageIds(self): """ The same as *getAllInboxMessages* but returns only *msgid*s, result key - *inboxMessageIds*. """ queryreturn = sqlQuery( "SELECT msgid FROM inbox where folder='inbox' ORDER BY received") return {"inboxMessageIds": [ {'msgid': hexlify(msgid)} for msgid, in queryreturn ]} @command('getInboxMessageById', 'getInboxMessageByID') def HandleGetInboxMessageById(self, hid, readStatus=None): """ Returns a dict with list containing single message in the result key *inboxMessage*. May also return None if message was not found. :param str hid: hex encoded msgid :param bool readStatus: sets the message's read status if present """ msgid = self._decode(hid, "hex") if readStatus is not None: if not isinstance(readStatus, bool): raise APIError( 23, 'Bool expected in readStatus, saw %s instead.' % type(readStatus)) queryreturn = sqlQuery( "SELECT read FROM inbox WHERE msgid=?", msgid) # UPDATE is slow, only update if status is different try: if (queryreturn[0][0] == 1) != readStatus: sqlExecute( "UPDATE inbox set read = ? WHERE msgid=?", readStatus, msgid) queues.UISignalQueue.put(('changedInboxUnread', None)) except IndexError: pass queryreturn = sqlQuery( "SELECT msgid, toaddress, fromaddress, subject, received, message," " encodingtype, read FROM inbox WHERE msgid=?", msgid ) try: return {"inboxMessage": [ self._dump_inbox_message(*queryreturn[0])]} except IndexError: pass # FIXME inconsistent @command('getAllSentMessages') def HandleGetAllSentMessages(self): """ The same as *getAllInboxMessages* but for sent, result key - *sentMessages*. Message dict keys are: *msgid*, *toAddress*, *fromAddress*, *subject*, *message*, *encodingType*, *lastActionTime*, *status*, *ackData*. *ackData* is also a hex encoded string. """ queryreturn = sqlQuery( "SELECT msgid, toaddress, fromaddress, subject, lastactiontime," " message, encodingtype, status, ackdata FROM sent" " WHERE folder='sent' ORDER BY lastactiontime" ) return {"sentMessages": [ self._dump_sent_message(*data) for data in queryreturn ]} @command('getAllSentMessageIds', 'getAllSentMessageIDs') def HandleGetAllSentMessageIds(self): """ The same as *getAllInboxMessageIds* but for sent, result key - *sentMessageIds*. """ queryreturn = sqlQuery( "SELECT msgid FROM sent WHERE folder='sent'" " ORDER BY lastactiontime" ) return {"sentMessageIds": [ {'msgid': hexlify(msgid)} for msgid, in queryreturn ]} # after some time getInboxMessagesByAddress should be removed @command('getInboxMessagesByReceiver', 'legacy:getInboxMessagesByAddress') def HandleInboxMessagesByReceiver(self, toAddress): """ The same as *getAllInboxMessages* but returns only messages for toAddress. """ queryreturn = sqlQuery( "SELECT msgid, toaddress, fromaddress, subject, received," " message, encodingtype, read FROM inbox WHERE folder='inbox'" " AND toAddress=?", toAddress) return {"inboxMessages": [ self._dump_inbox_message(*data) for data in queryreturn ]} @command('getSentMessageById', 'getSentMessageByID') def HandleGetSentMessageById(self, hid): """ Similiar to *getInboxMessageById* but doesn't change message's read status (sent messages have no such field). Result key is *sentMessage* """ msgid = self._decode(hid, "hex") queryreturn = sqlQuery( "SELECT msgid, toaddress, fromaddress, subject, lastactiontime," " message, encodingtype, status, ackdata FROM sent WHERE msgid=?", msgid ) try: return {"sentMessage": [ self._dump_sent_message(*queryreturn[0]) ]} except IndexError: pass # FIXME inconsistent @command('getSentMessagesByAddress', 'getSentMessagesBySender') def HandleGetSentMessagesByAddress(self, fromAddress): """ The same as *getAllSentMessages* but returns only messages from fromAddress. """ queryreturn = sqlQuery( "SELECT msgid, toaddress, fromaddress, subject, lastactiontime," " message, encodingtype, status, ackdata FROM sent" " WHERE folder='sent' AND fromAddress=? ORDER BY lastactiontime", fromAddress ) return {"sentMessages": [ self._dump_sent_message(*data) for data in queryreturn ]} @command('getSentMessageByAckData') def HandleGetSentMessagesByAckData(self, ackData): """ Similiar to *getSentMessageById* but searches by ackdata (also hex encoded). """ ackData = self._decode(ackData, "hex") queryreturn = sqlQuery( "SELECT msgid, toaddress, fromaddress, subject, lastactiontime," " message, encodingtype, status, ackdata FROM sent" " WHERE ackdata=?", ackData ) try: return {"sentMessage": [ self._dump_sent_message(*queryreturn[0]) ]} except IndexError: pass # FIXME inconsistent @command('trashMessage') def HandleTrashMessage(self, msgid): """ Trash message by msgid (encoded in hex). Returns a simple message saying that the message was trashed assuming it ever even existed. Prior existence is not checked. """ msgid = self._decode(msgid, "hex") # Trash if in inbox table helper_inbox.trash(msgid) # Trash if in sent table sqlExecute("UPDATE sent SET folder='trash' WHERE msgid=?", msgid) return 'Trashed message (assuming message existed).' @command('trashInboxMessage') def HandleTrashInboxMessage(self, msgid): """Trash inbox message by msgid (encoded in hex).""" msgid = self._decode(msgid, "hex") helper_inbox.trash(msgid) return 'Trashed inbox message (assuming message existed).' @command('trashSentMessage') def HandleTrashSentMessage(self, msgid): """Trash sent message by msgid (encoded in hex).""" msgid = self._decode(msgid, "hex") sqlExecute('''UPDATE sent SET folder='trash' WHERE msgid=?''', msgid) return 'Trashed sent message (assuming message existed).' @command('sendMessage') def HandleSendMessage( self, toAddress, fromAddress, subject, message, encodingType=2, TTL=4 * 24 * 60 * 60 ): """ Send the message and return ackdata (hex encoded string). subject and message must be encoded in base64 which may optionally include line breaks. TTL is specified in seconds; values outside the bounds of 3600 to 2419200 will be moved to be within those bounds. TTL defaults to 4 days. """ # pylint: disable=too-many-locals if encodingType not in (2, 3): raise APIError(6, 'The encoding type must be 2 or 3.') subject = self._decode(subject, "base64") message = self._decode(message, "base64") if len(subject + message) > (2 ** 18 - 500): raise APIError(27, 'Message is too long.') if TTL < 60 * 60: TTL = 60 * 60 if TTL > 28 * 24 * 60 * 60: TTL = 28 * 24 * 60 * 60 toAddress = addBMIfNotPresent(toAddress) fromAddress = addBMIfNotPresent(fromAddress) self._verifyAddress(fromAddress) try: fromAddressEnabled = self.config.getboolean(fromAddress, 'enabled') except configparser.NoSectionError: raise APIError( 13, 'Could not find your fromAddress in the keys.dat file.') if not fromAddressEnabled: raise APIError(14, 'Your fromAddress is disabled. Cannot send.') ackdata = helper_sent.insert( toAddress=toAddress, fromAddress=fromAddress, subject=subject, message=message, encoding=encodingType, ttl=TTL) toLabel = '' queryreturn = sqlQuery( "SELECT label FROM addressbook WHERE address=?", toAddress) try: toLabel = queryreturn[0][0] except IndexError: pass queues.UISignalQueue.put(('displayNewSentMessage', ( toAddress, toLabel, fromAddress, subject, message, ackdata))) queues.workerQueue.put(('sendmessage', toAddress)) return hexlify(ackdata) @command('sendBroadcast') def HandleSendBroadcast( self, fromAddress, subject, message, encodingType=2, TTL=4 * 24 * 60 * 60): """Send the broadcast message. Similiar to *sendMessage*.""" if encodingType not in (2, 3): raise APIError(6, 'The encoding type must be 2 or 3.') subject = self._decode(subject, "base64") message = self._decode(message, "base64") if len(subject + message) > (2 ** 18 - 500): raise APIError(27, 'Message is too long.') if TTL < 60 * 60: TTL = 60 * 60 if TTL > 28 * 24 * 60 * 60: TTL = 28 * 24 * 60 * 60 fromAddress = addBMIfNotPresent(fromAddress) self._verifyAddress(fromAddress) try: fromAddressEnabled = self.config.getboolean(fromAddress, 'enabled') except configparser.NoSectionError: raise APIError( 13, 'Could not find your fromAddress in the keys.dat file.') if not fromAddressEnabled: raise APIError(14, 'Your fromAddress is disabled. Cannot send.') toAddress = str_broadcast_subscribers ackdata = helper_sent.insert( fromAddress=fromAddress, subject=subject, message=message, status='broadcastqueued', encoding=encodingType) toLabel = str_broadcast_subscribers queues.UISignalQueue.put(('displayNewSentMessage', ( toAddress, toLabel, fromAddress, subject, message, ackdata))) queues.workerQueue.put(('sendbroadcast', '')) return hexlify(ackdata) @command('getStatus') def HandleGetStatus(self, ackdata): """ Get the status of sent message by its ackdata (hex encoded). Returns one of these strings: notfound, msgqueued, broadcastqueued, broadcastsent, doingpubkeypow, awaitingpubkey, doingmsgpow, forcepow, msgsent, msgsentnoackexpected or ackreceived. """ if len(ackdata) < 76: # The length of ackData should be at least 38 bytes (76 hex digits) raise APIError(15, 'Invalid ackData object size.') ackdata = self._decode(ackdata, "hex") queryreturn = sqlQuery( "SELECT status FROM sent where ackdata=?", ackdata) try: return queryreturn[0][0] except IndexError: return 'notfound' @command('addSubscription') def HandleAddSubscription(self, address, label=''): """Subscribe to the address. label must be base64 encoded.""" if label: label = self._decode(label, "base64") try: label.decode('utf-8') except UnicodeDecodeError: raise APIError(17, 'Label is not valid UTF-8 data.') self._verifyAddress(address) address = addBMIfNotPresent(address) # First we must check to see if the address is already in the # subscriptions list. queryreturn = sqlQuery( "SELECT * FROM subscriptions WHERE address=?", address) if queryreturn: raise APIError(16, 'You are already subscribed to that address.') sqlExecute( "INSERT INTO subscriptions VALUES (?,?,?)", label, address, True) shared.reloadBroadcastSendersForWhichImWatching() queues.UISignalQueue.put(('rerenderMessagelistFromLabels', '')) queues.UISignalQueue.put(('rerenderSubscriptions', '')) return 'Added subscription.' @command('deleteSubscription') def HandleDeleteSubscription(self, address): """ Unsubscribe from the address. The program does not check whether you were subscribed in the first place. """ address = addBMIfNotPresent(address) sqlExecute("DELETE FROM subscriptions WHERE address=?", address) shared.reloadBroadcastSendersForWhichImWatching() queues.UISignalQueue.put(('rerenderMessagelistFromLabels', '')) queues.UISignalQueue.put(('rerenderSubscriptions', '')) return 'Deleted subscription if it existed.' @command('listSubscriptions') def ListSubscriptions(self): """ Returns dict with a list of all subscriptions in the *subscriptions* key. """ queryreturn = sqlQuery( "SELECT label, address, enabled FROM subscriptions") data = [] for label, address, enabled in queryreturn: label = shared.fixPotentiallyInvalidUTF8Data(label) data.append({ 'label': base64.b64encode(label), 'address': address, 'enabled': enabled == 1 }) return {'subscriptions': data} @command('disseminatePreEncryptedMsg', 'disseminatePreparedObject') def HandleDisseminatePreparedObject( self, encryptedPayload, nonceTrialsPerByte=networkDefaultProofOfWorkNonceTrialsPerByte, payloadLengthExtraBytes=networkDefaultPayloadLengthExtraBytes ): """ Handle a request to disseminate an encrypted message. The device issuing this command to PyBitmessage supplies an object that has already been encrypted but which may still need the PoW to be done. PyBitmessage accepts this object and sends it out to the rest of the Bitmessage network as if it had generated the message itself. *encryptedPayload* is a hex encoded string starting with the nonce, 8 zero bytes in case of no PoW done. """ encryptedPayload = self._decode(encryptedPayload, "hex") nonce, = unpack('>Q', encryptedPayload[:8]) objectType, toStreamNumber, expiresTime = \ protocol.decodeObjectParameters(encryptedPayload) if nonce == 0: # Let us do the POW and attach it to the front encryptedPayload = encryptedPayload[8:] TTL = expiresTime - time.time() + 300 # a bit of extra padding # Let us do the POW and attach it to the front logger.debug("expiresTime: %s", expiresTime) logger.debug("TTL: %s", TTL) logger.debug("objectType: %s", objectType) logger.info( '(For msg message via API) Doing proof of work. Total required' ' difficulty: %s\nRequired small message difficulty: %s', float(nonceTrialsPerByte) / networkDefaultProofOfWorkNonceTrialsPerByte, float(payloadLengthExtraBytes) / networkDefaultPayloadLengthExtraBytes, ) powStartTime = time.time() target = 2**64 / ( nonceTrialsPerByte * ( len(encryptedPayload) + 8 + payloadLengthExtraBytes + (( TTL * ( len(encryptedPayload) + 8 + payloadLengthExtraBytes )) / (2 ** 16)) )) initialHash = hashlib.sha512(encryptedPayload).digest() trialValue, nonce = proofofwork.run(target, initialHash) logger.info( '(For msg message via API) Found proof of work %s\nNonce: %s\n' 'POW took %s seconds. %s nonce trials per second.', trialValue, nonce, int(time.time() - powStartTime), nonce / (time.time() - powStartTime) ) encryptedPayload = pack('>Q', nonce) + encryptedPayload inventoryHash = calculateInventoryHash(encryptedPayload) state.Inventory[inventoryHash] = ( objectType, toStreamNumber, encryptedPayload, expiresTime, b'' ) logger.info( 'Broadcasting inv for msg(API disseminatePreEncryptedMsg' ' command): %s', hexlify(inventoryHash)) invQueue.put((toStreamNumber, inventoryHash)) return hexlify(inventoryHash).decode() @command('trashSentMessageByAckData') def HandleTrashSentMessageByAckDAta(self, ackdata): """Trash a sent message by ackdata (hex encoded)""" # This API method should only be used when msgid is not available ackdata = self._decode(ackdata, "hex") sqlExecute("UPDATE sent SET folder='trash' WHERE ackdata=?", ackdata) return 'Trashed sent message (assuming message existed).' @command('disseminatePubkey') def HandleDissimatePubKey(self, payload): """Handle a request to disseminate a public key""" # The device issuing this command to PyBitmessage supplies a pubkey # object to be disseminated to the rest of the Bitmessage network. # PyBitmessage accepts this pubkey object and sends it out to the rest # of the Bitmessage network as if it had generated the pubkey object # itself. Please do not yet add this to the api doc. payload = self._decode(payload, "hex") # Let us do the POW target = 2 ** 64 / (( len(payload) + networkDefaultPayloadLengthExtraBytes + 8 ) * networkDefaultProofOfWorkNonceTrialsPerByte) logger.info('(For pubkey message via API) Doing proof of work...') initialHash = hashlib.sha512(payload).digest() trialValue, nonce = proofofwork.run(target, initialHash) logger.info( '(For pubkey message via API) Found proof of work %s Nonce: %s', trialValue, nonce ) payload = pack('>Q', nonce) + payload pubkeyReadPosition = 8 # bypass the nonce if payload[pubkeyReadPosition:pubkeyReadPosition + 4] == \ '\x00\x00\x00\x00': # if this pubkey uses 8 byte time pubkeyReadPosition += 8 else: pubkeyReadPosition += 4 addressVersionLength = decodeVarint( payload[pubkeyReadPosition:pubkeyReadPosition + 10])[1] pubkeyReadPosition += addressVersionLength pubkeyStreamNumber = decodeVarint( payload[pubkeyReadPosition:pubkeyReadPosition + 10])[0] inventoryHash = calculateInventoryHash(payload) objectType = 1 # .. todo::: support v4 pubkeys TTL = 28 * 24 * 60 * 60 state.Inventory[inventoryHash] = ( objectType, pubkeyStreamNumber, payload, int(time.time()) + TTL, '' ) logger.info( 'broadcasting inv within API command disseminatePubkey with' ' hash: %s', hexlify(inventoryHash)) invQueue.put((pubkeyStreamNumber, inventoryHash)) @command( 'getMessageDataByDestinationHash', 'getMessageDataByDestinationTag') def HandleGetMessageDataByDestinationHash(self, requestedHash): """Handle a request to get message data by destination hash""" # Method will eventually be used by a particular Android app to # select relevant messages. Do not yet add this to the api # doc. if len(requestedHash) != 32: raise APIError( 19, 'The length of hash should be 32 bytes (encoded in hex' ' thus 64 characters).') requestedHash = self._decode(requestedHash, "hex") # This is not a particularly commonly used API function. Before we # use it we'll need to fill out a field in our inventory database # which is blank by default (first20bytesofencryptedmessage). queryreturn = sqlQuery( "SELECT hash, payload FROM inventory WHERE tag = ''" " and objecttype = 2") with SqlBulkExecute() as sql: for hash01, payload in queryreturn: readPosition = 16 # Nonce length + time length # Stream Number length readPosition += decodeVarint( payload[readPosition:readPosition + 10])[1] t = (payload[readPosition:readPosition + 32], hash01) sql.execute("UPDATE inventory SET tag=? WHERE hash=?", *t) queryreturn = sqlQuery( "SELECT payload FROM inventory WHERE tag = ?", requestedHash) return {"receivedMessageDatas": [ {'data': hexlify(payload)} for payload, in queryreturn ]} @command('clientStatus') def HandleClientStatus(self): """ Returns the bitmessage status as dict with keys *networkConnections*, *numberOfMessagesProcessed*, *numberOfBroadcastsProcessed*, *numberOfPubkeysProcessed*, *pendingDownload*, *networkStatus*, *softwareName*, *softwareVersion*. *networkStatus* will be one of these strings: "notConnected", "connectedButHaveNotReceivedIncomingConnections", or "connectedAndReceivingIncomingConnections". """ connections_num = len(stats.connectedHostsList()) if connections_num == 0: networkStatus = 'notConnected' elif state.clientHasReceivedIncomingConnections: networkStatus = 'connectedAndReceivingIncomingConnections' else: networkStatus = 'connectedButHaveNotReceivedIncomingConnections' return { 'networkConnections': connections_num, 'numberOfMessagesProcessed': state.numberOfMessagesProcessed, 'numberOfBroadcastsProcessed': state.numberOfBroadcastsProcessed, 'numberOfPubkeysProcessed': state.numberOfPubkeysProcessed, 'pendingDownload': stats.pendingDownload(), 'networkStatus': networkStatus, 'softwareName': 'PyBitmessage', 'softwareVersion': softwareVersion } @command('listConnections') def HandleListConnections(self): """ Returns bitmessage connection information as dict with keys *inbound*, *outbound*. """ if connectionpool is None: raise APIError(21, 'Could not import BMConnectionPool.') inboundConnections = [] outboundConnections = [] for i in connectionpool.pool.inboundConnections.values(): inboundConnections.append({ 'host': i.destination.host, 'port': i.destination.port, 'fullyEstablished': i.fullyEstablished, 'userAgent': str(i.userAgent) }) for i in connectionpool.pool.outboundConnections.values(): outboundConnections.append({ 'host': i.destination.host, 'port': i.destination.port, 'fullyEstablished': i.fullyEstablished, 'userAgent': str(i.userAgent) }) return { 'inbound': inboundConnections, 'outbound': outboundConnections } @command('helloWorld') def HandleHelloWorld(self, a, b): """Test two string params""" return a + '-' + b @command('add') def HandleAdd(self, a, b): """Test two numeric params""" return a + b @command('statusBar') def HandleStatusBar(self, message): """Update GUI statusbar message""" queues.UISignalQueue.put(('updateStatusBar', message)) return "success" @testmode('undeleteMessage') def HandleUndeleteMessage(self, msgid): """Undelete message""" msgid = self._decode(msgid, "hex") helper_inbox.undeleteMessage(msgid) return "Undeleted message" @command('deleteAndVacuum') def HandleDeleteAndVacuum(self): """Cleanup trashes and vacuum messages database""" sqlStoredProcedure('deleteandvacuume') return 'done' @command('shutdown') def HandleShutdown(self): """Shutdown the bitmessage. Returns 'done'.""" # backward compatible trick because False == 0 is True state.shutdown = False return 'done' def _handle_request(self, method, params): try: # pylint: disable=attribute-defined-outside-init self._method = method func = self._handlers[method] return func(self, *params) except KeyError: raise APIError(20, 'Invalid method: %s' % method) except TypeError as e: msg = 'Unexpected API Failure - %s' % e if 'argument' not in str(e): raise APIError(21, msg) argcount = len(params) maxcount = func.func_code.co_argcount if argcount > maxcount: msg = ( 'Command %s takes at most %s parameters (%s given)' % (method, maxcount, argcount)) else: mincount = maxcount - len(func.func_defaults or []) if argcount < mincount: msg = ( 'Command %s takes at least %s parameters (%s given)' % (method, mincount, argcount)) raise APIError(0, msg) finally: state.last_api_response = time.time() def _dispatch(self, method, params): _fault = None try: return self._handle_request(method, params) except APIError as e: _fault = e except varintDecodeError as e: logger.error(e) _fault = APIError( 26, 'Data contains a malformed varint. Some details: %s' % e) except Exception as e: logger.exception(e) _fault = APIError(21, 'Unexpected API Failure - %s' % e) if _fault: if self.config.safeGet( 'bitmessagesettings', 'apivariant') == 'legacy': return str(_fault) else: raise _fault # pylint: disable=raising-bad-type def _listMethods(self): """List all API commands""" return self._handlers.keys() def _methodHelp(self, method): return self._handlers[method].__doc__