Skip to content

feat: Native OSCORE (RFC 8613) Integration#397

Open
stoprocent wants to merge 17 commits intocoapjs:masterfrom
stoprocent:feat/oscore-support
Open

feat: Native OSCORE (RFC 8613) Integration#397
stoprocent wants to merge 17 commits intocoapjs:masterfrom
stoprocent:feat/oscore-support

Conversation

@stoprocent
Copy link
Copy Markdown
Contributor

feat: Native OSCORE (RFC 8613) Integration

Summary

Add built-in OSCORE (Object Security for Constrained RESTful Environments) support to node-coap using the coap-oscore package. OSCORE provides end-to-end encryption at the CoAP message level, operating on raw binary buffers so that all existing CoAP features — block transfers, observe, caching, retransmissions — work transparently over encrypted channels.

Changes

New Files

File Description
lib/oscore.ts SecurityContextManager — server-side registry for OSCORE contexts, keyed by recipientId + optional idContext. Manages token-to-context bindings for response encoding and aggregates SSN change events for persistence.
lib/oscore_helpers.ts OSCORE option parsing utilities — extracts and parses the OSCORE option (number 9) from CoAP packets per RFC 8613 Section 6.1 (KID, KID Context, PIV fields).
test/oscore.ts Comprehensive test suite with 12 tests covering all major scenarios.

Modified Files

File Changes
package.json Added coap-oscore dependency
models/models.ts Extended AgentOptions (oscoreOnly), CoapServerOptions (oscoreContexts, oscoreOnly), and MiddlewareParameters (OSCORE context/metadata fields) — all optional, zero breaking changes
lib/incoming_message.ts Added oscoreContext property and isOscore getter for server-side request introspection
lib/agent.ts OSCORE context map with addOscoreContext()/removeOscoreContext(), async decode on incoming messages via _handlePlainMessage() extraction, sender.send() wrapping for transparent outgoing encryption, observe _disableFiltering for OSCORE streams, Echo auto-retry for 4.01+Echo responses
lib/middlewares.ts New oscoreDecryptRequest middleware — detects OSCORE option, looks up context by KID, decodes raw buffer, re-parses inner packet, binds token for response encoding, handles Echo challenge (status 201) for first-request-after-reboot. Updated handleServerRequest to forward OSCORE metadata.
lib/server.ts _oscoreContextManager and _oscoreOnly fields, middleware pipeline insertion, _finishSendResponse() helper extracted for async OSCORE response encoding, token lifecycle management (unbind on response/observe finish), updated _handle() signature to accept OSCORE metadata, dynamic addOscoreContext()/removeOscoreContext() with lazy middleware initialization
index.ts Export SecurityContextManager, re-export OSCORE, OscoreContext, OscoreContextStatus types from coap-oscore
README.md Added RFC 8613 to introduction, new OSCORE section with client/server/observe/dynamic examples, documented all new API surface (server options, agent methods, IncomingMessage properties, SecurityContextManager)

Design Decisions

  1. OSCORE at buffer boundaries — Encrypt after generate(), decrypt before parse(). This keeps all existing CoAP logic working on decrypted packets with zero modifications to block transfer, observe, or caching code.

  2. Agent wraps sender.send() — Instead of modifying every generate() call site, the agent monkey-patches sender.send() to encrypt all outgoing buffers. This automatically handles initial requests, block1 segments, block2 continuations, and retransmissions (RetrySend stores the already-encoded buffer).

  3. Server uses middleware pipelineoscoreDecryptRequest slots into the existing fastseries middleware chain between parseRequest and handleServerRequest. Uses .then()/.catch() (not async/await) for fastseries compatibility.

  4. Per-block OSCORE — Per RFC 8613 §4.1.3.4, each block-wise message is independently OSCORE-protected. The sender.send() wrapping handles this automatically.

  5. All additions are optional — Every new field is optional. A server/agent without OSCORE configuration behaves identically to before.

Test Coverage

Test Description
GET round-trip Basic GET with OSCORE encryption/decryption, verifies isOscore and oscoreContext.senderId
POST round-trip POST with payload, OSCORE-protected in both directions
Mixed secure/insecure Server handles both OSCORE and plaintext requests correctly
oscoreOnly server Server rejects plaintext with 4.01
oscoreOnly agent Agent throws on requests to peers without context
Multiple contexts on agent Separate OSCORE contexts route correctly to different peers
Multiple contexts on server Server handles requests from different OSCORE clients
Unknown client Server returns 4.01 for OSCORE request with unregistered KID
SSN persistence Verifies ssn events fire via SecurityContextManager
Dynamic add/remove (agent) Add and remove contexts at runtime
Dynamic add/remove (server) Add context at runtime on server created without OSCORE
Observe with OSCORE Observe notifications encrypted transparently, _disableFiltering active

Results: 486 passing (474 existing + 12 new), 6 pending (unchanged), 0 failing

@coveralls
Copy link
Copy Markdown

coveralls commented Feb 15, 2026

Pull Request Test Coverage Report for Build 22158602291

Details

  • 474 of 653 (72.59%) changed or added relevant lines in 7 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-2.9%) to 87.263%

