From 387f6db3b6a137f72fa06aa95e45a1bd3fb36858 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Sun, 7 Jan 2018 19:26:59 +0100 Subject: [PATCH] Password-protect the JSON RPC interface --- lib/daemon.py | 40 ++++++++++++++++++--- lib/jsonrpc.py | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ lib/util.py | 9 +++++ 3 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 lib/jsonrpc.py diff --git a/lib/daemon.py b/lib/daemon.py index 0b674af2..ef2a561d 100644 --- a/lib/daemon.py +++ b/lib/daemon.py @@ -29,7 +29,7 @@ import sys import time import jsonrpclib -from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer +from .jsonrpc import VerifyingJSONRPCServer from version import ELECTRUM_VERSION from network import Network @@ -73,7 +73,14 @@ def get_server(config): try: with open(lockfile) as f: (host, port), create_time = ast.literal_eval(f.read()) - server = jsonrpclib.Server('http://%s:%d' % (host, port)) + rpc_user, rpc_password = get_rpc_credentials(config) + if rpc_password == '': + # authentication disabled + server_url = 'http://%s:%d' % (host, port) + else: + server_url = 'http://%s:%s@%s:%d' % ( + rpc_user, rpc_password, host, port) + server = jsonrpclib.Server(server_url) # Test daemon is running server.ping() return server @@ -85,6 +92,26 @@ def get_server(config): time.sleep(1.0) +def get_rpc_credentials(config): + rpc_user = config.get('rpcuser', None) + rpc_password = config.get('rpcpassword', None) + if rpc_user is None or rpc_password is None: + rpc_user = 'user' + import ecdsa, base64 + bits = 128 + nbytes = bits // 8 + (bits % 8 > 0) + pw_int = ecdsa.util.randrange(pow(2, bits)) + pw_b64 = base64.b64encode( + pw_int.to_bytes(nbytes, 'big'), b'-_') + rpc_password = to_string(pw_b64, 'ascii') + config.set_key('rpcuser', rpc_user) + config.set_key('rpcpassword', rpc_password, save=True) + elif rpc_password == '': + from .util import print_stderr + print_stderr('WARNING: RPC authentication is disabled.') + return rpc_user, rpc_password + + class Daemon(DaemonThread): def __init__(self, config, fd, is_gui): @@ -107,10 +134,13 @@ class Daemon(DaemonThread): def init_server(self, config, fd, is_gui): host = config.get('rpchost', '127.0.0.1') port = config.get('rpcport', 0) + + rpc_user, rpc_password = get_rpc_credentials(config) try: - server = SimpleJSONRPCServer((host, port), logRequests=False) - except: - self.print_error('Warning: cannot initialize RPC server on host', host) + server = VerifyingJSONRPCServer((host, port), logRequests=False, + rpc_user=rpc_user, rpc_password=rpc_password) + except Exception as e: + self.print_error('Warning: cannot initialize RPC server on host', host, e) self.server = None os.close(fd) return diff --git a/lib/jsonrpc.py b/lib/jsonrpc.py new file mode 100644 index 00000000..02b54429 --- /dev/null +++ b/lib/jsonrpc.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2018 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer, SimpleJSONRPCRequestHandler +from base64 import b64decode +import time + +from . import util + + +class RPCAuthCredentialsInvalid(Exception): + def __str__(self): + return 'Authentication failed (bad credentials)' + + +class RPCAuthCredentialsMissing(Exception): + def __str__(self): + return 'Authentication failed (missing credentials)' + + +class RPCAuthUnsupportedType(Exception): + def __str__(self): + return 'Authentication failed (only basic auth is supported)' + + +# based on http://acooke.org/cute/BasicHTTPA0.html by andrew cooke +class VerifyingJSONRPCServer(SimpleJSONRPCServer): + + def __init__(self, *args, **kargs): + + self.rpc_user = kargs['rpc_user'] + self.rpc_password = kargs['rpc_password'] + + class VerifyingRequestHandler(SimpleJSONRPCRequestHandler): + def parse_request(myself): + # first, call the original implementation which returns + # True if all OK so far + if SimpleJSONRPCRequestHandler.parse_request(myself): + try: + self.authenticate(myself.headers) + return True + except (RPCAuthCredentialsInvalid, RPCAuthCredentialsMissing, + RPCAuthUnsupportedType) as e: + myself.send_error(401, str(e)) + except BaseException as e: + import traceback, sys + traceback.print_exc(file=sys.stderr) + myself.send_error(500, str(e)) + return False + + SimpleJSONRPCServer.__init__( + self, requestHandler=VerifyingRequestHandler, *args, **kargs) + + def authenticate(self, headers): + if self.rpc_password == '': + # RPC authentication is disabled + return + + auth_string = headers.get('Authorization', None) + if auth_string is None: + raise RPCAuthCredentialsMissing() + + (basic, _, encoded) = auth_string.partition(' ') + if basic != 'Basic': + raise RPCAuthUnsupportedType() + + encoded = util.to_bytes(encoded, 'utf8') + credentials = util.to_string(b64decode(encoded), 'utf8') + (username, _, password) = credentials.partition(':') + if not (util.constant_time_compare(username, self.rpc_user) + and util.constant_time_compare(password, self.rpc_password)): + time.sleep(0.050) + raise RPCAuthCredentialsInvalid() diff --git a/lib/util.py b/lib/util.py index 0bbaf296..552c2237 100644 --- a/lib/util.py +++ b/lib/util.py @@ -34,6 +34,8 @@ import urlparse import urllib import threading from i18n import _ +import hmac + base_units = {'BTC':8, 'mBTC':5, 'uBTC':2} fee_levels = [_('Within 25 blocks'), _('Within 10 blocks'), _('Within 5 blocks'), _('Within 2 blocks'), _('In the next block')] @@ -191,6 +193,13 @@ def json_decode(x): except: return x + +# taken from Django Source Code +def constant_time_compare(val1, val2): + """Return True if the two strings are equal, False otherwise.""" + return hmac.compare_digest(to_bytes(val1, 'utf8'), to_bytes(val2, 'utf8')) + + # decorator that prints execution time def profiler(func): def do_profile(func, args, kw_args):