bmultisig
Version:
Bcoin wallet plugin for multi signature transaction proposals
1,059 lines (831 loc) • 27.3 kB
JavaScript
/*!
* http.js - http server for bmultisig
* Copyright (c) 2018, The Bcoin Developers (MIT License).
* https://github.com/bcoin-org/bmultisig
*/
'use strict';
const assert = require('bsert');
const bcoin = require('bcoin');
const {Outpoint, Script, Address, Network} = bcoin;
const HDPublicKey = bcoin.hd.HDPublicKey;
const Validator = require('bval');
const Logger = require('blgr');
const base58 = require('bcrypto/lib/encoding/base58');
const sha256 = require('bcrypto/lib/sha256');
const random = require('bcrypto/lib/random');
const {safeEqual} = require('bcrypto/lib/safe');
const {Server} = require('bweb');
const MultisigDB = require('./multisigdb');
const Cosigner = require('./primitives/cosigner');
const Proposal = require('./primitives/proposal');
const RouteList = require('./utils/routelist');
const {WalletDetails} = require('./export');
const {FORCE} = Proposal.status;
/**
* Multisig HTTP server
* @alias module:multisig.HTTP
* @extends {Server}
* @property {MultisigHTTPOptions} options
* @property {MultisigDB} msdb
* @property {bcoin.Network} network
* @property {Logger} logger
* @property {bcoin.wallet.HTTP} whttp
* @property {RouteList} notauthRoutes - skip authentication
* @property {RouteList} proxyRoutes - proxy requests to the bwallet
*/
class MultisigHTTP extends Server {
/**
* Create an http server.
* @constructor
* @param {MultisigHTTPOptions} options
* @param {MultisigDB} options.msdb
* @param {Logger} options.logger
* @param {bcoin.Network} options.network
* @param {bcoin.wallet.HTTP} options.whttp
* @param {String} options.version
*/
constructor(options) {
super(new MultisigHTTPOptions(options));
this.msdb = this.options.msdb;
this.network = this.options.network;
this.logger = this.options.logger.context('multisig-http');
this.whttp = this.options.whttp;
this.noauthRoutes = new RouteList();
this.proxyRoutes = new RouteList();
this.init();
}
/*
* Initialize http server.
*/
init() {
this.on('request', (req, res) => {
this.logger.debug('Request for method=%s path=/multisig%s (%s).',
req.method, req.pathname, req.socket.remoteAddress);
});
this.setupNoAuthRoutes();
this.setupProxyRoutes();
this.initRouter();
this.initSockets();
}
/*
* Skip authentication for these routes
*/
setupNoAuthRoutes() {
// create wallet
this.noauthRoutes.put('/:id');
// join wallet
this.noauthRoutes.post('/:id/join');
// admin paths
this.noauthRoutes.get('/');
this.noauthRoutes.del('/:id');
}
/*
* Proxy these routes
*/
setupProxyRoutes() {
this.proxyRoutes.post('/:id/zap');
// abandon transactions
this.proxyRoutes.del('/:id/tx/:hash');
this.proxyRoutes.get('/:id/block');
this.proxyRoutes.get('/:id/block/:height');
this.proxyRoutes.get('/:id/key/:address');
this.proxyRoutes.post('/:id/address');
this.proxyRoutes.post('/:id/change');
this.proxyRoutes.post('/:id/nested');
this.proxyRoutes.get('/:id/balance');
this.proxyRoutes.get('/:id/coin');
this.proxyRoutes.get('/:id/coin/:hash/:index');
this.proxyRoutes.get('/:id/tx/history');
this.proxyRoutes.get('/:id/tx/unconfirmed');
this.proxyRoutes.get('/:id/tx/range');
this.proxyRoutes.get('/:id/tx/last');
this.proxyRoutes.get('/:id/tx/:hash');
this.proxyRoutes.post('/:id/resend');
}
/*
* Admin authentication
*/
async checkAdminHook(req, res) {
if (!this.options.walletAuth) {
req.admin = true;
return;
}
const valid = Validator.fromRequest(req);
const token = valid.buf('token');
if (token && safeEqual(token, this.options.adminToken)) {
req.admin = true;
return;
}
}
/*
* grab wallet and attach to request
*/
async getWalletHook(req, res) {
// contains - :id
if (!req.params.id)
return;
// ignore - PUT /multisig/:id
if (req.path.length === 1 && req.method === 'PUT')
return;
const id = req.params.id;
if (!id) {
res.json(400);
return;
}
const mswallet = await this.msdb.getWallet(id);
if (!mswallet) {
res.json(404);
return;
}
req.mswallet = mswallet;
req.wallet = mswallet.wallet;
}
/*
* Authenticate user with cosignerToken
*/
async cosignerAuth(req, res) {
if (req.admin)
return;
if (this.noauthRoutes.has(req))
return;
const valid = Validator.fromRequest(req);
const mswallet = req.mswallet;
const cosignerToken = valid.buf('token');
if (!cosignerToken || !mswallet)
error(403, 'Authentication error.');
const cosigner = mswallet.auth(cosignerToken);
req.cosigner = cosigner;
}
async proxyRequest(req, res) {
assert(this.whttp, 'Can not proxy without parent wallet http');
if (!this.proxyRoutes.has(req))
return;
// PROXY Routes don't go through normal hooks
// get wallet
await this.getWalletHook(req, res);
if (res.sent)
return;
// authenticate..
await this.cosignerAuth(req, res);
// We already did authentication with cosignerToken
// wallet.HTTP does not need to check walletToken
req.admin = true;
// replace /multisig with /wallet for
// wallet.HTTP to handle it.
const url = '/wallet' + req.url;
req.navigate(url);
// because we only use one account
// all account related stuff can use `default` account
req.query.account = 'default';
const route = await this.whttp.routes.handle(req, res);
if (!route)
res.json(404);
}
/*
* Initialize routes.
*/
initRouter() {
if (this.options.cors)
this.use(this.cors());
if (!this.options.noAuth) {
this.use(this.basicAuth({
hash: sha256.digest,
password: this.options.apiKey,
realm: 'wallet'
}));
}
this.use(this.bodyParser({
type: 'json'
}));
// check if token is for admin
this.use(this.checkAdminHook.bind(this));
// proxy request to wallet
this.use(this.proxyRequest.bind(this));
this.use(this.router());
this.error((err, req, res) => {
const code = err.statusCode || 500;
res.json(code, {
error: {
type: err.type,
code: err.code,
message: err.message
}
});
});
// load wallet
this.hook(this.getWalletHook.bind(this));
// authenticate cosigner
this.hook(this.cosignerAuth.bind(this));
// List wallets (Admin Only)
this.get('/', async (req, res) => {
if (!req.admin) {
res.json(403);
return;
}
const wallets = await this.msdb.getWallets();
res.json(200, { wallets });
});
// Get wallet information
this.get('/:id', async (req, res) => {
const balance = await req.wallet.getBalance();
const stats = await req.mswallet.getStats();
res.json(200, req.mswallet.getJSON({ balance, stats }));
});
// Create multisig wallet
this.put('/:id', async (req, res) => {
const valid = Validator.fromRequest(req);
const id = valid.str('id');
const joinSignature = valid.buf('joinSignature');
const joinPubKey = valid.buf('joinPubKey');
// wallet options
const walletOptions = {
id: id,
m: valid.u32('m'),
n: valid.u32('n'),
witness: valid.bool('witness', true),
joinPubKey: joinPubKey
};
// cosigner options
const cosignerVal = new Validator(valid.obj('cosigner'), false);
const name = cosignerVal.str('name');
const purpose = cosignerVal.u32('purpose');
const fingerPrint = cosignerVal.u32('fingerPrint');
const data = cosignerVal.buf('data');
const accountKey = cosignerVal.str('accountKey');
const key = HDPublicKey.fromBase58(accountKey, this.network);
const keyProof = cosignerVal.buf('accountKeyProof');
// multisig auth/validation options
const token = cosignerVal.buf('token');
const authPubKey = cosignerVal.buf('authPubKey');
const cosigner = Cosigner.fromOptions({
name,
purpose,
fingerPrint,
data,
authPubKey,
joinSignature,
token,
key
});
if (keyProof) {
const validKeyProof = cosigner.verifyProof(
keyProof,
id,
this.network
);
enforce(validKeyProof, 'accountKeyProof is not valid.');
}
const mswallet = await this.msdb.create(walletOptions, cosigner);
const stats = await mswallet.getStats();
res.json(200, mswallet.getJSON({ stats, cosignerIndex: 0 }));
});
// Removes wallet from WDB and MSDB unindexes all info
this.del('/:id', async (req, res) => {
if (!req.admin) {
res.json(403);
return;
}
const removed = await req.mswallet.remove();
res.json(200, { success: removed });
});
// Get locked coins in TXDB
this.get('/:id/locked', async (req, res) => {
const valid = Validator.fromRequest(req);
const proposalOnly = valid.bool('proposalOnly', false);
const locked = await req.mswallet.getLocked(proposalOnly);
const result = [];
for (const outpoint of locked)
result.push(outpoint.toJSON());
res.json(200, result);
});
// Lock coin
this.put('/:id/locked/:hash/:index', async (req, res) => {
const valid = Validator.fromRequest(req);
const hash = valid.brhash('hash');
const index = valid.u32('index');
enforce(hash, 'Hash is required.');
enforce(index != null, 'Index is required.');
const outpoint = new Outpoint(hash, index);
req.mswallet.lockCoinTXDB(outpoint);
res.json(200, { success: true });
});
// Unlock coin.
this.del('/:id/locked/:hash/:index', async (req, res) => {
const valid = Validator.fromRequest(req);
const hash = valid.brhash('hash');
const index = valid.u32('index');
const force = valid.bool('force', false);
enforce(hash, 'Hash is required.');
enforce(index != null, 'Index is required.');
const outpoint = new Outpoint(hash, index);
if (force && req.admin) {
await req.mswallet.forceUnlockCoin(outpoint);
res.json(200, { success: true });
return;
}
await req.mswallet.unlockCoin(outpoint);
res.json(200, { success: true });
});
// Export wallet.
this.get('/:id/export', async (req, res) => {
if (!req.admin) {
res.json(403);
return;
}
const id = req.mswallet.id;
const info = await this.msdb.export(id);
const json = info.getJSON(this.network);
res.json(200, json);
});
// Import wallet.
this.post('/import', async (req, res) => {
if (!req.admin) {
res.json(403);
return;
}
const valid = Validator.fromRequest(req);
const id = valid.str('id');
const options = valid.obj('importOptions');
const importDetails = WalletDetails.fromJSON(options);
const mswallet = await this.msdb.import(id, importDetails);
res.json(200, mswallet.getJSON({ cosignerIndex: 0 }));
});
// Join multisig wallet
this.post('/:id/join', async (req, res) => {
const valid = Validator.fromRequest(req);
const joinSignature = valid.buf('joinSignature');
// cosigner options
const cosignerVal = new Validator(valid.obj('cosigner'), false);
const name = cosignerVal.str('name');
const purpose = cosignerVal.u32('purpose');
const fingerPrint = cosignerVal.u32('fingerPrint');
const data = cosignerVal.buf('data');
const accountKey = cosignerVal.str('accountKey');
const key = HDPublicKey.fromBase58(accountKey, this.network);
const keyProof = cosignerVal.buf('accountKeyProof');
// multisig auth/validation options
const token = cosignerVal.buf('token');
const authPubKey = cosignerVal.buf('authPubKey');
const cosigner = Cosigner.fromOptions({
name,
purpose,
fingerPrint,
data,
authPubKey,
joinSignature,
token,
key
});
if (keyProof) {
const validKeyProof = cosigner.verifyProof(
keyProof,
req.mswallet.id,
this.network
);
enforce(validKeyProof, 'accountKeyProof is not valid.');
}
const joined = await req.mswallet.join(cosigner, key);
const cosignerIndex = joined.cosigners.length - 1;
res.json(200, joined.getJSON({ cosignerIndex }));
});
// List accounts (compatibility).
this.get('/:id/account', (req, res) => {
res.json(200, ['default']);
});
// Get account (compatibility).
this.get('/:id/account/:account', async (req, res) => {
const valid = Validator.fromRequest(req);
const acct = valid.str('account', 'default');
if (acct !== 'default') {
res.json(404);
return;
}
const account = await req.mswallet.getAccount();
const balance = await req.wallet.getBalance();
res.json(200, account.getJSON(balance));
});
// Set new token for cosigner
this.put('/:id/token', async (req, res) => {
enforce(req.cosigner, 'Cosigner not found.');
const valid = Validator.fromRequest(req);
const token = valid.buf('newToken');
enforce(token.length === 32, 'newToken must be 32 bytes.');
const cosigner = await req.mswallet.setToken(req.cosigner, token);
res.json(200, cosigner.getJSON(true, this.network));
});
// Create tx
this.post('/:id/create', async (req, res) => {
const valid = Validator.fromRequest(req);
const options = parseTXOptions(valid, this.network);
const tx = await req.mswallet.createTX(options);
res.json(200, tx.getJSON(this.network));
});
// Get list of proposals.
// TODO: Add limits.
this.get('/:id/proposal', async (req, res) => {
const valid = Validator.fromRequest(req);
const pending = valid.bool('pending', true);
let proposals;
if (pending) {
proposals = await req.mswallet.getPendingProposals();
} else {
proposals = await req.mswallet.getProposals();
}
return res.json(200, {
proposals: proposals.map((p) => {
return p.getJSON(null, req.mswallet.cosigners, this.network);
})
});
});
// Create proposal.
this.post('/:id/proposal', async (req, res) => {
enforce(req.cosigner, 'Cosigner not found.');
const requestValid = Validator.fromRequest(req);
const signature = requestValid.buf('signature');
const options = requestValid.obj('proposal');
const valid = new Validator(options, false);
const txValid = new Validator(valid.obj('txoptions'), false);
const txoptions = parseTXOptions(txValid, this.network);
const memo = valid.str('memo');
const timestamp = valid.u64('timestamp');
enforce(memo, 'Memo not found.');
enforce(timestamp, 'Timestamp not found.');
const [proposal, tx] = await req.mswallet.createProposal(
options,
req.cosigner,
txoptions,
signature
);
enforce(proposal, 'Could not create proposal.');
res.json(200, proposal.getJSON(tx, req.mswallet.cosigners, this.network));
});
// Get proposal info
this.get('/:id/proposal/:pid', async (req, res) => {
const valid = Validator.fromRequest(req);
const pid = valid.u32('pid');
const getTX = valid.bool('tx', true);
const proposal = await req.mswallet.getProposal(pid);
if (!proposal) {
res.json(404);
return;
}
let tx = null;
if (getTX)
tx = await req.mswallet.getProposalTX(pid);
const cosigners = req.mswallet.cosigners;
res.json(200, proposal.getJSON(tx, cosigners, this.network));
});
// Get proposal mtx
// TODO: Add option for returning previous transactions.
this.get('/:id/proposal/:pid/tx', async (req, res) => {
const valid = Validator.fromRequest(req);
const pid = valid.u32('pid');
const getPaths = valid.bool('paths', false);
const getScripts = valid.bool('scripts', false);
const getTXs = valid.bool('txs', false);
let paths, txs, rings, scripts;
const mtx = await req.mswallet.getProposalMTX(pid);
if (!mtx) {
res.json(404);
return;
}
if (getPaths)
paths = await req.mswallet.getInputPaths(mtx);
if (getScripts && rings)
scripts = rings.map(r => r.script);
if (getScripts && !rings) {
const rings = await req.mswallet.deriveInputs(mtx, paths);
scripts = rings.map(r => r.script);
}
if (getTXs) {
txs = [];
for (const {prevout} of mtx.inputs) {
const {hash} = prevout;
const record = await req.wallet.getTX(hash);
txs.push(record.tx);
}
}
res.json(200, {
tx: mtx.getJSON(this.network),
txs: txs ? txs.map(t => t.toRaw().toString('hex')) : null,
paths: paths ? paths.map((p) => {
if (!p)
return null;
return {
branch: p.branch,
index: p.index,
receive: p.branch === 0,
change: p.branch === 1,
nested: p.branch === 2
};
}) : null,
scripts: scripts ? scripts.map(s => s.toRaw().toString('hex')) : null
});
});
// Approve proposal
this.post('/:id/proposal/:pid/approve', async (req, res) => {
const valid = Validator.fromRequest(req);
const pid = valid.u32('pid');
const hexSigs = valid.array('signatures', []);
const broadcast = valid.bool('broadcast', true);
enforce(req.cosigner, 'Cosigner not found.');
enforce(hexSigs.length, 'Could not find signatures');
const sigs = hexSigs.map((sig) => {
if (!sig)
return null;
return Buffer.from(sig, 'hex');
});
enforce(sigs && sigs.length > 0, 'Signatures not found.');
const proposal = await req.mswallet.approveProposal(
pid,
req.cosigner,
sigs
);
if (!proposal) {
res.json(404);
return;
}
let broadcasted = false, tx, err;
if (proposal.isApproved() && broadcast) {
try {
tx = await req.mswallet.sendProposal(pid);
assert(tx, 'Could not broadcast approved proposal.');
broadcasted = true;
} catch (e) {
err = e;
this.logger.debug(`Failed to broadcast ${e.message}`);
}
}
res.json(200, {
broadcasted,
broadcastError: err ? err.message : null,
proposal: proposal.getJSON(tx)
});
});
// Reject proposal
this.post('/:id/proposal/:pid/reject', async (req, res) => {
const valid = Validator.fromRequest(req);
const pid = valid.u32('pid');
const force = valid.bool('force', false);
const signature = valid.buf('signature');
if (force && req.admin) {
const proposal = await req.mswallet.forceRejectProposal(pid, FORCE);
if (!proposal) {
res.json(404);
return;
}
res.json(200, proposal.toJSON());
return;
}
if (force) {
res.json(403);
return;
}
enforce(req.cosigner, 'Cosigner not found.');
const proposal = await req.mswallet.rejectProposal(
pid,
req.cosigner,
signature
);
if (!proposal) {
res.json(404);
return;
}
res.json(200, proposal.toJSON());
});
// Get proposal by coin (Admin or Cosigner)
this.get('/:id/proposal/coin/:hash/:index', async (req, res) => {
const valid = Validator.fromRequest(req);
const hash = valid.brhash('hash');
const index = valid.u32('index');
enforce(hash, 'Hash is required.');
enforce(index != null, 'Index is required.');
const outpoint = new Outpoint(hash, index);
const proposal = await req.mswallet.getProposalByOutpoint(outpoint);
if (!proposal) {
res.json(404);
return;
}
res.json(200, proposal.toJSON());
});
// Send proposal tx
this.post('/:id/proposal/:pid/send', async (req, res) => {
const valid = Validator.fromRequest(req);
const pid = valid.u32('pid');
const tx = await req.mswallet.sendProposal(pid);
if (!tx) {
res.json(404);
return;
}
res.json(200, tx.toJSON());
});
}
/*
* Initialize websockets.
*/
initSockets() {
const handleEvent = (event, wallet, json) => {
const name = `w:${wallet.id}`;
if (!this.channel(name) && !this.channel('w:*'))
return;
if (this.channel(name))
this.to(name, event, wallet.id, json);
if (this.channel('w:*'))
this.to('w:*', event, wallet.id, json);
};
this.msdb.on('join', (wallet, cosigner) => {
const json = cosigner.getJSON(false, this.network);
handleEvent('join', wallet, json);
});
this.msdb.on('proposal created', (wallet, proposal, tx) => {
const json = proposal.getJSON(tx, wallet.cosigners, this.network);
handleEvent('proposal created', wallet, json);
});
this.msdb.on('proposal rejected', (wallet, proposal, cosigner) => {
const json = {
proposal: proposal.getJSON(),
cosigner: cosigner ? cosigner.getJSON(false, this.network) : null
};
handleEvent('proposal rejected', wallet, json);
});
this.msdb.on('proposal approved', (wallet, proposal, cosigner, tx) => {
const json = {
proposal: proposal.getJSON(tx),
cosigner: cosigner.getJSON(false, this.network)
};
handleEvent('proposal approved', wallet, json);
});
}
handleSocket(socket) {
socket.hook('ms-join', async (...args) => {
const valid = new Validator(args);
const id = valid.str(0, '');
const token = valid.buf(1);
if (!id)
throw new Error('Invalid parameter.');
if (!this.options.walletAuth) {
socket.join('admin');
} else if (token) {
if (safeEqual(token, this.options.adminToken))
socket.join('admin');
}
if (socket.channel('admin') || !this.options.walletAuth) {
socket.join(`w:${id}`);
return null;
}
if (id === '*')
throw new Error('Bad token.');
if (!token)
throw new Error('Invalid parameter.');
const mswallet = await this.msdb.getWallet(id);
if (!mswallet)
throw new Error('Wallet does not exist.');
try {
mswallet.auth(token);
} catch (e) {
this.logger.info(`Wallet auth failure for ${id}: ${e.message}`);
throw new Error('Bad token.');
}
this.logger.info(`Successful wallet auth for ${id}.`);
socket.join(`w:${id}`);
return null;
});
}
}
/**
* Multisig HTTP Options.
* @property {MultisigDB} msdb
* @property {bcoin.Network} network
* @property {blgr.Logger} logger
* @property {MultisigDB} msdb
*/
class MultisigHTTPOptions {
constructor (options) {
this.network = Network.primary;
this.logger = Logger.global;
this.msdb = null;
this.whttp = null;
this.version = '0.0.0';
this.apiKey = base58.encode(random.randomBytes(20));
this.apiHash = sha256.digest(Buffer.from(this.apiKey, 'ascii'));
this.adminToken = random.randomBytes(32);
this.serviceHash = this.apiHash;
this.noAuth = false;
this.walletAuth = false;
this.cors = false;
this.fromOptions(options);
}
fromOptions(options) {
assert(options, 'MultisigHTTP Server requires options');
assert(options.msdb instanceof MultisigDB,
'MultiHTTP Server requires MultisigDB');
this.msdb = options.msdb;
this.logger = options.msdb.logger;
this.network = options.msdb.network;
if (options.logger != null) {
assert(typeof options.logger === 'object',
'MultiHTTP Server requires correct logger'
);
this.logger = options.logger;
}
if (options.version != null) {
assert(typeof options.version === 'string');
this.version = options.version;
}
if (options.apiKey != null) {
assert(typeof options.apiKey === 'string',
'API key must be a string.');
assert(options.apiKey.length <= 255,
'API key must be under 255 bytes.');
this.apiKey = options.apiKey;
this.apiHash = sha256.digest(Buffer.from(this.apiKey, 'ascii'));
}
if (options.whttp != null) {
assert(typeof options.whttp === 'object',
'Incorrect wallet http'
);
this.whttp = options.whttp;
}
if (options.adminToken != null) {
if (typeof options.adminToken === 'string') {
assert(options.adminToken.length === 64,
'Admin token must be a 32 byte hex string.');
const token = Buffer.from(options.adminToken, 'hex');
assert(token.length === 32,
'Admin token must be a 32 byte hex string.');
this.adminToken = token;
} else {
assert(Buffer.isBuffer(options.adminToken),
'Admin token must be a hex string or buffer.');
assert(options.adminToken.length === 32,
'Admin token must be 32 bytes.');
this.adminToken = options.adminToken;
}
}
if (options.noAuth != null) {
assert(typeof options.noAuth === 'boolean');
this.noAuth = options.noAuth;
}
if (options.walletAuth != null) {
assert(typeof options.walletAuth === 'boolean');
this.walletAuth = options.walletAuth;
}
if (options.cors != null) {
assert(typeof options.cors === 'boolean');
this.cors = options.cors;
}
}
}
/*
* Helpers
*/
/**
* Parse transaction options.
* @ignore
* @param {Validator} valid
* @param {Network} network
* @returns {Object}
*/
function parseTXOptions(valid, network) {
const outputs = valid.array('outputs', []);
const options = {
rate: valid.u64('rate'),
blocks: valid.u32('blocks'),
maxFee: valid.u64('maxFee'),
selection: valid.str('selection'),
smart: valid.bool('smart'),
sort: valid.bool('sort'),
subtractFee: valid.bool('subtractFee'),
subtractIndex: valid.i32('subtractIndex'),
depth: valid.u32(['confirmations', 'depth']),
outputs: []
};
for (const output of outputs) {
const valid = new Validator(output);
let addr = valid.str('address');
let script = valid.buf('script');
if (addr)
addr = Address.fromString(addr, network);
if (script)
script = Script.fromRaw(script);
options.outputs.push({
address: addr,
script: script,
value: valid.u64('value')
});
}
return options;
}
function error(statusCode, msg) {
const err = new Error(msg);
err.statusCode = statusCode;
throw err;
}
function enforce(value, msg) {
if (!value)
error(400, msg);
}
/*
* Expose
*/
module.exports = MultisigHTTP;