sctp
Version:
SCTP network protocol (RFC4960) in plain Javascript
620 lines (570 loc) • 22.4 kB
JavaScript
const crypto = require('crypto')
const EventEmitter = require('events').EventEmitter
const debug = require('debug')
const transport = require('./transport')
const Packet = require('./packet')
const Chunk = require('./chunk')
const Association = require('./association')
const defs = require('./defs')
debug.formatters.h = v => {
return v.toString('hex')
}
class Endpoint extends EventEmitter {
constructor (options) {
super()
options = options || {}
this.ootb = options.ootb
this.localPort = options.localPort
if (options.localAddress && options.localAddress.length > 0) {
this.localAddress = options.localAddress
this.localActiveAddress = options.localAddress[0]
}
this.udpTransport = options.udpTransport
this.debugger = {}
const label = `[${this.localPort}]`
this.debugger.warn = debug(`sctp:endpoint:### ${label}`)
this.debugger.info = debug(`sctp:endpoint:## ${label}`)
this.debugger.debug = debug(`sctp:endpoint:# ${label}`)
this.debugger.trace = debug(`sctp:endpoint: ${label}`)
this.debugger.info('creating endpoint %o', options)
this.a_rwnd = options.a_rwnd || defs.NET_SCTP.RWND
this.MIS = options.MIS || 2
this.OS = options.OS || 2
this.cookieSecretKey = crypto.randomBytes(32)
this.valid_cookie_life = defs.NET_SCTP.valid_cookie_life
this.cookie_hmac_alg = defs.NET_SCTP.cookie_hmac_alg === 'md5' ? 'md5' : 'sha1'
this.cookie_hmac_len = defs.NET_SCTP.cookie_hmac_alg === 'md5' ? 16 : 20
this._cookieInterval = setInterval(() => {
// TODO change interval when valid_cookie_life changes
this.cookieSecretKey = crypto.randomBytes(32)
}, this.valid_cookie_life * 5)
this.associations_lookup = {}
this.associations = []
this.on('icmp', this.onICMP.bind(this))
this.on('packet', this.onPacket.bind(this))
}
onICMP (packet, src, dst, code) {
const association = this._getAssociation(dst, packet.dst_port)
if (association) {
association.emit('icmp', packet, code)
}
}
onPacket (packet, src, dst) {
if (!Array.isArray(packet.chunks)) {
this.debugger.warn('< received empty packet from %s:%d', src, packet.src_port)
return
}
this.debugger.debug('< received packet from %s:%d', src, packet.src_port)
let emulateLoss
if (emulateLoss) {
this.debugger.warn('emulate loss of remote packet')
return
}
let lastDataChunk = -1
let decodedChunks = []
const errors = []
const chunkTypes = {}
let discardPacket = false
// Check if packet should be discarded because of unrecognized chunks
// Also collect errors, chunk types present, decoded chunks
packet.chunks.every((buffer, index) => {
const chunk = Chunk.fromBuffer(buffer)
if (!chunk || chunk.error) {
/*
If the receiver detects a partial chunk, it MUST drop the chunk.
*/
return true
}
if (chunk.chunkType) {
chunkTypes[chunk.chunkType] = chunk
decodedChunks.push(chunk)
chunk.buffer = buffer
if (chunk.chunkType === 'data') {
lastDataChunk = index
} else if (chunk.chunkType === 'init') {
// Ok
} else if (chunk.chunkType === 'abort') {
// Remaining chunks should be ignored
return false
}
} else {
this.debugger.warn('unrecognized chunk %s, action %s', chunk.chunkId, chunk.action)
switch (chunk.action || 0) {
case 0:
/* 00 - Stop processing this SCTP packet and discard it, do not
process any further chunks within it. */
discardPacket = true
return false
case 1:
/* 01 - Stop processing this SCTP packet and discard it, do not
process any further chunks within it, and report the
unrecognized chunk in an 'Unrecognized Chunk Type'. */
discardPacket = true
errors.push({
cause: 'UNRECONGNIZED_CHUNK_TYPE',
unrecognized_chunk: buffer
})
return false
case 2:
/* 10 - Skip this chunk and continue processing. */
break
case 3:
/* 11 - Skip this chunk and continue processing, but report in an
ERROR chunk using the 'Unrecognized Chunk Type' cause of
error. */
errors.push({
cause: 'UNRECONGNIZED_CHUNK_TYPE',
unrecognized_chunk: buffer
})
break
default:
}
}
return true
})
let association = this._getAssociation(src, packet.src_port)
if (association) {
if (errors.length > 0 && !chunkTypes.abort) {
this.debugger.warn('informing unrecognized chunks in packet', errors)
association.ERROR(errors, packet.src)
}
}
if (discardPacket) {
return
}
if (decodedChunks.length === 0) {
return
}
if (!association) {
// 8.4. Handle "Out of the Blue" Packets
this.debugger.debug('Handle "Out of the Blue" Packets')
if (chunkTypes.abort) {
// If the OOTB packet contains an ABORT chunk, the receiver MUST
// silently discard the OOTB packet and take no further action.
this.debugger.debug('OOTB ABORT, discard')
return
}
if (chunkTypes.init) {
/*
If the packet contains an INIT chunk with a Verification Tag set
to '0', process it as described in Section 5.1. If, for whatever
reason, the INIT cannot be processed normally and an ABORT has to
be sent in response, the Verification Tag of the packet
containing the ABORT chunk MUST be the Initiate Tag of the
received INIT chunk, and the T bit of the ABORT chunk has to be
set to 0, indicating that the Verification Tag is NOT reflected.
When an endpoint receives an SCTP packet with the Verification
Tag set to 0, it should verify that the packet contains only an
INIT chunk. Otherwise, the receiver MUST silently discard the
packet.
Furthermore, we require
that the receiver of an INIT chunk MUST enforce these rules by
silently discarding an arriving packet with an INIT chunk that is
bundled with other chunks or has a non-zero verification tag and
contains an INIT-chunk.
*/
if (packet.v_tag === 0 && packet.chunks.length === 1) {
this.onInit(decodedChunks[0], src, dst, packet)
} else {
// all chunks count, including bogus
this.debugger.warn('INIT rules violation, discard')
}
return
} else if (chunkTypes.cookie_echo && decodedChunks[0].chunkType === 'cookie_echo') {
association = this.onCookieEcho(decodedChunks[0], src, dst, packet)
decodedChunks.shift()
if (!association) {
this.debugger.warn('Cookie Echo failed to establish association')
return
}
} else if (chunkTypes.shutdown_ack) {
/*
If the packet contains a SHUTDOWN ACK chunk, the receiver should
respond to the sender of the OOTB packet with a SHUTDOWN
COMPLETE. When sending the SHUTDOWN COMPLETE, the receiver of
the OOTB packet must fill in the Verification Tag field of the
outbound packet with the Verification Tag received in the
SHUTDOWN ACK and set the T bit in the Chunk Flags to indicate
that the Verification Tag is reflected.
*/
const chunk = new Chunk('shutdown_complete', { flags: { T: 1 } })
this._sendPacket(src, packet.src_port, packet.v_tag, [chunk.toBuffer()])
return
} else if (chunkTypes.shutdown_complete) {
/*
If the packet contains a SHUTDOWN COMPLETE chunk, the receiver
should silently discard the packet and take no further action.
*/
this.debugger.debug('OOTB SHUTDOWN COMPLETE, discard')
return
} else if (chunkTypes.error) {
/*
If the packet contains a "Stale Cookie" ERROR or a COOKIE ACK,
the SCTP packet should be silently discarded.
*/
// TODO
this.debugger.debug('OOTB ERROR, discard')
return
} else if (chunkTypes.cookie_ack) {
this.debugger.debug('OOTB COOKIE ACK, discard')
return
} else {
/*
The receiver should respond to the sender of the OOTB packet with
an ABORT. When sending the ABORT, the receiver of the OOTB
packet MUST fill in the Verification Tag field of the outbound
packet with the value found in the Verification Tag field of the
OOTB packet and set the T bit in the Chunk Flags to indicate that
the Verification Tag is reflected. After sending this ABORT, the
receiver of the OOTB packet shall discard the OOTB packet and
take no further action.
*/
if (this.ootb) {
this.debugger.debug('OOTB packet, tolerate')
} else {
this.debugger.debug('OOTB packet, abort')
const chunk = new Chunk('abort', { flags: { T: 1 } })
this._sendPacket(src, packet.src_port, packet.v_tag, [chunk.toBuffer()])
}
return
}
}
if (!association) {
// To be sure
return
}
// all chunks count, including bogus
if (packet.chunks.length > 1 &&
(chunkTypes.init || chunkTypes.init_ack || chunkTypes.shutdown_complete)) {
this.debugger.warn('MUST NOT bundle INIT, INIT ACK, or SHUTDOWN COMPLETE.')
return
}
// 8.5.1. Exceptions in Verification Tag Rules
if (chunkTypes.abort) {
if (
(packet.v_tag === association.my_tag && !chunkTypes.abort.flags.T) ||
(packet.v_tag === association.peer_tag && chunkTypes.abort.flags.T)
) {
/*
An endpoint MUST NOT respond to any received packet
that contains an ABORT chunk (also see Section 8.4)
*/
association.mute = true
// DATA chunks MUST NOT be bundled with ABORT
// TODO. For now we just keep some types
// init_ack will be ignored, cause it needs reply
// all other control chunks are useful
decodedChunks = decodedChunks.filter(chunk =>
chunk.chunkType === 'sack' ||
chunk.chunkType === 'cookie_ack' ||
chunk.chunkType === 'abort'
)
} else {
this.debugger.warn('discard according to Rules for packet carrying ABORT %O', packet)
this.debugger.debug(
'v_tag %d, T-bit %s, my_tag %d, peer_tag %d',
packet.v_tag,
chunkTypes.abort.flags.T,
association.my_tag,
association.peer_tag
)
return
}
} else if (chunkTypes.init) {
if (packet.v_tag !== 0) {
return
}
} else if (chunkTypes.shutdown_complete) {
/*
- The receiver of a SHUTDOWN COMPLETE shall accept the packet if
the Verification Tag field of the packet matches its own tag and
the T bit is not set OR if it is set to its peer's tag and the T
bit is set in the Chunk Flags. Otherwise, the receiver MUST
silently discard the packet and take no further action. An
endpoint MUST ignore the SHUTDOWN COMPLETE if it is not in the
SHUTDOWN-ACK-SENT state.
*/
if (!((packet.v_tag === association.my_tag && !chunkTypes.shutdown_complete.flags.T) ||
(packet.v_tag === association.peer_tag && chunkTypes.shutdown_complete.flags.T))) {
return
}
} else {
// 8.5. Verification Tag
if (packet.v_tag !== association.my_tag) {
this.debugger.warn('discarding packet, v_tag %d != my_tag %d',
packet.v_tag,
association.my_tag
)
return
}
}
// TODO shutdown_ack and shutdown_complete
decodedChunks.forEach((chunk, index) => {
chunk.last_in_packet = index === lastDataChunk
this.debugger.debug('processing chunk %s from %s:%d', chunk.chunkType, src, packet.src_port)
this.debugger.debug('emit chunk %s for association', chunk.chunkType)
association.emit(chunk.chunkType, chunk, src, packet)
})
}
onInit (chunk, src, dst, header) {
this.debugger.info('< CHUNK init', chunk.initiate_tag)
// Check for errors in parameters. Note that chunk can already have parse errors.
const errors = []
if (
chunk.initiate_tag === 0 ||
chunk.a_rwnd < 1500 ||
chunk.inbound_streams === 0 ||
chunk.outbound_streams === 0
) {
/*
If the value of the Initiate Tag in a received INIT chunk is found
to be 0, the receiver MUST treat it as an error and close the
association by transmitting an ABORT.
An SCTP receiver MUST be able to receive a minimum of 1500 bytes in
one SCTP packet. This means that an SCTP endpoint MUST NOT indicate
less than 1500 bytes in its initial a_rwnd sent in the INIT or INIT
ACK.
A receiver of an INIT with the MIS value of 0 SHOULD abort
the association.
Note: A receiver of an INIT with the OS value set to 0 SHOULD
abort the association.
Invalid Mandatory Parameter: This error cause is returned to the
originator of an INIT or INIT ACK chunk when one of the mandatory
parameters is set to an invalid value.
*/
errors.push({ cause: 'INVALID_MANDATORY_PARAMETER' })
}
if (errors.length > 0) {
const abort = new Chunk('abort', { error_causes: errors })
this._sendPacket(src, header.src_port, chunk.initiate_tag, [abort.toBuffer()])
return
}
const myTag = crypto.randomBytes(4).readUInt32BE(0)
const cookie = this.createCookie(chunk, header, myTag)
const initAck = new Chunk('init_ack', {
initiate_tag: myTag,
initial_tsn: myTag,
a_rwnd: this.a_rwnd,
state_cookie: cookie,
outbound_streams: chunk.inbound_streams,
inbound_streams: this.MIS
})
if (this.localAddress) {
initAck.ipv4_address = this.localAddress
}
if (chunk.errors) {
this.debugger.warn('< CHUNK has errors (unrecognized parameters)', chunk.errors)
initAck.unrecognized_parameter = chunk.errors
}
this.debugger.trace('> sending cookie', cookie)
this._sendPacket(src, header.src_port, chunk.initiate_tag, [initAck.toBuffer()])
/*
After sending the INIT ACK with the State Cookie parameter, the
sender SHOULD delete the TCB and any other local resource related to
the new association, so as to prevent resource attacks.
*/
}
onCookieEcho (chunk, src, dst, header) {
this.debugger.info('< CHUNK cookie_echo ', chunk.cookie)
/*
If the State Cookie is valid, create an association to the sender
of the COOKIE ECHO chunk with the information in the TCB data
carried in the COOKIE ECHO and enter the ESTABLISHED state.
*/
const cookieData = this.validateCookie(chunk.cookie, header)
if (cookieData) {
this.debugger.trace('cookie is valid')
const initChunk = Chunk.fromBuffer(cookieData.initChunk)
if (initChunk.chunkType !== 'init') {
this.debugger.warn('--> this should be init chunk', initChunk)
throw new Error('bug in chunk validation function')
}
const options = {
remoteAddress: src,
my_tag: cookieData.my_tag,
remotePort: cookieData.src_port,
MIS: this.MIS,
OS: this.OS
}
const association = new Association(this, options)
this.emit('association', association)
association.acceptRemote(initChunk)
return association
}
}
_sendPacket (host, port, vTag, chunks, callback) {
this.debugger.debug('> send packet %d chunks %s -> %s:%d vTag %d',
chunks.length,
this.localActiveAddress,
host,
port,
vTag
)
const packet = new Packet(
{
src_port: this.localPort,
dst_port: port,
v_tag: vTag
},
chunks
)
// TODO multi-homing select active address
this.transport.sendPacket(this.localActiveAddress, host, packet, callback)
}
createCookie (chunk, header, myTag) {
const created = Math.floor(new Date() / 1000)
const information = Buffer.alloc(16)
information.writeUInt32BE(created, 0)
information.writeUInt32BE(this.valid_cookie_life, 4)
information.writeUInt16BE(header.src_port, 8)
information.writeUInt16BE(header.dst_port, 10)
information.writeUInt32BE(myTag, 12)
const hash = crypto.createHash(this.cookie_hmac_alg)
hash.update(information)
/*
The receiver of the PAD
parameter MUST silently discard this parameter and continue
processing the rest of the INIT chunk. This means that the size of
the generated COOKIE parameter in the INIT-ACK MUST NOT depend on the
existence of the PAD parameter in the INIT chunk. A receiver of a
PAD parameter MUST NOT include the PAD parameter within any State
Cookie parameter it generates.
Note: sctp_test doesn't follow this rule.
*/
delete chunk.pad
const strippedInit = new Chunk('init', chunk)
const initBuffer = strippedInit.toBuffer()
hash.update(initBuffer)
hash.update(this.cookieSecretKey)
const mac = hash.digest()
this.debugger.debug('created cookie mac %h %d bytes', mac, mac.length)
/*
0 1 2 3 4
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| MAC | Information | INIT chunk ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| MAC | time | life |spt|dpt| my tag | INIT chunk ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
*/
return Buffer.concat([mac, information, initBuffer])
}
validateCookie (cookie, header) {
let result
// MAC 16 + Info 16 + Init chunk 20 = 52
if (cookie.length < 52) {
return
}
const receivedMAC = cookie.slice(0, this.cookie_hmac_len)
const information = cookie.slice(this.cookie_hmac_len, this.cookie_hmac_len + 16)
const initChunk = cookie.slice(this.cookie_hmac_len + 16)
/*
Compute a MAC using the TCB data carried in the State Cookie and
the secret key (note the timestamp in the State Cookie MAY be
used to determine which secret key to use).
*/
const hash = crypto.createHash(defs.NET_SCTP.cookie_hmac_alg)
hash.update(information)
hash.update(initChunk)
hash.update(this.cookieSecretKey)
const mac = hash.digest()
/*
Authenticate the State Cookie as one that it previously generated
by comparing the computed MAC against the one carried in the
State Cookie. If this comparison fails, the SCTP header,
including the COOKIE ECHO and any DATA chunks, should be silently
discarded
*/
if (mac.equals(receivedMAC)) {
result = {
created: new Date(information.readUInt32BE(0) * 1000),
cookie_lifespan: information.readUInt32BE(4),
src_port: information.readUInt16BE(8),
dst_port: information.readUInt16BE(10),
my_tag: information.readUInt32BE(12)
}
/*
Compare the port numbers and the Verification Tag contained
within the COOKIE ECHO chunk to the actual port numbers and the
Verification Tag within the SCTP common header of the received
header. If these values do not match, the packet MUST be
silently discarded.
*/
if (
header.src_port === result.src_port &&
header.dst_port === result.dst_port &&
header.v_tag === result.my_tag
) {
/*
Compare the creation timestamp in the State Cookie to the current
local time. If the elapsed time is longer than the lifespan
carried in the State Cookie, then the packet, including the
COOKIE ECHO and any attached DATA chunks, SHOULD be discarded,
and the endpoint MUST transmit an ERROR chunk with a "Stale
Cookie" error cause to the peer endpoint.
*/
if (new Date() - result.created < result.cookie_lifespan) {
result.initChunk = initChunk
return result
}
} else {
this.debugger.warn('port verification error', header, result)
}
} else {
this.debugger.warn('mac verification error %h != %h', receivedMAC, mac)
}
}
close () {
this.emit('close')
this.associations.forEach(association => {
association.emit('COMMUNICATION LOST')
association._destroy()
})
this._destroy()
}
_destroy () {
clearInterval(this._cookieInterval)
this.transport.unallocate(this.localPort)
}
_getAssociation (host, port) {
const key = host + ':' + port
this.debugger.trace('trying to find association for %s', key)
return this.associations_lookup[key]
}
ASSOCIATE (options) {
/*
Format: ASSOCIATE(local SCTP instance name,
destination transport addr, outbound stream count)
-> association id [,destination transport addr list]
[,outbound stream count]
*/
this.debugger.info('API ASSOCIATE', options)
options = options || {}
if (!options.remotePort) {
throw new Error('port is required')
}
options.OS = options.OS || this.OS
options.MIS = options.MIS || this.MIS
const association = new Association(this, options)
association.init()
return association
}
DESTROY () {
/*
Format: DESTROY(local SCTP instance name)
*/
this.debugger.trace('API DESTROY')
this._destroy()
}
static INITIALIZE (options, transportOptions, callback) {
const endpoint = new Endpoint(options)
// TODO register is synchronous for now, but could be async
const port = transport.register(endpoint, transportOptions)
if (port) {
callback(null, endpoint)
} else {
callback(new Error('bind EADDRINUSE 0.0.0.0:' + options.localPort))
}
}
}
module.exports = Endpoint