Skip to content

feat(tls): give MQTT's raw-socket listener its own TLS usage type (split from #1999) - #2003

Merged
kriszyp merged 9 commits into
mainfrom
kris/mqtt-usage-type
Jul 30, 2026
Merged

feat(tls): give MQTT's raw-socket listener its own TLS usage type (split from #1999)#2003
kriszyp merged 9 commits into
mainfrom
kris/mqtt-usage-type

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

Split out of #1999 per review feedback there, to unblock the v5.1 patch cherry-pick of the customer-facing readiness fix: this half is a no-op for every stock deployment (Harper only ever writes uses: [] / ['operations-api'] / ['replication']) and is what made the cherry-pick conflict, since v5.1 predates getEffectiveTlsCiphers.

A raw-socket TLS listener (onSocket(), used by MQTT's network.securePort) always resolved its TLS usage type as the generic 'server', so a certificate tagged for a specific listener type (uses: ['mqtt']) never got its intended priority over a generic node certificate. onSocket() now accepts a usageType option (falling back to 'server') and MQTT passes 'mqtt' — applied to both certificate quality scoring and effective cipher/@SECLEVEL resolution.

Backward compat: certs tagged uses: ['server'] targeted MQTT under the old default, so 'server' earns the legacy generic-use credit via an explicit allowlist (LEGACY_SERVER_FALLBACK_TYPES, currently just 'mqtt'). An allowlist rather than "everything except operations-api" because every other existing type (operations-api, replication, …) has always had its own dedicated identity and must not newly start accepting a ['server']-tagged record's ciphers — this scoping went through three review rounds on #1999 (Codex flagged the compat break, a Harper-domain pass caught the operations-api leak, human review caught the replication leak and requested the allowlist + negative tests).

Known caveat (carried over from #1999's writeup)

Quality-scoring crossover for uses: ['server']: previously a self-signed cert tagged ['server'] for MQTT's hostname beat a CA-signed ['https'] cert for the same hostname (4 vs 3.5, since MQTT's type literally was 'server'). Now the 'server' tag only earns the legacy-fallback credit (+0.5), so that same cert loses (1.5 vs 3.5). Only matters when multiple certs cover the same hostname with different uses tags; flagged for a reviewer with an opinion.

Where to look

  • server/threads/threadServer.jsonSocket() accepts usageType, threads it into createTLSSelector and getEffectiveTlsCiphers.
  • server/mqtt.ts — passes usageType: 'mqtt'.
  • security/keys.tsLEGACY_SERVER_FALLBACK_TYPES + its use in ciphersCandidateRelevant() and the selector's quality scoring.
  • unitTests/security/keys.test.js — allowlist test with negative operations-api/replication cases.

Testing

unitTests/security/keys.test.js: 48/48 passing on this branch (47 on main + the new allowlist test). npm run build, prettier --check, oxlint clean on changed files.

Refs #1999.

Generated by Claude (Opus 5) for dispatch task fix-harper-1999.

🤖 Generated with Claude Code

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kriszyp added a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a granular usageType configuration for socket listeners, migrating the MQTT listener to its own explicit 'mqtt' usage type. To maintain backward compatibility, a fallback mechanism (LEGACY_SERVER_FALLBACK_TYPES) is introduced, ensuring that legacy certificates tagged with uses: ['server'] continue to apply to MQTT listeners without incorrectly affecting other listener types like operations-api or replication. Unit tests have been added to verify this behavior. There are no review comments to evaluate, and I have no additional feedback to provide.

github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review July 30, 2026 17:43
@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed 7b102591 — no issues found. This PR looks good, nice job!

Traced the full path (onSocketcreateTLSSelector/getEffectiveTlsCiphersciphersCandidateRelevant): the 'mqtt' usage type is correctly isolated. getEffectiveTlsCiphers only special-cases the config layers for operations-api, so 'mqtt' reads the same root tls layer 'server' did, and the LEGACY_SERVER_FALLBACK_TYPES allowlist keeps uses: ['server'] certs relevant — no configured cipher/@SECLEVEL that previously applied to the raw-socket MQTT listener is silently dropped. The operations-api/replication exclusion is confirmed by the negative tests, and each listener's selector is independent so other listeners are unaffected. MQTT-over-WebSocket goes through the http.ts path and is untouched.

On the flagged quality-scoring caveat: preferring a CA-signed ['https'] cert over a self-signed ['server'] one for the same hostname is a reasonable direction — the change only ever lowers a 'server'-tagged cert's score for MQTT, never raises a weaker cert above a stronger one, so it's not a security regression. Fine to keep as-is.

(Could not execute the unit tests locally — isolated worktree had no node_modules and a full install/build was out of scope; verified the added allowlist test exercises the changed relevance path correctly by reading it. Trusting CI + the reported 48/48 for execution.)


Generated by Barber AI

github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
Split out per review feedback to unblock the v5.1 patch cherry-pick:

- The usageType/'server'-allowlist half (server/mqtt.ts,
  server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES
  in keys.ts, and its cipher-scoping test) moves to #2003. It is a
  no-op for stock deployments and was the source of the wide v5.1
  conflict, since v5.1 predates getEffectiveTlsCiphers.
- The unrelated auditLog.test.js deflake moves to #2002.

What remains here is exactly the customer-facing fix: createTLSSelector's
readiness guard/retry, the hdb_certificate subscription lifecycle
(instance tracking, teardown, failure reset), the per-row cert parse
guard, and the zero-certificates retry — plus their tests.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jul 30, 2026
…geType

Addresses review on #2003: the resolveEffectiveTlsCiphers allowlist test
only proved cipher/@SECLEVEL relevance, not that createTLSSelector's
quality scoring actually prioritizes uses:['mqtt'] certs or that MQTT's
raw-socket listener forwards usageType at all. Adds coverage for both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jul 30, 2026
createTLSSelector's updateTLS() accepts a falsy `server` everywhere else
in the function (`if (server) {...}`, `server?.secureContextsListeners`),
but one trace-log line dereffed `server.ports` unguarded. Harmless while
trace logging is disabled (logger.trace?.() short-circuits the argument
evaluation), but with it enabled every candidate throws inside the
per-cert try/catch before `defaultContext` gets assigned for that
candidate — surfaced by the #2003 review test additions, which are the
first callers of createTLSSelector(...).initialize(null) to assert on
`defaultContext`. One-character fix: `server?.ports`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed 69959095 (incremental since 7b102591) — no issues found. The only production change is a defensive optional-chaining guard on a log line in createTLSSelector; the rest is regression coverage for the mqtt usageType contract (cert exact-match priority, server-tagged fallback, and the threadServer.onSocket handoff). TLS isolation, no-fail-open, and cipher/minVersion behavior all unchanged. This PR looks good, nice job!


Generated by Barber AI

kriszyp and others added 9 commits July 30, 2026 16:47
A raw-socket TLS listener (onSocket, used by MQTT's network.securePort)
always resolved its TLS usage type as the generic 'server', so a
certificate tagged for a specific listener type (uses: ['mqtt']) never
got its intended priority over a generic node certificate. onSocket()
now accepts a usageType option (falling back to 'server') and MQTT
passes 'mqtt', for both certificate quality scoring and effective
cipher/@SECLEVEL resolution.

Backward compat: certs tagged uses: ['server'] targeted MQTT under the
old default, so 'server' earns the legacy generic-use credit for an
explicit allowlist (LEGACY_SERVER_FALLBACK_TYPES, currently just
'mqtt'). It is an allowlist rather than "everything except
operations-api" because every other existing type (operations-api,
replication, ...) has always had its own dedicated identity and must
not newly start accepting a ['server']-tagged record's ciphers.

Split from #1999 per review feedback: this half is a no-op for stock
deployments (Harper only writes uses: [] / ['operations-api'] /
['replication']) and is what made the v5.1 patch cherry-pick of the
customer-facing readiness fix conflict, since v5.1 predates
getEffectiveTlsCiphers.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…geType

Addresses review on #2003: the resolveEffectiveTlsCiphers allowlist test
only proved cipher/@SECLEVEL relevance, not that createTLSSelector's
quality scoring actually prioritizes uses:['mqtt'] certs or that MQTT's
raw-socket listener forwards usageType at all. Adds coverage for both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- unitTests/server/mqtt.test.js: drop sinon/chai for a plain assert +
  local call recorder (AGENTS.md's no-new-sinon rule for unitTests/server/).
- unitTests/security/keys.test.js: withCerts() now puts fixtures inside the
  try so a partial failure can't permanently pollute the shared cert table,
  and deletes are tolerant of an already-missing record; the transient
  selectors pass liveReload=false so they don't leak rebuild subscriptions
  into module-global state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Second-pass review findings: the liveReload=false comment claimed it
stops the hdb_certificate subscription leak, but it only gates
liveTLSRebuilders (pre-existing createTLSSelector behavior) — reworded
to say what it actually does. Also assert the winning certificate's
numeric quality, not just its name, so a future scoring change that
produces a tie (broken only by cert-table scan order) fails here
instead of passing by coincidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d spot

Third-pass review findings, both confirmed by mutation testing:

- The selector tests resolved through defaultContext (the global-best
  cert) rather than the per-hostname SNI map, so they'd still pass with
  secureContexts.set(hostname, ...) removed entirely. Candidates now
  register under a synthetic hostname via an explicit `hostnames`
  override, plus a same-table decoy (real cert SANs, so it wins
  defaultContext) that must never win a lookup for that hostname —
  proving the per-hostname map is what's actually exercised.
- server/threads/threadServer.js:592 (`const usageType = options.usageType
  ?? 'server'`, feeding both createTLSSelector and getEffectiveTlsCiphers)
  had no coverage at all: mqtt.test.js proves MQTT emits usageType,
  keys.test.js proves createTLSSelector honors it directly, but nothing
  drove the seam joining them. A new describe block calls the real
  server.socket() (onSocket) and asserts getEffectiveTlsCiphers' config-
  layer output differs by usageType (operations-api vs mqtt), which
  transitively proves createTLSSelector gets the same value — they're
  the same local variable, not independently computed.
- Renamed the mqtt.test.js "plain-port" case to describe what it actually
  pins (the port||securePort guard), not a nonexistent usageType branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…env, clean up sockets

Fourth-pass review findings, all confirmed by mutation testing or empirical probing:

- The threadServer seam describe block only asserted the cipher half of
  the contract; its selector was silently empty because server/threads/
  threadServer.js requires security/keys.ts via plain `require`, a
  DIFFERENT module instance than this file's `rewire()`'d `keys` (rewire
  duplicates the module rather than patching the cached one). Every
  candidate cert threw "Missing private key" against that instance's
  empty privateKeys map and was swallowed. Fixed by calling the plain-
  required instance's own loadCertificates() (same on-disk test key,
  same shared config_utils stub) and asserting directly against
  socketServer.secureContexts — confirmed to fail when createTLSSelector
  is mutated back to a hardcoded 'server' type.
- before()/after() replaced the live 'tls'/'operationsApi_tls' config
  wholesale and after() wiped them to undefined instead of restoring the
  prior values — latent state leakage into the rest of the mocha
  process. Now snapshots and restores, and spreads onto the existing
  config rather than replacing it.
- server.socket() registers real SERVERS/portServer entries and opens
  (unlistened) TLS Server objects with no teardown; after() now closes
  and deregisters everything this describe block created.
- unitTests/server/mqtt.test.js: the recording fake returned an integer
  from .socket() (Array.push's length) instead of an object, silently
  relying on handleApplication() never touching the return value; and
  added the missing negative case (neither port nor securePort configured
  registers nothing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ltContext directly

Fifth-pass review findings:

- describe-body-level requires for server/threads/threadServer (and its
  dependents) executed at mocha's load phase, before this file's own
  before() sets up the test environment. threadServer.js calls
  env.initSync() at module load, which could read a real machine's
  harperdb-config.yaml instead of the test config. Moved all of it into
  the describe block's own before().
- The decoy's role as the "wins on defaultContext fall-through" sentinel
  relied on an assumed +0.1 hostname-match margin that depends on
  getHost()/hostnamesFromCert case-matching, which isn't guaranteed in
  this environment (traced: getHost() lowercases the CN, hostnamesFromCert
  doesn't, so the bonus most likely never applies here). Replaced the
  quality-margin inference with a direct assertion that the decoy is
  selector.defaultContext, which is unambiguous regardless of the exact
  margin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ptions gaps

Sixth-pass review findings:

- The decoy's win over the intended per-hostname winner still depended
  on the +0.1 getHost() hostname-match bonus, which review proved is
  environment-dependent (case mismatch between getHost()'s lowercased
  node name and hostnamesFromCert's as-is SANs means the bonus silently
  doesn't apply on at least one real environment, and would make the
  defaultContext assertion order-dependent). Fixed by giving the
  hostname-scoped candidates is_self_signed: true (base quality 1) and
  keeping the decoy at is_self_signed: false + exact match (quality 6)
  — a deterministic 4-point margin that doesn't rely on any hostname
  bonus at all. Verified against the same secureContexts.set() mutation
  used in the prior round.
- The threadServer seam block now forces tls_unixDomainSockets off for
  its duration (restored after) — onSocket takes a second, unmirrored
  UDS-server code path when it's on, which the block's cleanup didn't
  cover.
- unitTests/server/mqtt.test.js: assert the full options object
  (port/securePort/mtls/usageType), not just usageType, so a regression
  dropping mtls or the port fields doesn't stay green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
createTLSSelector's updateTLS() accepts a falsy `server` everywhere else
in the function (`if (server) {...}`, `server?.secureContextsListeners`),
but one trace-log line dereffed `server.ports` unguarded. Harmless while
trace logging is disabled (logger.trace?.() short-circuits the argument
evaluation), but with it enabled every candidate throws inside the
per-cert try/catch before `defaultContext` gets assigned for that
candidate — surfaced by the #2003 review test additions, which are the
first callers of createTLSSelector(...).initialize(null) to assert on
`defaultContext`. One-character fix: `server?.ports`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/mqtt-usage-type branch from 6995909 to acb2ef3 Compare July 30, 2026 22:48
@kriszyp
kriszyp merged commit 163516a into main Jul 30, 2026
37 checks passed
kriszyp added a commit that referenced this pull request Jul 30, 2026
…geType

Addresses review on #2003: the resolveEffectiveTlsCiphers allowlist test
only proved cipher/@SECLEVEL relevance, not that createTLSSelector's
quality scoring actually prioritizes uses:['mqtt'] certs or that MQTT's
raw-socket listener forwards usageType at all. Adds coverage for both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp deleted the kris/mqtt-usage-type branch July 30, 2026 22:55
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.

2 participants