feat(vanity): grind and adopt vanity NodeNums / app colours - #74
Conversation
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>
|
Warning Review limit reachedNext included review available in 44 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (15)
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. Comment |
|
Reopening to re-fire CI — no Actions run was created for this PR (the last run on the repo was #73 yesterday). |
|
@coderabbitai review |
|
#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.
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/_stopare gated on themvgrindbinary;vanity_previewandvanity_applyare core, so a key ground on another machine still applies here.Two firmware traps are encoded rather than left to be discovered:
public_key.AdminModule.cpponly callsgenerateCryptoKeyPair(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.regionmust be set.generateCryptoKeyPairrefuses to derive while the region isUNSET, making the whole apply a silent no-op.vanity_applyisconfirm-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 readsmy_node_numback 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.crc32invanity.py, sharing no code with the grinder's OpenCL kernels:verified: falsemeans 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.pyso the grinder shares it rather than growing a second one;build_poll/flash_pollkeep theirenvkey.Hits are private-key material —
0600files under the data dir, and returned inline because that is what apply consumes. Covered inSECURITY.mdanddocs/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 onmasterand 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,!dc801051andcrimson ±6all re-derive correctly throughvanity_preview. No radio was on the bench, sovanity_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.hexists there but declares onlygetentropy). One-line fix, sent upstream as miketweaver/mvgrind#2;doctoranddocs/vanity.mdboth mention it.Checklist
ignore_errors/# noqa, pytest unit tier)confirmgit commit -s)