Wallet encryption (#783)

* update known commands
* add wallet_unlock, block wallet startup on being unlocked
* add wallet_decrypt and wallet_encrypt
* wallet encryption unit tests
* added use_keyring configuration option in order to make keyring password storage optional
This commit is contained in:
Jack Robison 2017-12-17 01:00:12 -05:00 committed by akinwale
parent bcdeea75d6
commit 8c2d381aee
6 changed files with 227 additions and 18 deletions

View file

@ -34,8 +34,20 @@ at anytime.
* Don't include file names when logging information about streams, only include sd hashes * 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 * 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 * Remove manual saving of the wallet in from lbrynet, let lbryum handle it
* Block wallet startup on being unlocked if it is encrypted
### Added ### 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 `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 `channel_import` and `channel_export` commands
* Added `is_mine` field to `channel_list` results * 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 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 `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 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
* Removed claim related filter arguments `name`, `claim_id`, and `outpoint` from `file_list`, `file_delete`, `file_set_status`, and `file_reflect` * Removed claim related filter arguments `name`, `claim_id`, and `outpoint` from `file_list`, `file_delete`, `file_set_status`, and `file_reflect`

View file

@ -282,6 +282,7 @@ ADJUSTABLE_SETTINGS = {
'peer_search_timeout': (int, 3), 'peer_search_timeout': (int, 3),
'use_auth_http': (bool, False), 'use_auth_http': (bool, False),
'use_upnp': (bool, True), 'use_upnp': (bool, True),
'use_keyring': (bool, False),
'wallet': (str, LBRYUM_WALLET), 'wallet': (str, LBRYUM_WALLET),
} }

View file

@ -16,6 +16,7 @@ from lbryum.network import Network
from lbryum.simple_config import SimpleConfig from lbryum.simple_config import SimpleConfig
from lbryum.constants import COIN from lbryum.constants import COIN
from lbryum.commands import Commands from lbryum.commands import Commands
from lbryum.errors import InvalidPassword
from lbryschema.uri import parse_lbry_uri from lbryschema.uri import parse_lbry_uri
from lbryschema.claim import ClaimDict 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): def get_least_used_address(self, account=None, for_change=False, max_count=100):
return defer.fail(NotImplementedError()) 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): def _start(self):
pass pass
@ -1172,6 +1179,9 @@ class LBRYumWallet(Wallet):
self.config = make_config(self._config) self.config = make_config(self._config)
self.network = None self.network = None
self.wallet = 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.is_first_run = False
self.printed_retrieving_headers = False self.printed_retrieving_headers = False
self._start_check = None self._start_check = None
@ -1185,6 +1195,12 @@ class LBRYumWallet(Wallet):
return (not self.printed_retrieving_headers and return (not self.printed_retrieving_headers and
self.network.blockchain.retrieving_headers) 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): def _start(self):
network_start_d = defer.Deferred() network_start_d = defer.Deferred()
@ -1206,6 +1222,34 @@ class LBRYumWallet(Wallet):
else: else:
network_start_d.errback(ValueError("Failed to connect to network.")) 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) self._start_check = task.LoopingCall(check_started)
d = setup_network() d = setup_network()
@ -1216,6 +1260,9 @@ class LBRYumWallet(Wallet):
d.addCallback(lambda _: log.info("Subscribing to addresses")) d.addCallback(lambda _: log.info("Subscribing to addresses"))
d.addCallback(lambda _: self.wallet.wait_until_synchronized(lambda _: None)) d.addCallback(lambda _: self.wallet.wait_until_synchronized(lambda _: None))
d.addCallback(lambda _: log.info("Synchronized wallet")) 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 return d
def _stop(self): def _stop(self):
@ -1297,9 +1344,6 @@ class LBRYumWallet(Wallet):
d.addCallback(lambda _: blockchain_caught_d) d.addCallback(lambda _: blockchain_caught_d)
return d return d
def _get_cmd_runner(self):
return Commands(self.config, self.wallet, self.network)
# run commands as a defer.succeed, # run commands as a defer.succeed,
# lbryum commands should be run this way , unless if the command # lbryum commands should be run this way , unless if the command
# only makes a lbrum server query, use _run_cmd_as_defer_to_thread() # 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): def claim_renew(self, txid, nout):
return self._run_cmd_as_defer_succeed('renewclaim', 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): class LBRYcrdAddressRequester(object):
implements([IRequestCreator]) implements([IRequestCreator])

View file

@ -21,6 +21,8 @@ from lbryschema.error import URIParseError, DecodeError
from lbryschema.validator import validate_claim_id from lbryschema.validator import validate_claim_id
from lbryschema.address import decode_address from lbryschema.address import decode_address
from lbryum.errors import InvalidPassword
# TODO: importing this when internet is disabled raises a socket.gaierror # TODO: importing this when internet is disabled raises a socket.gaierror
from lbrynet.core.system_info import get_lbrynet_version from lbrynet.core.system_info import get_lbrynet_version
from lbrynet import conf, analytics from lbrynet import conf, analytics
@ -165,7 +167,7 @@ class Daemon(AuthJSONRPCServer):
""" """
allowed_during_startup = [ allowed_during_startup = [
'daemon_stop', 'status', 'version', 'daemon_stop', 'status', 'version', 'wallet_unlock'
] ]
def __init__(self, analytics_manager): def __init__(self, analytics_manager):
@ -541,6 +543,8 @@ class Daemon(AuthJSONRPCServer):
elif self.wallet_type == LBRYUM_WALLET: elif self.wallet_type == LBRYUM_WALLET:
log.info("Using lbryum wallet") log.info("Using lbryum wallet")
config = {'auto_connect': True} config = {'auto_connect': True}
if 'use_keyring' in conf.settings:
config['use_keyring'] = conf.settings['use_keyring']
if conf.settings['lbryum_wallet_dir']: if conf.settings['lbryum_wallet_dir']:
config['lbryum_path'] = conf.settings['lbryum_wallet_dir'] config['lbryum_path'] = conf.settings['lbryum_wallet_dir']
storage = SqliteStorage(self.db_dir) storage = SqliteStorage(self.db_dir)
@ -971,16 +975,16 @@ class Daemon(AuthJSONRPCServer):
Returns: Returns:
(dict) lbrynet-daemon status (dict) lbrynet-daemon status
{ {
'lbry_id': lbry peer id, base58 'lbry_id': lbry peer id, base58,
'installation_id': installation id, base58 'installation_id': installation id, base58,
'is_running': bool 'is_running': bool,
'is_first_run': bool 'is_first_run': bool,
'startup_status': { 'startup_status': {
'code': status code 'code': status code,
'message': status message 'message': status message
}, },
'connection_status': { 'connection_status': {
'code': connection status code 'code': connection status code,
'message': connection status message 'message': connection status message
}, },
'blockchain_status': { 'blockchain_status': {
@ -988,6 +992,7 @@ class Daemon(AuthJSONRPCServer):
'blocks_behind': remote_height - local_height, 'blocks_behind': remote_height - local_height,
'best_blockhash': block hash of most recent block, 'best_blockhash': block hash of most recent block,
}, },
'wallet_is_encrypted': bool,
If given the session status option: If given the session status option:
'session_status': { 'session_status': {
@ -1001,13 +1006,13 @@ class Daemon(AuthJSONRPCServer):
'dht_status': { 'dht_status': {
'kbps_received': current kbps receiving, 'kbps_received': current kbps receiving,
'kbps_sent': current kdps being sent, 'kbps_sent': current kdps being sent,
'total_bytes_sent': total bytes sent 'total_bytes_sent': total bytes sent,
'total_bytes_received': total bytes received 'total_bytes_received': total bytes received,
'queries_received': number of queries received per second 'queries_received': number of queries received per second,
'queries_sent': number of queries sent per second 'queries_sent': number of queries sent per second,
'recent_contacts': count of recently contacted peers 'recent_contacts': count of recently contacted peers,
'unique_contacts': count of unique 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 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 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 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 = { response = {
'lbry_id': base58.b58encode(self.node_id), 'lbry_id': base58.b58encode(self.node_id),
@ -1034,6 +1041,7 @@ class Daemon(AuthJSONRPCServer):
else '' else ''
), ),
}, },
'wallet_is_encrypted': wallet_is_encrypted,
'blocks_behind': remote_height - local_height, # deprecated. remove from UI, then here 'blocks_behind': remote_height - local_height, # deprecated. remove from UI, then here
'blockchain_status': { 'blockchain_status': {
'blocks': local_height, 'blocks': local_height,
@ -1238,6 +1246,68 @@ class Daemon(AuthJSONRPCServer):
return self._render_response(float( return self._render_response(float(
self.session.wallet.get_address_balance(address, include_unconfirmed))) self.session.wallet.get_address_balance(address, include_unconfirmed)))
@defer.inlineCallbacks
def jsonrpc_wallet_unlock(self, password):
"""
Unlock an encrypted wallet
Usage:
wallet_unlock (<password>)
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 (<new_password>)
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 @defer.inlineCallbacks
def jsonrpc_daemon_stop(self): def jsonrpc_daemon_stop(self):
""" """

View file

@ -1,10 +1,17 @@
import os
import shutil
import tempfile
import lbryum.wallet
from decimal import Decimal from decimal import Decimal
from collections import defaultdict from collections import defaultdict
from twisted.trial import unittest from twisted.trial import unittest
from twisted.internet import threads, defer from twisted.internet import threads, defer
from lbrynet.core.Error import InsufficientFundsError 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 = { test_metadata = {
'license': 'NASA', 'license': 'NASA',
@ -44,8 +51,36 @@ class MocLbryumWallet(Wallet):
return defer.succeed(True) 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): 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 test_failed_send_name_claim(self):
def not_enough_funds_send_name_claim(self, name, val, amount): def not_enough_funds_send_name_claim(self, name, val, amount):
claim_out = {'success':False, 'reason':'Not enough funds'} claim_out = {'success':False, 'reason':'Not enough funds'}
@ -198,3 +233,29 @@ class WalletTest(unittest.TestCase):
'test', "f43dc06256a69988bdbea09a58c80493ba15dcfa", 4)) 'test', "f43dc06256a69988bdbea09a58c80493ba15dcfa", 4))
self.assertFailure(d, InsufficientFundsError) self.assertFailure(d, InsufficientFundsError)
return d 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)

View file

@ -14,6 +14,7 @@ gmpy==1.17
jsonrpc==1.2 jsonrpc==1.2
jsonrpclib==0.1.7 jsonrpclib==0.1.7
jsonschema==2.6.0 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/lbryum.git@v3.2.0rc7#egg=lbryum
git+https://github.com/lbryio/lbryschema.git@v0.0.15rc2#egg=lbryschema git+https://github.com/lbryio/lbryschema.git@v0.0.15rc2#egg=lbryschema
miniupnpc==1.9 miniupnpc==1.9
@ -30,4 +31,4 @@ six>=1.9.0
slowaes==0.1a1 slowaes==0.1a1
txJSON-RPC==0.5 txJSON-RPC==0.5
wsgiref==0.1.2 wsgiref==0.1.2
zope.interface==4.3.3 zope.interface==4.3.3