diff --git a/bit-wallet/bit-create b/bit-wallet/bit-create index 9b484be..ddb4195 100755 --- a/bit-wallet/bit-create +++ b/bit-wallet/bit-create @@ -8,6 +8,7 @@ program = utils.configureCommander(program); program .option('-t, --testnet', 'Create a Testnet Wallet') + .option('-n, --nopasswd [level]', 'Set access for no password usage: none(default), readonly, readwrite, full', 'none') .usage('[options] [copayerName]') .parse(process.argv); diff --git a/bit-wallet/bit-genkey b/bit-wallet/bit-genkey index 83b7164..fe3102d 100755 --- a/bit-wallet/bit-genkey +++ b/bit-wallet/bit-genkey @@ -8,6 +8,7 @@ program = utils.configureCommander(program); program .option('-t, --testnet', 'Create a Testnet Extended Private Key') + .option('-n, --nopasswd [level]', 'Set access for no password usage: none(default), readonly, readwrite, full', 'none') .parse(process.argv); var args = program.args; diff --git a/bit-wallet/bit-join b/bit-wallet/bit-join index a4bc9a6..fb169b6 100755 --- a/bit-wallet/bit-join +++ b/bit-wallet/bit-join @@ -7,6 +7,7 @@ program = utils.configureCommander(program); program .usage('[options] [copayerName]') + .option('-n, --nopasswd [level]', 'Set access for no password usage: none(default), readonly, readwrite, full', 'none') .parse(process.argv); var args = program.args; diff --git a/bit-wallet/cli-utils.js b/bit-wallet/cli-utils.js index f9abc22..6bbbfff 100644 --- a/bit-wallet/cli-utils.js +++ b/bit-wallet/cli-utils.js @@ -1,5 +1,6 @@ var _ = require('lodash'); var Client = require('../lib/client'); +var read = require('read') var Utils = function() {}; @@ -38,11 +39,27 @@ Utils.getClient = function(args) { var storage = new Client.FileStorage({ filename: args.file || process.env['BIT_FILE'], }); - return new Client({ + var c = new Client({ storage: storage, baseUrl: args.host || process.env['BIT_HOST'], - verbose: args.verbose + verbose: args.verbose, }); + + + if (args.nopasswd) + c.setNopasswdAccess(args.nopasswd); + + c.on('needPassword', function(cb) { + if (args.password) { + return cb(args.password); + } else { + read({ prompt: 'Password: ', silent: true }, function(er, password) { + return cb(password); + }) + } + }); + + return c; } Utils.findOneTxProposal = function(txps, id) { diff --git a/lib/client/api.js b/lib/client/api.js index c12fdc0..0077028 100644 --- a/lib/client/api.js +++ b/lib/client/api.js @@ -6,6 +6,7 @@ var util = require('util'); var async = require('async'); var log = require('npmlog'); var request = require('request') +var events = require('events'); log.debug = log.verbose; var Bitcore = require('bitcore') @@ -112,6 +113,7 @@ function API(opts) { this.request = request || opts.request; this.baseUrl = opts.baseUrl || BASE_URL; this.basePath = this.baseUrl.replace(/http.?:\/\/[a-zA-Z0-9:-]*\//, '/'); + this.noPasswdAccess = opts.noPasswdAccess || 'full'; if (this.verbose) { log.level = 'debug'; } else { @@ -119,6 +121,8 @@ function API(opts) { } }; +util.inherits(API, events.EventEmitter); + API.prototype._tryToCompleteFromServer = function(wcd, cb) { if (!wcd.walletPrivKey) @@ -142,7 +146,7 @@ API.prototype._tryToCompleteFromServer = function(wcd, cb) { wcd.publicKeyRing = _.pluck(wallet.copayers, 'xPubKey') - self.storage.save(wcd, function(err) { + self.save(wcd, function(err) { return cb(err, wcd); }); }); @@ -162,7 +166,7 @@ API.prototype._tryToCompleteFromData = function(wcd, toComplete, cb) { return cb(ex); } - this.storage.save(wcd, function(err) { + this.save(wcd, function(err) { return cb(err, wcd); }); }; @@ -177,15 +181,82 @@ API.prototype._tryToComplete = function(opts, wcd, cb) { }; +// access: 'full' > 'readwrite' > readonly' +API.prototype._processWcdAfterRead = function(rawData, requiredAccess, cb) { + var WU = WalletUtils; + requiredAccess = requiredAccess || 'full'; + + if (!rawData) + return cb(null, rawData); + + var requiredAccessLevel = WU.accessNameToLevel(requiredAccess); + + var access = WU.accessFromData(rawData); + var accessLevel = WU.accessNameToLevel(access); + + // Is the data available? + if (requiredAccessLevel <= accessLevel) + return cb(null, rawData); + + // Has any encrypted info? + if (!rawData.enc) + return cb('NOTAUTH'); + + // Decrypt it and try again + this.emit('needPassword', function(password) { + if (!password) return cb('No password'); + + try { + rawData = WU.decryptWallet(rawData, password); + } catch (e) {}; + + if (!rawData) + return cb('NOTAUTH'); + + access = WU.accessFromData(rawData); + accessLevel = WU.accessNameToLevel(access); + + // Is the data available? + if (requiredAccessLevel <= accessLevel) + return cb(null, rawData); + + return cb('NOTAUTH'); + }); +}; + + +API.prototype.setNopasswdAccess = function(noPasswdAccess) { + if (!_.contains(['none', 'readonly', 'readwrite', 'full'], noPasswdAccess)) + throw new Error('Bad nopasswd access:' + noPasswdAccess); + + this.noPasswdAccess = noPasswdAccess; +}; + +API.prototype._processWcdBeforeWrite = function(wcd, cb) { + var self = this; + // Is any encrypted? + if (this.noPasswdAccess == 'full') { + return cb(null, wcd); + } else { + this.emit('needPassword', function(password) { + if (!password) return cb('No password given'); + var ewcd = WalletUtils.encryptWallet(wcd, self.noPasswdAccess, password); + return cb(null, ewcd); + }); + } +}; + + -API.prototype._load = function(cb) { +API.prototype._load = function(opts, cb) { var self = this; + $.shouldBeFunction(cb); - this.storage.load(function(err, wcd) { - if (err || !wcd) { + this.storage.load(function(err, rawdata) { + if (err || !rawdata) { return cb(err || 'wcd file not found.'); } - return cb(null, wcd); + self._processWcdAfterRead(rawdata, opts.requiredAccess, cb); }); }; @@ -198,7 +269,7 @@ API.prototype._load = function(cb) { API.prototype._loadAndCheck = function(opts, cb) { var self = this; - this._load(function(err, wcd) { + this._load(opts, function(err, wcd) { if (err) return cb(err); if (!wcd.n || (wcd.n > 1 && wcd.publicKeyRing.length != wcd.n)) { @@ -275,6 +346,18 @@ API.prototype._doJoinWallet = function(walletId, walletPrivKey, xPubKey, copayer }); }; +API.prototype.save = function(inWcd, cb) { + var self = this; + + self._processWcdBeforeWrite(inWcd, function(err, wcd) { + if (err) return cb(err); + + self.storage.save(wcd, function(err) { + return cb(err, null); + }); + }); +} + API.prototype.generateKey = function(network, cb) { var self = this; network = network || 'livenet'; @@ -286,7 +369,8 @@ API.prototype.generateKey = function(network, cb) { return cb(self.storage.getName() + ' already contains a wallet'); var wcd = _initWcd(network); - self.storage.save(wcd, function(err) { + + self.save(wcd, function(err) { return cb(err, null); }); }); @@ -327,7 +411,7 @@ API.prototype.createWallet = function(walletName, copayerName, m, n, network, cb self._doJoinWallet(walletId, walletPrivKey, wcd.publicKeyRing[0], copayerName, function(err, wallet) { if (err) return cb(err); - self.storage.save(wcd, function(err) { + self.save(wcd, function(err) { return cb(err, n > 1 ? secret : null); }); }); @@ -338,7 +422,9 @@ API.prototype.createWallet = function(walletName, copayerName, m, n, network, cb API.prototype.reCreateWallet = function(walletName, cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'readonly', + }, function(err, wcd) { if (err) return cb(err); var walletPrivKey = new Bitcore.PrivateKey(); @@ -386,7 +472,7 @@ API.prototype.joinWallet = function(secret, copayerName, cb) { function(err, joinedWallet) { if (err) return cb(err); _addWalletToWcd(wcd, secretData.walletPrivKey, joinedWallet.m, joinedWallet.n); - self.storage.save(wcd, cb); + self.save(wcd, cb); }); }); }; @@ -394,7 +480,9 @@ API.prototype.joinWallet = function(secret, copayerName, cb) { API.prototype.getStatus = function(cb) { var self = this; - this._load(function(err, wcd) { + this._load({ + requiredAccess: 'readonly' + }, function(err, wcd) { if (err) return cb(err); var url = '/v1/wallets/'; @@ -419,7 +507,9 @@ API.prototype.sendTxProposal = function(opts, cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'readonly', + }, function(err, wcd) { if (err) return cb(err); if (!wcd.rwPrivKey) @@ -442,7 +532,9 @@ API.prototype.sendTxProposal = function(opts, cb) { API.prototype.createAddress = function(cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'readwrite', + }, function(err, wcd) { if (err) return cb(err); var url = '/v1/addresses/'; @@ -464,7 +556,9 @@ API.prototype.createAddress = function(cb) { API.prototype.getMainAddresses = function(opts, cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'readonly', + }, function(err, wcd) { if (err) return cb(err); var url = '/v1/addresses/'; @@ -486,7 +580,9 @@ API.prototype.getMainAddresses = function(opts, cb) { API.prototype.getBalance = function(cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'readonly', + }, function(err, wcd) { if (err) return cb(err); var url = '/v1/balance/'; self._doGetRequest(url, wcd, cb); @@ -505,7 +601,9 @@ API.prototype.export = function(opts, cb) { opts = opts || {}; var access = opts.access || 'full'; - this._load(function(err, wcd) { + this._load({ + requiredAccess: access, + }, function(err, wcd) { if (err) return cb(err); var v = []; @@ -570,7 +668,7 @@ API.prototype.import = function(str, cb) { return cb('Invalid source wallet'); wcd.network = wcd.publicKeyRing[0].substr(0, 4) == 'tpub' ? 'testnet' : 'livenet'; - self.storage.save(wcd, function(err) { + self.save(wcd, function(err) { return cb(err, WalletUtils.accessFromData(wcd)); }); }); @@ -584,6 +682,7 @@ API.prototype.parseTxProposals = function(txData, cb) { var self = this; this._loadAndCheck({ + requiredAccess: 'readonly', toComplete: txData.toComplete }, function(err, wcd) { if (err) return cb(err); @@ -614,7 +713,9 @@ API.prototype.parseTxProposals = function(txData, cb) { API.prototype.getTxProposals = function(opts, cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'readonly' + }, function(err, wcd) { if (err) return cb(err); var url = '/v1/txproposals/'; self._doGetRequest(url, wcd, function(err, txps) { @@ -678,7 +779,9 @@ API.prototype.getSignatures = function(txp, cb) { $.checkArgument(txp.creatorId); var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'full' + }, function(err, wcd) { if (err) return cb(err); if (!Verifier.checkTxProposal(wcd, txp)) { @@ -692,7 +795,9 @@ API.prototype.getSignatures = function(txp, cb) { API.prototype.getEncryptedWalletData = function(cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: 'readonly' + }, function(err, wcd) { if (err) return cb(err); var toComplete = JSON.stringify(_.pick(wcd, WALLET_AIRGAPPED_TOCOMPLETE)); return cb(null, _encryptMessage(toComplete, WalletUtils.privateKeyToAESKey(wcd.roPrivKey))); @@ -706,7 +811,9 @@ API.prototype.signTxProposal = function(txp, cb) { var self = this; - this._loadAndCheck({}, function(err, wcd) { + this._loadAndCheck({ + requiredAccess: txp.signatures ? 'readwrite' : 'full' + }, function(err, wcd) { if (err) return cb(err); if (!Verifier.checkTxProposal(wcd, txp)) { @@ -729,7 +836,9 @@ API.prototype.rejectTxProposal = function(txp, reason, cb) { var self = this; - this._loadAndCheck({}, + this._loadAndCheck({ + requiredAccess: 'readwrite' + }, function(err, wcd) { if (err) return cb(err); @@ -744,7 +853,9 @@ API.prototype.rejectTxProposal = function(txp, reason, cb) { API.prototype.broadcastTxProposal = function(txp, cb) { var self = this; - this._loadAndCheck({}, + this._loadAndCheck({ + requiredAccess: 'readwrite' + }, function(err, wcd) { if (err) return cb(err); @@ -757,7 +868,9 @@ API.prototype.broadcastTxProposal = function(txp, cb) { API.prototype.removeTxProposal = function(txp, cb) { var self = this; - this._loadAndCheck({}, + this._loadAndCheck({ + requiredAccess: 'readwrite' + }, function(err, wcd) { if (err) return cb(err); var url = '/v1/txproposals/' + txp.id; diff --git a/lib/walletutils.js b/lib/walletutils.js index d99fdf9..88de849 100644 --- a/lib/walletutils.js +++ b/lib/walletutils.js @@ -40,9 +40,26 @@ WalletUtils.accessFromData = function(data) { if (data.rwPrivKey) return 'readwrite'; - return 'readonly'; + if (data.roPrivKey) + return 'readonly'; + + return 'none'; +}; + +WalletUtils.accessNameToLevel = function(name) { + + if (name === 'full') + return 30; + if (name === 'readwrite') + return 20; + if (name === 'readonly') + return 10; + if (name === 'none') + return 0; + throw new Error('Bad access name:' + name); }; + WalletUtils.verifyMessage = function(text, signature, pubKey) { $.checkArgument(text); $.checkArgument(pubKey); @@ -143,4 +160,42 @@ WalletUtils.privateKeyToAESKey = function(privKey) { return Bitcore.crypto.Hash.sha256(pk.toBuffer()).slice(0, 16).toString('base64'); }; +WalletUtils.decryptWallet = function(data, password) { + $.checkArgument(data.enc); + var extraFields = JSON.parse(sjcl.decrypt(password, data.enc)); + delete data.enc; + return _.extend(data, extraFields); +}; + + +WalletUtils.sjclOpts = { + iter: 5000, +}; + +WalletUtils.encryptWallet = function(data, accessWithoutEncrytion, password) { + + // Fields to encrypt, given the NOPASSWD access level + var fieldsEncryptByLevel = { + none: _.keys(data), + readonly: ['xPrivKey', 'rwPrivKey', 'publicKeyRing' ], + readwrite: ['xPrivKey', ], + full: [], + }; + + var fieldsEncrypt = fieldsEncryptByLevel[accessWithoutEncrytion]; + $.checkState(!_.isUndefined(fieldsEncrypt)); + + if (!_.every(fieldsEncrypt, function(k) { + return data[k]; + })) throw new Error('Wallet does not contain necesary info to encrypt'); + + var toEncrypt = _.pick(data, fieldsEncrypt); + var enc = sjcl.encrypt(password, JSON.stringify(toEncrypt), WalletUtils.sjclOpts); + + var ret = _.omit(data, fieldsEncrypt); + ret.enc = enc; + return ret; +}; + + module.exports = WalletUtils; diff --git a/package.json b/package.json index 4f5841e..3b7a224 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "npmlog": "^0.1.1", "preconditions": "^1.0.7", "qr-image": "*", + "read": "^1.0.5", "request": "^2.53.0", "sjcl": "^1.0.2", "uuid": "*" diff --git a/test/integration/clientApi.js b/test/integration/clientApi.js index ae4413b..c132c0d 100644 --- a/test/integration/clientApi.js +++ b/test/integration/clientApi.js @@ -221,6 +221,108 @@ describe('client API ', function() { }); + describe('Storage Encryption', function() { + beforeEach(function() { + _.each(_.range(3), function(i) { + clients[i].on('needPassword', function(cb) { + return cb('1234#$@#%F,./.**'); + }); + }); + }); + + + it('full encryption roundtrip', function(done) { + clients[0].setNopasswdAccess('none'); + helpers.createAndJoinWallet(clients, 1, 1, function(err) { + should.not.exist(err); + + // Load it + var wcd = JSON.parse(fsmock._get('client0')); + fsmock._set('client1', wcd); + clients[1].getBalance(function(err, bal0) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should fail if wrong password', function(done) { + clients[0].setNopasswdAccess('none'); + helpers.createAndJoinWallet(clients, 1, 1, function(err) { + should.not.exist(err); + + // Load it + var wcd = JSON.parse(fsmock._get('client0')); + fsmock._set('client4', wcd); + + clients[4].on('needPassword', function(cb) { + return cb('1'); + }); + + clients[4].getBalance(function(err, bal0) { + err.should.equal('NOTAUTH'); + done(); + }); + }); + }); + + + it('should encrypt everything', function(done) { + clients[0].setNopasswdAccess('none'); + helpers.createAndJoinWallet(clients, 1, 1, function(err) { + should.not.exist(err); + var wcd = JSON.parse(fsmock._get('client0')); + _.keys(wcd).should.deep.equal(['enc']); + done(); + }); + }); + + it('should encrypt xpriv access', function(done) { + clients[0].setNopasswdAccess('readwrite'); + helpers.createAndJoinWallet(clients, 1, 1, function(err) { + should.not.exist(err); + var wcd = JSON.parse(fsmock._get('client0')); + should.exist(wcd.enc); + should.not.exist(wcd.xpriv); + done(); + }); + }); + + it('should encrypt rwkey', function(done) { + clients[0].setNopasswdAccess('readonly'); + helpers.createAndJoinWallet(clients, 1, 1, function(err) { + should.not.exist(err); + var wcd = JSON.parse(fsmock._get('client0')); + should.exist(wcd.enc); + should.not.exist(wcd.xpriv); + should.not.exist(wcd.rwPrivKey); + done(); + }); + }); + + + _.each(['full', 'readwrite', 'readonly', 'none'], function(k) { + it('full encryption roundtrip: type:' + k, function(done) { + clients[0].setNopasswdAccess(k); + helpers.createAndJoinWallet(clients, 1, 1, function(err) { + should.not.exist(err); + + // Load it + var wcd = JSON.parse(fsmock._get('client0')); + fsmock._set('client1', wcd); + clients[1].getBalance(function(err, bal0) { + should.not.exist(err); + done(); + }); + }); + }); + }); + + it.skip('should not ask for password if not needed (readonly)', function(done) {}); + it.skip('should not ask for password if not needed (readwrite)', function(done) {}); + }); + + describe('Wallet Creation', function() { it('should check balance in a 1-1 ', function(done) { helpers.createAndJoinWallet(clients, 1, 1, function(err) { @@ -281,7 +383,7 @@ describe('client API ', function() { should.not.exist(err); // Get right response - clients[0]._load(function(err, data) { + clients[0]._load({}, function(err, data) { var url = '/v1/wallets/'; clients[0]._doGetRequest(url, data, function(err, x) { @@ -305,7 +407,7 @@ describe('client API ', function() { should.not.exist(err); // Get right response - var data = clients[0]._load(function(err, data) { + var data = clients[0]._load({}, function(err, data) { var url = '/v1/wallets/'; clients[0]._doGetRequest(url, data, function(err, x) { @@ -330,7 +432,7 @@ describe('client API ', function() { should.not.exist(err); // Get right response - var data = clients[0]._load(function(err, data) { + var data = clients[0]._load({}, function(err, data) { var url = '/v1/wallets/'; clients[0]._doGetRequest(url, data, function(err, x) { @@ -362,6 +464,12 @@ describe('client API ', function() { delete data.rwPrivKey; fsmock._set('client0', JSON.stringify(data)); data.rwPrivKey = null; + + // Overwrite client's API auth checks + clients[0]._processWcdAfterRead = function(rawData, xx, cb) { + return cb(null, rawData); + }; + clients[0].createAddress(function(err, x0) { err.code.should.equal('NOTAUTHORIZED'); done(); @@ -378,6 +486,11 @@ describe('client API ', function() { clients[1].import(str, function(err, wallet) { should.not.exist(err); + // Overwrite client's API auth checks + clients[1]._processWcdAfterRead = function(rawData, xx, cb) { + return cb(null, rawData); + }; + clients[1].createAddress(function(err, x0) { err.code.should.equal('NOTAUTHORIZED'); clients[0].createAddress(function(err, x0) { @@ -425,6 +538,12 @@ describe('client API ', function() { }; clients[1].sendTxProposal(opts, function(err, x) { should.not.exist(err); + + // Overwrite client's API auth checks + clients[1]._processWcdAfterRead = function(rawData, xx, cb) { + return cb(null, rawData); + }; + clients[1].signTxProposal(x, function(err, tx) { err.code.should.be.equal('BADSIGNATURES'); clients[1].getTxProposals({}, function(err, txs) { @@ -656,7 +775,7 @@ describe('client API ', function() { should.not.exist(err); // Get right response - clients[0]._load(function(err, data) { + clients[0]._load({}, function(err, data) { var url = '/v1/addresses/'; clients[0]._doPostRequest(url, {}, data, function(err, address) { @@ -680,7 +799,7 @@ describe('client API ', function() { should.not.exist(err); // Get right response - clients[0]._load(function(err, data) { + clients[0]._load({}, function(err, data) { var url = '/v1/addresses/'; clients[0]._doPostRequest(url, {}, data, function(err, address) { @@ -865,7 +984,7 @@ describe('client API ', function() { // Get right response - clients[0]._load(function(err, data) { + clients[0]._load({}, function(err, data) { var url = '/v1/txproposals/'; clients[0]._doGetRequest(url, data, function(err, txps) { @@ -904,7 +1023,7 @@ describe('client API ', function() { // Get right response - clients[0]._load(function(err, data) { + clients[0]._load({}, function(err, data) { var url = '/v1/txproposals/'; clients[0]._doGetRequest(url, data, function(err, txps) { @@ -943,7 +1062,7 @@ describe('client API ', function() { // Get right response - clients[0]._load(function(err, data) { + clients[0]._load({}, function(err, data) { var url = '/v1/txproposals/'; clients[0]._doGetRequest(url, data, function(err, txps) { // Tamper data