Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/binding-coap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions packages/binding-coap/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import rootConfig from "../../eslint.config.mjs";

export default [
...rootConfig,
{
rules: {
"@typescript-eslint/no-unnecessary-condition": "warn",
},
},
];
21 changes: 15 additions & 6 deletions packages/binding-coap/src/coap-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -190,7 +197,9 @@ export default class CoapClient implements ProtocolClient {
req.setOption("Accept", "application/td+json");
return new Promise<Content>((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));
Expand Down
96 changes: 43 additions & 53 deletions packages/binding-coap/src/coap-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ const { debug, warn, info, error } = createLoggers("binding-coap", "coap-server"

type CoreLinkFormatParameters = Map<string, string[] | number[]>;

type AffordanceElement = PropertyElement | ActionElement | EventElement;
type AffordanceElement =
Omit<PropertyElement, "forms"> |
Omit<ActionElement, "forms"> |
Omit<EventElement, "forms">;

// TODO: Move to core?
type AugmentedInteractionOptions = WoT.InteractionOptions & { formIndex: number };
Expand Down Expand Up @@ -145,11 +148,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}'`);
Expand Down Expand Up @@ -236,12 +234,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) {
Expand Down Expand Up @@ -354,9 +348,8 @@ export default class CoapServer implements ProtocolServer {

public async destroy(thingId: string): Promise<boolean> {
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);
Expand All @@ -374,7 +367,7 @@ export default class CoapServer implements ProtocolServer {
return Array.from(this.coreResources.values())
.map((resource) => {
const formattedPath = `</${resource.urlPath}>`;
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];
Expand Down Expand Up @@ -499,20 +492,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);
Expand Down Expand Up @@ -554,11 +551,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) {
Expand Down Expand Up @@ -588,7 +580,7 @@ export default class CoapServer implements ProtocolServer {
) {
const forms = thing.forms;

if (forms == null) {
if (!forms || forms.length === 0) {
this.sendNotFoundResponse(res);
return;
}
Expand Down Expand Up @@ -618,26 +610,33 @@ 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<string, DataSchemaValue> = {};
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"
Comment on lines +621 to +623
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In handleReadMultipleProperties(), the JSON filter is too strict: it only accepts content.type exactly equal to application/json / ContentSerdes.DEFAULT. JSON content types with parameters (e.g. application/json; charset=utf-8) will be skipped even though they are JSON. Consider comparing ContentSerdes.getMediaType(content.type) against application/json (or just attempt JSON parsing inside the try/catch without the exact-type guard).

Suggested change
if (
content.type !== ContentSerdes.DEFAULT &&
content.type !== "application/json"
const mediaType = ContentSerdes.getMediaType(content.type);
if (
mediaType !== ContentSerdes.DEFAULT &&
mediaType !== "application/json"

Copilot uses AI. Check for mistakes.
) {
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}`);
Expand Down Expand Up @@ -775,11 +774,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;
Expand Down Expand Up @@ -837,19 +831,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
Expand Down Expand Up @@ -923,17 +912,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 {
Expand Down Expand Up @@ -974,7 +964,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);
Expand Down
10 changes: 5 additions & 5 deletions packages/binding-coap/src/coaps-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ export default class CoapsClient implements ProtocolClient {
): Promise<Subscription> {
return new Promise<Subscription>((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)));
}
};

Expand Down Expand Up @@ -163,14 +163,14 @@ export default class CoapsClient implements ProtocolClient {
}

public setSecurity(metadata: Array<SecurityScheme>, 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") {
Expand Down Expand Up @@ -224,7 +224,7 @@ export default class CoapsClient implements ProtocolClient {
): Promise<CoapResponse> {
// 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);
}

Expand Down
Loading
Loading