Changes Missing Coverage Covered Lines Changed/Added Lines %
lib/server.ts 117 124 94.35%
lib/oscore.ts 116 133 87.22%
lib/oscore_helpers.ts 71 92 77.17%
lib/agent.ts 90 135 66.67%
lib/middlewares.ts 65 154 42.21%
Totals Coverage Status
Change from base Build 19027860563: -2.9%
Covered Lines: 3260
Relevant Lines: 3710

💛 - Coveralls

…ror handling

- Drop plaintext messages when OSCORE context exists (no fallback)
- Limit Echo auto-retry to 1 attempt to prevent infinite loops
- Carry over options and payload on Echo retry
- OSCORE-encrypt Echo 4.01 challenges (not plaintext)
- Store and verify Echo nonces with timingSafeEqual
- Namespace token-to-context bindings by senderId to prevent collisions
- Cap token bindings at 10k with LRU eviction
- Validate OSCORE option parsing (reject truncated fields)
- Standardize error messages to prevent information leakage
- Fix _sendError missing address parameter
- Use 'close' event for observe token cleanup
@stoprocent
Copy link
Copy Markdown
Contributor Author

Hey, is there any plan to review and merge? If not I will do a @stoprocent/node-coap but i rather contribute here :)

stoprocent and others added 6 commits February 18, 2026 22:36
Each Server and Agent now snapshots its own frozen parameters at
construction time via the new `parameters` option, allowing multiple
instances with different timing/size configs without mutating the global.
Extract applyOverrides() and deriveTimings() helpers so refreshTiming()
and createParameters() share a single implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@stoprocent
Copy link
Copy Markdown
Contributor Author

stoprocent commented Feb 22, 2026

Hey guys, if you cannot review I will move to new namespace on npm. Just please let me know I understand everyones busy. @JKRhb @Apollon77

@JKRhb
Copy link
Copy Markdown
Member

JKRhb commented Feb 22, 2026

Hi @stoprocent, sorry for getting back to you earlier! I will probably have the time to do a review by tomorrow, I hope that works for you :) In any case thank you for working on this feature, having an OSCORE implementation in node-coap is pretty exciting! :)

@stoprocent
Copy link
Copy Markdown
Contributor Author

No worries! I don’t want to rush the review. That’s the last thing I’d want to do. I just wanted to at least get it started.

Extract SZX via parseBlockOption instead of passing raw block1 byte,
and only re-segment when payload exceeds one block size.
Copy link
Copy Markdown
Member

@JKRhb JKRhb left a comment

Choose a reason for hiding this comment

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

Sorry for the delay! See a couple of initial comments below, I will continue the review in the upcoming days.

