Skip to content
This repository was archived by the owner on May 5, 2022. It is now read-only.

Commit 6338cfe

Browse files
authored
feat: add receipts (#133)
feat: add receipts fix: explicitly specify mocha test directories The recursive wildcard (**) isn't supported universally, including on circleci
1 parent 629a990 commit 6338cfe

18 files changed

+531
-42
lines changed

scripts/generate-fixtures.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ const variants = Array.prototype.concat.apply([], [
100100
NUMBERS.map((pair) => ({
101101
name: 'frame:stream_data_blocked:offset:' + pair.name,
102102
frame: new Packet.StreamDataBlockedFrame(123, pair.value)
103-
}))
103+
})),
104+
{
105+
name: 'frame:stream_receipt',
106+
frame: new Packet.StreamReceiptFrame(1,
107+
Buffer.from('AQAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAfTBIvoCUt67Zy1ZGCP3EOmVFtZzhc85fah8yPnoyL9RMA==', 'base64'))
108+
}
104109
])
105110

106111
const fixtures = variants.map(function (params: any) {

src/connection.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import {
2020
ConnectionMaxStreamIdFrame,
2121
StreamMaxDataFrame,
2222
StreamDataBlockedFrame,
23+
StreamReceiptFrame,
2324
ConnectionMaxDataFrame,
2425
ConnectionDataBlockedFrame,
2526
StreamMoneyBlockedFrame
2627
} from './packet'
27-
import { Reader } from 'oer-utils'
28+
import { Reader, Writer } from 'oer-utils'
2829
import { CongestionController } from './util/congestion'
2930
import { Plugin } from './util/plugin-interface'
3031
import {
@@ -38,6 +39,7 @@ import {
3839
} from './util/long'
3940
import * as Long from 'long'
4041
import Rational from './util/rational'
42+
import { createReceipt, RECEIPT_VERSION } from './util/receipt'
4143
import { v4 as uuid } from 'uuid'
4244

4345
const RETRY_DELAY_START = 100
@@ -65,6 +67,10 @@ export interface ConnectionOpts {
6567
enablePadding?: boolean,
6668
/** User-specified connection identifier that was passed into [`generateAddressAndSecret`]{@link Server#generateAddressAndSecret} */
6769
connectionTag?: string,
70+
/** User-specified receipt nonce that was passed into [`generateAddressAndSecret`]{@link Server#generateAddressAndSecret} */
71+
receiptNonce?: Buffer,
72+
/** User-specified receipt secret that was passed into [`generateAddressAndSecret`]{@link Server#generateAddressAndSecret} */
73+
receiptSecret?: Buffer,
6874
/** Maximum number of streams the other entity can have open at once. Defaults to 10 */
6975
maxRemoteStreams?: number,
7076
/** Number of bytes each connection can have in the buffer. Defaults to 65534 */
@@ -147,6 +153,8 @@ function defaultGetExpiry (): Date {
147153
export class Connection extends EventEmitter {
148154
/** Application identifier for a certain connection */
149155
readonly connectionTag?: string
156+
protected readonly _receiptNonce?: Buffer
157+
protected readonly _receiptSecret?: Buffer
150158

151159
protected connectionId: string
152160
protected plugin: Plugin
@@ -221,6 +229,11 @@ export class Connection extends EventEmitter {
221229
this.allowableReceiveExtra = Rational.fromNumber(1.01, true)
222230
this.enablePadding = !!opts.enablePadding
223231
this.connectionTag = opts.connectionTag
232+
if (!opts.receiptNonce !== !opts.receiptSecret) {
233+
throw new Error('receiptNonce and receiptSecret must accompany each other')
234+
}
235+
this._receiptNonce = opts.receiptNonce
236+
this._receiptSecret = opts.receiptSecret
224237
this.maxStreamId = 2 * (opts.maxRemoteStreams || DEFAULT_MAX_REMOTE_STREAMS)
225238
this.maxBufferedData = opts.connectionBufferSize || MAX_DATA_SIZE * 2
226239
this.minExchangeRatePrecision = opts.minExchangeRatePrecision || DEFAULT_MINIMUM_EXCHANGE_RATE_PRECISION
@@ -681,8 +694,22 @@ export class Connection extends EventEmitter {
681694
}
682695

683696
// Add incoming amounts to each stream
697+
const totalsReceived: Map<number, string> = new Map()
684698
for (let { stream, amount } of amountsToReceive) {
685699
stream._addToIncoming(amount, prepare)
700+
totalsReceived.set(stream.id, stream.totalReceived)
701+
}
702+
703+
// Add receipt frame(s)
704+
if (this._receiptNonce && this._receiptSecret) {
705+
for (let [streamId, totalReceived] of totalsReceived) {
706+
responseFrames.push(new StreamReceiptFrame(streamId, createReceipt({
707+
nonce: this._receiptNonce,
708+
streamId,
709+
totalReceived,
710+
secret: this._receiptSecret
711+
})))
712+
}
686713
}
687714

688715
// TODO make sure the queued frames aren't too big
@@ -1142,6 +1169,17 @@ export class Connection extends EventEmitter {
11421169
}
11431170

11441171
if (responsePacket.ilpPacketType === IlpPacketType.Fulfill) {
1172+
for (let frame of responsePacket.frames) {
1173+
if (frame.type === FrameType.StreamReceipt) {
1174+
const stream = this.streams.get(frame.streamId.toNumber())
1175+
if (stream) {
1176+
stream._setReceipt(frame.receipt)
1177+
} else {
1178+
this.log.debug('received receipt for unknown stream %d: %h', frame.streamId, frame.receipt)
1179+
}
1180+
}
1181+
}
1182+
11451183
for (let stream of streamsSentFrom) {
11461184
stream._executeHold(requestPacket.sequence.toString())
11471185
}
@@ -1158,7 +1196,7 @@ export class Connection extends EventEmitter {
11581196
}
11591197

11601198
/**
1161-
* (Internal) Send volly of test packests to find the exchange rate, its precision, and potential other amounts to try.
1199+
* (Internal) Send volley of test packets to find the exchange rate, its precision, and potential other amounts to try.
11621200
* @private
11631201
*/
11641202
protected async sendTestPacketVolley (testPacketAmounts: number[]): Promise<any> {

src/crypto.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@
22
import { hmac, randomBytes } from './util/crypto-node'
33
export {
44
decrypt,
5+
decryptConnectionAddressToken, // only in node, not browser
56
encrypt,
7+
encryptConnectionAddressToken, // only in node, not browser
68
generateSharedSecretFromToken, // only in node, not browser
9+
generateReceiptHMAC, // only in node, not browser
710
hash,
11+
hmac,
812
randomBytes
913
} from './util/crypto-node'
1014

11-
const TOKEN_LENGTH = 18
15+
export const TOKEN_NONCE_LENGTH = 18
1216
const ENCRYPTION_KEY_STRING = Buffer.from('ilp_stream_encryption', 'utf8')
1317
const FULFILLMENT_GENERATION_STRING = Buffer.from('ilp_stream_fulfillment', 'utf8')
1418
const PACKET_ID_STRING = Buffer.from('ilp_stream_packet_id', 'utf8')
1519
export const ENCRYPTION_OVERHEAD = 28
1620

17-
export function generateToken (): Buffer {
18-
return randomBytes(TOKEN_LENGTH)
21+
export function generateTokenNonce (): Buffer {
22+
return randomBytes(TOKEN_NONCE_LENGTH)
1923
}
2024

2125
export function generateRandomCondition (): Buffer {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Connection, ConnectionOpts } from './connection'
66

77
export { Connection } from './connection'
88
export { DataAndMoneyStream } from './stream'
9-
export { Server, ServerOpts, createServer } from './server'
9+
export { Server, ServerOpts, createServer, GenerateAddressSecretOpts } from './server'
1010

1111
export interface CreateConnectionOpts extends ConnectionOpts {
1212
/** ILP Address of the server */

src/packet.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export enum FrameType {
4747
StreamMoneyBlocked = 0x13,
4848
StreamData = 0x14,
4949
StreamMaxData = 0x15,
50-
StreamDataBlocked = 0x16
50+
StreamDataBlocked = 0x16,
51+
StreamReceipt = 0x17
5152
}
5253

5354
/**
@@ -68,6 +69,7 @@ export type Frame =
6869
| StreamDataFrame
6970
| StreamMaxDataFrame
7071
| StreamDataBlockedFrame
72+
| StreamReceiptFrame
7173

7274
/**
7375
* STREAM Protocol Packet
@@ -487,6 +489,33 @@ export class StreamDataBlockedFrame extends BaseFrame {
487489
}
488490
}
489491

492+
export class StreamReceiptFrame extends BaseFrame {
493+
type: FrameType.StreamReceipt
494+
streamId: Long
495+
receipt: Buffer
496+
497+
constructor (streamId: LongValue, receipt: Buffer) {
498+
super('StreamReceipt')
499+
this.streamId = longFromValue(streamId, true)
500+
this.receipt = receipt
501+
}
502+
503+
static fromContents (reader: Reader): StreamReceiptFrame {
504+
const streamId = reader.readVarUIntLong()
505+
const receipt = reader.readVarOctetString()
506+
return new StreamReceiptFrame(streamId, receipt)
507+
}
508+
509+
toJSON (): Object {
510+
return {
511+
type: this.type,
512+
name: this.name,
513+
streamId: this.streamId,
514+
receipt: this.receipt.toString('base64')
515+
}
516+
}
517+
}
518+
490519
function parseFrame (reader: Reader): Frame | undefined {
491520
const type = reader.readUInt8Number()
492521
const contents = Reader.from(reader.readVarOctetString())
@@ -520,6 +549,8 @@ function parseFrame (reader: Reader): Frame | undefined {
520549
return StreamMaxDataFrame.fromContents(contents)
521550
case FrameType.StreamDataBlocked:
522551
return StreamDataBlockedFrame.fromContents(contents)
552+
case FrameType.StreamReceipt:
553+
return StreamReceiptFrame.fromContents(contents)
523554
default:
524555
return undefined
525556
}

src/pool.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import createLogger from 'ilp-logger'
22
import * as IlpPacket from 'ilp-packet'
3+
import { Reader } from 'oer-utils'
34
import { Connection, BuildConnectionOpts } from './connection'
45
import * as cryptoHelper from './crypto'
56

@@ -52,15 +53,46 @@ export class ServerConnectionPool {
5253
if (pendingConnection) return pendingConnection
5354

5455
const connectionPromise = (async () => {
55-
const sharedSecret = await this.getSharedSecret(id, prepare)
56+
const token = Buffer.from(id, 'base64')
57+
const sharedSecret = await this.getSharedSecret(token, prepare)
5658
// If we get here, that means it was a token + sharedSecret we created
57-
const tilde = id.indexOf('~')
58-
const connectionTag = tilde !== -1 ? id.slice(tilde + 1) : undefined
59+
let connectionTag: string | undefined
60+
let receiptNonce: Buffer | undefined
61+
let receiptSecret: Buffer | undefined
62+
const reader = new Reader(cryptoHelper.decryptConnectionAddressToken(this.serverSecret, token))
63+
reader.skipOctetString(cryptoHelper.TOKEN_NONCE_LENGTH)
64+
if (reader.peekVarOctetString().length) {
65+
connectionTag = reader.readVarOctetString().toString('ascii')
66+
} else {
67+
reader.skipVarOctetString()
68+
}
69+
switch (reader.peekVarOctetString().length) {
70+
case 0:
71+
reader.skipVarOctetString()
72+
break
73+
case 16:
74+
receiptNonce = reader.readVarOctetString()
75+
break
76+
default:
77+
throw new Error('receiptNonce must be 16 bytes')
78+
}
79+
switch (reader.peekVarOctetString().length) {
80+
case 0:
81+
reader.skipVarOctetString()
82+
break
83+
case 32:
84+
receiptSecret = reader.readVarOctetString()
85+
break
86+
default:
87+
throw new Error('receiptSecret must be 32 bytes')
88+
}
5989
const conn = await Connection.build({
6090
...this.connectionOpts,
6191
sharedSecret,
6292
connectionTag,
63-
connectionId: id
93+
connectionId: id,
94+
receiptNonce,
95+
receiptSecret
6496
})
6597
log.debug('got incoming packet for new connection: %s%s', id, (connectionTag ? ' (connectionTag: ' + connectionTag + ')' : ''))
6698
try {
@@ -92,11 +124,10 @@ export class ServerConnectionPool {
92124
}
93125

94126
private async getSharedSecret (
95-
id: string,
127+
token: Buffer,
96128
prepare: IlpPacket.IlpPrepare
97129
): Promise<Buffer> {
98130
try {
99-
const token = Buffer.from(id, 'ascii')
100131
const sharedSecret = cryptoHelper.generateSharedSecretFromToken(
101132
this.serverSecret, token)
102133
// TODO just pass this into the connection?

src/server.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import createLogger from 'ilp-logger'
55
import * as cryptoHelper from './crypto'
66
import { Connection, ConnectionOpts } from './connection'
77
import { ServerConnectionPool } from './pool'
8+
import { Predictor, Writer } from 'oer-utils'
89
import { Plugin } from './util/plugin-interface'
910

10-
const CONNECTION_ID_REGEX = /^[a-zA-Z0-9~_-]+$/
1111
const DEFAULT_DISCONNECT_DELAY = 100
1212

1313
export interface ServerOpts extends ConnectionOpts {
@@ -20,6 +20,12 @@ export interface ServerOpts extends ConnectionOpts {
2020
disconnectDelay?: number
2121
}
2222

23+
export interface GenerateAddressSecretOpts {
24+
connectionTag?: string,
25+
receiptNonce?: Buffer,
26+
receiptSecret?: Buffer
27+
}
28+
2329
/**
2430
* STREAM Server that can listen on an account and handle multiple incoming [`Connection`s]{@link Connection}.
2531
* Note: the connections this refers to are over ILP, not over the Internet.
@@ -155,23 +161,61 @@ export class Server extends EventEmitter {
155161
* Two different clients SHOULD NOT be given the same address and secret.
156162
*
157163
* @param connectionTag Optional connection identifier that will be appended to the ILP address and can be used to identify incoming connections. Can only include characters that can go into an ILP Address
164+
* @param receiptNonce Optional nonce to include in STREAM receipts
165+
* @param receiptSecret Optional secret to use for signing STREAM receipts
158166
*/
159-
generateAddressAndSecret (connectionTag?: string): { destinationAccount: string, sharedSecret: Buffer } {
167+
generateAddressAndSecret (opts?: string | GenerateAddressSecretOpts): { destinationAccount: string, sharedSecret: Buffer, receiptsEnabled: boolean } {
160168
if (!this.connected) {
161169
throw new Error('Server must be connected to generate address and secret')
162170
}
163-
let token = base64url(cryptoHelper.generateToken())
164-
if (connectionTag) {
165-
if (!CONNECTION_ID_REGEX.test(connectionTag)) {
166-
throw new Error('connectionTag can only include ASCII characters a-z, A-Z, 0-9, "_", "-", and "~"')
171+
let connectionTag = Buffer.alloc(0)
172+
let receiptNonce = Buffer.alloc(0)
173+
let receiptSecret = Buffer.alloc(0)
174+
let receiptsEnabled = false
175+
if (opts) {
176+
if (typeof opts === 'object') {
177+
if (opts.connectionTag) {
178+
connectionTag = Buffer.from(opts.connectionTag, 'ascii')
179+
}
180+
if (!opts.receiptNonce !== !opts.receiptSecret) {
181+
throw new Error('receiptNonce and receiptSecret must accompany each other')
182+
}
183+
if (opts.receiptNonce) {
184+
if (opts.receiptNonce.length !== 16) {
185+
throw new Error('receiptNonce must be 16 bytes')
186+
}
187+
receiptsEnabled = true
188+
receiptNonce = opts.receiptNonce
189+
}
190+
if (opts.receiptSecret) {
191+
if (opts.receiptSecret.length !== 32) {
192+
throw new Error('receiptSecret must be 32 bytes')
193+
}
194+
receiptSecret = opts.receiptSecret
195+
}
196+
} else {
197+
connectionTag = Buffer.from(opts, 'ascii')
167198
}
168-
token = token + '~' + connectionTag
169199
}
170-
const sharedSecret = cryptoHelper.generateSharedSecretFromToken(this.serverSecret, Buffer.from(token, 'ascii'))
200+
const tokenNonce = cryptoHelper.generateTokenNonce()
201+
const predictor = new Predictor()
202+
predictor.writeOctetString(tokenNonce, cryptoHelper.TOKEN_NONCE_LENGTH)
203+
predictor.writeVarOctetString(connectionTag)
204+
predictor.writeVarOctetString(receiptNonce)
205+
predictor.writeVarOctetString(receiptSecret)
206+
const writer = new Writer(predictor.length)
207+
writer.writeOctetString(tokenNonce, cryptoHelper.TOKEN_NONCE_LENGTH)
208+
writer.writeVarOctetString(connectionTag)
209+
writer.writeVarOctetString(receiptNonce)
210+
writer.writeVarOctetString(receiptSecret)
211+
212+
const token = cryptoHelper.encryptConnectionAddressToken(this.serverSecret, writer.getBuffer())
213+
const sharedSecret = cryptoHelper.generateSharedSecretFromToken(this.serverSecret, token)
171214
return {
172215
// TODO should this be called serverAccount or serverAddress instead?
173-
destinationAccount: `${this.serverAccount}.${token}`,
174-
sharedSecret
216+
destinationAccount: `${this.serverAccount}.${base64url(token)}`,
217+
sharedSecret,
218+
receiptsEnabled
175219
}
176220
}
177221

0 commit comments

Comments
 (0)