diff --git a/CHANGELOG.md b/CHANGELOG.md index 174f8e77a..3f4f81391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,20 @@ at anytime. * Don't include file names when logging information about streams, only include sd hashes * Re-use addresses used for lbrycrd info exchange, this was a significant source of address bloat in the wallet * Remove manual saving of the wallet in from lbrynet, let lbryum handle it + * Block wallet startup on being unlocked if it is encrypted ### Added + * Add link to instructions on how to change the default peer port + * Add `peer_port` to settings configurable using `settings_set` + * Added an option to disable max key fee check. + * Add `wallet_unlock`, a command available during startup to unlock an encrypted wallet + +### Changed + * claim_show API command no longer takes name as argument + * Linux default downloads folder changed from `~/Downloads` to `XDG_DOWNLOAD_DIR` + * Linux folders moved from the home directory to `~/.local/share/lbry` + * Windows folders moved from `%AppData%/Roaming` to `%AppData%/Local/lbry` + * Block wallet startup on being unlocked * Added `status`, `blobs_completed`, and `blobs_in_stream` fields to file objects returned by `file_list` and `get` * Added `channel_import` and `channel_export` commands * Added `is_mine` field to `channel_list` results @@ -43,6 +55,8 @@ at anytime. * Added user configurable `auto_renew_claim_height_delta` setting, defaults to 0 (off) * Added `lbrynet-console`, a tool to run or connect to lbrynet-daemon and launch an interactive python console with the api functions built in. * Added a table to the lbry file database to store the outpoint of the claim downloaded from + * Added `wallet_unlock`, a command available during startup to unlock an encrypted wallet + * Added support for wallet encryption via new commands `wallet_decrypt` and `wallet_encrypt` ### Removed * Removed claim related filter arguments `name`, `claim_id`, and `outpoint` from `file_list`, `file_delete`, `file_set_status`, and `file_reflect` diff --git a/lbrynet/conf.py b/lbrynet/conf.py index 9c016a98c..a3e175f04 100644 --- a/lbrynet/conf.py +++ b/lbrynet/conf.py @@ -282,6 +282,7 @@ ADJUSTABLE_SETTINGS = { 'peer_search_timeout': (int, 3), 'use_auth_http': (bool, False), 'use_upnp': (bool, True), + 'use_keyring': (bool, False), 'wallet': (str, LBRYUM_WALLET), } diff --git a/lbrynet/core/Wallet.py b/lbrynet/core/Wallet.py index 38b6aabcb..c058ed64e 100644 --- a/lbrynet/core/Wallet.py +++ b/lbrynet/core/Wallet.py @@ -16,6 +16,7 @@ from lbryum.network import Network from lbryum.simple_config import SimpleConfig from lbryum.constants import COIN from lbryum.commands import Commands +from lbryum.errors import InvalidPassword from lbryschema.uri import parse_lbry_uri from lbryschema.claim import ClaimDict @@ -1158,6 +1159,12 @@ class Wallet(object): def get_least_used_address(self, account=None, for_change=False, max_count=100): return defer.fail(NotImplementedError()) + def decrypt_wallet(self): + return defer.fail(NotImplementedError()) + + def encrypt_wallet(self, new_password, update_keyring=False): + return defer.fail(NotImplementedError()) + def _start(self): pass @@ -1172,6 +1179,9 @@ class LBRYumWallet(Wallet): self.config = make_config(self._config) self.network = None self.wallet = None + self._cmd_runner = None + self.wallet_pw_d = None + self.wallet_unlocked_d = defer.Deferred() self.is_first_run = False self.printed_retrieving_headers = False self._start_check = None @@ -1185,6 +1195,12 @@ class LBRYumWallet(Wallet): return (not self.printed_retrieving_headers and self.network.blockchain.retrieving_headers) + def get_cmd_runner(self): + if self._cmd_runner is None: + self._cmd_runner = Commands(self.config, self.wallet, self.network) + + return self._cmd_runner + def _start(self): network_start_d = defer.Deferred() @@ -1206,6 +1222,34 @@ class LBRYumWallet(Wallet): else: network_start_d.errback(ValueError("Failed to connect to network.")) + def unlock(password): + if self._cmd_runner and self._cmd_runner.locked: + try: + self._cmd_runner.unlock_wallet(password) + except InvalidPassword: + log.warning("Incorrect password") + check_locked() + raise InvalidPassword + if self._cmd_runner and self._cmd_runner.locked: + raise Exception("Failed to unlock wallet") + elif not self._cmd_runner: + raise Exception("Command runner hasn't been initialized yet") + self.wallet_unlocked_d.callback(True) + log.info("Unlocked the wallet!") + + def check_locked(): + if self._cmd_runner and self._cmd_runner.locked: + log.info("Waiting for wallet password") + d = defer.Deferred() + d.addCallback(unlock) + self.wallet_pw_d = d + return self.wallet_unlocked_d + elif not self._cmd_runner: + raise Exception("Command runner hasn't been initialized yet") + if not self.wallet.use_encryption: + log.info("Wallet is not encrypted") + self.wallet_unlocked_d.callback(True) + self._start_check = task.LoopingCall(check_started) d = setup_network() @@ -1216,6 +1260,9 @@ class LBRYumWallet(Wallet): d.addCallback(lambda _: log.info("Subscribing to addresses")) d.addCallback(lambda _: self.wallet.wait_until_synchronized(lambda _: None)) d.addCallback(lambda _: log.info("Synchronized wallet")) + d.addCallback(lambda _: self.get_cmd_runner()) + d.addCallbacks(lambda _: log.info("Set up lbryum command runner")) + d.addCallbacks(lambda _: check_locked()) return d def _stop(self): @@ -1297,9 +1344,6 @@ class LBRYumWallet(Wallet): d.addCallback(lambda _: blockchain_caught_d) return d - def _get_cmd_runner(self): - return Commands(self.config, self.wallet, self.network) - # run commands as a defer.succeed, # lbryum commands should be run this way , unless if the command # only makes a lbrum server query, use _run_cmd_as_defer_to_thread() @@ -1517,6 +1561,24 @@ class LBRYumWallet(Wallet): def claim_renew(self, txid, nout): return self._run_cmd_as_defer_succeed('renewclaim', txid, nout) + def decrypt_wallet(self): + if not self.wallet.use_encryption: + return False + if not self._cmd_runner: + return False + if self._cmd_runner.locked: + return False + self._cmd_runner.decrypt_wallet() + return not self.wallet.use_encryption + + def encrypt_wallet(self, new_password, update_keyring=False): + if not self._cmd_runner: + return False + if self._cmd_runner.locked: + return False + self._cmd_runner.update_password(new_password, update_keyring) + return not self.wallet.use_encryption + class LBRYcrdAddressRequester(object): implements([IRequestCreator]) diff --git a/lbrynet/daemon/Daemon.py b/lbrynet/daemon/Daemon.py index d87a62868..b657276ef 100644 --- a/lbrynet/daemon/Daemon.py +++ b/lbrynet/daemon/Daemon.py @@ -21,6 +21,8 @@ from lbryschema.error import URIParseError, DecodeError from lbryschema.validator import validate_claim_id from lbryschema.address import decode_address +from lbryum.errors import InvalidPassword + # TODO: importing this when internet is disabled raises a socket.gaierror from lbrynet.core.system_info import get_lbrynet_version from lbrynet import conf, analytics @@ -165,7 +167,7 @@ class Daemon(AuthJSONRPCServer): """ allowed_during_startup = [ - 'daemon_stop', 'status', 'version', + 'daemon_stop', 'status', 'version', 'wallet_unlock' ] def __init__(self, analytics_manager): @@ -541,6 +543,8 @@ class Daemon(AuthJSONRPCServer): elif self.wallet_type == LBRYUM_WALLET: log.info("Using lbryum wallet") config = {'auto_connect': True} + if 'use_keyring' in conf.settings: + config['use_keyring'] = conf.settings['use_keyring'] if conf.settings['lbryum_wallet_dir']: config['lbryum_path'] = conf.settings['lbryum_wallet_dir'] storage = SqliteStorage(self.db_dir) @@ -971,16 +975,16 @@ class Daemon(AuthJSONRPCServer): Returns: (dict) lbrynet-daemon status { - 'lbry_id': lbry peer id, base58 - 'installation_id': installation id, base58 - 'is_running': bool - 'is_first_run': bool + 'lbry_id': lbry peer id, base58, + 'installation_id': installation id, base58, + 'is_running': bool, + 'is_first_run': bool, 'startup_status': { - 'code': status code + 'code': status code, 'message': status message }, 'connection_status': { - 'code': connection status code + 'code': connection status code, 'message': connection status message }, 'blockchain_status': { @@ -988,6 +992,7 @@ class Daemon(AuthJSONRPCServer): 'blocks_behind': remote_height - local_height, 'best_blockhash': block hash of most recent block, }, + 'wallet_is_encrypted': bool, If given the session status option: 'session_status': { @@ -1001,13 +1006,13 @@ class Daemon(AuthJSONRPCServer): 'dht_status': { 'kbps_received': current kbps receiving, 'kbps_sent': current kdps being sent, - 'total_bytes_sent': total bytes sent - 'total_bytes_received': total bytes received - 'queries_received': number of queries received per second - 'queries_sent': number of queries sent per second - 'recent_contacts': count of recently contacted peers + 'total_bytes_sent': total bytes sent, + 'total_bytes_received': total bytes received, + 'queries_received': number of queries received per second, + 'queries_sent': number of queries sent per second, + 'recent_contacts': count of recently contacted peers, 'unique_contacts': count of unique peers - } + }, } """ @@ -1016,6 +1021,8 @@ class Daemon(AuthJSONRPCServer): local_height = self.session.wallet.network.get_local_height() if has_wallet else 0 remote_height = self.session.wallet.network.get_server_height() if has_wallet else 0 best_hash = (yield self.session.wallet.get_best_blockhash()) if has_wallet else None + wallet_is_encrypted = has_wallet and self.session.wallet.wallet and \ + self.session.wallet.wallet.use_encryption response = { 'lbry_id': base58.b58encode(self.node_id), @@ -1034,6 +1041,7 @@ class Daemon(AuthJSONRPCServer): else '' ), }, + 'wallet_is_encrypted': wallet_is_encrypted, 'blocks_behind': remote_height - local_height, # deprecated. remove from UI, then here 'blockchain_status': { 'blocks': local_height, @@ -1238,6 +1246,68 @@ class Daemon(AuthJSONRPCServer): return self._render_response(float( self.session.wallet.get_address_balance(address, include_unconfirmed))) + @defer.inlineCallbacks + def jsonrpc_wallet_unlock(self, password): + """ + Unlock an encrypted wallet + + Usage: + wallet_unlock () + + Returns: + (bool) true if wallet is unlocked, otherwise false + """ + + cmd_runner = self.session.wallet.get_cmd_runner() + if cmd_runner is not None and cmd_runner.locked: + result = True + elif self.session.wallet.wallet_pw_d is not None: + d = self.session.wallet.wallet_pw_d + if not d.called: + d.addCallback(lambda _: not self.session.wallet._cmd_runner.locked) + self.session.wallet.wallet_pw_d.callback(password) + try: + result = yield d + except InvalidPassword: + result = False + else: + result = self.session.wallet._cmd_runner.locked + response = yield self._render_response(result) + defer.returnValue(response) + + @defer.inlineCallbacks + def jsonrpc_wallet_decrypt(self): + """ + Decrypt an encrypted wallet, this will remove the wallet password + + Usage: + wallet_decrypt + + Returns: + (bool) true if wallet is decrypted, otherwise false + """ + + result = self.session.wallet.decrypt_wallet() + response = yield self._render_response(result) + defer.returnValue(response) + + @defer.inlineCallbacks + def jsonrpc_wallet_encrypt(self, new_password): + """ + Encrypt a wallet with a password, if the wallet is already encrypted this will update + the password + + Usage: + wallet_encrypt () + + Returns: + (bool) true if wallet is decrypted, otherwise false + """ + + self.session.wallet.encrypt_wallet(new_password) + response = yield self._render_response(self.session.wallet.wallet.use_encryption) + defer.returnValue(response) + @defer.inlineCallbacks def jsonrpc_daemon_stop(self): """ diff --git a/lbrynet/tests/unit/core/test_Wallet.py b/lbrynet/tests/unit/core/test_Wallet.py index 947b5f2c2..48b6404bb 100644 --- a/lbrynet/tests/unit/core/test_Wallet.py +++ b/lbrynet/tests/unit/core/test_Wallet.py @@ -1,10 +1,17 @@ +import os +import shutil +import tempfile +import lbryum.wallet + from decimal import Decimal from collections import defaultdict from twisted.trial import unittest from twisted.internet import threads, defer from lbrynet.core.Error import InsufficientFundsError -from lbrynet.core.Wallet import Wallet, ReservedPoints, InMemoryStorage +from lbrynet.core.Wallet import Wallet, LBRYumWallet, ReservedPoints, InMemoryStorage +from lbryum.commands import Commands + test_metadata = { 'license': 'NASA', @@ -44,8 +51,36 @@ class MocLbryumWallet(Wallet): return defer.succeed(True) +class MocEncryptedWallet(LBRYumWallet): + def __init__(self): + LBRYumWallet.__init__(self, InMemoryStorage()) + self.wallet_balance = Decimal(10.0) + self.total_reserved_points = Decimal(0.0) + self.queued_payments = defaultdict(Decimal) + class WalletTest(unittest.TestCase): + def setUp(self): + wallet = MocEncryptedWallet() + seed_text = "travel nowhere air position hill peace suffer parent beautiful rise " \ + "blood power home crumble teach" + password = "secret" + + user_dir = tempfile.mkdtemp() + path = os.path.join(user_dir, "somewallet") + storage = lbryum.wallet.WalletStorage(path) + wallet.wallet = lbryum.wallet.NewWallet(storage) + wallet.wallet.add_seed(seed_text, password) + wallet.wallet.create_master_keys(password) + wallet.wallet.create_main_account() + + self.wallet_path = path + self.enc_wallet = wallet + self.enc_wallet_password = password + + def tearDown(self): + shutil.rmtree(os.path.dirname(self.wallet_path)) + def test_failed_send_name_claim(self): def not_enough_funds_send_name_claim(self, name, val, amount): claim_out = {'success':False, 'reason':'Not enough funds'} @@ -198,3 +233,29 @@ class WalletTest(unittest.TestCase): 'test', "f43dc06256a69988bdbea09a58c80493ba15dcfa", 4)) self.assertFailure(d, InsufficientFundsError) return d + + def test_unlock_wallet(self): + wallet = self.enc_wallet + wallet._cmd_runner = Commands( + wallet.config, wallet.wallet, wallet.network, None, self.enc_wallet_password) + cmd_runner = wallet.get_cmd_runner() + cmd_runner.unlock_wallet(self.enc_wallet_password) + self.assertIsNone(cmd_runner.new_password) + self.assertEqual(cmd_runner._password, self.enc_wallet_password) + + def test_encrypt_decrypt_wallet(self): + wallet = self.enc_wallet + wallet._cmd_runner = Commands( + wallet.config, wallet.wallet, wallet.network, None, self.enc_wallet_password) + wallet.encrypt_wallet("secret2", False) + wallet.decrypt_wallet() + + def test_update_password_keyring_off(self): + wallet = self.enc_wallet + wallet.config.use_keyring = False + wallet._cmd_runner = Commands( + wallet.config, wallet.wallet, wallet.network, None, self.enc_wallet_password) + + # no keyring available, so ValueError is expected + with self.assertRaises(ValueError): + wallet.encrypt_wallet("secret2", True) diff --git a/requirements.txt b/requirements.txt index 19d6167a1..218123de3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ gmpy==1.17 jsonrpc==1.2 jsonrpclib==0.1.7 jsonschema==2.6.0 +keyring==10.4.0 git+https://github.com/lbryio/lbryum.git@v3.2.0rc7#egg=lbryum git+https://github.com/lbryio/lbryschema.git@v0.0.15rc2#egg=lbryschema miniupnpc==1.9 @@ -30,4 +31,4 @@ six>=1.9.0 slowaes==0.1a1 txJSON-RPC==0.5 wsgiref==0.1.2 -zope.interface==4.3.3 +zope.interface==4.3.3 \ No newline at end of file