ilp-plugin-mini-accounts
Version:
Plugin that implements a mini ephemeral ledger
512 lines (442 loc) • 18.1 kB
text/typescript
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)
}
}
}