diff --git a/README.md b/README.md
index 4b7da367..92b758ab 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ __node-coap__ is a client and server library for CoAP modeled after the `http` m
* Installation
* Basic Example
* Proxy features
+ * OSCORE (Object Security)
* API
* Contributing
* License & copyright
@@ -33,7 +34,8 @@ This library follows:
for the observe specification,
* [RFC 7959](https://datatracker.ietf.org/doc/html/rfc7959) for
the blockwise specification,
-* [RFC 8132](https://datatracker.ietf.org/doc/html/rfc8132) for the PATCH, FETCH, and iPATCH methods.
+* [RFC 8132](https://datatracker.ietf.org/doc/html/rfc8132) for the PATCH, FETCH, and iPATCH methods,
+* [RFC 8613](https://datatracker.ietf.org/doc/html/rfc8613) for OSCORE (Object Security for Constrained RESTful Environments) via the [coap-oscore](https://github.com/stoprocent/node-coap-oscore) package.
It does not parse the protocol but it use
[CoAP-packet](http://github.com/mcollina/coap-packet) instead.
@@ -123,6 +125,147 @@ that writes all the information it receives along with the origin port and a pro
The example shows that the target server sees the last ten requests as coming from the same port (the proxy), while the
first ten come from different ports.
+
+## OSCORE (Object Security)
+
+node-coap has built-in support for [OSCORE (RFC 8613)](https://datatracker.ietf.org/doc/html/rfc8613),
+providing end-to-end encryption for CoAP messages using the
+[coap-oscore](https://github.com/stoprocent/node-coap-oscore) package. OSCORE operates at the CoAP
+message level, encrypting after serialization and decrypting before parsing, so all existing CoAP
+features (block transfers, observe, caching) work transparently over OSCORE.
+
+### Client Example
+
+```js
+const coap = require('coap')
+const { OSCORE, OscoreContextStatus } = require('coap-oscore')
+
+// Create an OSCORE security context
+const oscore = new OSCORE({
+ masterSecret: Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex'),
+ masterSalt: Buffer.from('9e7ca92223786340', 'hex'),
+ senderId: Buffer.from('01', 'hex'),
+ recipientId: Buffer.from('02', 'hex'),
+ idContext: Buffer.alloc(0),
+ status: OscoreContextStatus.Fresh
+})
+
+// Persist Sender Sequence Number across restarts
+oscore.on('ssn', (ssn) => {
+ saveToStorage('client-ssn', ssn.toString())
+})
+
+// Create an agent and register the OSCORE context for the target peer
+const agent = new coap.Agent({ type: 'udp4' })
+agent.addOscoreContext('192.168.1.100', 5683, oscore)
+
+// All requests to this peer are now automatically OSCORE-protected
+const req = coap.request({
+ hostname: '192.168.1.100',
+ port: 5683,
+ pathname: '/temperature',
+ agent
+})
+
+req.on('response', (res) => {
+ console.log(res.payload.toString())
+})
+
+req.end()
+```
+
+### Server Example
+
+```js
+const coap = require('coap')
+const { OSCORE, SecurityContextManager } = require('coap')
+const { OscoreContextStatus } = require('coap-oscore')
+
+const contexts = new SecurityContextManager()
+
+// Register a context for client "01"
+const oscoreClient1 = new OSCORE({
+ masterSecret: Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex'),
+ masterSalt: Buffer.from('9e7ca92223786340', 'hex'),
+ senderId: Buffer.from('02', 'hex'), // server's sender ID
+ recipientId: Buffer.from('01', 'hex'), // client's sender ID
+ idContext: Buffer.alloc(0),
+ status: OscoreContextStatus.Fresh
+})
+contexts.addContext(oscoreClient1, Buffer.from('01', 'hex'))
+
+// Persist SSN across restarts
+contexts.on('ssn', (recipientId, idContext, ssn) => {
+ saveToStorage(`server-ssn-${recipientId.toString('hex')}`, ssn.toString())
+})
+
+// Pass contexts to the server — it accepts both OSCORE and plaintext requests
+const server = coap.createServer({ oscoreContexts: contexts })
+
+server.on('request', (req, res) => {
+ if (req.isOscore) {
+ console.log('Secure request from:', req.oscoreContext.senderId.toString('hex'))
+ } else {
+ console.log('Unprotected request')
+ }
+ // Response is automatically OSCORE-encrypted if the request was protected
+ res.end('Hello World')
+})
+
+server.listen(5683)
+```
+
+### OSCORE-Only Mode
+
+Both client and server can enforce that all traffic must be OSCORE-protected:
+
+```js
+// Server: reject unprotected requests with 4.01
+const server = coap.createServer({
+ oscoreContexts: contexts,
+ oscoreOnly: true
+})
+
+// Agent: throw if no OSCORE context exists for the target peer
+const agent = new coap.Agent({ type: 'udp4', oscoreOnly: true })
+```
+
+### Observe over OSCORE
+
+Observe works transparently. Each notification is independently encrypted:
+
+```js
+const req = coap.request({
+ hostname: '192.168.1.100',
+ pathname: '/temperature',
+ observe: true,
+ agent // agent with OSCORE context
+})
+
+req.on('response', (res) => {
+ res.on('data', (payload) => {
+ console.log('Update:', payload.toString())
+ })
+})
+
+req.end()
+```
+
+### Dynamic Context Registration
+
+Contexts can be added or removed at runtime on both agent and server,
+which is useful after an EDHOC key exchange completes:
+
+```js
+// Agent
+agent.addOscoreContext('192.168.1.100', 5683, oscoreInstance)
+agent.removeOscoreContext('192.168.1.100', 5683)
+
+// Server
+server.addOscoreContext(oscoreInstance, recipientId, idContext)
+server.removeOscoreContext(recipientId, idContext)
+```
+
## API
@@ -136,6 +279,7 @@ first ten come from different ports.
* coap.ignoreOption()
* coap.registerFormat()
* coap.Agent
+ * coap.SecurityContextManager
* coap.globalAgent
* coap.globalAgentIPv6
* coap.updateTiming
@@ -230,6 +374,8 @@ The constructor can be given an optional options object, containing one of the f
* `sendAcksForNonConfirmablePackets`: Optional. Use this to suppress sending ACK messages for non-confirmable packages
* `clientIdentifier`: Optional. If specified, it should be a callback function with a signature like `clientIdentifier(request)`, where request is an `IncomingMessage`. The function should return a string that the caches can assume will uniquely identify the client.
* `reuseAddr`: Optional. Use this to specify whether it should be possible to have multiple server instances listening to the same port. Default `true`.
+* `oscoreContexts`: Optional. A [`SecurityContextManager`](#securitycontextmanager) instance containing pre-registered OSCORE contexts. When set, the server will automatically decrypt incoming OSCORE-protected requests and encrypt responses. See OSCORE.
+* `oscoreOnly`: Optional. If `true`, the server will reject unprotected (non-OSCORE) requests with a `4.01` response. Only meaningful when `oscoreContexts` is also set. Default `false`.
#### Event: 'request'
@@ -264,6 +410,17 @@ will not bind, add multicast groups or do any other configuration.
This function is asynchronous.
+#### server.addOscoreContext(oscoreInstance, recipientId[, idContext])
+
+Register an OSCORE security context at runtime. If the server was created without `oscoreContexts`, this will
+lazily initialize the OSCORE middleware. `oscoreInstance` is an `OSCORE` instance from the `coap-oscore` package.
+`recipientId` is the client's Sender ID (a `Buffer`). `idContext` is an optional `Buffer` for disambiguation when
+multiple contexts share the same Recipient ID.
+
+#### server.removeOscoreContext(recipientId[, idContext])
+
+Remove a previously registered OSCORE context.
+
#### server.close([callback])
Closes the server.
@@ -420,6 +577,19 @@ See [the `dgram` docs](http://nodejs.org/api/dgram.html#dgram_event_message) for
Information about the socket used for the communication (address and port).
+#### message.isOscore
+
+`true` if this request was received with OSCORE protection, `false` otherwise.
+Only meaningful on the server side (in the `'request'` event handler).
+
+#### message.oscoreContext
+
+Present when `isOscore` is `true`. An object with the following properties:
+
+- `senderId`: `Buffer` — the client's Sender ID (which is the server's Recipient ID).
+- `idContext`: `Buffer | undefined` — the ID Context, if present in the OSCORE option.
+
+This can be used to identify which OSCORE client sent the request.
-------------------------------------------------------
@@ -568,6 +738,54 @@ Opts is an optional object with the following optional properties:
* `socket`: use existing socket instead of creating a new one.
+* `oscoreOnly`: if `true`, the agent will throw an error when sending a request
+ to a peer that has no registered OSCORE context. Default `false`.
+
+#### agent.addOscoreContext(host, port, oscoreInstance)
+
+Register an OSCORE security context for a remote peer. `host` is the peer's IP address or hostname (string),
+`port` is the peer's port number, and `oscoreInstance` is an `OSCORE` instance from the `coap-oscore` package.
+Once registered, all requests to this `host:port` will be automatically OSCORE-encrypted, and all responses
+from this peer will be automatically decrypted.
+
+#### agent.removeOscoreContext(host, port)
+
+Remove a previously registered OSCORE context for a remote peer.
+
+-------------------------------------------------------
+
+### coap.SecurityContextManager
+
+A manager for server-side OSCORE security contexts. Contexts are keyed by
+`recipientId` and optional `idContext`, matching the KID and KID Context
+fields from the OSCORE option in incoming requests.
+
+```js
+const { SecurityContextManager } = require('coap')
+const contexts = new SecurityContextManager()
+```
+
+#### contexts.addContext(oscoreInstance, recipientId[, idContext])
+
+Register an OSCORE context. `oscoreInstance` is an `OSCORE` instance, `recipientId` is the client's Sender ID
+(`Buffer`), and `idContext` is an optional `Buffer` for disambiguation.
+
+#### contexts.removeContext(recipientId[, idContext])
+
+Remove a previously registered context. Returns `true` if found and removed.
+
+#### Event: 'ssn'
+
+Emitted when a Sender Sequence Number changes on any managed context. The listener
+receives `(recipientId: Buffer, idContext: Buffer | undefined, ssn: bigint)`. Use this
+to persist SSN values for context recovery after restarts.
+
+```js
+contexts.on('ssn', (recipientId, idContext, ssn) => {
+ saveToStorage(`server-ssn-${recipientId.toString('hex')}`, ssn.toString())
+})
+```
+
-------------------------------------------------------
### coap.globalAgent
diff --git a/index.ts b/index.ts
index 3e8d6c51..3152ee9f 100644
--- a/index.ts
+++ b/index.ts
@@ -12,6 +12,7 @@ import IncomingMessage from './lib/incoming_message'
import OutgoingMessage from './lib/outgoing_message'
import ObserveReadStream from './lib/observe_read_stream'
import ObserveWriteStream from './lib/observe_write_stream'
+import { SecurityContextManager } from './lib/oscore'
import { parameters, refreshTiming, defaultTiming } from './lib/parameters'
import { isIPv6 } from 'net'
import { registerOption, registerFormat, ignoreOption } from './lib/option_converter'
@@ -96,6 +97,7 @@ export {
OutgoingMessage,
ObserveReadStream,
ObserveWriteStream,
+ SecurityContextManager,
Agent,
Server,
type ParametersUpdate,
@@ -106,3 +108,5 @@ export {
type OptionValue,
type CoapServerOptions
}
+
+export type { OSCORE, OscoreContext, OscoreContextStatus } from 'coap-oscore'
diff --git a/lib/agent.ts b/lib/agent.ts
index 58c5c454..96828ba5 100644
--- a/lib/agent.ts
+++ b/lib/agent.ts
@@ -8,9 +8,10 @@
import crypto = require('crypto')
import { Socket, createSocket } from 'dgram'
-import { AgentOptions, CoapRequestParams, Block } from '../models/models'
+import { AgentOptions, CoapRequestParams, Block, Parameters } from '../models/models'
import { EventEmitter } from 'events'
import { parse, generate, ParsedPacket } from 'coap-packet'
+import type { OSCORE } from 'coap-oscore'
import IncomingMessage from './incoming_message'
import OutgoingMessage from './outgoing_message'
import ObserveStream from './observe_read_stream'
@@ -19,7 +20,11 @@ import { parseBlock2, createBlock2, getOption, removeOption } from './helpers'
import { SegmentedTransmission } from './segmentation'
import { parseBlockOption } from './block'
import { AddressInfo } from 'net'
-import { parameters } from './parameters'
+import { parameters, createParameters } from './parameters'
+
+interface OscoreRsinfo extends AddressInfo {
+ oscore?: boolean
+}
const maxToken = Math.pow(2, 32)
const maxMessageId = Math.pow(2, 16)
@@ -35,6 +40,9 @@ class Agent extends EventEmitter {
_lastMessageId: number
private _msgInFlight: number
_requests: number
+ private _oscoreContexts: Map
+ private _oscoreOnly: boolean
+ _parameters: Parameters
constructor (opts?: AgentOptions) {
super()
@@ -42,6 +50,8 @@ class Agent extends EventEmitter {
opts = {}
}
+ this._parameters = createParameters(opts.parameters)
+
if (opts.type == null) {
opts.type = 'udp4'
}
@@ -53,10 +63,25 @@ class Agent extends EventEmitter {
}
this._opts = opts
+ this._oscoreContexts = new Map()
+ this._oscoreOnly = opts.oscoreOnly ?? false
this._init(opts.socket)
}
+ addOscoreContext (host: string, port: number, instance: OSCORE): void {
+ const key = `${host.toLowerCase()}:${port}`
+ this._oscoreContexts.set(key, instance)
+ }
+
+ removeOscoreContext (host: string, port: number): void {
+ this._oscoreContexts.delete(`${host.toLowerCase()}:${port}`)
+ }
+
+ private _getOscoreContext (host: string, port: number): OSCORE | undefined {
+ return this._oscoreContexts.get(`${host.toLowerCase()}:${port}`)
+ }
+
_init (socket?: Socket): void {
this._closing = false
@@ -66,22 +91,34 @@ class Agent extends EventEmitter {
this._sock = socket ?? createSocket({ type: this._opts.type ?? 'udp4' })
this._sock.on('message', (msg, rsinfo) => {
- let packet: ParsedPacket
- try {
- packet = parse(msg)
- } catch (err) {
- return
- }
-
- if (packet.code[0] === '0' && packet.code !== '0.00') {
- // ignore this packet since it's not a response.
+ const oscoreCtx = this._getOscoreContext(rsinfo.address, rsinfo.port)
+ if (oscoreCtx != null) {
+ oscoreCtx.decode(msg)
+ .then((decoded) => {
+ let packet: ParsedPacket
+ try {
+ packet = parse(decoded)
+ } catch {
+ return
+ }
+ if (packet.code[0] === '0' && packet.code !== '0.00') {
+ return
+ }
+ if (this._sock != null) {
+ const oscoreRsinfo: OscoreRsinfo = {
+ ...rsinfo,
+ oscore: true
+ }
+ this._handle(packet, oscoreRsinfo, this._sock.address())
+ }
+ })
+ .catch(() => {
+ // OSCORE decode failed — drop the message.
+ // Do NOT fall back to plaintext when a security context exists.
+ })
return
}
-
- if (this._sock != null) {
- const outSocket = this._sock.address()
- this._handle(packet, rsinfo, outSocket)
- }
+ this._handlePlainMessage(msg, rsinfo)
})
if (this._opts.port != null) {
@@ -103,6 +140,24 @@ class Agent extends EventEmitter {
this._requests = 0
}
+ private _handlePlainMessage (msg: Buffer, rsinfo: AddressInfo): void {
+ let packet: ParsedPacket
+ try {
+ packet = parse(msg)
+ } catch (err) {
+ return
+ }
+
+ if (packet.code[0] === '0' && packet.code !== '0.00') {
+ return
+ }
+
+ if (this._sock != null) {
+ const outSocket = this._sock.address()
+ this._handle(packet, rsinfo, outSocket)
+ }
+ }
+
close (done?: (err?: Error) => void): this {
if (this._msgIdToReq.size === 0 && this._msgInFlight === 0) {
// No requests in flight, close immediately
@@ -157,7 +212,7 @@ class Agent extends EventEmitter {
})
}
- _handle (packet: ParsedPacket, rsinfo: AddressInfo, outSocket: AddressInfo): void {
+ _handle (packet: ParsedPacket, rsinfo: OscoreRsinfo, outSocket: AddressInfo): void {
let buf: Buffer
let response: IncomingMessage
let req: OutgoingMessage | undefined = this._msgIdToReq.get(packet.messageId)
@@ -321,13 +376,57 @@ class Agent extends EventEmitter {
}
}
+ // Echo auto-retry for OSCORE peers
+ if (packet.code === '4.01' && req != null && this._getOscoreContext(rsinfo.address, rsinfo.port) != null) {
+ const echoOpt = getOption(packet.options, '252')
+ if (echoOpt != null) {
+ // Limit Echo retries to prevent infinite loops from malicious servers
+ const retryCount = (req as any)._echoRetries ?? 0
+ if (retryCount >= 1) {
+ // Max retries exceeded — deliver the 4.01 to the application
+ // (fall through to normal response handling below)
+ } else {
+ const retryUrl = { ...req.url, token: req._packet.token }
+ const retryReq = this.request(retryUrl)
+
+ // Carry over options from original packet (Content-Format, Accept, custom, etc.)
+ // Skip Uri-Path, Uri-Query, Observe — these are reconstructed from retryUrl by request()
+ const skipOptions = new Set(['Uri-Path', 'Uri-Query', 'Observe'])
+ if (req._packet.options != null) {
+ for (const opt of req._packet.options) {
+ if (!skipOptions.has(String(opt.name))) {
+ retryReq.setOption(String(opt.name), opt.value)
+ }
+ }
+ }
+ retryReq.setOption('252', echoOpt)
+
+ ;(retryReq as any)._echoRetries = retryCount + 1
+
+ retryReq.on('response', (res) => req.emit('response', res))
+ retryReq.on('error', (err) => req.emit('error', err))
+
+ // Carry over payload from original request
+ const payload = req.slice()
+ if (payload.length > 0) {
+ retryReq.end(payload)
+ } else {
+ retryReq.end()
+ }
+ return
+ }
+ }
+ }
+
const observe = req.url.observe != null && [true, 0, '0'].includes(req.url.observe)
if (req.response != null) {
const response: any = req.response
if (response.append != null) {
- // it is an observe request
- // and we are already streaming
+ // Drop notifications without decode proof on OSCORE-protected streams
+ if (response.oscoreProtected === true && rsinfo.oscore !== true) {
+ return
+ }
return response.append(packet)
} else {
// TODO There is a previous response but is not an ObserveStream !
@@ -342,6 +441,10 @@ class Agent extends EventEmitter {
if (observe && packet.code !== '4.04') {
response = new ObserveStream(packet, rsinfo, outSocket)
+ if (rsinfo.oscore === true) {
+ (response as ObserveStream)._disableFiltering = true
+ ;(response as ObserveStream).oscoreProtected = true
+ }
response.on('close', () => {
this._tkToReq.delete(packet.token.toString('hex'))
this._cleanUp()
@@ -358,6 +461,9 @@ class Agent extends EventEmitter {
})
} else {
response = new IncomingMessage(packet, rsinfo, outSocket)
+ if (rsinfo.oscore === true) {
+ response.oscoreProtected = true
+ }
}
if (!req.multicast) {
@@ -398,7 +504,7 @@ class Agent extends EventEmitter {
const options = url.options ?? url.headers
const multicastTimeout = url.multicastTimeout != null ? url.multicastTimeout : 20000
const host = url.hostname ?? url.host
- const port = url.port ?? parameters.coapPort
+ const port = url.port ?? this._parameters.coapPort
const req = new OutgoingMessage({}, (req, packet) => {
if (url.confirmable !== false) {
@@ -451,7 +557,22 @@ class Agent extends EventEmitter {
}
})
- req.sender = new RetrySend(this._sock, port, host, url.retrySend)
+ req.sender = new RetrySend(this._sock, port, host, url.retrySend, this._parameters)
+
+ const oscoreCtx = this._getOscoreContext(host ?? '', port)
+
+ if (oscoreCtx == null && this._oscoreOnly) {
+ throw new Error('No OSCORE context for ' + (host ?? '') + ':' + port)
+ }
+
+ if (oscoreCtx != null) {
+ const originalSend = req.sender.send.bind(req.sender)
+ req.sender.send = (message: Buffer, avoidBackoff?: boolean) => {
+ oscoreCtx.encode(message)
+ .then((encoded) => originalSend(encoded, avoidBackoff))
+ .catch((err) => req.emit('error', err))
+ }
+ }
req.url = url
diff --git a/lib/incoming_message.ts b/lib/incoming_message.ts
index 5453f050..1a24e6a5 100644
--- a/lib/incoming_message.ts
+++ b/lib/incoming_message.ts
@@ -13,6 +13,11 @@ import type { ReadableOptions } from 'readable-stream'
import type { CoapPacket, OptionValue } from '../models/models'
import { packetToMessage } from './helpers'
+export interface OscoreRequestContext {
+ senderId: Buffer
+ idContext?: Buffer
+}
+
class IncomingMessage extends Readable {
rsinfo: AddressInfo
outSocket?: AddressInfo
@@ -23,6 +28,12 @@ class IncomingMessage extends Readable {
headers: Partial>
method: CoapMethod
code: string
+ oscoreContext?: OscoreRequestContext
+ oscoreProtected: boolean = false
+
+ get isOscore (): boolean {
+ return this.oscoreProtected || this.oscoreContext != null
+ }
constructor (packet: CoapPacket, rsinfo: AddressInfo, outSocket?: AddressInfo, options?: ReadableOptions) {
super(options)
diff --git a/lib/middlewares.ts b/lib/middlewares.ts
index 24344716..7ef49b04 100644
--- a/lib/middlewares.ts
+++ b/lib/middlewares.ts
@@ -7,9 +7,11 @@
*/
import crypto from 'crypto'
-import { parse, ParsedPacket } from 'coap-packet'
-import { or, isOption } from './helpers'
+import { parse, generate, ParsedPacket } from 'coap-packet'
+import { Socket } from 'dgram'
+import { or, isOption, getOption } from './helpers'
import { MiddlewareParameters } from '../models/models'
+import { getOscoreOptionValue, parseOscoreOption } from './oscore_helpers'
type middlewareCallback = (nullOrError: null | Error) => void
@@ -43,7 +45,13 @@ export function handleServerRequest (request: MiddlewareParameters, next: middle
}
try {
- request.server._handle(request.packet, request.rsinfo)
+ request.server._handle(
+ request.packet,
+ request.rsinfo,
+ request.wasOscoreProtected ?? false,
+ request.oscoreSenderId,
+ request.oscoreIdContext
+ )
next(null)
} catch (err) {
next(err)
@@ -100,3 +108,146 @@ export function handleProxyResponse (request: MiddlewareParameters, next: middle
next(null)
}
+
+export function oscoreDecryptRequest (request: MiddlewareParameters, next: middlewareCallback): void {
+ if (request.packet == null) {
+ return next(null)
+ }
+
+ // Skip OSCORE for proxied requests
+ if (request.proxy != null) {
+ return next(null)
+ }
+
+ const oscoreOptValue = getOscoreOptionValue(request.packet)
+
+ if (oscoreOptValue == null) {
+ // Not OSCORE-protected
+ if (request.server._oscoreOnly) {
+ request.server._sendError(
+ Buffer.from('Unauthorized'),
+ request.rsinfo, request.packet, '4.01'
+ )
+ return
+ }
+ return next(null)
+ }
+
+ const { kid, kidContext } = parseOscoreOption(oscoreOptValue)
+ const ctxMgr = request.server._oscoreContextManager
+
+ if (ctxMgr == null || kid == null) {
+ request.server._sendError(
+ Buffer.from('Unauthorized'),
+ request.rsinfo, request.packet, '4.01'
+ )
+ return
+ }
+
+ const oscore = ctxMgr.getByKid(kid, kidContext ?? undefined)
+ if (oscore == null) {
+ request.server._sendError(
+ Buffer.from('Unauthorized'),
+ request.rsinfo, request.packet, '4.01'
+ )
+ return
+ }
+
+ oscore.decode(request.raw)
+ .then((decoded) => {
+ request.raw = decoded
+ request.packet = parse(decoded)
+ request.wasOscoreProtected = true
+ request.oscoreContext = oscore
+ request.oscoreSenderId = kid
+ request.oscoreIdContext = kidContext ?? undefined
+
+ const tokenHex = request.packet.token?.toString('hex')
+ if (tokenHex != null && tokenHex.length > 0) {
+ ctxMgr.bindToken(tokenHex, oscore, kid)
+ }
+
+ next(null)
+ })
+ .catch((err) => {
+ if (err?.status === 201) {
+ // FIRST_REQUEST_AFTER_REBOOT — Echo challenge per RFC 9175
+ const decrypted = err.decrypted as Buffer | undefined
+
+ // Parse inner (decrypted) packet to check for Echo option
+ let innerEcho: Buffer | null = null
+ if (decrypted != null) {
+ try {
+ const innerPacket = parse(decrypted)
+ const echoVal = getOption(innerPacket.options, '252' as any)
+ if (Buffer.isBuffer(echoVal)) {
+ innerEcho = echoVal
+ }
+ } catch (_) {
+ // Failed to parse decrypted payload; treat as no Echo
+ }
+ }
+
+ // Check if the retry contains a valid Echo nonce
+ const storedNonce = ctxMgr.getPendingEcho(kid, kidContext ?? undefined)
+ if (
+ innerEcho != null &&
+ storedNonce != null &&
+ innerEcho.length === storedNonce.length &&
+ crypto.timingSafeEqual(innerEcho, storedNonce)
+ ) {
+ // Echo verified — complete the reboot recovery
+ // Note: clearRebootRecovery() is added in the concurrent node-oscore update
+ ;(oscore as any).clearRebootRecovery()
+ ctxMgr.clearPendingEcho(kid, kidContext ?? undefined)
+
+ // Continue processing with the decrypted inner message
+ request.raw = decrypted!
+ request.packet = parse(decrypted!)
+ request.wasOscoreProtected = true
+ request.oscoreContext = oscore
+ request.oscoreSenderId = kid
+ request.oscoreIdContext = kidContext ?? undefined
+
+ const tokenHex = request.packet.token?.toString('hex')
+ if (tokenHex != null && tokenHex.length > 0) {
+ ctxMgr.bindToken(tokenHex, oscore, kid)
+ }
+
+ next(null)
+ return
+ }
+
+ // No valid Echo — send a new 4.01 + Echo challenge (OSCORE-encrypted)
+ const echoNonce = crypto.randomBytes(8)
+ ctxMgr.storePendingEcho(kid, kidContext ?? undefined, echoNonce)
+
+ const innerResponse = generate({
+ code: '4.01',
+ ack: request.packet?.confirmable === true,
+ messageId: request.packet?.messageId,
+ token: request.packet?.token,
+ options: [{ name: '252' as any, value: echoNonce }]
+ })
+
+ oscore.encode(innerResponse)
+ .then((encrypted) => {
+ if (request.server._sock instanceof Socket) {
+ request.server._sock.send(
+ encrypted, 0, encrypted.length,
+ request.rsinfo.port, request.rsinfo.address
+ )
+ }
+ })
+ .catch(() => {
+ // Fallback: if OSCORE encode fails, drop silently
+ // (we must not send plaintext Echo challenges)
+ })
+ return
+ }
+ request.server._sendError(
+ Buffer.from('Unauthorized'),
+ request.rsinfo, request.packet, '4.01'
+ )
+ })
+}
diff --git a/lib/oscore.ts b/lib/oscore.ts
new file mode 100644
index 00000000..23af0d96
--- /dev/null
+++ b/lib/oscore.ts
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2013-2021 node-coap contributors.
+ *
+ * node-coap is licensed under an MIT +no-false-attribs license.
+ * All rights not explicitly granted in the MIT license are reserved.
+ * See the included LICENSE file for more details.
+ */
+
+import { EventEmitter } from 'events'
+import type { OSCORE } from 'coap-oscore'
+
+/**
+ * Server-side manager for multiple OSCORE security contexts.
+ * Contexts are keyed by recipientId:idContext (hex) — matching
+ * the KID + KID Context fields from the OSCORE option in incoming requests.
+ */
+export class SecurityContextManager extends EventEmitter {
+ private static readonly MAX_TOKEN_BINDINGS = 10000
+ private _contexts: Map
+ private _tokenToContext: Map
+ private _pendingEchoNonces: Map
+
+ constructor () {
+ super()
+ this._contexts = new Map()
+ this._tokenToContext = new Map()
+ this._pendingEchoNonces = new Map()
+ }
+
+ /**
+ * Register an OSCORE context for a client.
+ * @param instance Pre-built OSCORE instance
+ * @param recipientId The client's Sender ID (= server's Recipient ID). Used for lookup.
+ * @param idContext Optional ID Context for disambiguation
+ */
+ addContext (instance: OSCORE, recipientId: Buffer, idContext?: Buffer): this {
+ const key = this._toKey(recipientId, idContext)
+ this._contexts.set(key, instance)
+
+ instance.on('ssn', (ssn: bigint) => {
+ this.emit('ssn', recipientId, idContext, ssn)
+ })
+
+ return this
+ }
+
+ /**
+ * Remove a context.
+ */
+ removeContext (recipientId: Buffer, idContext?: Buffer): boolean {
+ const key = this._toKey(recipientId, idContext)
+ return this._contexts.delete(key)
+ }
+
+ /**
+ * Look up context by KID/KID-Context extracted from OSCORE option.
+ */
+ getByKid (kid: Buffer, kidContext?: Buffer): OSCORE | undefined {
+ const key = this._toKey(kid, kidContext)
+ return this._contexts.get(key)
+ }
+
+ /**
+ * Compute a namespaced key for the token-to-context map.
+ * Prevents collisions when two clients use the same token.
+ */
+ private _tokenKey (senderId: Buffer, tokenHex: string): string {
+ return `${senderId.toString('hex')}:${tokenHex}`
+ }
+
+ /**
+ * Bind a token to a context for response encoding.
+ */
+ bindToken (tokenHex: string, context: OSCORE, senderId: Buffer): void {
+ // Evict oldest if at capacity
+ if (this._tokenToContext.size >= SecurityContextManager.MAX_TOKEN_BINDINGS) {
+ const firstKey = this._tokenToContext.keys().next().value
+ if (firstKey != null) {
+ this._tokenToContext.delete(firstKey)
+ }
+ }
+ this._tokenToContext.set(this._tokenKey(senderId, tokenHex), context)
+ }
+
+ /**
+ * Look up context by token (for response encoding).
+ */
+ getByToken (tokenHex: string, senderId: Buffer): OSCORE | undefined {
+ return this._tokenToContext.get(this._tokenKey(senderId, tokenHex))
+ }
+
+ /**
+ * Unbind a token (after response sent, or observe ended).
+ */
+ unbindToken (tokenHex: string, senderId: Buffer): void {
+ this._tokenToContext.delete(this._tokenKey(senderId, tokenHex))
+ }
+
+ /**
+ * Store a pending Echo nonce for a given security context.
+ */
+ storePendingEcho (recipientId: Buffer, idContext: Buffer | undefined, nonce: Buffer): void {
+ const key = this._toKey(recipientId, idContext)
+ this._pendingEchoNonces.set(key, nonce)
+ }
+
+ /**
+ * Retrieve the pending Echo nonce for a given security context.
+ */
+ getPendingEcho (recipientId: Buffer, idContext: Buffer | undefined): Buffer | undefined {
+ const key = this._toKey(recipientId, idContext)
+ return this._pendingEchoNonces.get(key)
+ }
+
+ /**
+ * Clear the pending Echo nonce for a given security context.
+ */
+ clearPendingEcho (recipientId: Buffer, idContext: Buffer | undefined): void {
+ const key = this._toKey(recipientId, idContext)
+ this._pendingEchoNonces.delete(key)
+ }
+
+ private _toKey (recipientId: Buffer, idContext?: Buffer): string {
+ return `${recipientId.toString('hex')}:${idContext?.toString('hex') ?? ''}`
+ }
+}
diff --git a/lib/oscore_helpers.ts b/lib/oscore_helpers.ts
new file mode 100644
index 00000000..aa0486f3
--- /dev/null
+++ b/lib/oscore_helpers.ts
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2013-2021 node-coap contributors.
+ *
+ * node-coap is licensed under an MIT +no-false-attribs license.
+ * All rights not explicitly granted in the MIT license are reserved.
+ * See the included LICENSE file for more details.
+ */
+
+import type { ParsedPacket } from 'coap-packet'
+
+const OSCORE_OPTION_NAME = 'OSCORE'
+const OSCORE_OPTION_NUMBER = 9
+const FLAG_KID = 0x08
+const FLAG_KID_CTX = 0x10
+const FLAG_PIV_MASK = 0x07
+
+export interface OscoreOptionFields {
+ kid: Buffer | null
+ kidContext: Buffer | null
+ piv: Buffer | null
+}
+
+/**
+ * Extract the OSCORE option value from a parsed CoAP packet.
+ * The OSCORE option has number 9.
+ */
+export function getOscoreOptionValue (packet: ParsedPacket): Buffer | null {
+ if (packet.options == null) {
+ return null
+ }
+
+ for (const option of packet.options) {
+ if (option.name === OSCORE_OPTION_NAME || option.name === OSCORE_OPTION_NUMBER) {
+ if (Buffer.isBuffer(option.value)) {
+ return option.value
+ }
+ return null
+ }
+ }
+ return null
+}
+
+/**
+ * Parse an OSCORE option value into its constituent fields.
+ * Follows RFC 8613 Section 6.1:
+ * [flags_byte] [piv_bytes...] [kid_context_len] [kid_context_bytes...] [kid_bytes...]
+ */
+export function parseOscoreOption (value: Buffer): OscoreOptionFields {
+ if (value.length === 0) {
+ return { kid: null, kidContext: null, piv: null }
+ }
+
+ let offset = 0
+ const flags = value[offset++]
+
+ const pivLen = flags & FLAG_PIV_MASK
+ let piv: Buffer | null = null
+ if (pivLen > 0) {
+ if (offset + pivLen > value.length) {
+ throw new Error('Malformed OSCORE option: PIV truncated')
+ }
+ piv = value.slice(offset, offset + pivLen)
+ offset += pivLen
+ }
+
+ let kidContext: Buffer | null = null
+ if ((flags & FLAG_KID_CTX) !== 0) {
+ if (offset >= value.length) {
+ throw new Error('Malformed OSCORE option: missing KID Context length')
+ }
+ const kidCtxLen = value[offset++]
+ if (offset + kidCtxLen > value.length) {
+ throw new Error('Malformed OSCORE option: KID Context truncated')
+ }
+ kidContext = value.slice(offset, offset + kidCtxLen)
+ offset += kidCtxLen
+ }
+
+ let kid: Buffer | null = null
+ if ((flags & FLAG_KID) !== 0) {
+ kid = value.slice(offset)
+ }
+
+ return { kid, kidContext, piv }
+}
+
+/**
+ * Check if a parsed packet has an OSCORE option.
+ */
+export function hasOscoreOption (packet: ParsedPacket): boolean {
+ return getOscoreOptionValue(packet) != null
+}
diff --git a/lib/parameters.ts b/lib/parameters.ts
index 8232d315..ef9bbfde 100644
--- a/lib/parameters.ts
+++ b/lib/parameters.ts
@@ -192,30 +192,35 @@ const p: Parameters = {
const defaultParameters: Parameters = JSON.parse(JSON.stringify(p))
-function refreshTiming (values?: ParametersUpdate): void {
- for (const key in values) {
- if (p[key] != null) {
- p[key] = values[key]
+function applyOverrides (target: Parameters, overrides?: ParametersUpdate): void {
+ if (overrides != null) {
+ for (const key in overrides) {
+ if (key in target) {
+ target[key] = overrides[key]
+ }
}
}
+}
- p.maxTransmitSpan = p.ackTimeout * ((Math.pow(2, p.maxRetransmit)) - 1) * p.ackRandomFactor
-
- p.maxTransmitWait = p.ackTimeout * (Math.pow(2, p.maxRetransmit + 1) - 1) * p.ackRandomFactor
-
- p.processingDelay = p.ackTimeout
-
- p.maxRTT = 2 * p.maxLatency + p.processingDelay
-
- p.exchangeLifetime = p.maxTransmitSpan + p.maxRTT
+function deriveTimings (target: Parameters, overrides?: ParametersUpdate): void {
+ target.maxTransmitSpan = target.ackTimeout * ((Math.pow(2, target.maxRetransmit)) - 1) * target.ackRandomFactor
+ target.maxTransmitWait = target.ackTimeout * (Math.pow(2, target.maxRetransmit + 1) - 1) * target.ackRandomFactor
+ target.processingDelay = target.ackTimeout
+ target.maxRTT = 2 * target.maxLatency + target.processingDelay
+ target.exchangeLifetime = target.maxTransmitSpan + target.maxRTT
- if (values != null && typeof values.pruneTimerPeriod === 'number') {
- p.pruneTimerPeriod = values.pruneTimerPeriod
+ if (overrides != null && typeof overrides.pruneTimerPeriod === 'number') {
+ target.pruneTimerPeriod = overrides.pruneTimerPeriod
} else {
- p.pruneTimerPeriod = (0.5 * p.exchangeLifetime)
+ target.pruneTimerPeriod = (0.5 * target.exchangeLifetime)
}
}
+function refreshTiming (values?: ParametersUpdate): void {
+ applyOverrides(p, values)
+ deriveTimings(p, values)
+}
+
function defaultTiming (): void {
refreshTiming(defaultParameters)
}
@@ -223,4 +228,15 @@ function defaultTiming (): void {
p.defaultTiming = defaultTiming
p.refreshTiming = refreshTiming
-export { p as parameters, refreshTiming, defaultTiming }
+function createParameters (overrides?: ParametersUpdate): Parameters {
+ const result: Parameters = JSON.parse(JSON.stringify(p))
+ delete result.defaultTiming
+ delete result.refreshTiming
+
+ applyOverrides(result, overrides)
+ deriveTimings(result, overrides)
+
+ return Object.freeze(result)
+}
+
+export { p as parameters, refreshTiming, defaultTiming, createParameters }
diff --git a/lib/retry_send.ts b/lib/retry_send.ts
index e97e0e51..eb544fe9 100644
--- a/lib/retry_send.ts
+++ b/lib/retry_send.ts
@@ -9,6 +9,7 @@
import { EventEmitter } from 'events'
import { parse } from 'coap-packet'
import { parameters } from './parameters'
+import { Parameters } from '../models/models'
import { Socket } from 'dgram'
class RetrySendError extends Error {
@@ -31,19 +32,22 @@ export default class RetrySend extends EventEmitter {
_message: Buffer
_timer: NodeJS.Timeout
_bOffTimer: NodeJS.Timeout
- constructor (sock: any, port: number, host?: string, maxRetransmit?: number) {
+ _parameters: Parameters
+ constructor (sock: any, port: number, host?: string, maxRetransmit?: number, instanceParameters?: Parameters) {
super()
+ this._parameters = instanceParameters ?? parameters
+
this._sock = sock
- this._port = port ?? parameters.coapPort
+ this._port = port ?? this._parameters.coapPort
this._host = host
- this._maxRetransmit = maxRetransmit ?? parameters.maxRetransmit
+ this._maxRetransmit = maxRetransmit ?? this._parameters.maxRetransmit
this._sendAttemp = 0
this._lastMessageId = -1
- this._currentTime = parameters.ackTimeout * (1 + (parameters.ackRandomFactor - 1) * Math.random()) * 1000
+ this._currentTime = this._parameters.ackTimeout * (1 + (this._parameters.ackRandomFactor - 1) * Math.random()) * 1000
this._bOff = () => {
this._currentTime = this._currentTime * 2
@@ -77,7 +81,7 @@ export default class RetrySend extends EventEmitter {
this._message = message
this._send(avoidBackoff)
- const timeout = avoidBackoff === true ? parameters.maxRTT : parameters.exchangeLifetime
+ const timeout = avoidBackoff === true ? this._parameters.maxRTT : this._parameters.exchangeLifetime
this._timer = setTimeout(() => {
const err = new RetrySendError(timeout)
if (avoidBackoff === false) {
diff --git a/lib/server.ts b/lib/server.ts
index 504ec66a..f3b27ef3 100644
--- a/lib/server.ts
+++ b/lib/server.ts
@@ -8,7 +8,7 @@
import { EventEmitter } from 'events'
import { isIPv6, type AddressInfo } from 'net'
-import { type CoapServerOptions, type requestListener, type CoapPacket, type Block, type MiddlewareParameters } from '../models/models'
+import { type CoapServerOptions, type requestListener, type CoapPacket, type Block, type MiddlewareParameters, type Parameters } from '../models/models'
import BlockCache from './cache'
import OutgoingMessage from './outgoing_message'
import { Socket, createSocket, type SocketOptions } from 'dgram'
@@ -17,11 +17,13 @@ import os from 'os'
import IncomingMessage from './incoming_message'
import ObserveStream from './observe_write_stream'
import RetrySend from './retry_send'
-import { handleProxyResponse, handleServerRequest, parseRequest, proxyRequest } from './middlewares'
+import { handleProxyResponse, handleServerRequest, parseRequest, proxyRequest, oscoreDecryptRequest } from './middlewares'
+import { SecurityContextManager } from './oscore'
+import type { OSCORE } from 'coap-oscore'
import { parseBlockOption } from './block'
import { generate, type NamedOption, type Option, type ParsedPacket } from 'coap-packet'
import { parseBlock2, createBlock2, getOption, isNumeric, isBoolean } from './helpers'
-import { parameters } from './parameters'
+import { parameters, createParameters } from './parameters'
import series from 'fastseries'
import Debug from 'debug'
const debug = Debug('CoAP Server')
@@ -105,6 +107,10 @@ class CoAPServer extends EventEmitter {
_sock: Socket | EventEmitter | null
_internal_socket: boolean
_clientIdentifier: (request: IncomingMessage) => string
+ _oscoreContextManager: SecurityContextManager | null = null
+ _oscoreOnly: boolean = false
+ _parameters: Parameters
+ private _maxBlock2: number
constructor (serverOptions?: CoapServerOptions | typeof requestListener, listener?: typeof requestListener) {
super()
@@ -117,6 +123,8 @@ class CoAPServer extends EventEmitter {
this._options = serverOptions
}
+ this._parameters = createParameters(this._options.parameters)
+
this._middlewares = [parseRequest]
if (this._options.proxy === true) {
@@ -136,13 +144,20 @@ class CoAPServer extends EventEmitter {
(this._options.piggybackReplyMs == null) ||
!isNumeric(this._options.piggybackReplyMs)
) {
- this._options.piggybackReplyMs = parameters.piggybackReplyMs
+ this._options.piggybackReplyMs = this._parameters.piggybackReplyMs
}
if (!isBoolean(this._options.sendAcksForNonConfirmablePackets)) {
this._options.sendAcksForNonConfirmablePackets =
- parameters.sendAcksForNonConfirmablePackets
+ this._parameters.sendAcksForNonConfirmablePackets
+ }
+
+ if (this._options.oscoreContexts != null) {
+ this._oscoreContextManager = this._options.oscoreContexts
+ this._oscoreOnly = this._options.oscoreOnly ?? false
+ this._middlewares.push(oscoreDecryptRequest)
}
+
this._middlewares.push(handleServerRequest)
// Multicast settings
@@ -168,7 +183,7 @@ class CoAPServer extends EventEmitter {
sizeCalculation: (n, key) => {
return n.buffer.byteLength
},
- ttl: parameters.exchangeLifetime * 1000,
+ ttl: this._parameters.exchangeLifetime * 1000,
dispose: (value, key) => {
if (value.sender != null) {
value.sender.reset()
@@ -179,18 +194,25 @@ class CoAPServer extends EventEmitter {
this._series = series()
this._block1Cache = new BlockCache(
- parameters.exchangeLifetime * 1000,
+ this._parameters.exchangeLifetime * 1000,
() => {
return {}
}
)
this._block2Cache = new BlockCache(
- parameters.exchangeLifetime * 1000,
+ this._parameters.exchangeLifetime * 1000,
() => {
return null
}
)
+ // Compute max block2 size from parameters
+ this._maxBlock2 = 1024
+ if (this._parameters.maxPayloadSize < 1024) {
+ const exponent = Math.floor(Math.log2(this._parameters.maxPayloadSize))
+ this._maxBlock2 = Math.pow(2, exponent)
+ }
+
if (listener != null) {
this.on('request', listener)
}
@@ -221,10 +243,10 @@ class CoAPServer extends EventEmitter {
payload,
messageId: packet != null ? packet.messageId : undefined,
token: packet != null ? packet.token : undefined
- }, parameters.maxMessageSize)
+ }, this._parameters.maxMessageSize)
if (this._sock instanceof Socket) {
- this._sock.send(message, 0, message.length, rsinfo.port)
+ this._sock.send(message, 0, message.length, rsinfo.port, rsinfo.address)
}
}
@@ -232,7 +254,7 @@ class CoAPServer extends EventEmitter {
const url = new URL(proxyUri)
const host = url.hostname
const port = parseInt(url.port)
- const message = generate(removeProxyOptions(packet), parameters.maxMessageSize)
+ const message = generate(removeProxyOptions(packet), this._parameters.maxMessageSize)
if (this._sock instanceof Socket) {
this._sock.send(message, port, host, callback)
@@ -242,7 +264,7 @@ class CoAPServer extends EventEmitter {
_sendReverseProxied (packet: ParsedPacket, rsinfo: AddressInfo, callback?: (error: Error | null, bytes: number) => void): void {
const host = rsinfo.address
const port = rsinfo.port
- const message = generate(packet, parameters.maxMessageSize)
+ const message = generate(packet, this._parameters.maxMessageSize)
if (this._sock instanceof Socket) {
this._sock.send(message, port, host, callback)
@@ -300,10 +322,10 @@ class CoAPServer extends EventEmitter {
}
listen (portOrCallback?: number | EventEmitter | ((err?: Error) => void), addressOrCallback?: string | ((err?: Error) => void), done?: (err?: Error) => void): this {
- let port = parameters.coapPort
+ let port = this._parameters.coapPort
if (typeof portOrCallback === 'function') {
done = portOrCallback
- port = parameters.coapPort
+ port = this._parameters.coapPort
} else if (typeof portOrCallback === 'number') {
port = portOrCallback
}
@@ -353,11 +375,11 @@ class CoAPServer extends EventEmitter {
this.emit('error', error)
})
- if (parameters.pruneTimerPeriod != null) {
+ if (this._parameters.pruneTimerPeriod != null) {
// Start LRU pruning timer
this._lru.pruneTimer = setInterval(() => {
this._lru.purgeStale()
- }, parameters.pruneTimerPeriod * 1000)
+ }, this._parameters.pruneTimerPeriod * 1000)
if (this._lru.pruneTimer.unref != null) {
this._lru.pruneTimer.unref()
}
@@ -397,7 +419,13 @@ class CoAPServer extends EventEmitter {
* @param packet The packet that was sent from the client.
* @param rsinfo Connection info
*/
- _handle (packet: CoapPacket, rsinfo: AddressInfo): void {
+ _handle (
+ packet: CoapPacket,
+ rsinfo: AddressInfo,
+ oscoreProtected: boolean = false,
+ oscoreSenderId?: Buffer,
+ oscoreIdContext?: Buffer
+ ): void {
if (packet.code == null || packet.code[0] !== '0') {
// According to RFC7252 Section 4.2 receiving a confirmable messages
// that can't be processed, should be rejected by ignoring it AND
@@ -412,6 +440,13 @@ class CoAPServer extends EventEmitter {
const lru = this._lru
let Message: typeof ObserveStream | typeof OutMessage = OutMessage
const request = new IncomingMessage(packet, rsinfo)
+
+ if (oscoreProtected && oscoreSenderId != null) {
+ request.oscoreContext = {
+ senderId: oscoreSenderId,
+ idContext: oscoreIdContext
+ }
+ }
const cached = lru.peek(this._toKey(request, packet, true))
if (cached != null && !(packet.ack ?? false) && !(packet.reset ?? false) && sock instanceof Socket) {
@@ -421,6 +456,10 @@ class CoAPServer extends EventEmitter {
if (cached.response != null && (packet.reset ?? false)) {
cached.response.end()
}
+ // Stop retransmissions when ACK/RST is received
+ if (cached.sender != null) {
+ cached.sender.reset()
+ }
lru.delete(this._toKey(request, packet, false))
return
} else if (packet.ack ?? packet.reset ?? false) {
@@ -454,48 +493,52 @@ class CoAPServer extends EventEmitter {
packet.piggybackReplyMs = this._options.piggybackReplyMs
const generateResponse = (): OutgoingMessage | ObserveStream | undefined => {
const response = new Message(packet, (response, packet: ParsedPacket) => {
- /**
- * Extended `Buffer` with additional fields for caching.
- *
- * TODO: Find a more elegant solution for this type.
- */
let buf: any
- const sender = new RetrySend(sock, rsinfo.port, rsinfo.address)
+ const sender = new RetrySend(sock, rsinfo.port, rsinfo.address, undefined, this._parameters)
try {
- buf = generate(packet, parameters.maxMessageSize)
+ buf = generate(packet, this._parameters.maxMessageSize)
} catch (err) {
response.emit('error', err)
return
}
- if (Message === OutMessage) {
- sender.on('error', response.emit.bind(response, 'error'))
- } else {
- buf.response = response
- sender.on('error', () => {
- response.end()
- })
+
+ // OSCORE encode response if request was protected
+ if (oscoreProtected && this._oscoreContextManager != null && oscoreSenderId != null) {
+ const tokenHex = packet.token?.toString('hex')
+ const oscore = tokenHex != null && tokenHex.length > 0
+ ? this._oscoreContextManager.getByToken(tokenHex, oscoreSenderId)
+ : undefined
+
+ if (oscore != null) {
+ oscore.encode(buf)
+ .then((encoded) => {
+ this._finishSendResponse(
+ encoded, response, sender, request,
+ packet, lru, Message
+ )
+ // Token cleanup
+ if (Message === OutMessage && this._oscoreContextManager != null && tokenHex != null) {
+ this._oscoreContextManager.unbindToken(tokenHex, oscoreSenderId)
+ }
+ })
+ .catch((err) => response.emit('error', err))
+ // Observe token cleanup on stream close (covers both finish and error)
+ if (Message === ObserveStream) {
+ (response as ObserveStream).once('close', () => {
+ if (tokenHex != null) {
+ this._oscoreContextManager?.unbindToken(tokenHex, oscoreSenderId)
+ }
+ })
+ }
+ return
+ }
}
- const key = this._toKey(
- request,
- packet,
- packet.ack || !packet.confirmable
+ this._finishSendResponse(
+ buf, response, sender, request,
+ packet, lru, Message
)
- lru.set(key, buf)
- buf.sender = sender
-
- if (
- this._options.sendAcksForNonConfirmablePackets === true ||
- packet.confirmable
- ) {
- sender.send(
- buf,
- packet.ack || packet.reset || !packet.confirmable
- )
- } else {
- debug('OMIT ACK PACKAGE')
- }
})
response.statusCode = '2.05'
@@ -503,6 +546,9 @@ class CoAPServer extends EventEmitter {
if (cacheKey != null) {
response._cachekey = cacheKey
}
+ if (response instanceof OutMessage) {
+ response._maxBlock2 = this._maxBlock2
+ }
// inject this function so the response can add an entry to the cache
response._addCacheEntry = this._block2Cache.add.bind(this._block2Cache)
@@ -609,6 +655,65 @@ class CoAPServer extends EventEmitter {
this.saveAdditionalBlock2Options(cacheKey, response)
}
+ private _finishSendResponse (
+ buf: any,
+ response: OutgoingMessage | ObserveStream,
+ sender: RetrySend,
+ request: IncomingMessage,
+ packet: ParsedPacket,
+ lru: CoapLRUCache,
+ Message: typeof ObserveStream | typeof OutMessage
+ ): void {
+ if (Message === OutMessage) {
+ sender.on('error', response.emit.bind(response, 'error'))
+ } else {
+ buf.response = response
+ sender.on('error', () => {
+ (response as ObserveStream).end()
+ })
+ }
+
+ const key = this._toKey(
+ request,
+ packet,
+ packet.ack || !packet.confirmable
+ )
+ lru.set(key, buf)
+ buf.sender = sender
+
+ if (
+ this._options.sendAcksForNonConfirmablePackets === true ||
+ packet.confirmable
+ ) {
+ const avoidBackoff = packet.ack || packet.reset || !packet.confirmable
+ debug(`Sending packet: ack=${packet.ack}, reset=${packet.reset}, confirmable=${packet.confirmable}, avoidBackoff=${avoidBackoff}, messageId=${packet.messageId}`)
+ sender.send(
+ buf,
+ avoidBackoff
+ )
+ } else {
+ debug('OMIT ACK PACKAGE')
+ }
+ }
+
+ addOscoreContext (instance: OSCORE, recipientId: Buffer, idContext?: Buffer): void {
+ if (this._oscoreContextManager == null) {
+ this._oscoreContextManager = new SecurityContextManager()
+ // Insert OSCORE middleware before handleServerRequest if not already present
+ const hsrIndex = this._middlewares.indexOf(handleServerRequest)
+ if (hsrIndex >= 0) {
+ this._middlewares.splice(hsrIndex, 0, oscoreDecryptRequest)
+ } else {
+ this._middlewares.push(oscoreDecryptRequest)
+ }
+ }
+ this._oscoreContextManager.addContext(instance, recipientId, idContext)
+ }
+
+ removeOscoreContext (recipientId: Buffer, idContext?: Buffer): void {
+ this._oscoreContextManager?.removeContext(recipientId, idContext)
+ }
+
private saveAdditionalBlock2Options (cacheKey: string | null, response?: OutgoingMessage | ObserveStream): void {
if (cacheKey != null) {
const cacheEntry = this._block2Cache.get(cacheKey)
@@ -654,19 +759,6 @@ class CoAPServer extends EventEmitter {
}
}
-// Max block size defined in the protocol is 2^(6+4) = 1024
-let maxBlock2 = 1024
-
-// Some network stacks (e.g. 6LowPAN/Thread) might have a lower IP MTU.
-// In those cases the maxPayloadSize parameter can be adjusted
-if (parameters.maxPayloadSize < 1024) {
- // CoAP Block2 header only has sizes of 2^(i+4) for i in 0 to 6 inclusive,
- // so pick the next size down that is supported
- let exponent = Math.log2(parameters.maxPayloadSize)
- exponent = Math.floor(exponent)
- maxBlock2 = Math.pow(2, exponent)
-}
-
/*
new out message
inherit from OutgoingMessage
@@ -676,6 +768,7 @@ class OutMessage extends OutgoingMessage {
_cachekey: string
// eslint-disable-next-line @typescript-eslint/ban-types
_addCacheEntry: Function
+ _maxBlock2: number
/**
* Entry point for a response from the server
@@ -687,6 +780,8 @@ class OutMessage extends OutgoingMessage {
// removeOption(this._request.options, 'Block1');
// add logic for Block1 sending
+ const maxBlock2 = this._maxBlock2
+
const block2Buff = getOption(this._request.options, 'Block2')
let requestedBlockOption: Block | null = null
// if we got blockwise (2) request
diff --git a/models/models.ts b/models/models.ts
index a0d9927f..128fb7ec 100644
--- a/models/models.ts
+++ b/models/models.ts
@@ -9,10 +9,12 @@
import { CoapMethod, OptionName, Packet, ParsedPacket } from 'coap-packet'
import { Socket } from 'dgram'
import { AddressInfo } from 'net'
+import type { OSCORE } from 'coap-oscore'
import Agent from '../lib/agent'
import IncomingMessage from '../lib/incoming_message'
import OutgoingMessage from '../lib/outgoing_message'
import CoAPServer from '../lib/server'
+import type { SecurityContextManager } from '../lib/oscore'
export declare function requestListener (req: IncomingMessage, res: OutgoingMessage): void
@@ -37,6 +39,10 @@ export interface MiddlewareParameters {
server: CoAPServer
packet?: ParsedPacket
proxy?: string
+ oscoreContext?: OSCORE
+ wasOscoreProtected?: boolean
+ oscoreSenderId?: Buffer
+ oscoreIdContext?: Buffer
}
export interface CoapPacket extends Packet {
@@ -114,10 +120,15 @@ export interface CoapServerOptions {
clientIdentifier?: (request: IncomingMessage) => string
reuseAddr?: boolean
cacheSize?: number
+ oscoreContexts?: SecurityContextManager
+ oscoreOnly?: boolean
+ parameters?: ParametersUpdate
}
export interface AgentOptions {
type?: 'udp4' | 'udp6'
socket?: Socket
port?: number
+ oscoreOnly?: boolean
+ parameters?: ParametersUpdate
}
diff --git a/package.json b/package.json
index fbb6593d..550da353 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "coap",
- "version": "1.4.2",
+ "version": "1.6.0",
"description": "A CoAP library for node modelled after 'http'",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -57,6 +57,7 @@
"bl": "^6.0.12",
"@types/readable-stream": "^4.0.14",
"capitalize": "^2.0.4",
+ "coap-oscore": "^2.2.1",
"coap-packet": "^1.1.1",
"debug": "^4.3.5",
"fastseries": "^2.0.0",
diff --git a/test/oscore.ts b/test/oscore.ts
new file mode 100644
index 00000000..6947c8da
--- /dev/null
+++ b/test/oscore.ts
@@ -0,0 +1,576 @@
+/*
+ * Copyright (c) 2013-2021 node-coap contributors.
+ *
+ * node-coap is licensed under an MIT +no-false-attribs license.
+ * All rights not explicitly granted in the MIT license are reserved.
+ * See the included LICENSE file for more details.
+ */
+
+import { nextPort } from './common'
+import { expect } from 'chai'
+import { request, createServer, Agent, SecurityContextManager } from '../index'
+import { OSCORE, OscoreContextStatus } from 'coap-oscore'
+import type IncomingMessage from '../lib/incoming_message'
+import type OutgoingMessage from '../lib/outgoing_message'
+import type Server from '../lib/server'
+
+function createOscorePair (opts?: { idContext?: Buffer, clientId?: Buffer, serverId?: Buffer, masterSecret?: Buffer, masterSalt?: Buffer }): { client: OSCORE, server: OSCORE } {
+ const masterSecret = opts?.masterSecret ?? Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
+ const masterSalt = opts?.masterSalt ?? Buffer.from('9e7ca92223786340', 'hex')
+ const idContext = opts?.idContext ?? Buffer.alloc(0)
+ const clientId = opts?.clientId ?? Buffer.from('01', 'hex')
+ const serverId = opts?.serverId ?? Buffer.from('02', 'hex')
+
+ const client = new OSCORE({
+ masterSecret,
+ masterSalt,
+ senderId: clientId,
+ recipientId: serverId,
+ idContext,
+ status: OscoreContextStatus.Fresh
+ })
+
+ const server = new OSCORE({
+ masterSecret,
+ masterSalt,
+ senderId: serverId,
+ recipientId: clientId,
+ idContext,
+ status: OscoreContextStatus.Fresh
+ })
+
+ return { client, server }
+}
+
+describe('OSCORE', function () {
+ const servers: Server[] = []
+ const agents: InstanceType[] = []
+
+ function trackServer (s: Server): Server {
+ servers.push(s)
+ return s
+ }
+
+ function trackAgent (a: InstanceType): InstanceType {
+ agents.push(a)
+ return a
+ }
+
+ afterEach(function (done) {
+ for (const s of servers) {
+ try { s.close() } catch {}
+ }
+ servers.length = 0
+ for (const a of agents) {
+ try { a.close() } catch {}
+ }
+ agents.length = 0
+ setImmediate(done)
+ })
+
+ describe('client-server round-trip', function () {
+ it('should complete a GET request with OSCORE encryption', function (done) {
+ const port = nextPort()
+ const { client: clientOscore, server: serverOscore } = createOscorePair()
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+ server.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ expect(req.isOscore).to.equal(true)
+ expect(req.oscoreContext).to.not.be.undefined
+ expect(req.oscoreContext!.senderId.toString('hex')).to.equal('01')
+ res.end(Buffer.from('Hello OSCORE'))
+ })
+ server.listen(port, () => {
+ const agent = trackAgent(new Agent({ type: 'udp4' }))
+ agent.addOscoreContext('127.0.0.1', port, clientOscore)
+
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent
+ })
+ req.on('response', (res) => {
+ expect(res.payload.toString()).to.equal('Hello OSCORE')
+ done()
+ })
+ req.end()
+ })
+ })
+
+ it('should complete a POST request with OSCORE encryption', function (done) {
+ const port = nextPort()
+ const { client: clientOscore, server: serverOscore } = createOscorePair()
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+ server.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ expect(req.isOscore).to.equal(true)
+ expect(req.payload.toString()).to.equal('request data')
+ res.end(Buffer.from('response data'))
+ })
+ server.listen(port, () => {
+ const agent = trackAgent(new Agent({ type: 'udp4' }))
+ agent.addOscoreContext('127.0.0.1', port, clientOscore)
+
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ method: 'POST',
+ agent
+ })
+ req.on('response', (res) => {
+ expect(res.payload.toString()).to.equal('response data')
+ done()
+ })
+ req.end(Buffer.from('request data'))
+ })
+ })
+ })
+
+ describe('mixed secure/insecure server', function () {
+ it('should handle both OSCORE and plaintext requests', function (done) {
+ const port = nextPort()
+ const { client: clientOscore, server: serverOscore } = createOscorePair()
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+ let requestCount = 0
+
+ server.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ requestCount++
+ if (requestCount === 1) {
+ expect(req.isOscore).to.equal(true)
+ res.end(Buffer.from('secure'))
+ } else {
+ expect(req.isOscore).to.equal(false)
+ res.end(Buffer.from('plain'))
+ }
+ })
+
+ server.listen(port, () => {
+ const oscoreAgent = trackAgent(new Agent({ type: 'udp4' }))
+ oscoreAgent.addOscoreContext('127.0.0.1', port, clientOscore)
+
+ const req1 = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent: oscoreAgent
+ })
+ req1.on('response', (res1) => {
+ expect(res1.payload.toString()).to.equal('secure')
+
+ const plainAgent = trackAgent(new Agent({ type: 'udp4' }))
+ const req2 = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent: plainAgent
+ })
+ req2.on('response', (res2) => {
+ expect(res2.payload.toString()).to.equal('plain')
+ done()
+ })
+ req2.end()
+ })
+ req1.end()
+ })
+ })
+ })
+
+ describe('oscoreOnly server', function () {
+ it('should reject unprotected requests with 4.01', function (done) {
+ const port = nextPort()
+ const { server: serverOscore } = createOscorePair()
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts, oscoreOnly: true }))
+ server.on('request', () => {
+ done(new Error('Should not receive request'))
+ })
+
+ server.listen(port, () => {
+ const plainAgent = trackAgent(new Agent({ type: 'udp4' }))
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent: plainAgent
+ })
+ req.on('response', (res) => {
+ expect(res.code).to.equal('4.01')
+ done()
+ })
+ req.end()
+ })
+ })
+ })
+
+ describe('oscoreOnly agent', function () {
+ it('should throw on requests to peers without context', function () {
+ const agent = trackAgent(new Agent({ type: 'udp4', oscoreOnly: true }))
+ expect(() => {
+ request({
+ hostname: '127.0.0.1',
+ port: 5683,
+ pathname: '/test',
+ agent
+ }).end()
+ }).to.throw('No OSCORE context for')
+ })
+ })
+
+ describe('multiple client contexts on agent', function () {
+ it('should route OSCORE correctly to different peers', function (done) {
+ const port1 = nextPort()
+ const port2 = nextPort()
+
+ // Two completely separate key pairs
+ const pair1 = createOscorePair({
+ clientId: Buffer.from('01', 'hex'),
+ serverId: Buffer.from('02', 'hex')
+ })
+ const pair2 = createOscorePair({
+ masterSecret: Buffer.from('aabbccddeeff00112233445566778899', 'hex'),
+ masterSalt: Buffer.from('1122334455667788', 'hex'),
+ clientId: Buffer.from('03', 'hex'),
+ serverId: Buffer.from('04', 'hex')
+ })
+
+ const contexts1 = new SecurityContextManager()
+ contexts1.addContext(pair1.server, Buffer.from('01', 'hex'))
+
+ const contexts2 = new SecurityContextManager()
+ contexts2.addContext(pair2.server, Buffer.from('03', 'hex'))
+
+ const server1 = trackServer(createServer({ oscoreContexts: contexts1 }))
+ const server2 = trackServer(createServer({ oscoreContexts: contexts2 }))
+
+ server1.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ res.end(Buffer.from('server1'))
+ })
+ server2.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ res.end(Buffer.from('server2'))
+ })
+
+ server1.listen(port1, () => {
+ server2.listen(port2, () => {
+ // Use separate agents per peer — agent closes socket after
+ // its request completes, so sequential requests from response
+ // handlers need separate agents
+ const agent1 = trackAgent(new Agent({ type: 'udp4' }))
+ agent1.addOscoreContext('127.0.0.1', port1, pair1.client)
+
+ const agent2 = trackAgent(new Agent({ type: 'udp4' }))
+ agent2.addOscoreContext('127.0.0.1', port2, pair2.client)
+
+ const req1 = request({
+ hostname: '127.0.0.1',
+ port: port1,
+ pathname: '/test',
+ agent: agent1
+ })
+ req1.on('response', (res1) => {
+ expect(res1.payload.toString()).to.equal('server1')
+
+ const req2 = request({
+ hostname: '127.0.0.1',
+ port: port2,
+ pathname: '/test',
+ agent: agent2
+ })
+ req2.on('response', (res2) => {
+ expect(res2.payload.toString()).to.equal('server2')
+ done()
+ })
+ req2.end()
+ })
+ req1.end()
+ })
+ })
+ })
+ })
+
+ describe('multiple client contexts on server', function () {
+ it('should handle requests from different OSCORE clients', function (done) {
+ const port = nextPort()
+
+ const pair1 = createOscorePair({
+ clientId: Buffer.from('01', 'hex'),
+ serverId: Buffer.from('03', 'hex')
+ })
+ const pair2 = createOscorePair({
+ masterSecret: Buffer.from('aabbccddeeff00112233445566778899', 'hex'),
+ masterSalt: Buffer.from('1122334455667788', 'hex'),
+ clientId: Buffer.from('02', 'hex'),
+ serverId: Buffer.from('03', 'hex')
+ })
+
+ const contexts = new SecurityContextManager()
+ contexts.addContext(pair1.server, Buffer.from('01', 'hex'))
+ contexts.addContext(pair2.server, Buffer.from('02', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+ const senderIds: string[] = []
+
+ server.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ senderIds.push(req.oscoreContext!.senderId.toString('hex'))
+ res.end(Buffer.from(`hello ${req.oscoreContext!.senderId.toString('hex')}`))
+ })
+
+ server.listen(port, () => {
+ const agentA = trackAgent(new Agent({ type: 'udp4' }))
+ agentA.addOscoreContext('127.0.0.1', port, pair1.client)
+
+ const reqA = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent: agentA
+ })
+ reqA.on('response', (resA) => {
+ expect(resA.payload.toString()).to.equal('hello 01')
+
+ const agentB = trackAgent(new Agent({ type: 'udp4' }))
+ agentB.addOscoreContext('127.0.0.1', port, pair2.client)
+
+ const reqB = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent: agentB
+ })
+ reqB.on('response', (resB) => {
+ expect(resB.payload.toString()).to.equal('hello 02')
+ expect(senderIds).to.deep.equal(['01', '02'])
+ done()
+ })
+ reqB.end()
+ })
+ reqA.end()
+ })
+ })
+ })
+
+ describe('unknown client on server', function () {
+ it('should not emit request for unknown KID', function (done) {
+ const port = nextPort()
+
+ // Client with sender ID 0x05 (not registered on server)
+ const unknownClient = new OSCORE({
+ masterSecret: Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex'),
+ masterSalt: Buffer.from('9e7ca92223786340', 'hex'),
+ senderId: Buffer.from('05', 'hex'),
+ recipientId: Buffer.from('02', 'hex'),
+ idContext: Buffer.alloc(0),
+ status: OscoreContextStatus.Fresh
+ })
+
+ // Server only has context for sender ID 0x01
+ const serverOscore = new OSCORE({
+ masterSecret: Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex'),
+ masterSalt: Buffer.from('9e7ca92223786340', 'hex'),
+ senderId: Buffer.from('02', 'hex'),
+ recipientId: Buffer.from('01', 'hex'),
+ idContext: Buffer.alloc(0),
+ status: OscoreContextStatus.Fresh
+ })
+
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+ server.on('request', () => {
+ done(new Error('Should not receive request from unknown client'))
+ })
+
+ server.listen(port, () => {
+ const agent = trackAgent(new Agent({ type: 'udp4' }))
+ agent.addOscoreContext('127.0.0.1', port, unknownClient)
+
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent,
+ retrySend: 0
+ })
+ // Server rejects unknown KID with plaintext 4.01
+ // Client drops it (correct OSCORE behavior — no plaintext accepted from secured peer)
+ // Give the server time to process and verify it didn't emit 'request'
+ req.end()
+
+ setTimeout(() => {
+ // If we got here, server correctly rejected without emitting 'request'
+ done()
+ }, 500)
+ })
+ })
+ })
+
+ describe('SSN persistence', function () {
+ it('should emit ssn events via SecurityContextManager', function (done) {
+ const port = nextPort()
+ const { client: clientOscore, server: serverOscore } = createOscorePair()
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ let ssnFired = false
+ contexts.on('ssn', (recipientId, idContext, ssn) => {
+ ssnFired = true
+ expect(recipientId.toString('hex')).to.equal('01')
+ expect(typeof ssn).to.equal('bigint')
+ })
+
+ // Use observe so the server sends notifications — notifications
+ // increment SSN and emit the 'ssn' event (normal responses reuse
+ // the request nonce and don't increment SSN per RFC 8613).
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+ server.on('request', (req: IncomingMessage, res: any) => {
+ res.write(Buffer.from('first'))
+ setTimeout(() => res.write(Buffer.from('second')), 20)
+ })
+ server.listen(port, () => {
+ const agent = trackAgent(new Agent({ type: 'udp4' }))
+ agent.addOscoreContext('127.0.0.1', port, clientOscore)
+
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ observe: true,
+ agent
+ })
+ req.on('response', (res) => {
+ // After receiving first notification, give SSN event time to propagate
+ res.once('data', () => {
+ setTimeout(() => {
+ expect(ssnFired).to.equal(true)
+ done()
+ }, 50)
+ })
+ })
+ req.end()
+ })
+ })
+ })
+
+ describe('dynamic context management', function () {
+ it('should add/remove contexts at runtime on agent', function (done) {
+ const port = nextPort()
+ const { client: clientOscore, server: serverOscore } = createOscorePair()
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+ server.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ res.end(Buffer.from('OK'))
+ })
+
+ server.listen(port, () => {
+ const agent = trackAgent(new Agent({ type: 'udp4' }))
+ agent.addOscoreContext('127.0.0.1', port, clientOscore)
+
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent
+ })
+ req.on('response', (res) => {
+ expect(res.payload.toString()).to.equal('OK')
+ agent.removeOscoreContext('127.0.0.1', port)
+ done()
+ })
+ req.end()
+ })
+ })
+
+ it('should add contexts at runtime on server', function (done) {
+ const port = nextPort()
+ const { client: clientOscore, server: serverOscore } = createOscorePair()
+
+ const server = trackServer(createServer())
+ server.on('request', (req: IncomingMessage, res: OutgoingMessage) => {
+ expect(req.isOscore).to.equal(true)
+ res.end(Buffer.from('dynamic'))
+ })
+
+ server.listen(port, () => {
+ server.addOscoreContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const agent = trackAgent(new Agent({ type: 'udp4' }))
+ agent.addOscoreContext('127.0.0.1', port, clientOscore)
+
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ agent
+ })
+ req.on('response', (res) => {
+ expect(res.payload.toString()).to.equal('dynamic')
+ done()
+ })
+ req.end()
+ })
+ })
+ })
+
+ describe('observe with OSCORE', function () {
+ it('should receive observe notifications with OSCORE', function (done) {
+ const port = nextPort()
+ const { client: clientOscore, server: serverOscore } = createOscorePair()
+ const contexts = new SecurityContextManager()
+ contexts.addContext(serverOscore, Buffer.from('01', 'hex'))
+
+ const server = trackServer(createServer({ oscoreContexts: contexts }))
+
+ let observeStream: any
+ server.on('request', (req: IncomingMessage, res: any) => {
+ expect(req.isOscore).to.equal(true)
+ observeStream = res
+ res.statusCode = '2.05'
+ res.write(Buffer.from('first'))
+ })
+
+ server.listen(port, () => {
+ const agent = trackAgent(new Agent({ type: 'udp4' }))
+ agent.addOscoreContext('127.0.0.1', port, clientOscore)
+
+ const req = request({
+ hostname: '127.0.0.1',
+ port,
+ pathname: '/test',
+ observe: true,
+ agent
+ })
+
+ const payloads: string[] = []
+ req.on('response', (res) => {
+ res.on('data', (data: Buffer) => {
+ payloads.push(data.toString())
+ if (payloads.length === 1) {
+ expect(payloads[0]).to.equal('first')
+ setTimeout(() => {
+ observeStream.write(Buffer.from('second'))
+ }, 50)
+ } else if (payloads.length === 2) {
+ expect(payloads[1]).to.equal('second')
+ res.close()
+ done()
+ }
+ })
+ })
+ req.end()
+ })
+ })
+ })
+})