Skip to content

fix(meshcore): derive hashtag channel secret at save time (#3607)#3609

Merged
Yeraze merged 1 commit into
mainfrom
fix/3607-meshcore-hashtag-key
Jun 21, 2026
Merged

fix(meshcore): derive hashtag channel secret at save time (#3607)#3609
Yeraze merged 1 commit into
mainfrom
fix/3607-meshcore-hashtag-key

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #3607.

MeshCore # (hashtag) channels created in MeshMonitor were being saved with a random secret that differed on every attempt and never matched the MeshCore app's well-known key. Reporters on v4.11.3 saw two different secrets for the same #bot channel (e16510550f7c984bea37d0005b28c4a1, 1e8fdf3de67a9d01cfcbda1a1961724a) — both random, neither equal to the deterministic SHA-256("#bot")[0:16].

Root cause

Both symptoms (non-determinism and mismatch) had a single cause: a race condition, not a wrong algorithm.

MeshCoreChannelsConfigSection already had a correct deterministic helper (deriveHashtagSecretHexSHA-256("#name")[0:16]) and a live-derive useEffect that fills the secret field when the name starts with #. But:

  • startAdd()/Regenerate seed the field with crypto.getRandomValues (random placeholder).
  • The live-derive effect runs asynchronously (it awaits crypto.subtle.digest).
  • handleSave() read the secret straight from the editSecretHex state.

A user who typed #bot and clicked Save before the async derivation effect committed persisted the stale random placeholder. Because it's random, it differs every attempt and never matches the app.

Fix

handleSave() now re-derives the deterministic key at save time whenever the (trimmed) name is a hashtag channel, ignoring whatever value sits in the field:

let secretHexToSave = editSecretHex;
if (isHashtagChannelName(trimmedName)) {
  secretHexToSave = await deriveHashtagSecretHex(trimmedName);
}

Non-hashtag channels are untouched — they keep their explicit/random PSK (a legitimate, different case).

Derivation algorithm (verified)

secret = SHA-256(<name including the leading "#">)[0:16], UTF-8, case-sensitive, displayed as lowercase hex.

Confirmed against:

  • The MeshCore reference / public docs (the well-known #test key is 9cd8fcf22a47333b591d96a2b848b73f = SHA-256("#test")[0:16], hashing with the #), and
  • The existing in-repo helper + its pinned test (src/utils/meshcoreHelpers.ts, meshcoreHelpers.test.ts), which already encode this scheme.

Worked examples: #test9cd8fcf22a47333b591d96a2b848b73f, #boteb50a1bcb3e4e5d7bf69a57c9dada211.

Tests

Added a regression test that types #bot and clicks Save in the same flush — i.e. without waiting for the live-derive effect — and asserts the PUT body carries the deterministic key (eb50a1bcb3e4e5d7bf69a57c9dada211, base64 61ChvLPk5de/aaV8na2iEQ==) and not the random placeholder. Two existing hashtag PUT assertions were made robust to the extra async derive tick via waitFor.

  • Full Vitest suite: success=true, 7088 passed, 0 failed (0 failed suites).
  • tsc -p tsconfig.server.json --noEmit: only pre-existing TelemetryChart.tsx errors (unrelated, untouched).

Files changed

  • src/components/MeshCore/MeshCoreChannelsConfigSection.tsx
  • src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx

🤖 Generated with Claude Code

A user who typed a "#hashtag" channel name and clicked Save before the
asynchronous live-derive useEffect committed could persist the random
placeholder secret seeded by startAdd()/regenerate. The result: the saved
PSK was non-deterministic (different on every attempt) and never matched
the MeshCore app's well-known key.

handleSave now re-derives the deterministic SHA-256("#name")[0:16] key at
save time whenever the channel name is a hashtag channel, ignoring whatever
(possibly stale, random) value sits in the secret field. Non-hashtag
channels keep their explicit/random PSK unchanged.

Derivation confirmed against the MeshCore reference (SHA-256 of the UTF-8
name INCLUDING the leading "#"); e.g. #test = 9cd8fcf22a47333b591d96a2b848b73f
and #bot = eb50a1bcb3e4e5d7bf69a57c9dada211.

Adds a regression test that types "#bot" and saves in the same flush
(no wait on the live-derive effect) and asserts the PUT carries the
deterministic key, not the random placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(meshcore): derive hashtag channel secret at save time

  • Gather context and understand the request
  • Read changed files
  • Review code quality and best practices
  • Check test coverage
  • Post review feedback

Summary

This is a clean, well-targeted fix for a real race condition. The root cause analysis in the PR description is accurate, and the fix is straightforward and correct. A few observations:


Code Quality

MeshCoreChannelsConfigSection.tsxhandleSave (lines 213–271)

The fix is correct and well-placed. Re-deriving at save time is the right defensive approach rather than relying on state that may be stale from an async useEffect.

One minor style note: the error toast message used on derivation failure (line 234) is the same i18n key as the "invalid hex" error below it:

// line 234 — derivation failure
showToast(t('meshcore.channels.invalid_secret', 'Secret must be exactly 32 hex characters (16 bytes)'), 'error');
// line 240 — invalid hex
showToast(t('meshcore.channels.invalid_secret', 'Secret must be exactly 32 hex characters (16 bytes)'), 'error');

The derivation failure isn't meaningfully about "invalid hex" — it's a crypto.subtle.digest failure (browser API unavailable / insecure context). A more accurate message (even if that failure is astronomically unlikely in practice) would be a separate key. This is a low-priority nit since this path is practically unreachable in a secure context.

handleSave dependency array (line 271)

isHashtagChannelName and deriveHashtagSecretHex are module-level pure functions imported at the top of the file — they're stable references and don't need to be in the useCallback dependency array. This is already the pattern in the existing code and is correct as-is. ✅


Potential Bugs / Issues

handleRegenerate and the isHashtag guard in the UI

handleRegenerate (line 199–201) just calls generateSecretHex() and sets editSecretHex without checking whether the current name is a hashtag channel. The Regenerate button is correctly disabled={derivedFromHashtag} in ChannelEditor, so the user can't click it for hashtag channels. But since the fix in handleSave now re-derives unconditionally at save time regardless of field value, any code path that mistakenly sets a random secret in the field is harmless for hashtag channels. No bug here, just confirming the defense-in-depth is sound. ✅

No feedback that the displayed secret was overridden at save time

If the live-derive effect hasn't fired (the race scenario), the user sees a random hex placeholder in the field, clicks Save, and the correct deterministic secret is stored silently. The field and toast (Channel N saved) give no indication that the saved value differed from what was shown. This is mildly confusing UX, but acceptable since:

  • The live-derive effect will catch up shortly after and update the display
  • The channel will work correctly
  • Adding extra UI noise here would be overkill

Worth tracking as a potential UX improvement for a future pass.


Performance

No concerns. crypto.subtle.digest is invoked at most once per save and is already called in the existing useEffect. The overhead is negligible.


Security

The derivation correctly hashes the full channel name including the leading # (verified in meshcoreHelpers.ts:91), matching the MeshCore spec. The hexToBase64 conversion path that goes to the wire is unchanged. No issues.


Test Coverage

The new regression test (re-derives the deterministic hashtag PSK at save time, lines 275–315) is exactly the right test:

  • It types the hashtag name and clicks Save in the same act() flush, not waiting for the live-derive effect
  • It asserts the PUT body carries the deterministic base64 (61ChvLPk5de/aaV8na2iEQ== = SHA-256("#bot")[0:16])
  • It explicitly asserts the random placeholder is not present

Two minor observations:

  1. The existing "Save sends the # name and the derived PSK" test (lines 227–267) now has a redundant waitFor waiting for the field to show the derived secret before saving — that's fine for clarity, but the new regression test proves that the waitFor is no longer required for correctness.
  2. Test comment on line 296 says "The displayed field may still hold the random placeholder" — this is accurate and well-documented. ✅

Overall test coverage for the changed behavior is solid.


Verdict

Approve. The fix is correct, minimal, and well-tested. The PR description is thorough and the root cause analysis is accurate. The nits above are non-blocking.

@Yeraze Yeraze merged commit c985e21 into main Jun 21, 2026
19 of 20 checks passed
@Yeraze Yeraze deleted the fix/3607-meshcore-hashtag-key branch June 21, 2026 21:12
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.

MeshCore # channel secret key mismatch between MeshCore app and MeshMonitor

1 participant