Skip to content

feat(vanity): grind and adopt vanity NodeNums / app colours - #74

Merged
jamesarich merged 3 commits into
masterfrom
feat/vanity-identities
Aug 26, 2026
Merged

feat(vanity): grind and adopt vanity NodeNums / app colours#74
jamesarich merged 3 commits into
masterfrom
feat/vanity-identities

Conversation

@jamesarich

Copy link
Copy Markdown
Collaborator

Summary

Pick a node's id — or the colour every app paints it — and adopt it. On a PKI build identity is derived, not assigned (my_node_num = crc32(x25519_public_key), and the clients read the low 24 bits of that number straight as RGB), so a chosen id or colour means grinding the keypair space; mvgrind does that on the GPU and this wires it in, verifies every hit independently, and writes the winner to a radio.

Five tools — vanity_grind_start / _poll / _stop are gated on the mvgrind binary; vanity_preview and vanity_apply are core, so a key ground on another machine still applies here.

Two firmware traps are encoded rather than left to be discovered:

  • The write must clear public_key. AdminModule.cpp only calls generateCryptoKeyPair(private_key) when the incoming public key is empty — echo the old 32-byte key back and neither keygen branch fires, so the node keeps its old NodeNum and a DH key that no longer matches. The write looks like it succeeded and changed nothing. A unit test pins the proto construction.
  • lora.region must be set. generateCryptoKeyPair refuses to derive while the region is UNSET, making the whole apply a silent no-op.

