diff --git a/packages/binding-coap/README.md b/packages/binding-coap/README.md index 1562f44ec..5b5d9406c 100644 --- a/packages/binding-coap/README.md +++ b/packages/binding-coap/README.md @@ -52,6 +52,75 @@ servient }); ``` +## Using PSK with CoAPs (DTLS) + +The CoAP binding also supports secure communication over `coaps://` using DTLS with Pre-Shared Keys (PSK). + +To use PSK security, define a `psk` security scheme in the Thing Description and provide the credentials when consuming the Thing. + +### Thing Description Example (PSK) + +```json +{ + "title": "SecureThing", + "securityDefinitions": { + "psk_sc": { + "scheme": "psk" + } + }, + "security": ["psk_sc"], + "properties": { + "count": { + "type": "integer", + "forms": [ + { + "href": "coaps://localhost:5684/count" + } + ] + } + } +} +``` + +### Client Example with PSK + +```js +const { Servient } = require("@node-wot/core"); +const { CoapClientFactory } = require("@node-wot/binding-coap"); + +const servient = new Servient(); +servient.addClientFactory(new CoapClientFactory()); + +servient + .start() + .then(async (WoT) => { + try { + const td = await WoT.requestThingDescription("coaps://localhost:5684/secureThing"); + const thing = await WoT.consume(td); + + // configure PSK security + thing.setSecurity(td.securityDefinitions, { + identity: "Client_identity", + psk: "secretPSK", + }); + + const value = await thing.readProperty("count"); + console.log("count value is:", await value.value()); + } catch (err) { + console.error("Script error:", err); + } + }) + .catch((err) => { + console.error("Start error:", err); + }); +``` + +### Notes + +- The `identity` must match the server configuration. +- The `psk` must match the server's configured secret. +- Currently, only the `psk` security scheme is supported for `coaps://` in this binding. + ### Server Example The server example produces a thing that allows for setting a property `count`. The thing is reachable through CoAP. diff --git a/packages/binding-coap/eslint.config.mjs b/packages/binding-coap/eslint.config.mjs new file mode 100644 index 000000000..04032d1ae --- /dev/null +++ b/packages/binding-coap/eslint.config.mjs @@ -0,0 +1,10 @@ +import rootConfig from "../../eslint.config.mjs"; + +export default [ + ...rootConfig, + { + rules: { + "@typescript-eslint/no-unnecessary-condition": "warn", + }, + }, +]; diff --git a/packages/binding-coap/src/coap-client.ts b/packages/binding-coap/src/coap-client.ts index d264c7cfe..1cb095f7d 100644 --- a/packages/binding-coap/src/coap-client.ts +++ b/packages/binding-coap/src/coap-client.ts @@ -65,7 +65,9 @@ export default class CoapClient implements ProtocolClient { debug(`CoapClient received Content-Format: ${res.headers["Content-Format"]}`); // FIXME does not work with blockwise because of node-coap - const contentType = (res.headers["Content-Format"] as string) ?? form.contentType; + const rawContentType = res.headers["Content-Format"]; + const contentType = + typeof rawContentType === "string" ? rawContentType : (form.contentType ?? ContentSerdes.DEFAULT); resolve(new Content(contentType, Readable.from(res.payload))); }); @@ -109,8 +111,11 @@ export default class CoapClient implements ProtocolClient { debug(`CoapClient received ${res.code} from ${form.href}`); debug(`CoapClient received Content-Format: ${res.headers["Content-Format"]}`); debug(`CoapClient received headers: ${JSON.stringify(res.headers)}`); - const contentType = res.headers["Content-Format"] as string; - resolve(new Content(contentType ?? "", Readable.from(res.payload))); + + const rawContentType = res.headers["Content-Format"]; + const contentType = typeof rawContentType === "string" ? rawContentType : ContentSerdes.DEFAULT; + + resolve(new Content(contentType, Readable.from(res.payload))); }); req.on("error", (err: Error) => reject(err)); (async () => { @@ -156,10 +161,12 @@ export default class CoapClient implements ProtocolClient { debug(`CoapClient received Content-Format: ${res.headers["Content-Format"]}`); // FIXME does not work with blockwise because of node-coap - const contentType = res.headers["Content-Format"] ?? form.contentType ?? ContentSerdes.DEFAULT; + const rawContentType = res.headers["Content-Format"]; + const contentType = + typeof rawContentType === "string" ? rawContentType : (form.contentType ?? ContentSerdes.DEFAULT); res.on("data", (data: Buffer) => { - next(new Content(`${contentType}`, Readable.from(res.payload))); + next(new Content(contentType, Readable.from(res.payload))); }); resolve( @@ -190,7 +197,9 @@ export default class CoapClient implements ProtocolClient { req.setOption("Accept", "application/td+json"); return new Promise((resolve, reject) => { req.on("response", (res: IncomingMessage) => { - const contentType = (res.headers["Content-Format"] as string) ?? "application/td+json"; + const rawContentType = res.headers["Content-Format"]; + const contentType = typeof rawContentType === "string" ? rawContentType : "application/td+json"; + resolve(new Content(contentType, Readable.from(res.payload))); }); req.on("error", (err: Error) => reject(err)); diff --git a/packages/binding-coap/src/coap-server.ts b/packages/binding-coap/src/coap-server.ts index d31acfa72..7c5be0ca0 100644 --- a/packages/binding-coap/src/coap-server.ts +++ b/packages/binding-coap/src/coap-server.ts @@ -41,7 +41,7 @@ const { debug, warn, info, error } = createLoggers("binding-coap", "coap-server" type CoreLinkFormatParameters = Map; -type AffordanceElement = PropertyElement | ActionElement | EventElement; +type AffordanceElement = Omit | Omit | Omit; // TODO: Move to core? type AugmentedInteractionOptions = WoT.InteractionOptions & { formIndex: number }; @@ -145,11 +145,6 @@ export default class CoapServer implements ProtocolServer { const port = this.getPort(); const urlPath = this.createThingUrlPath(thing); - if (port === -1) { - warn("CoapServer is assigned an invalid port, aborting expose process."); - return; - } - this.fillInBindingData(thing, port, urlPath); debug(`CoapServer on port ${port} exposes '${thing.title}' as unique '/${urlPath}'`); @@ -236,12 +231,8 @@ export default class CoapServer implements ProtocolServer { } private addFormToAffordance(form: Form, affordance: AffordanceElement): void { - const affordanceForms = affordance.forms; - if (affordanceForms == null) { - affordance.forms = [form]; - } else { - affordanceForms.push(form); - } + const withForms = affordance as AffordanceElement & { forms?: Form[] }; + (withForms.forms ??= []).push(form); } private fillInPropertyBindingData(thing: ExposedThing, base: string, offeredMediaType: string) { @@ -354,9 +345,8 @@ export default class CoapServer implements ProtocolServer { public async destroy(thingId: string): Promise { debug(`CoapServer on port ${this.getPort()} destroying thingId '${thingId}'`); - for (const name of this.things.keys()) { - const exposedThing = this.things.get(name); - if (exposedThing?.id === thingId) { + for (const [name, exposedThing] of this.things.entries()) { + if (exposedThing.id === thingId) { this.things.delete(name); this.coreResources.delete(name); this.mdnsIntroducer?.delete(name); @@ -374,7 +364,7 @@ export default class CoapServer implements ProtocolServer { return Array.from(this.coreResources.values()) .map((resource) => { const formattedPath = ``; - const parameters = Array.from(resource.parameters?.entries() ?? []); + const parameters = resource.parameters ? Array.from(resource.parameters.entries()) : []; const parameterValues = parameters.map((parameter) => { const key = parameter[0]; @@ -499,20 +489,24 @@ export default class CoapServer implements ProtocolServer { const { thingKey, affordanceType, affordanceKey } = this.parseUriSegments(requestUri); const thing = this.things.get(thingKey); - if (thing == null) { + if (thing === undefined) { this.sendNotFoundResponse(res); return; } // TODO: Remove support for trailing slashes (or rather: trailing empty URI path segments) - if (affordanceType == null || affordanceType === "") { + if (!affordanceType) { await this.handleTdRequest(req, res, thing); return; } switch (affordanceType) { case this.PROPERTY_DIR: - this.handlePropertyRequest(thing, affordanceKey, req, res, contentType); + if (!affordanceKey) { + this.handlePropertiesRequest(req, contentType, thing, res); + } else { + this.handlePropertyRequest(thing, affordanceKey, req, res, contentType); + } break; case this.ACTION_DIR: this.handleActionRequest(thing, affordanceKey, req, res, contentType); @@ -554,11 +548,6 @@ export default class CoapServer implements ProtocolServer { ) { const property = thing.properties[affordanceKey]; - if (property == null) { - this.handlePropertiesRequest(req, contentType, thing, res); - return; - } - switch (req.method) { case "GET": if (req.headers.Observe == null) { @@ -588,7 +577,7 @@ export default class CoapServer implements ProtocolServer { ) { const forms = thing.forms; - if (forms == null) { + if (!forms || forms.length === 0) { this.sendNotFoundResponse(res); return; } @@ -618,26 +607,30 @@ export default class CoapServer implements ProtocolServer { contentType, thing.uriVariables ); - const readablePropertyKeys = this.getReadableProperties(thing).map(([key, _]) => key); + + const readablePropertyKeys = this.getReadableProperties(thing).map(([key]) => key); + const contentMap = await thing.handleReadMultipleProperties(readablePropertyKeys, interactionOptions); const recordResponse: Record = {}; for (const [key, content] of contentMap.entries()) { - const value = ContentSerdes.get().contentToValue( - { type: ContentSerdes.DEFAULT, body: await content.toBuffer() }, - {} - ); - - if (value == null) { - // TODO: How should this case be handled? + try { + if (content.type !== ContentSerdes.DEFAULT && content.type !== "application/json") { + continue; + } + const buffer = await content.toBuffer(); + const parsed = JSON.parse(buffer.toString()); + + recordResponse[key] = parsed; + } catch { + // Ignore non-JSON properties continue; } - - recordResponse[key] = value; } - const content = ContentSerdes.get().valueToContent(recordResponse, undefined, contentType); - this.streamContentResponse(res, content); + const responseContent = ContentSerdes.get().valueToContent(recordResponse, undefined, contentType); + + this.streamContentResponse(res, responseContent); } catch (err) { const errorMessage = `${err}`; error(`CoapServer on port ${this.getPort()} got internal error on read '${req.url}': ${errorMessage}`); @@ -775,11 +768,6 @@ export default class CoapServer implements ProtocolServer { ) { const action = thing.actions[affordanceKey]; - if (action == null) { - this.sendNotFoundResponse(res); - return; - } - if (req.method !== "POST") { this.sendMethodNotAllowedResponse(res); return; @@ -837,19 +825,14 @@ export default class CoapServer implements ProtocolServer { ) { const event = thing.events[affordanceKey]; - if (event == null) { - this.sendNotFoundResponse(res); - return; - } - if (req.method !== "GET") { this.sendMethodNotAllowedResponse(res); return; } - const observe = req.headers.Observe as number; + const observe = req.headers.Observe as number | undefined; - if (observe == null) { + if (observe === undefined) { debug( `CoapServer on port ${this.getPort()} rejects '${affordanceKey}' event subscription from ${Helpers.toUriLiteral( req.rsinfo.address @@ -923,17 +906,18 @@ export default class CoapServer implements ProtocolServer { } private getContentTypeFromRequest(req: IncomingMessage): string { - const contentType = req.headers["Content-Format"] as string; + const contentType = req.headers["Content-Format"] as string | undefined; - if (contentType == null) { + if (contentType === undefined) { warn( `CoapServer on port ${this.getPort()} received no Content-Format from ${Helpers.toUriLiteral( req.rsinfo.address )}:${req.rsinfo.port}` ); + return ContentSerdes.DEFAULT; } - return contentType ?? ContentSerdes.DEFAULT; + return contentType; } private checkContentTypeSupportForInput(method: string, contentType: string): boolean { @@ -974,7 +958,7 @@ export default class CoapServer implements ProtocolServer { } // TODO: The name of this method might not be ideal yet. - private streamContentResponse(res: OutgoingMessage, content: Content, options?: { end?: boolean | undefined }) { + private streamContentResponse(res: OutgoingMessage, content: Content, options?: { end?: boolean }) { res.setOption("Content-Format", content.type); res.code = "2.05"; content.body.pipe(res, options); diff --git a/packages/binding-coap/src/coaps-client.ts b/packages/binding-coap/src/coaps-client.ts index 0120e831f..8e885e4bb 100644 --- a/packages/binding-coap/src/coaps-client.ts +++ b/packages/binding-coap/src/coaps-client.ts @@ -110,13 +110,13 @@ export default class CoapsClient implements ProtocolClient { ): Promise { return new Promise((resolve, reject) => { const requestUri = new URL(form.href.replace(/$coaps/, "https")); - if (this.authorization != null) { + if (this.authorization !== undefined) { coaps.setSecurityParams(requestUri.hostname, this.authorization); } const callback = (resp: CoapResponse) => { if (resp.payload != null) { - next(new Content(form?.contentType ?? ContentSerdes.DEFAULT, Readable.from(resp.payload))); + next(new Content(form.contentType ?? ContentSerdes.DEFAULT, Readable.from(resp.payload))); } }; @@ -163,14 +163,14 @@ export default class CoapsClient implements ProtocolClient { } public setSecurity(metadata: Array, credentials?: pskSecurityParameters): boolean { - if (metadata === undefined || !Array.isArray(metadata) || metadata.length === 0) { + if (!Array.isArray(metadata) || metadata.length === 0) { warn(`CoapsClient received empty security metadata`); return false; } const security: SecurityScheme = metadata[0]; - if (security.scheme === "psk" && credentials != null) { + if (security.scheme === "psk" && credentials !== undefined) { this.authorization = { psk: {} }; this.authorization.psk[credentials.identity] = credentials.psk; } else if (security.scheme === "apikey") { @@ -224,7 +224,7 @@ export default class CoapsClient implements ProtocolClient { ): Promise { // url only works with http* const requestUri = new URL(form.href.replace(/$coaps/, "https")); - if (this.authorization != null) { + if (this.authorization !== undefined) { coaps.setSecurityParams(requestUri.hostname, this.authorization); } diff --git a/packages/binding-coap/src/mdns-introducer.ts b/packages/binding-coap/src/mdns-introducer.ts index e2979881c..6cfee92b8 100644 --- a/packages/binding-coap/src/mdns-introducer.ts +++ b/packages/binding-coap/src/mdns-introducer.ts @@ -73,7 +73,7 @@ export class MdnsIntroducer { private determineTarget(): string { const interfaces = networkInterfaces(); - for (const iface of Object.values(interfaces ?? {})) { + for (const iface of Object.values(interfaces)) { for (const entry of iface ?? []) { if (entry.internal === false) { if (entry.family === this.ipAddressFamily) {