UNPKG

ilp-plugin-mini-accounts

Version:
512 lines (442 loc) 18.1 kB
import * as crypto from 'crypto' const BtpPacket = require('btp-packet') import * as WebSocket from 'ws' import * as assert from 'assert' import AbstractBtpPlugin, * as BtpPlugin from 'ilp-plugin-btp' import * as ILDCP from 'ilp-protocol-ildcp' import * as IlpPacket from 'ilp-packet' const { Errors } = IlpPacket const StoreWrapper = require('ilp-store-wrapper') import OriginWhitelist from './lib/origin-whitelist' import Token from './token' import { Store, StoreWrapper } from './types' const createLogger = require('ilp-logger') import * as http from 'http' import * as https from 'https' const DEBUG_NAMESPACE = 'ilp-plugin-mini-accounts' function tokenToAccount (token: string): string { return BtpPacket.base64url(crypto.createHash('sha256').update(token).digest()) } interface Logger { info (...msg: any[]): void warn (...msg: any[]): void error (...msg: any[]): void debug (...msg: any[]): void trace (...msg: any[]): void } export interface IlpPluginMiniAccountsConstructorOptions { port?: number wsOpts?: WebSocket.ServerOptions currencyScale?: number debugHostIldcpInfo?: ILDCP.IldcpResponse allowedOrigins?: string[] generateAccount?: boolean _store?: Store } export interface IlpPluginMiniAccountsConstructorModules { log?: Logger store?: Store } enum AccountMode { // Account is set using the `auth_username` BTP subprotocol. // A store is required in this mode. Username, // Account is set to sha256(token). The `auth_username` subprotocol is disallowed. HashToken, // Account is set to `auth_username` if available, otherwise sha256(token) is used. UsernameOrHashToken } function accountModeIsStored (mode: AccountMode): boolean { return mode === AccountMode.Username || mode === AccountMode.UsernameOrHashToken } /* tslint:disable-next-line:no-empty */ function noopTrace (...msg: any[]): void { } export default class Plugin extends AbstractBtpPlugin { static version = 2 private _port: number private _wsOpts: WebSocket.ServerOptions // There's an extra underscore because AbstractBtpPlugin already has a property // named `_httpServer`. /* tslint:disable-next-line:variable-name */ private _miniAccountsHttpServer: http.Server | https.Server protected _currencyScale: number private _debugHostIldcpInfo?: ILDCP.IldcpResponse protected _log: Logger private _trace: (...msg: any[]) => void private _connections: Map<string, Set<WebSocket>> = new Map() private _allowedOrigins: OriginWhitelist private _accountMode: AccountMode protected _store?: StoreWrapper private _hostIldcpInfo: ILDCP.IldcpResponse protected _prefix: string // These can be overridden. // TODO can this be overridden via `extends`?? protected _handleCustomData: (from: string, btpPacket: BtpPlugin.BtpPacket) => Promise<BtpPlugin.BtpSubProtocol[]> protected _handlePrepareResponse: (destination: string, parsedIlpResponse: IlpPacket.IlpPacket, preparePacket: { type: IlpPacket.Type.TYPE_ILP_PREPARE, typeString?: 'ilp_prepare', data: IlpPacket.IlpPrepare }) => void constructor (opts: IlpPluginMiniAccountsConstructorOptions, { log, store }: IlpPluginMiniAccountsConstructorModules = {}) { super({}) if (opts.wsOpts && opts.wsOpts.port && opts.port) { throw new Error('Specify at most one of: `ops.wsOpts.port`, `opts.port`.') } else if (opts.wsOpts && opts.wsOpts.port) { this._port = opts.wsOpts.port } else if (opts.port) { this._port = opts.port } else { this._port = 3000 } this._wsOpts = opts.wsOpts || { port: this._port } if (this._wsOpts.server) { this._miniAccountsHttpServer = this._wsOpts.server } this._currencyScale = opts.currencyScale || 9 this._debugHostIldcpInfo = opts.debugHostIldcpInfo const _store = store || opts._store this._accountMode = _store ? AccountMode.UsernameOrHashToken : AccountMode.HashToken if (opts.generateAccount === true) this._accountMode = AccountMode.HashToken if (opts.generateAccount === false) { if (!_store) { throw new Error('_store is required when generateAccount is false') } this._accountMode = AccountMode.Username } this._log = log || createLogger(DEBUG_NAMESPACE) this._log.trace = this._log.trace || noopTrace this._allowedOrigins = new OriginWhitelist(opts.allowedOrigins || []) if (_store) { this._store = new StoreWrapper(_store) } } /* tslint:disable:no-empty */ // These can be overridden. protected async _preConnect (): Promise<void> {} // plugin-btp and plugin-mini-accounts use slightly different signatures for _connect // making the mini-accounts params optional makes them kinda compatible protected async _connect (address: string, authPacket: BtpPlugin.BtpPacket, opts: { ws: WebSocket, req: http.IncomingMessage }): Promise<void> {} protected async _close (account: string, err?: Error): Promise<void> {} protected _sendPrepare (destination: string, parsedPacket: IlpPacket.IlpPacket): void {} /* tslint:enable:no-empty */ ilpAddressToAccount (ilpAddress: string): string { if (ilpAddress.substr(0, this._prefix.length) !== this._prefix) { throw new Error('ILP address (' + ilpAddress + ') must start with prefix (' + this._prefix + ')') } return ilpAddress.substr(this._prefix.length).split('.')[0] } async connect (): Promise<void> { if (this._wss) return if (this._debugHostIldcpInfo) { this._hostIldcpInfo = this._debugHostIldcpInfo } else if (this._dataHandler) { this._hostIldcpInfo = await ILDCP.fetch(this._dataHandler.bind(this)) } else { throw new Error('no request handler registered') } this._prefix = this._hostIldcpInfo.clientAddress + '.' if (this._preConnect) { try { await this._preConnect() } catch (err) { this._log.debug(`Error on _preConnect. Reason is: ${err.message}`) throw new Error('Failed to connect') } } this._log.info('listening on port ' + this._port) if (this._miniAccountsHttpServer) { this._miniAccountsHttpServer.listen(this._port) } const wss = this._wss = new WebSocket.Server(this._wsOpts) wss.on('connection', (wsIncoming, req) => { this._log.trace('got connection') if (typeof req.headers.origin === 'string' && !this._allowedOrigins.isOk(req.headers.origin)) { this._log.debug(`Closing a websocket connection received from a browser. Origin is ${req.headers.origin}`) this._log.debug('If you are running moneyd, you may allow this origin with the flag --allow-origin.' + ' Run moneyd --help for details.') wsIncoming.close() return } let token: string let account: string const closeHandler = (error?: Error) => { this._log.debug('incoming ws closed. error=', error) if (account) this._removeConnection(account, wsIncoming) if (this._close) { this._close(this._prefix + account, error) .catch(e => { this._log.debug('error during custom close handler. error=', e) }) } } wsIncoming.on('close', closeHandler) wsIncoming.on('error', closeHandler) // The first message must be an auth packet // with the macaroon as the auth_token let authPacket: BtpPlugin.BtpPacket wsIncoming.once('message', async (binaryAuthMessage) => { try { authPacket = BtpPacket.deserialize(binaryAuthMessage) assert.strictEqual(authPacket.type, BtpPacket.TYPE_MESSAGE, 'First message sent over BTP connection must be auth packet') assert(authPacket.data.protocolData.length >= 2, 'Auth packet must have auth and auth_token subprotocols') assert.strictEqual(authPacket.data.protocolData[0].protocolName, 'auth', 'First subprotocol must be auth') for (let subProtocol of authPacket.data.protocolData) { if (subProtocol.protocolName === 'auth_token') { // TODO: Do some validation on the token token = subProtocol.data.toString() } else if (subProtocol.protocolName === 'auth_username') { account = subProtocol.data.toString() } } assert(token, 'auth_token subprotocol is required') switch (this._accountMode) { case AccountMode.Username: assert(account, 'auth_username subprotocol is required') break case AccountMode.HashToken: assert(!account || account === tokenToAccount(token), 'auth_username subprotocol is not available') break } // Default the account to sha256(token). if (!account) account = tokenToAccount(token) this._addConnection(account, wsIncoming) this._log.trace('got auth info. token=' + token, 'account=' + account) if (accountModeIsStored(this._accountMode) && this._store) { const storedToken = await Token.load({ account, store: this._store }) const receivedToken = new Token({ account, token, store: this._store }) if (storedToken) { if (!storedToken.equal(receivedToken)) { throw new Error('incorrect token for account.' + ' account=' + account + ' token=' + token) } } else { receivedToken.save() } } if (this._connect) { await this._connect(this._prefix + account, authPacket, { ws: wsIncoming, req }) } wsIncoming.send(BtpPacket.serializeResponse(authPacket.requestId, [])) } catch (err) { if (authPacket) { this._log.debug('not accepted error during auth. error="%s" readyState=%d', err, wsIncoming.readyState) const errorResponse = BtpPacket.serializeError({ code: 'F00', name: 'NotAcceptedError', data: err.message || err.name, triggeredAt: new Date().toISOString() }, authPacket.requestId, []) if (wsIncoming.readyState === WebSocket.OPEN) { wsIncoming.send(errorResponse) } } wsIncoming.close() return } this._log.trace('connection authenticated') wsIncoming.on('message', async (binaryMessage) => { let btpPacket try { btpPacket = BtpPacket.deserialize(binaryMessage) } catch (err) { wsIncoming.close() return } this._log.trace('account %s: processing btp packet %o', account, btpPacket) try { this._log.trace('packet is authorized, forwarding to host') await this._handleIncomingBtpPacket(this._prefix + account, btpPacket) } catch (err) { this._log.debug('btp packet not accepted. error="%s" readyState=%d', err, wsIncoming.readyState) const errorResponse = BtpPacket.serializeError({ code: 'F00', name: 'NotAcceptedError', triggeredAt: new Date().toISOString(), data: err.message }, btpPacket.requestId, []) // The websocket may have been closed during _handleIncomingBtpPacket. if (wsIncoming.readyState === WebSocket.OPEN) { wsIncoming.send(errorResponse) } } }) }) }) } async disconnect () { if (this._disconnect) { await this._disconnect() } if (this._wss) { const wss = this._wss // Close the websocket server await new Promise((resolve) => wss.close(resolve)) // The above doesn't wait until the individual sockets have been closed. So they wouldn't be removed before this function returns. // Remove the individual sockets manually this._connections.clear() if (this._miniAccountsHttpServer) { await new Promise((resolve) => { this._miniAccountsHttpServer.close(resolve) }) } this._wss = null } } isConnected () { return !!this._wss } async sendData (buffer: Buffer): Promise<Buffer> { const parsedPacket = IlpPacket.deserializeIlpPacket(buffer) if (parsedPacket.type !== IlpPacket.Type.TYPE_ILP_PREPARE) { throw new Error(`can't route packet that's not a PREPARE.`) } const { destination, expiresAt, executionCondition } = parsedPacket.data if (this._sendPrepare) { this._sendPrepare(destination, parsedPacket) } if (destination === 'peer.config') { return ILDCP.serializeIldcpResponse(this._hostIldcpInfo) } if (!destination.startsWith(this._prefix)) { throw new Error(`can't route packet that is not meant for one of my clients. destination=${destination} prefix=${this._prefix}`) } let timeout: NodeJS.Timer const duration = expiresAt.getTime() - Date.now() const timeoutPacket = () => IlpPacket.serializeIlpReject({ code: 'R00', message: 'Packet expired', triggeredBy: this._hostIldcpInfo.clientAddress, data: Buffer.alloc(0) }) // Set timeout to expire the ILP packet const timeoutPromise = new Promise<Buffer>(resolve => { timeout = setTimeout(() => resolve( timeoutPacket() ), duration) }) // Forward ILP packet to peer over BTP const responsePromise = this._call(destination, { type: BtpPacket.TYPE_MESSAGE, requestId: crypto.randomBytes(4).readUInt32BE(0), data: { protocolData: [{ protocolName: 'ilp', contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, data: buffer }] } }).then(response => // Extract the ILP packet from the BTP response response.protocolData.filter(p => p.protocolName === 'ilp')[0].data ) const ilpResponse = await Promise.race([ timeoutPromise, responsePromise ]) /* tslint:disable-next-line:no-unnecessary-type-assertion */ clearTimeout(timeout!) const parsedIlpResponse = IlpPacket.deserializeIlpPacket(ilpResponse) if (parsedIlpResponse.type === IlpPacket.Type.TYPE_ILP_FULFILL) { // In case the plugin is overloaded with events, confirm the FULFILL hasn't expired const isExpired = Date.now() > expiresAt.getTime() if (isExpired) { return timeoutPacket() } const { fulfillment } = parsedIlpResponse.data if (!crypto.createHash('sha256') .update(fulfillment) .digest() .equals(executionCondition)) { return IlpPacket.errorToReject(this._hostIldcpInfo.clientAddress, new Errors.WrongConditionError( 'condition and fulfillment don\'t match. ' + `condition=${executionCondition.toString('hex')} ` + `fulfillment=${fulfillment.toString('hex')}`)) } } if (this._handlePrepareResponse) { try { this._handlePrepareResponse(destination, parsedIlpResponse, parsedPacket) } catch (e) { return IlpPacket.errorToReject(this._hostIldcpInfo.clientAddress, e) } } return ilpResponse || Buffer.alloc(0) } protected async _handleData (from: string, btpPacket: BtpPlugin.BtpPacket): Promise<BtpPlugin.BtpSubProtocol[]> { const { ilp } = this.protocolDataToIlpAndCustom(btpPacket.data) if (ilp) { const parsedPacket = IlpPacket.deserializeIlpPacket(ilp) if (parsedPacket.data['destination'] === 'peer.config') { this._log.trace('responding to ILDCP request. clientAddress=%s', from) return [{ protocolName: 'ilp', contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, data: await ILDCP.serve({ requestPacket: ilp, handler: async () => ({ ...this._hostIldcpInfo, clientAddress: from }), serverAddress: this._hostIldcpInfo.clientAddress }) }] } } if (this._handleCustomData) { this._log.trace('passing non-ILDCP data to custom handler') return this._handleCustomData(from, btpPacket) } if (!ilp) { this._log.debug('invalid packet, no ilp protocol data. from=%s', from) throw new Error('invalid packet, no ilp protocol data.') } if (!this._dataHandler) { throw new Error('no request handler registered') } const response = await this._dataHandler(ilp) return this.ilpAndCustomToProtocolData({ ilp: response }) } protected async _handleOutgoingBtpPacket (to: string, btpPacket: BtpPlugin.BtpPacket) { if (!to.startsWith(this._prefix)) { throw new Error(`invalid destination, must start with prefix. destination=${to} prefix=${this._prefix}`) } const account = this.ilpAddressToAccount(to) const connections = this._connections.get(account) if (!connections) { throw new Error('No clients connected for account ' + account) } connections.forEach((wsIncoming) => { const result = new Promise(resolve => wsIncoming.send(BtpPacket.serialize(btpPacket), resolve)) result.catch(err => { const errorInfo = (typeof err === 'object' && err.stack) ? err.stack : String(err) this._log.debug('unable to send btp message to client: %s; btp packet: %o', errorInfo, btpPacket) }) }) } private _addConnection (account: string, wsIncoming: WebSocket) { let connections = this._connections.get(account) if (!connections) { this._connections.set(account, connections = new Set()) } connections.add(wsIncoming) } private _removeConnection (account: string, wsIncoming: WebSocket) { const connections = this._connections.get(account) if (!connections) return connections.delete(wsIncoming) if (connections.size === 0) { this._connections.delete(account) } } }