diff --git a/lib/bcoin/http/request.js b/lib/bcoin/http/request.js index 08a5f4be..a77edfcb 100644 --- a/lib/bcoin/http/request.js +++ b/lib/bcoin/http/request.js @@ -45,7 +45,6 @@ var USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1)' function request(options, callback, stream) { var qs = require('querystring'); var url = require('url'); - var uri = options.uri; var query = options.query; var body = options.body; diff --git a/lib/bcoin/http/server.js b/lib/bcoin/http/server.js index d133478c..a47bcc97 100644 --- a/lib/bcoin/http/server.js +++ b/lib/bcoin/http/server.js @@ -43,6 +43,16 @@ function HTTPServer(options) { this.pool = this.node.pool; this.logger = options.logger || this.node.logger; this.loaded = false; + this.apiKey = options.apiKey; + + if (this.apiKey) { + if (typeof this.apiKey === 'string') { + assert(utils.isHex(this.apiKey), 'API key must be a hex string.'); + this.apiKey = new Buffer(this.apiKey, 'hex'); + } + assert(Buffer.isBuffer(this.apiKey)); + assert(this.apiKey.length === 32, 'API key must be 32 bytes.'); + } options.sockets = true; @@ -62,8 +72,8 @@ HTTPServer.prototype._init = function _init() { var self = this; this.server.on('request', function(req, res) { - self.logger.debug('Request from %s path=%s', - req.socket.remoteAddress, req.pathname); + self.logger.debug('Request for path=%s (%s).', + req.pathname, req.socket.remoteAddress); }); this.use(function(req, res, next, send) { @@ -87,6 +97,30 @@ HTTPServer.prototype._init = function _init() { next(); }); + this.use(function(req, res, next, send) { + var auth = req.headers['authorization']; + var parts; + + if (!auth) { + req.username = null; + req.password = null; + return next(); + } + + parts = auth.split(' '); + assert(parts.length === 2, 'Invalid auth token.'); + assert(parts[0] === 'Basic', 'Invalid auth token.'); + + auth = new Buffer(parts[1], 'base64').toString('utf8'); + parts = auth.split(':'); + assert(parts.length >= 2, 'Invalid auth token.'); + + req.username = parts.shift(); + req.password = parts.join(':'); + + next(); + }); + this.use(function(req, res, next, send) { var params = utils.merge({}, req.params, req.query, req.body); var options = {}; @@ -184,16 +218,52 @@ HTTPServer.prototype._init = function _init() { if (params.passphrase) options.passphrase = params.passphrase; + if (req.password) { + assert(utils.isHex(req.password), 'API key must be a hex string.'); + assert(req.password.length === 64, 'API key must be 32 bytes.'); + options.apiKey = new Buffer(req.password, 'hex'); + } + req.options = options; next(); }); + this.use(function(req, res, next, send) { + if (req.path.length < 2 || req.path[0] !== 'wallet') { + if (self.apiKey) { + if (!utils.ccmp(req.options.apiKey, self.apiKey)) { + res.setHeader('WWW-Authenticate', 'Basic realm="node"'); + send(401, { error: 'Unauthorized.' }); + return; + } + } + return next(); + } + + if (!self.options.auth) + return next(); + + self.walletdb.auth(req.options.id, req.options.apiKey, function(err) { + if (err) { + if (err.message === 'Wallet not found.') + return next(); + self.logger.info('Auth failure for %s: %s.', + req.options.id, err.message); + res.setHeader('WWW-Authenticate', 'Basic realm="wallet"'); + send(401, { error: err.message }); + return; + } + + next(); + }); + }); + function decodeScript(script) { if (!script) return; if (typeof script === 'string') - return new bcoin.script(new Buffer(script, 'hex')); + return bcoin.script.fromRaw(script, 'hex'); return new bcoin.script(script); } @@ -356,9 +426,7 @@ HTTPServer.prototype._init = function _init() { if (err) return next(err); - send(200, { - success: true - }); + send(200, { success: true }); }); }); @@ -421,6 +489,42 @@ HTTPServer.prototype._init = function _init() { }); }); + // Change passphrase + this.post('/wallet/:id/passphrase', function(req, res, next, send) { + var id = req.options.id; + var options = req.options; + var old = options.old; + var new_ = options.passphrase; + self.walletdb.setPassphrase(id, old, _new, function(err) { + if (err) + return next(err); + + send(200, { success: true }); + }); + }); + + // Generate new token + this.post('/wallet/:id/retoken', function(req, res, next, send) { + var id = req.options.id; + var options = req.options; + self.walletdb.retoken(id, options.passphrase, function(err, token) { + if (err) + return next(err); + + send(200, { token: token.toString('hex') }); + }); + }); + + // Broadcast TX + this.post('/wallet/:id/broadcast', function(req, res, next, send) { + self.node.sendTX(req.options.tx, function(err) { + if (err) + return next(err); + + send(200, { success: true }); + }); + }); + // Send TX this.post('/wallet/:id/send', function(req, res, next, send) { var id = req.options.id; @@ -498,9 +602,7 @@ HTTPServer.prototype._init = function _init() { if (err) return next(err); - send(200, { - success: true - }); + send(200, { success: true }); }); }); @@ -731,8 +833,27 @@ HTTPServer.prototype._initIO = function _initIO() { self.emit('error', err); }); - socket.on('join', function(id) { - socket.join(id); + socket.on('join', function(id, apiKey) { + if (!self.options.auth) { + socket.join(id); + return; + } + if (id === '!all') { + if (self.apiKey) { + if (!utils.ccmp(apiKey, self.apiKey)) { + self.logger.info('Auth failure for %s.', id); + return; + } + } + return socket.join(id); + } + self.walletdb.auth(id, apiKey, function(err) { + if (err) { + self.logger.info('Auth failure for %s: %s.', id, err.message); + return; + } + socket.join(id); + }); }); socket.on('leave', function(id) { @@ -748,37 +869,30 @@ HTTPServer.prototype._initIO = function _initIO() { }); this.walletdb.on('tx', function(tx, map) { + var summary = map.toJSON(); tx = tx.toJSON(); - map.all.forEach(function(id) { - self.server.io.to(id).emit('tx', tx); + map.getWallets().forEach(function(id) { + self.server.io.to(id).emit('tx', tx, summary); }); - self.server.io.to('!all').emit('tx', tx, map); + self.server.io.to('!all').emit('tx', tx, summary); }); this.walletdb.on('confirmed', function(tx, map) { + var summary = map.toJSON(); tx = tx.toJSON(); - map.all.forEach(function(id) { - self.server.io.to(id).emit('confirmed', tx); + map.getWallets().forEach(function(id) { + self.server.io.to(id).emit('confirmed', tx, summary); }); - self.server.io.to('!all').emit('confirmed', tx, map); + self.server.io.to('!all').emit('confirmed', tx, summary); }); this.walletdb.on('updated', function(tx, map) { + var summary = map.toJSON(); tx = tx.toJSON(); - map.all.forEach(function(id) { - self.server.io.to(id).emit('updated', tx); + map.getWallets().forEach(function(id) { + self.server.io.to(id).emit('updated', tx, summary); }); - self.server.io.to('!all').emit('updated', tx, map); - }); - - this.walletdb.on('balance', function(balance, id) { - var json = { - confirmed: utils.btc(balance.confirmed), - unconfirmed: utils.btc(balance.unconfirmed), - total: utils.btc(balance.total) - }; - self.server.io.to(id).emit('balance', json); - self.server.io.to('!all').emit('balance', json, id); + self.server.io.to('!all').emit('updated', tx, summary); }); this.walletdb.on('balances', function(balances) { @@ -789,11 +903,15 @@ HTTPServer.prototype._initIO = function _initIO() { unconfirmed: utils.btc(balances[id].unconfirmed), total: utils.btc(balances[id].total) }; + self.server.io.to(id).emit('balance', json[id], id); + self.server.io.to('!all').emit('balance', json[id], id); }); self.server.io.to('!all').emit('balances', json); }); this.walletdb.on('address', function(receive, change, map) { + var summary = map.toJSON(); + if (receive) { receive = receive.map(function(address) { return address.toJSON(); @@ -806,11 +924,11 @@ HTTPServer.prototype._initIO = function _initIO() { }); } - map.all.forEach(function(id) { - self.server.io.to(id).emit('address', receive, change, map); + map.getWallets().forEach(function(id) { + self.server.io.to(id).emit('address', receive, change, summary); }); - self.server.io.to('!all').emit('address', receive, change, map); + self.server.io.to('!all').emit('address', receive, change, summary); }); }; diff --git a/lib/bcoin/txdb.js b/lib/bcoin/txdb.js index ac007faa..0824c53a 100644 --- a/lib/bcoin/txdb.js +++ b/lib/bcoin/txdb.js @@ -2031,6 +2031,58 @@ WalletMap.prototype.hasPaths = function hasPaths(address) { return paths && paths.length !== 0; }; +/** + * Return a unique list of wallet IDs for the map. + * @returns {WalletID[]} + */ + +WalletMap.prototype.getWallets = function getWallets() { + var ids = {}; + var i, member; + + for (i = 0; i < this.accounts.length; i++) { + member = this.accounts[i]; + ids[member.id] = true; + } + + return Object.keys(ids); +}; + +/** + * Return a unique list of wallet IDs for the map. + * @returns {WalletID[]} + */ + +WalletMap.prototype.getInputWallets = function getInputWallets() { + var ids = {}; + var i, member; + + for (i = 0; i < this.inputs.length; i++) { + member = this.inputs[i]; + ids[member.id] = true; + } + + return Object.keys(ids); +}; + + +/** + * Return a unique list of wallet IDs for the map. + * @returns {WalletID[]} + */ + +WalletMap.prototype.getOutputWallets = function getOutputWallets() { + var ids = {}; + var i, member; + + for (i = 0; i < this.outputs.length; i++) { + member = this.outputs[i]; + ids[member.id] = true; + } + + return Object.keys(ids); +}; + /** * Get paths for a given address hash. * @param {Hash} address diff --git a/lib/bcoin/utils.js b/lib/bcoin/utils.js index 206f2eb6..ceef9074 100644 --- a/lib/bcoin/utils.js +++ b/lib/bcoin/utils.js @@ -1928,6 +1928,11 @@ utils.ccmp = function ccmp(a, b) { if (!Buffer.isBuffer(b)) return false; + // It's assumed the target length + // would be known to an attacker anyway. + if (a.length !== b.length) + return false; + for (i = 0; i < a.length; i++) res |= a[i] ^ b[i]; diff --git a/lib/bcoin/wallet.js b/lib/bcoin/wallet.js index 96038bdf..d99bebf1 100644 --- a/lib/bcoin/wallet.js +++ b/lib/bcoin/wallet.js @@ -9,6 +9,7 @@ var bcoin = require('./env'); var EventEmitter = require('events').EventEmitter; +var constants = bcoin.protocol.constants; var utils = require('./utils'); var assert = utils.assert; var BufferReader = require('./reader'); @@ -55,6 +56,8 @@ function Wallet(db, options) { this.master = null; this.initialized = false; this.accountDepth = 0; + this.token = constants.ZERO_HASH; + this.tokenDepth = 0; this.account = null; @@ -72,6 +75,7 @@ utils.inherits(Wallet, EventEmitter); Wallet.prototype.fromOptions = function fromOptions(options) { var master = options.master; + var id, token; if (!master) master = bcoin.hd.fromMnemonic(null, this.network); @@ -79,9 +83,11 @@ Wallet.prototype.fromOptions = function fromOptions(options) { if (!bcoin.hd.isHD(master) && !MasterKey.isMasterKey(master)) master = bcoin.hd.from(master, this.network); - if (!MasterKey.isMasterKey(master)) + if (bcoin.hd.isHD(master)) master = MasterKey.fromKey(master); + assert(MasterKey.isMasterKey(master)); + this.master = master; if (options.initialized != null) { @@ -96,11 +102,28 @@ Wallet.prototype.fromOptions = function fromOptions(options) { if (options.id) { assert(utils.isAlpha(options.id), 'Wallet ID must be alphanumeric.'); - this.id = options.id; + id = options.id; } - if (!this.id) - this.id = this.getID(); + if (!id) + id = this.getID(); + + if (options.token) { + assert(Buffer.isBuffer(options.token)); + assert(options.token.length === 32); + token = options.token; + } + + if (options.tokenDepth != null) { + assert(utils.isNumber(options.tokenDepth)); + this.tokenDepth = options.tokenDepth; + } + + if (!token) + token = this.getToken(this.master.key, this.tokenDepth); + + this.id = id; + this.token = token; return this; }; @@ -294,6 +317,43 @@ Wallet.prototype.setPassphrase = function setPassphrase(old, new_, callback) { }); }; +/** + * Generate a new token. + * @param {(String|Buffer)?} passphrase + * @param {Function} callback + */ + +Wallet.prototype.retoken = function retoken(passphrase, callback) { + var self = this; + var unlock; + + if (typeof passphrase === 'function') { + callback = passphrase; + passphrase = null; + } + + unlock = this.locker.lock(retoken, [passphrase, callback]); + + if (!unlock) + return; + + callback = utils.wrap(callback, unlock); + + this.unlock(passphrase, null, function(err, master) { + if (err) + return callback(err); + + self.tokenDepth++; + self.token = self.getToken(master, self.tokenDepth); + + self.save(function(err) { + if (err) + return callback(err); + return callback(null, self.token); + }); + }); +}; + /** * Lock the wallet, destroy decrypted key. */ @@ -314,7 +374,7 @@ Wallet.prototype.unlock = function unlock(passphrase, timeout, callback) { /** * Generate the wallet ID if none was passed in. - * It is represented as `m/44'` (public) hashed + * It is represented as `m/44` (public) hashed * and converted to an address with a prefix * of `0x03be04` (`WLT` in base58). * @returns {Base58String} @@ -325,7 +385,7 @@ Wallet.prototype.getID = function getID() { assert(this.master.key, 'Cannot derive id.'); - key = this.master.key.derive(44, true); + key = this.master.key.derive(44); p = new BufferWriter(); p.writeU8(0x03); @@ -337,6 +397,27 @@ Wallet.prototype.getID = function getID() { return utils.toBase58(p.render()); }; +/** + * Generate the wallet api key if none was passed in. + * It is represented as HASH256(m/44'->public|nonce). + * @param {Number} nonce + * @returns {Buffer} + */ + +Wallet.prototype.getToken = function getToken(master, nonce) { + var key, p; + + assert(master, 'Cannot derive token.'); + + key = master.derive(44, true); + + p = new BufferWriter(); + p.writeBytes(key.publicKey); + p.writeU32(nonce); + + return utils.hash256(p.render()); +}; + /** * Create an account. Requires passphrase if master key is encrypted. * @param {Object} options - See {@link Account} options. @@ -1437,6 +1518,8 @@ Wallet.prototype.inspect = function inspect() { network: this.network.type, initialized: this.initialized, accountDepth: this.accountDepth, + token: this.token.toString('hex'), + tokenDepth: this.tokenDepth, master: this.master, account: this.account }; @@ -1455,6 +1538,8 @@ Wallet.prototype.toJSON = function toJSON() { id: this.id, initialized: this.initialized, accountDepth: this.accountDepth, + token: this.token.toString('hex'), + tokenDepth: this.tokenDepth, master: this.master.toJSON(), account: this.account ? this.account.toJSON() : null }; @@ -1470,11 +1555,15 @@ Wallet.prototype.fromJSON = function fromJSON(json) { assert(utils.isAlpha(json.id), 'Wallet ID must be alphanumeric.'); assert(typeof json.initialized === 'boolean'); assert(utils.isNumber(json.accountDepth)); + assert(typeof json.token === 'string'); + assert(json.token.length === 64); + assert(utils.isNumber(json.tokenDepth)); this.network = bcoin.network.get(json.network); this.id = json.id; this.initialized = json.initialized; this.accountDepth = json.accountDepth; + this.token = new Buffer(json.token, 'hex'); this.master = MasterKey.fromJSON(json.master); return this; @@ -1492,6 +1581,8 @@ Wallet.prototype.toRaw = function toRaw(writer) { p.writeVarString(this.id, 'utf8'); p.writeU8(this.initialized ? 1 : 0); p.writeU32(this.accountDepth); + p.writeBytes(this.token); + p.writeU32(this.tokenDepth); p.writeVarBytes(this.master.toRaw()); if (!writer) @@ -1512,6 +1603,8 @@ Wallet.prototype.fromRaw = function fromRaw(data) { this.id = p.readVarString('utf8'); this.initialized = p.readU8() === 1; this.accountDepth = p.readU32(); + this.token = p.readBytes(32); + this.tokenDepth = p.readU32(); this.master = MasterKey.fromRaw(p.readVarBytes()); return this; }; @@ -1585,8 +1678,6 @@ function Account(db, options) { this.network = db.network; this.lookahead = Account.MAX_LOOKAHEAD; - this.loaded = false; - this.loading = false; this.receiveAddress = null; this.changeAddress = null; @@ -2268,13 +2359,13 @@ Account.prototype.toJSON = function toJSON() { receiveDepth: this.receiveDepth, changeDepth: this.changeDepth, receiveAddress: this.receiveAddress - ? this.receiveAddress.getAddress() + ? this.receiveAddress.getAddress('base58') : null, programAddress: this.receiveAddress - ? this.receiveAddress.getProgramAddress() + ? this.receiveAddress.getProgramAddress('base58') : null, changeAddress: this.changeAddress - ? this.changeAddress.getAddress() + ? this.changeAddress.getAddress('base58') : null, accountKey: this.accountKey.xpubkey, keys: this.keys.map(function(key) { @@ -2469,7 +2560,7 @@ MasterKey.prototype.fromOptions = function fromOptions(options) { } if (options.key) { - assert(Buffer.isBuffer(options.key)); + assert(bcoin.hd.isHD(options.key)); this.key = options.key; } diff --git a/lib/bcoin/walletdb.js b/lib/bcoin/walletdb.js index 4da8f0cd..4d1f1d82 100644 --- a/lib/bcoin/walletdb.js +++ b/lib/bcoin/walletdb.js @@ -185,17 +185,12 @@ WalletDB.prototype.updateBalances = function updateBalances(tx, map, callback) { var balances = {}; var i, id, keys; - utils.forEachSerial(map.outputs, function(output, next) { - id = output.id; - - if (self.listeners('balance').length === 0 + utils.forEachSerial(map.getOutputWallets(), function(id, next) { + if (self.listeners('balances').length === 0 && !self.hasListener(id, 'balance')) { return next(); } - if (balances[id] != null) - return next(); - self.getBalance(id, function(err, balance) { if (err) return next(err); @@ -215,7 +210,7 @@ WalletDB.prototype.updateBalances = function updateBalances(tx, map, callback) { self.fire(id, 'balance', balances[id]); } - self.emit('balance', balances, map); + self.emit('balances', balances, map); return callback(null, balances); }); @@ -231,11 +226,8 @@ WalletDB.prototype.updateBalances = function updateBalances(tx, map, callback) { WalletDB.prototype.syncOutputs = function syncOutputs(tx, map, callback) { var self = this; - var id; - - utils.forEachSerial(map.outputs, function(output, next) { - id = output.id; + utils.forEachSerial(map.getOutputWallets(), function(id, next) { self.syncOutputDepth(id, tx, function(err, receive, change) { if (err) return next(err); @@ -474,6 +466,46 @@ WalletDB.prototype.save = function save(wallet, callback) { this.db.put('w/' + wallet.id, wallet.toRaw(), callback); }; +/** + * Test an api key against a wallet's api key. + * @param {WalletID} id + * @param {String} token + * @param {Function} callback + */ + +WalletDB.prototype.auth = function auth(id, token, callback) { + var wallet; + + if (!id) + return callback(new Error('Wallet not found.')); + + this.db.get('w/' + id, function(err, data) { + if (err) + return callback(err); + + if (!data) + return callback(new Error('Wallet not found.')); + + try { + wallet = bcoin.wallet.fromRaw(self, data); + } catch (e) { + return callback(e); + } + + if (typeof token === 'string') { + if (!utils.isHex(token)) + return callback(new Error('Authentication error.')); + token = new Buffer(token, 'hex'); + } + + // Compare in constant time: + if (!utils.ccmp(token, wallet.token)) + return callback(new Error('Authentication error.')); + + return callback(); + }); +}; + /** * Create a new wallet, save to database, setup watcher. * @param {Object} options - See {@link Wallet}. @@ -613,6 +645,9 @@ WalletDB.prototype.getAccount = function getAccount(id, name, callback) { WalletDB.prototype.getAccounts = function getAccounts(id, callback) { var accounts = []; + if (!utils.isAlpha(id)) + return callback(new Error('Wallet IDs must be alphanumeric.')); + this.db.iterate({ gte: 'i/' + id + '/', lte: 'i/' + id + '/~', @@ -1184,6 +1219,29 @@ WalletDB.prototype.removeKey = function removeKey(id, name, key, callback) { }); }; +WalletDB.prototype.setPassphrase = function setPassphrase(id, old, new_, callback) { + if (typeof new_ === 'function') { + callback = new_; + new_ = old; + old = null; + } + + this.fetchWallet(id, callback, function(wallet, callback) { + wallet.setPassphrase(old, new_, callback); + }); +}; + +WalletDB.prototype.retoken = function retoken(id, passphrase, callback) { + if (typeof passphrase === 'function') { + callback = passphrase; + passphrase = null; + } + + this.fetchWallet(id, callback, function(wallet, callback) { + wallet.retoken(passphrase, callback); + }); +}; + WalletDB.prototype.getInfo = function getInfo(id, callback) { this.fetchWallet(id, callback, function(wallet, callback) { callback(null, wallet);