From 64af74fe4a72a20b8abea2f3d93ffd19e11f1e7f Mon Sep 17 00:00:00 2001 From: Christopher Jeffrey Date: Wed, 1 Jun 2016 14:59:23 -0700 Subject: [PATCH] master key. use locks to avoid race conditions in wallet. --- lib/bcoin/hd.js | 4 +- lib/bcoin/wallet.js | 363 +++++++++++++++++++++++++++++++++--------- lib/bcoin/walletdb.js | 25 +++ test/wallet-test.js | 43 ++++- 4 files changed, 360 insertions(+), 75 deletions(-) diff --git a/lib/bcoin/hd.js b/lib/bcoin/hd.js index 08b0674c..d5b102e7 100644 --- a/lib/bcoin/hd.js +++ b/lib/bcoin/hd.js @@ -925,7 +925,7 @@ HDPrivateKey.parseBase58 = function parseBase58(xkey) { */ HDPrivateKey.parseRaw = function parseRaw(raw) { - var p = new BufferReader(raw, true); + var p = new BufferReader(raw); var data = {}; var i, type, prefix; @@ -1426,7 +1426,7 @@ HDPublicKey.parseBase58 = function parseBase58(xkey) { */ HDPublicKey.parseRaw = function parseRaw(raw) { - var p = new BufferReader(raw, true); + var p = new BufferReader(raw); var data = {}; data.version = p.readU32BE(); diff --git a/lib/bcoin/wallet.js b/lib/bcoin/wallet.js index 7d627ee6..ca1819fc 100644 --- a/lib/bcoin/wallet.js +++ b/lib/bcoin/wallet.js @@ -53,6 +53,7 @@ function Wallet(options) { this.options = options; this.network = bcoin.network.get(options.network); this.db = options.db; + this.locker = new bcoin.locker(this); if (!master) master = bcoin.hd.fromMnemonic(null, this.network); @@ -146,6 +147,8 @@ Wallet.prototype.destroy = function destroy(callback) { assert(!this.loading); + this.master.destroy(); + try { this.db.unregister(this); } catch (e) { @@ -219,12 +222,20 @@ Wallet.prototype.init = function init(callback) { */ Wallet.prototype.addKey = function addKey(account, key, callback) { + var unlock; + if (typeof key === 'function') { callback = key; key = account; account = 0; } + unlock = this.locker.lock(addKey, [account, key, callback]); + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); + this.getAccount(account, function(err, account) { if (err) return callback(err); @@ -233,7 +244,7 @@ Wallet.prototype.addKey = function addKey(account, key, callback) { return callback(new Error('Account not found.')); account.addKey(key, callback); - }); + }, true); }; /** @@ -244,12 +255,20 @@ Wallet.prototype.addKey = function addKey(account, key, callback) { */ Wallet.prototype.removeKey = function removeKey(account, key, callback) { + var unlock; + if (typeof key === 'function') { callback = key; key = account; account = 0; } + unlock = this.locker.lock(removeKey, [account, key, callback]); + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); + this.getAccount(account, function(err, account) { if (err) return callback(err); @@ -258,7 +277,7 @@ Wallet.prototype.removeKey = function removeKey(account, key, callback) { return callback(new Error('Account not found.')); account.addKey(key, callback); - }); + }, true); }; /** @@ -294,6 +313,25 @@ Wallet.prototype.setPassphrase = function setPassphrase(old, new_, callback) { return this.save(callback); }; + +/** + * Lock the wallet, destroy decrypted key. + */ + +Wallet.prototype.lock = function lock() { + this.master.destroy(); +}; + +/** + * Unlock the key for `timeout` milliseconds. + * @param {Buffer|String} passphrase + * @param {Number?} [timeout=60000] - ms. + */ + +Wallet.prototype.unlock = function unlock(passphrase, timeout) { + this.master.toKey(passphrase, timeout); +}; + /** * Generate the wallet ID if none was passed in. * It is represented as `m/44'` (public) hashed @@ -325,12 +363,18 @@ Wallet.prototype.getID = function getID() { * @param {Function} callback - Returns [Error, {@link Account}]. */ -Wallet.prototype.createAccount = function createAccount(options, callback) { +Wallet.prototype.createAccount = function createAccount(options, callback, force) { var self = this; - var master, key; + var master, key, unlock; + + unlock = this.locker.lock(createAccount, [options, callback], force); + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); try { - master = this.master.toKey(options.passphrase); + master = this.master.toKey(options.passphrase, options.timeout); } catch (e) { return callback(e); } @@ -364,17 +408,33 @@ Wallet.prototype.createAccount = function createAccount(options, callback) { }); }; +/** + * List account names and indexes from the db. + * @param {Function} callback - Returns [Error, Array]. + */ + +Wallet.prototype.getAccounts = function getAccounts(callback) { + this.db.getAccounts(this.id, callback); +}; + /** * Retrieve an account from the database. * @param {Number|String} account * @param {Function} callback - Returns [Error, {@link Account}]. */ -Wallet.prototype.getAccount = function getAccount(account, callback) { +Wallet.prototype.getAccount = function getAccount(account, callback, force) { + var unlock = this.locker.lock(getAccount, [account, callback], force); + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); + if (this.account) { if (account === 0 || account === 'default') return callback(null, this.account); } + return this.db.getAccount(this.id, account, callback); }; @@ -414,11 +474,20 @@ Wallet.prototype.createChange = function createChange(account, callback) { */ Wallet.prototype.createAddress = function createAddress(account, change, callback) { + var unlock; + if (typeof change === 'function') { callback = change; change = account; account = 0; } + + unlock = this.locker.lock(createAddress, [account, change, callback]); + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); + this.getAccount(account, function(err, account) { if (err) return callback(err); @@ -427,7 +496,7 @@ Wallet.prototype.createAddress = function createAddress(account, change, callbac return callback(new Error('Account not found.')); account.createAddress(change, callback); - }); + }, true); }; /** @@ -767,7 +836,13 @@ Wallet.prototype.syncOutputDepth = function syncOutputDepth(tx, callback) { var self = this; var accounts = {}; var result = false; - var i, path; + var i, path, unlock; + + unlock = this.locker.lock(syncOutputDepth, [tx, callback]); + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); this.getOutputPaths(tx, function(err, paths) { if (err) @@ -825,7 +900,7 @@ Wallet.prototype.syncOutputDepth = function syncOutputDepth(tx, callback) { next(); }); }); - }); + }, true); }, function(err) { if (err) return callback(err); @@ -876,6 +951,7 @@ Wallet.prototype.getRedeem = function getRedeem(hash, callback) { /** * Zap stale TXs from wallet (accesses db). + * @param {(Number|String)?} account * @param {Number} age - Age threshold (unix time, default=72 hours). * @param {Function} callback - Returns [Error]. */ @@ -906,6 +982,13 @@ Wallet.prototype.zap = function zap(account, age, callback) { Wallet.prototype.scan = function scan(getByAddress, callback) { var self = this; var total = 0; + var unlock; + + unlock = this.locker.lock(scan, [getByAddress, callback]); + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); if (!this.initialized) return callback(new Error('Wallet is not initialized.')); @@ -923,7 +1006,7 @@ Wallet.prototype.scan = function scan(getByAddress, callback) { total += result; - self.createAccount(self.options, next); + self.createAccount(self.options, next, true); }); })(null, this.account); }; @@ -990,7 +1073,7 @@ Wallet.prototype.sign = function sign(tx, options, callback) { return callback(err); try { - master = self.master.toKey(options.passphrase); + master = self.master.toKey(options.passphrase, options.timeout); } catch (e) { return callback(e); } @@ -1526,7 +1609,10 @@ Wallet.isWallet = function isWallet(obj) { }; /** - * BIP44 Account + * Represents a BIP44 Account belonging to a {@link Wallet}. + * Note that this object does not enforce locks. Any method + * that does a write is internal API only and will lead + * to race conditions if used elsewhere. * @exports Account * @constructor * @param {Object} options @@ -1998,11 +2084,11 @@ Account.prototype.deriveAddress = function deriveAddress(change, index) { return new bcoin.keyring({ network: this.network, key: key.publicKey, + name: this.name, account: this.accountIndex, change: change, index: index, type: this.type, - name: this.name, witness: this.witness, m: this.m, n: this.n, @@ -2373,8 +2459,12 @@ Account.isAccount = function isAccount(obj) { && obj.deriveAddress === 'function'; }; -/* - * Master Key +/** + * Master BIP32 key which can exist + * in an timed out encrypted state. + * @exports Master + * @constructor + * @param {Object} options */ function MasterKey(options) { @@ -2386,30 +2476,101 @@ function MasterKey(options) { this.phrase = options.phrase; this.passphrase = options.passphrase; this.key = options.key || null; + this.timer = null; + this._destroy = this.destroy.bind(this); + + assert(this.encrypted ? !this.key : this.key); } -MasterKey.prototype.toKey = function toKey(passphrase) { +/** + * Decrypt the key and set a timeout to destroy decrypted data. + * @param {Buffer|String} passphrase - Zero this yourself. + * @param {Number} [timeout=60000] timeout in ms. + * @returns {HDPrivateKey} + */ + +MasterKey.prototype.toKey = function toKey(passphrase, timeout) { + var self = this; var xprivkey; - if (this.key) - return this.key; - - if (this.encrypted) { - assert(passphrase, 'Passphrase is required.'); + if (!this.key) { + assert(this.encrypted); xprivkey = utils.decrypt(this.xprivkey, passphrase); - } else { - xprivkey = this.xprivkey; + this.key = bcoin.hd.fromRaw(xprivkey); + xprivkey.fill(0); + this.start(timeout); } - return bcoin.hd.fromRaw(xprivkey); + return this.key; }; -MasterKey.prototype.decrypt = function decrypt(passphrase) { - if (!this.encrypted) +/** + * Start the destroy timer. + * @private + * @param {Number} [timeout=60000] timeout in ms. + */ + +MasterKey.prototype.start = function start(timeout) { + if (!timeout) + timeout = 60000; + + this.stop(); + + if (timeout === -1) return; + this.timer = setTimeout(this._destroy, timeout); +}; + +/** + * Stop the destroy timer. + * @private + */ + +MasterKey.prototype.stop = function stop() { + if (this.timer != null) { + clearTimeout(this.timer); + this.timer = null; + } +}; + +/** + * Destroy the key by zeroing the + * privateKey and chainCode. Stop + * the timer if there is one. + */ + +MasterKey.prototype.destroy = function destroy() { + if (!this.encrypted) { + assert(this.timer == null); + assert(this.key); + return; + } + + this.stop(); + + if (this.key) { + this.key.chainCode.fill(0); + this.key.privateKey.fill(0); + this.key = null; + } +}; + +/** + * Decrypt the key permanently. + * @param {Buffer|String} passphrase - Zero this yourself. + */ + +MasterKey.prototype.decrypt = function decrypt(passphrase) { + if (!this.encrypted) { + assert(this.key); + return; + } + assert(passphrase, 'Passphrase is required.'); + this.destroy(); + this.encrypted = false; this.xprivkey = utils.decrypt(this.xprivkey, passphrase); @@ -2418,10 +2579,19 @@ MasterKey.prototype.decrypt = function decrypt(passphrase) { this.passphrase = utils.decrypt(this.passphrase, passphrase); } - this.key = this.toKey(); + this.key = bcoin.hd.fromRaw(this.xprivkey); }; +/** + * Encrypt the key permanently. + * @param {Buffer|String} passphrase - Zero this yourself. + */ + MasterKey.prototype.encrypt = function encrypt(passphrase) { + var xprivkey = this.xprivkey; + var phrase = this.phrase; + var pass = this.passphrase; + if (this.encrypted) return; @@ -2429,14 +2599,23 @@ MasterKey.prototype.encrypt = function encrypt(passphrase) { this.key = null; this.encrypted = true; - this.xprivkey = utils.encrypt(this.xprivkey, passphrase); + this.xprivkey = utils.encrypt(xprivkey, passphrase); + xprivkey.fill(0); if (this.phrase) { - this.phrase = utils.encrypt(this.phrase, passphrase); - this.passphrase = utils.encrypt(this.passphrase, passphrase); + this.phrase = utils.encrypt(phrase, passphrase); + this.passphrase = utils.encrypt(pass, passphrase); + phrase.fill(0); + pass.fill(0); } }; +/** + * Serialize the key in the form of: + * `[enc-flag][phrase-marker][phrase?][passphrase?][xprivkey]` + * @returns {Buffer} + */ + MasterKey.prototype.toRaw = function toRaw(writer) { var p = new BufferWriter(writer); @@ -2458,89 +2637,129 @@ MasterKey.prototype.toRaw = function toRaw(writer) { return p; }; -MasterKey.fromRaw = function fromRaw(raw) { - var data = {}; - var p = new BufferReader(raw); +/** + * Instantiate master key from serialized data. + * @returns {MasterKey} + */ - data.encrypted = p.readU8() === 1; +MasterKey.fromRaw = function fromRaw(raw) { + var p = new BufferReader(raw); + var encrypted, phrase, passphrase, xprivkey, key; + + encrypted = p.readU8() === 1; if (p.readU8() === 1) { - data.phrase = p.readVarBytes(); - data.passphrase = p.readVarBytes(); + phrase = p.readVarBytes(); + passphrase = p.readVarBytes(); } - data.xprivkey = p.readBytes(82); + xprivkey = p.readBytes(82); - if (!data.encrypted) - data.key = bcoin.hd.fromRaw(data.xprivkey); + if (!encrypted) + key = bcoin.hd.fromRaw(xprivkey); - return new MasterKey(data); + return new MasterKey({ + encrypted: encrypted, + phrase: phrase, + passphrase: passphrase, + xprivkey: xprivkey, + key: key + }); }; +/** + * Instantiate master key from an HDPrivateKey. + * @param {HDPrivateKey} key + * @returns {MasterKey} + */ + MasterKey.fromKey = function fromKey(key) { - var data = {}; - - data.encrypted = false; + var phrase, passphrase; if (key.mnemonic) { - data.phrase = new Buffer(key.mnemonic.phrase, 'utf8'); - data.passphrase = new Buffer(key.mnemonic.passphrase, 'utf8'); + phrase = new Buffer(key.mnemonic.phrase, 'utf8'); + passphrase = new Buffer(key.mnemonic.passphrase, 'utf8'); } - data.xprivkey = key.toRaw(); - - data.key = key; - - return new MasterKey(data); + return new MasterKey({ + encrypted: false, + phrase: phrase, + passphrase: passphrase, + xprivkey: key.toRaw(), + key: key + }); }; -MasterKey.prototype.toJSON = function toJSON() { - var json = {}; +/** + * Convert master key to a jsonifiable object. + * @returns {Object} + */ - json.encrypted = this.encrypted; +MasterKey.prototype.toJSON = function toJSON() { + var phrase, passphrase, xprivkey; if (this.encrypted) { if (this.phrase) { - json.phrase = this.phrase.toString('hex'); - json.passphrase = this.passphrase.toString('hex'); + phrase = this.phrase.toString('hex'); + passphrase = this.passphrase.toString('hex'); } - json.xprivkey = this.xprivkey.toString('hex'); + xprivkey = this.xprivkey.toString('hex'); } else { if (this.phrase) { - json.phrase = this.phrase.toString('utf8'); - json.passphrase = this.passphrase.toString('utf8'); + phrase = this.phrase.toString('utf8'); + passphrase = this.passphrase.toString('utf8'); } - json.xprivkey = utils.toBase58(this.xprivkey); + xprivkey = utils.toBase58(this.xprivkey); } - return json; + return { + encrypted: this.encrypted, + phrase: phrase, + passphrase: passphrase, + xprivkey: xprivkey + }; }; -MasterKey.fromJSON = function fromJSON(json) { - var data = {}; +/** + * Instantiate master key from jsonified object. + * @returns {MasterKey} + */ - data.encrypted = json.encrypted; +MasterKey.fromJSON = function fromJSON(json) { + var phrase, passphrase, xprivkey, key; if (json.encrypted) { if (json.phrase) { - data.phrase = new Buffer(json.phrase, 'hex'); - data.passphrase = new Buffer(json.passphrase, 'hex'); + phrase = new Buffer(json.phrase, 'hex'); + passphrase = new Buffer(json.passphrase, 'hex'); } - data.xprivkey = new Buffer(json.xprivkey, 'hex'); + xprivkey = new Buffer(json.xprivkey, 'hex'); } else { if (json.phrase) { - data.phrase = new Buffer(json.phrase, 'utf8'); - data.passphrase = new Buffer(json.passphrase, 'utf8'); + phrase = new Buffer(json.phrase, 'utf8'); + passphrase = new Buffer(json.passphrase, 'utf8'); } - data.xprivkey = utils.fromBase58(json.xprivkey); + xprivkey = utils.fromBase58(json.xprivkey); } - if (!data.encrypted) - data.key = bcoin.hd.fromRaw(data.xprivkey); + if (!json.encrypted) + key = bcoin.hd.fromRaw(xprivkey); - return new MasterKey(data); + return new MasterKey({ + encrypted: json.encrypted, + phrase: phrase, + passphrase: passphrase, + xprivkey: xprivkey, + key: key + }); }; +/** + * Test whether an object is a MasterKey. + * @param {Object} obj + * @returns {Boolean} + */ + MasterKey.isMasterKey = function isMasterKey(obj) { return obj && typeof obj.encrypted === 'boolean' diff --git a/lib/bcoin/walletdb.js b/lib/bcoin/walletdb.js index 7e8ba4c4..777f1118 100644 --- a/lib/bcoin/walletdb.js +++ b/lib/bcoin/walletdb.js @@ -505,6 +505,31 @@ WalletDB.prototype.getAccount = function getAccount(id, name, callback) { }); }; +/** + * List account names and indexes from the db. + * @param {WalletID} id + * @param {Function} callback - Returns [Error, Array]. + */ + +WalletDB.prototype.getAccounts = function getAccounts(id, callback) { + var accounts = []; + this.db.iterate({ + gte: 'i/' + id + '/', + lte: 'i/' + id + '/~', + values: true, + parse: function(value, key) { + var name = key.split('/')[2]; + var index = value.readUInt32LE(0, true); + accounts[index] = name; + } + }, function(err) { + if (err) + return callback(err); + + return callback(null, accounts); + }); +}; + /** * Lookup the corresponding account name's index. * @param {WalletID} id diff --git a/test/wallet-test.js b/test/wallet-test.js index 99caabe9..eb1514a7 100644 --- a/test/wallet-test.js +++ b/test/wallet-test.js @@ -768,7 +768,11 @@ describe('Wallet', function() { w1.fill(t3, { rate: 10000, round: true }, function(err) { assert(err); assert.equal(err.requiredFunds, 25000); - cb(); + w1.getAccounts(function(err, accounts) { + assert.ifError(err); + assert.deepEqual(accounts, ['default', 'foo']); + cb(); + }); }); }); }); @@ -843,6 +847,43 @@ describe('Wallet', function() { }); }); + it('should fill tx with inputs when encrypted', function(cb) { + wdb.create({ passphrase: 'foo' }, function(err, w1) { + assert.ifError(err); + w1.master.destroy(); + + // Coinbase + var t1 = bcoin.mtx() + .addOutput(w1, 5460) + .addOutput(w1, 5460) + .addOutput(w1, 5460) + .addOutput(w1, 5460); + + t1.addInput(dummyInput); + + wdb.addTX(t1, function(err) { + assert.ifError(err); + + // Create new transaction + var t2 = bcoin.mtx().addOutput(w1, 5460); + w1.fill(t2, { rate: 10000, round: true }, function(err) { + assert.ifError(err); + // Should fail + w1.sign(t2, 'bar', function(err) { + assert(err); + assert(!t2.verify()); + // Should succeed + w1.sign(t2, 'foo', function(err) { + assert.ifError(err); + assert(t2.verify()); + cb(); + }); + }); + }); + }); + }); + }); + it('should cleanup', function(cb) { constants.tx.COINBASE_MATURITY = 100; cb();