vanity_apply is confirm-gated + destructiveHint (it replaces the identity: the old NodeNum is dropped from the node's own DB and peers must re-learn the key), refuses an unclamped key, reports the previous node id, and reads my_node_num back after the self-reboot — which doubles as the empirical "does this build have PKI keygen" check.

Every hit is re-derived by a from-scratch RFC 7748 X25519 ladder + zlib.crc32 in vanity.py, sharing no code with the grinder's OpenCL kernels: verified: false means the key does not produce the id it claims. No new dependency (one scalarmult per call, ~1 ms).

The build/flash job registry moves to jobs.py so the grinder shares it rather than growing a second one; build_poll/flash_poll keep their env key.

Hits are private-key material — 0600 files under the data dir, and returned inline because that is what apply consumes. Covered in SECURITY.md and docs/vanity.md.

Test plan

Gates pass (ruff check · ruff format --check · mypy · check_spdx.py · pytest tests/unit → 741 passed; the one failure, test_boards.py::test_filter_by_architecture, also fails on master and is firmware-tree dependent). 37 new unit tests cover the RFC 7748 vectors, the identity chain against a real mvgrind hit, hit parsing/verification, argv validation, and the apply path against a fake node built on the real protobufs.

Exercised live end-to-end on an Apple M4 (~92 M keys/s via Apple OpenCL, full 8-digit id ≈48 s mean): real grinds for !dead5d54, !dc801051 and crimson ±6 all re-derive correctly through vanity_preview. No radio was on the bench, so vanity_apply's device write is covered by unit tests, not hardware — worth one bench run before relying on it.

Note for anyone building mvgrind on macOS: it doesn't compile as-is (getrandom(2) probe misfires — sys/random.h exists there but declares only getentropy). One-line fix, sent upstream as miketweaver/mvgrind#2; doctor and docs/vanity.md both mention it.

Checklist

  • Gates pass (ruff, mypy — no new ignore_errors/# noqa, pytest unit tier)
  • New MCP tools have read/destructive/openWorld annotations; destructive ones take confirm
  • Core changes import/run with no firmware checkout
  • DCO sign-off (git commit -s)

On a PKI firmware build a node's identity is derived, not assigned:
my_node_num = crc32(x25519_public_key) (NodeDB.cpp::createNewIdentity), and
every client paints the node with the low 24 bits of that number read straight
as RGB (Meshtastic-Android NodeColors.kt, Meshtastic-Apple Color.swift agree,
foreground included). Both steps are one-way, so a chosen id -- or a chosen
colour, which is the same thing over fewer bits -- means searching the keypair
space. mvgrind (https://github.com/miketweaver/mvgrind) does that on the GPU.

Five tools. Grinding is gated on the binary ($MESHTASTIC_MCP_MVGRIND or PATH):

  vanity_grind_start / _poll / _stop   background job, mvgrind capability
  vanity_preview                       key -> id + colour, pure, core
  vanity_apply                         write the key to a radio, core

preview and apply stay core deliberately: a key ground on another machine (or a
friend's GPU) is still inspectable and applicable here.

Every hit is re-derived by this repo's own RFC 7748 X25519 ladder + zlib.crc32,
sharing no code with the grinder's OpenCL kernels -- verified: false means the
key does not produce the id it claims, which is a grinder bug, not a near miss.
No new dependency: one scalarmult per call, ~1 ms.

Two firmware traps the apply path encodes rather than discovers:

- The write must CLEAR public_key. AdminModule.cpp only calls
  generateCryptoKeyPair(private_key) when the incoming public key is empty;
  echo the old 32-byte key back and neither keygen branch fires, so the node
  keeps its old NodeNum and a DH key that no longer matches. The write appears
  to succeed and changes nothing. Pinned by a unit test on the proto build.
- lora.region must be set. generateCryptoKeyPair refuses to derive while the
  region is UNSET, making the whole write a silent no-op.

apply_key is confirm-gated and destructiveHint: it replaces the identity, the
old NodeNum is dropped from the node's own DB, and peers must re-learn the key.
It refuses an unclamped key (the firmware signs with a clamped copy of the
scalar, so an unclamped one yields unverifiable signatures), reports the
previous node id, and reads my_node_num back after the self-reboot -- which is
also the empirical check that the build has PKI keygen at all.

The build/flash job registry moves to jobs.py so the grinder shares it instead
of growing a second one; build_poll/flash_poll keep their "env" key.

Hits are private-key material: 0600 files under the data dir, and returned
inline because that is what apply consumes. SECURITY.md and docs/vanity.md say
so. doctor reports the binary and prints the build command, including the
one-line macOS getrandom-probe fix upstream currently needs.

Verified against an Apple M4 (~92 M keys/s, full 8-digit id ~48 s mean): real
grinds for !dead5d54, !dc801051 and crimson+/-6 all re-derive correctly here.

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
The doc showed a different patch (skip the probe, fall through to
/dev/urandom) than the one actually submitted. Match miketweaver/mvgrind#2:
seed from getentropy() on Apple, same fails-closed guarantee without depending
on /dev being present. doctor's hint names the PR too.

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 44 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f6bbbe6f-303a-4e72-ab19-43c780efc650

📥 Commits

Reviewing files that changed from the base of the PR and between d498810 and ca7945e.

📒 Files selected for processing (15)
  • AGENTS.md
  • CHANGELOG.md
  • README.md
  • SECURITY.md
  • docs/vanity.md
  • llms.txt
  • src/meshtastic_mcp/capabilities.py
  • src/meshtastic_mcp/doctor.py
  • src/meshtastic_mcp/flash.py
  • src/meshtastic_mcp/jobs.py
  • src/meshtastic_mcp/server.py
  • src/meshtastic_mcp/skills/meshtastic-device-ops/SKILL.md
  • src/meshtastic_mcp/vanity.py
  • tests/unit/test_upload_port_guard.py
  • tests/unit/test_vanity.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jamesarich

Copy link
Copy Markdown
Collaborator Author

Reopening to re-fire CI — no Actions run was created for this PR (the last run on the repo was #73 yesterday).

@jamesarich jamesarich closed this Aug 26, 2026
@jamesarich jamesarich reopened this Aug 26, 2026
@jamesarich

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jamesarich jamesarich closed this Aug 26, 2026
@jamesarich jamesarich reopened this Aug 26, 2026
@jamesarich jamesarich closed this Aug 26, 2026
@jamesarich jamesarich reopened this Aug 26, 2026
@jamesarich
jamesarich added this pull request to the merge queue Aug 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 26, 2026
#73 landed the flash port-safety work in flash.py while this branch was
extracting the same job machinery into jobs.py. Resolution:

- kept the jobs.py registry; master's inline _start_job/_poll_job were what it
  replaced. jobs.poll() already reads every field under LOCK, so #73's
  poll-snapshot fix is inherent here — its regression test went with it, since
  the mechanism it patched (fields read after the log, outside the lock) no
  longer exists. The status-last worker ordering carried over intact.
- kept the per-port upload lock, including the release-on-start-failure guard
  now wrapping jobs.start.
- flash_poll maps the registry's `worker_error` back to `error`, the key #73
  documented for wrong-port and silent-DFU failures.
@jamesarich
jamesarich added this pull request to the merge queue Aug 26, 2026
Merged via the queue into master with commit 150f7aa Aug 26, 2026
12 checks passed
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.

1 participant