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() + }) + }) + }) +})