Comment thread package.json
{
"name": "coap",
"version": "1.4.2",
"version": "1.6.0",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the version should only be bumped after merging this PR.

Comment thread lib/agent.ts Outdated
Comment thread lib/agent.ts
Comment thread lib/agent.ts
Comment thread lib/agent.ts Outdated
Comment thread lib/retry_send.ts Outdated
claude and others added 2 commits March 4, 2026 16:13
- Make _parameters field private in Agent and RetrySend classes
- Remove unnecessary leading semicolons before type assertions
- Fix error message to omit colon when host is undefined

https://claude.ai/code/session_017esjyFaP9jrHiBsad1ajp9
@JKRhb
Copy link
Copy Markdown
Member

JKRhb commented Mar 5, 2026

Seems like the CI is failing now since the semicolons were necessary after all :/ Sorry for the mistake, could you revert these two particular changes (or find a different solution that does not involve the semicolons)? Thank you!

claude and others added 3 commits May 5, 2026 07:57
The leading semicolons prevent ASI from treating `(expr as Type)` as a
function call on the previous line's return value.

https://claude.ai/code/session_017esjyFaP9jrHiBsad1ajp9
…Zjaal

fix: restore defensive semicolons before parenthesized expressions
@Apollon77
Copy link
Copy Markdown
Collaborator

@stoprocent Ok I also had a look and basically here is my summary:

Overview

A 1572-line addition that wires OSCORE end-to-end encryption into node-coap by using the coap-oscore package as the crypto engine. The design encrypts after generate() and decrypts before parse(), so existing block / observe / cache code stays untouched. New SecurityContextManager (server) and addOscoreContext()/removeOscoreContext() methods (server + agent) provide the public API. All new options are optional → no breaking change. CI is green; the only previously raised review comment still open is the package.json version bump.

Things that should change

1. package.json version bump (already requested)

Master is 1.4.2; PR sets it to 1.6.0. Per @JKRhb's existing review comment, the maintainer would do the bump on merge. Please revert package.json:3 back to 1.4.2.

2. oscoreOnly is silently dropped when contexts are added dynamically

In lib/server.ts:155-159, _oscoreOnly is only assigned inside the if (this._options.oscoreContexts != null) branch. If a user does createServer({ oscoreOnly: true }) without oscoreContexts and later calls server.addOscoreContext(...), the flag is ignored. Either:

  • Set this._oscoreOnly = this._options.oscoreOnly ?? false unconditionally in the constructor, or
  • Have addOscoreContext() also honour this._options.oscoreOnly.

3. Tight coupling to an unreleased coap-oscore API

lib/middlewares.ts:201 calls (oscore as any).clearRebootRecovery() with the comment "Note: clearRebootRecovery() is added in the concurrent node-oscore update". package.json pins coap-oscore: ^2.2.1. If a consumer resolves to a 2.x version that doesn't yet expose this method, this throws inside .then() and the Echo path fails silently. Either:

  • Bump the dep to a >= that actually contains clearRebootRecovery, or
  • Guard with typeof oscore.clearRebootRecovery === 'function'.

4. oscoreProtected is stamped onto streams without being declared

lib/agent.ts:471, 490 and lib/server.ts set response.oscoreProtected = true on ObserveStream/IncomingMessage instances, but the field exists only on IncomingMessage. The agent code reads it back through an as any cast (agent.ts:449,452). Add oscoreProtected: boolean to ObserveStream (it already has _disableFiltering declared) so the typing is honest and the as any casts can go.

5. Plaintext responses still flow through to an oscoreOnly agent

Agent._oscoreOnly only blocks outgoing plaintext requests in request() (agent.ts:596-599). Incoming plaintext from a peer that has no registered context falls into _handlePlainMessage() and is processed normally. Either reject inbound plaintext when _oscoreOnly === true, or document that the flag is outbound-only.

6. _pendingEchoNonces map can grow without bound

SecurityContextManager.storePendingEcho() stores one nonce per (recipientId, idContext) and never expires it. A peer that triggers Echo and never replies leaves the entry forever. Add a TTL (analogous to EXCHANGE_LIFETIME) or evict on next request from that peer.

7. PIV length validation

lib/oscore_helpers.ts:56 masks the low 3 bits of the flags byte and uses any value 0–7 as PIV length. Per RFC 8613 §6.1 only 0–5 are valid; 6 and 7 (and the reserved high bits 0x20/0x40/0x80) should be rejected with a malformed-option error.

8. Dead export

hasOscoreOption() in lib/oscore_helpers.ts:90 is exported but never used. Remove or use.

Smaller / nitpick

  • lib/middlewares.ts:182getOption(innerPacket.options, '252' as any) and setOption('252', echoOpt) (agent.ts:427) leak the magic option number. Define a named constant (or use the registered 'Echo' option name) — both occurrences cast to any to bypass TS, which is a smell.
  • Token bookkeeping: bindToken() evicts the oldest entry when at 10k cap, but a long-running observe that was bound earliest will silently lose its context binding before its close handler unbinds. Unlikely in practice, but worth a comment in oscore.ts:74.
  • _oscoreContextManager is null initially + lazily created in addOscoreContext() — fine, but in _finishSendResponse and the encode path you use this._oscoreContextManager?.unbindToken(...). The tokenHex != null is checked but the inner branch already constrains tokenHex to a non-empty string at the call site; the duplicated check is harmless but noisy.
  • Tests use bare setTimeout(done, 500) and setTimeout(..., 20) in the SSN-persistence and unknown-client tests — these are likely to flake under CI load. Prefer event-driven completion or longer waits.
  • No test for the server-side Echo (4.01 + Echo) RFC 9175 path — the most security-sensitive new code in middlewares.ts:172-247 is uncovered (Coveralls confirms 42% on middlewares.ts).
  • README, server example: const { OSCORE, SecurityContextManager } = require('coap') then const { OscoreContextStatus } = require('coap-oscore')OscoreContextStatus is also re-exported from coap (per index.ts:112). Consistent with the import line above it would be friendlier.
  • lib/agent.ts:9 still uses import crypto = require('crypto') while other files use ES import syntax — minor style drift.

Things I think are good

  • Clean buffer-boundary design: encrypt in sender.send() wrapper, decrypt in middleware. Block / observe / RetrySend genuinely unaffected.
  • Defensive crypto choices: timingSafeEqual for Echo comparison, namespaced senderId:tokenHex keying to avoid collisions across peers, refusal to fall back to plaintext when an OSCORE context exists for the peer.
  • _handlePlainMessage() extraction and _finishSendResponse() extraction are good seams with no behavioural change.
  • The dynamic addOscoreContext()/removeOscoreContext() API and the lazy middleware insertion are nicely done.
  • Test scope (12 tests covering round-trip, mixed, oscoreOnly, multi-context, dynamic add/remove, observe, SSN) is reasonable for the surface area.

Risk summary

Risk Severity
Version bump prevents merge as-is High (blocking, easy fix)
clearRebootRecovery coupling to in-flight coap-oscore release Medium (silent Echo failure)
oscoreOnly silently dropped without oscoreContexts Medium (confusing)
Echo nonce map unbounded growth Low/Medium (slow leak)
PIV length not validated Low (malformed packets accepted at parse step)
Inbound plaintext accepted in oscoreOnly agent Low (semantic)
Echo server-side path uncovered by tests Medium (security-relevant code path)

Overall this is a substantial, well-structured contribution that's close to mergeable. Addressing items 1–4 above before merge would be my recommendation; the rest can land as follow-ups.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants