diff --git a/AGENTS.md b/AGENTS.md index 489315d..72ef88b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,18 @@ decoupled so the device/admin/recorder core works with **no firmware checkout**. (authoritative > maintainer > contributor > community) — role tracks trust on a server full of confident misinformation; `$DISCORD_TRUST_TIERS` overrides. Everything returned is untrusted user content — `openWorldHint`. See `docs/discord.md`. +- **mvgrind capability** (needs the `mvgrind` binary — `$MESHTASTIC_MCP_MVGRIND` or PATH — plus + an OpenCL driver): `vanity.py` vanity-identity tools. A PKI node's number is + `crc32(x25519_public_key)` and every client paints it with the low 24 bits read as RGB, so a + chosen id *or* colour means grinding the keyspace on the GPU + ([mvgrind](https://github.com/miketweaver/mvgrind)). Gated: `vanity_grind_start` / + `vanity_grind_poll` / `vanity_grind_stop` (async job pattern — see `jobs.py`). **Core, not + gated:** `vanity_preview` (key → id/colour, pure) and `vanity_apply` (write the key to a + radio), so a key ground on another machine still applies here. Every hit is re-derived by + this repo's own RFC 7748 ladder + `zlib.crc32`, sharing no code with the grinder; + `verified: false` means the key does not produce the id it claims. The apply path clears + `public_key` on the way out — the firmware only re-derives (and so only moves the NodeNum) + when the incoming public key is empty. See `docs/vanity.md`. - **FleetSuite web control plane** (the `[web]` extra, separate `meshtastic-mcp-web` entrypoint, not an MCP capability): `web/` FastAPI backend + `web-ui/` Vue SPA — device registry, build/flash queue, recovery ladder, camera streams, bench test runner, Datadog shipping, and @@ -64,9 +76,10 @@ decoupled so the device/admin/recorder core works with **no firmware checkout**. `capabilities.detect()` drives this; the active set is logged at startup. `config.firmware_root()` raises when absent; use `config.firmware_root_or_none()` for capability checks. The `firmware_tool` decorator (`_FIRMWARE_TOOLS` in `server.py`) registers the firmware-coupled tools only when -`CAPS.firmware` is active — 60 always-on tools (includes the 3 power-meter tools, always -registered); +14 android, +17 firmware, +2 sdr, and the apple/sdk-cli/local-model gates on top -(≈97 with everything active). Counts drift — `doctor` and the startup log are the source of truth. +`CAPS.firmware` is active — 62 always-on tools (includes the 3 power-meter tools and +`vanity_preview`/`vanity_apply`, always registered); +14 android, +17 firmware, +2 sdr, ++3 mvgrind, and the apple/sdk-cli/local-model gates on top (≈123 with everything active). +Counts drift — `doctor` and the startup log are the source of truth. **Provisioning:** `doctor.py` (the `doctor` MCP tool / `meshtastic-mcp doctor` CLI) probes every external dependency and emits the exact, platform-aware acquisition command for anything missing @@ -119,6 +132,9 @@ the session-key gate and every "from a remote node" branch. Use it to reproduce - **One MCP call per serial port** (non-blocking exclusive lock): open → act → close. Contention fails fast with a `... is busy ... Retry shortly.` error — it never queues or blocks, so the caller must catch and retry. +- **Anything that can outrun a 60 s MCP call gets a job, not a longer timeout.** `jobs.py` + is the one registry (build, flash, grind): `jobs.start()` returns a `job_id`, the tool + pairs it with a `_poll`. Don't add a second registry. - **Destructive tools stay `confirm`-gated** (`reboot`, `factory_reset`, `erase_and_flash`, `uhubctl_*`) **and `destructiveHint`-annotated** (see the annotation maps in `server.py`). Don't bypass the gate. New tools get the right read/destructive/open-world hint. @@ -287,6 +303,17 @@ App/AVD connects to `10.0.2.2:` (emulator) or the host IP (device). `fuzz` `duration` (whole capture in N wall-clock seconds) > `rate` (steady pkts/sec) > `speed` (cadence multiplier); `replay_status` reports `target_rate` vs live `achieved_rate`. +**Give a node a chosen id or colour** +``` +vanity_grind_start(color="crimson", tol=6) # or pattern="dc80", or both +vanity_grind_poll(job_id) # hits[] — check `verified` before using one +vanity_apply(private_key=, port=, confirm=True) +``` +`tol` costs nothing and finds a hit orders of magnitude sooner. `vanity_apply` +**replaces the node's identity** (NodeNum, keypair, colour) and reboots the board; +it needs `lora.region` set and a clamped key, and verifies the new number on +reconnect. Hits are private keys — see `SECURITY.md`. Full detail: `docs/vanity.md`. + ## Handling overflow / large result sets The windowed query tools (`logs_window`, `packets_window`, `events_window`, `telemetry_timeline`) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2ab22..3a7f111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ All notable changes are documented here. Format loosely follows ## [Unreleased] ### Added +- **Vanity node identities** (`mvgrind` capability + two core tools) — pick a node's id or the + colour every app paints it, then adopt it. A PKI node's number is + `crc32(x25519_public_key)` and the clients read the low 24 bits of it straight as RGB + (Android `NodeColors.kt`, Apple `Color.swift` agree), so a chosen id or colour means grinding + the keyspace: [mvgrind](https://github.com/miketweaver/mvgrind) does it on the GPU + (~92 M keys/s on an Apple M4 — a full 8-digit id averages ~48 s, a tolerant colour is + instant). `vanity_grind_start` / `vanity_grind_poll` / `vanity_grind_stop` drive it as a + background job (the `jobs.py` registry, now shared with build/flash), gated on the binary + (`$MESHTASTIC_MCP_MVGRIND` or PATH). `vanity_preview` (key → id + colour + the black/white + the apps put on it) and `vanity_apply` (write it to a radio) are **core**, so a key ground on + another machine still applies here. Every hit is re-derived by this repo's own RFC 7748 + X25519 ladder + `zlib.crc32` — no shared code with the grinder's kernels — and comes back + `verified: false` if the key does not produce the id it claims. `vanity_apply` 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), refuses an unclamped key or an UNSET + `lora.region` (the firmware silently skips keygen there), clears `public_key` on the way out + so the firmware actually re-derives and moves the NodeNum, and verifies the new number on + reconnect. Hits are private-key material: `0600` files under the data dir, and returned + inline — see `SECURITY.md`. `doctor` reports the binary and prints the build command + (including the one-line macOS `getrandom` patch upstream needs). Full detail: + `docs/vanity.md`. - **Discord read-only source** (`discord` capability) — ten `discord_*` tools read the Meshtastic community server: server-side `discord_search` (Discord's own index, full history; channel / author / mentions / has / date / pinned filters, `offset` paging, `"me"` diff --git a/README.md b/README.md index 485a7a4..1d1a598 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ with no firmware checkout. Optional capabilities activate when their prerequisit | **sdr** | `[sdr]` extra (bundles `pyrtlsdrlib`, a prebuilt librtlsdr) + an RTL-SDR dongle | RF-compliance oracle: `rf_scan` occupancy checks and `rf_confirm_tx` on-air verification, no second radio needed. *macOS/Homebrew note:* a system `librtlsdr` from Homebrew is the osmocom fork and lacks `rtlsdr_set_dithering`, so `import rtlsdr` fails — the bundled `pyrtlsdrlib` avoids this and is preferred by pyrtlsdr's loader. | | **sdk-cli** *(experimental)* | Kotlin SDK headless CLI | alternate device-IO backend over the JVM CLI; see [docs/sdk-cli-bridge.md](docs/sdk-cli-bridge.md) | | **discord** | a read-only bot token (`$DISCORD_BOT_TOKEN` or `/meshtastic-mcp/discord.token` — `doctor` prints the path) | read the Meshtastic Discord server — server-side search, history, threads/forum posts, pins, mentions of you, with a per-message role-derived `trust` tier (`discord_*`); stdlib only, never posts; see [docs/discord.md](docs/discord.md) | +| **mvgrind** | the [`mvgrind`](https://github.com/miketweaver/mvgrind) binary + an OpenCL driver | grind a **vanity NodeNum or app colour** on the GPU (`vanity_grind_start`/`_poll`/`_stop`) — a node's number is `crc32(x25519_public_key)` and the apps paint it with the low 24 bits, so a chosen id or colour means searching the keyspace. `vanity_preview` and `vanity_apply` are core, so a key ground elsewhere still applies here; see [docs/vanity.md](docs/vanity.md) | The active set is logged at startup (`meshtastic-mcp capabilities active: …`). diff --git a/SECURITY.md b/SECURITY.md index 2ca9883..480155e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,3 +54,14 @@ into argument lists. `recorder_export` writes to an arbitrary `dest_dir` on the MCP server's host filesystem. Ensure the path is within an expected directory. + +`vanity_grind_start` / `vanity_grind_poll` produce and return **private-key +material**. Hits are written to `/grinds/.keys` and to the +job log (mvgrind prints them to stdout), both mode `0600`, and are returned +inline so `vanity_apply` can consume them — so they also pass through the model's +context. Treat the transcript and those files as secrets. + +`vanity_apply` replaces a device's identity: the NodeNum, the keypair, and the +colour every app paints it. The old NodeNum is dropped from the node's own DB and +peers must re-learn the new key. It is `confirm`-gated and `destructiveHint`; +keep the previous private key if you want a way back. See `docs/vanity.md`. diff --git a/docs/vanity.md b/docs/vanity.md new file mode 100644 index 0000000..a259087 --- /dev/null +++ b/docs/vanity.md @@ -0,0 +1,181 @@ +# Vanity identities — chosen NodeNums and app colours + +A Meshtastic node on a PKI firmware build does not get told its number. It +derives it: + +``` +public_key = X25519(private_key, 9) +my_node_num = crc32(public_key) # NodeDB.cpp::createNewIdentity +node id = "!%08x" % my_node_num +app colour = the low 24 bits of that number, read straight as RGB +``` + +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 until one lands. +[mvgrind](https://github.com/miketweaver/mvgrind) does that search on the GPU. +This server drives it, checks every hit with its own arithmetic, and writes the +winning key to a radio. + +The colour is not a Meshtastic-specific invention layered on top: every client +paints a node with those 24 bits. `nodeColorsFromNum` in Meshtastic-Android's +`NodeColors.kt` and `Color.swift` in Meshtastic-Apple agree, down to the +black-or-white foreground each picks for legibility. So `!8adc143c` is crimson +in both apps, and picking a colour is just a pattern over the id. + +## Tools + +| Tool | Needs | What it does | +|---|---|---| +| `vanity_preview` | — | What node id + colour a private key produces. No device, no GPU. | +| `vanity_grind_start` | `mvgrind` | Launch a grind in the background, get a `job_id`. | +| `vanity_grind_poll` | `mvgrind` | Status, progress, and any verified hits so far. | +| `vanity_grind_stop` | `mvgrind` | Stop a grind; keep what it already found. | +| `vanity_apply` | — | Write a key to a device, moving it to the matching NodeNum. | + +Grinding is capability-gated on the binary; preview and apply are **core** — a +key ground on a friend's GPU is still inspectable and applicable here. + +## A worked run + +```python +vanity_grind_start(color="crimson", tol=6) # -> {"job_id": "ea27…"} +vanity_grind_poll("ea27…") # -> hits[0].node_id "!19d70f3f", verified true +vanity_apply(private_key=hits[0]["private_key_hex"], port="/dev/ttyUSB0", confirm=True) +# -> {"changed": true, "node_id": "!19d70f3f", "verified": true} +``` + +`pattern` constrains the id, `color` the colour, and they compose: + +| ask | what it means | +|---|---| +| `pattern="dc80"` | id starts `!dc80` | +| `pattern="dc801051"` | that exact id | +| `pattern="dc80****"` | the same as the prefix, spelled out | +| `pattern="dc80,801f,d0f0"` | a set — any of them wins, at no extra cost | +| `color="crimson"` / `color="#dc143c"` | a node the apps paint crimson | +| `color="teal", tol=6` | near enough to teal, ~2000x fewer keys | + +`tol` is free to check and lands a hit far sooner: exact `crimson` averages +~17 M keys, `crimson` ±6 averages ~8 K. On an Apple M4 (~92 M keys/s via Apple's +OpenCL) a full 8-digit id averages about 48 s; a tolerant colour is instant. + +The two constraints **share bits** — id nibbles 3-8 *are* the colour channels — +so `pattern="dc80"` already pins red to `0x80`. An impossible pair is rejected +before any grinding, and the reason lands verbatim in the job log: + +``` +the id pattern and that color disagree on the red channel: +the pattern needs (byte & 0xff) == 0xef, the color needs 0x00-0x08 +``` + +## Every hit is re-derived here + +`parse_hits` recomputes the public key and the CRC-32 with this repo's own +X25519 ladder (RFC 7748, `vanity.py`) and `zlib.crc32` — code that shares +nothing with the grinder's OpenCL kernels. A hit whose key does not actually +produce the id it claims comes back `verified: false`, and must not be applied. +That is a grinder bug, not a near miss. + +The same arithmetic backs `vanity_preview`, so a key from anywhere can be +checked before it touches a radio. + +## Applying a key: what actually happens + +`vanity_apply` sends a `security` config set carrying the new `private_key` +with **`public_key` cleared**. That clearing is the whole trick. In +`AdminModule.cpp`: + +```cpp +if (config.security.private_key.size != 32) { + nodeDB->generateCryptoKeyPair(); +} else if (config.security.public_key.size == 0) { + nodeDB->generateCryptoKeyPair(config.security.private_key.bytes); +} +``` + +Send a new private key *and* echo back the old 32-byte public key and **neither +branch fires**: the node keeps the old public key, the old NodeNum, and a DH key +that no longer matches. The write appears to succeed and changes nothing. + +With the public key empty the firmware re-derives it, `createNewIdentity()` +recomputes `my_node_num`, drops the old identity from the node DB, and +`saveChanges(…, requiresReboot=true)` reboots the board ~7 s later. `vanity_apply` +reconnects afterwards and reads `my_node_num` back — which doubles as the +empirical PKI check: a build compiled with `MESHTASTIC_EXCLUDE_PKI_KEYGEN` never +moves, and shows up as a mismatch rather than as firmware-version archaeology. + +Two preconditions the tool enforces rather than discovering the hard way: + +- **`lora.region` must be set.** `generateCryptoKeyPair` refuses to derive keys + while the region is `UNSET`, so the write would be a silent no-op. +- **The key must be clamped.** The firmware signs with a clamped copy of the + scalar, so an unclamped key yields a node whose signatures do not verify + against its own public key. mvgrind only emits clamped keys. + +### This is an identity change, not a setting + +The old NodeNum is *removed* from the node's own DB. Peers keep DMing the old +public key until they see the new NodeInfo. Anything that named the old node — +an `admin_key` entry on another radio, a channel binding, a DM history — has to +be re-pointed. Keep the old private key if you want a way back. Hence +`confirm=True`, `destructiveHint`, and the up-front `previous_node_id` in the +result. + +A 32-bit id is also not an identity: anyone can grind a different key with the +same id. It is a cosmetic label; security comes from the signature. + +## Installing mvgrind + +```sh +git clone --recursive https://github.com/miketweaver/mvgrind +cd mvgrind && make && make test +``` + +Then put `mvgrind` on `PATH`, or point `$MESHTASTIC_MCP_MVGRIND` at the binary. +`doctor` prints the command for this platform and reports where it resolved. + +**macOS needs a small patch until [miketweaver/mvgrind#2](https://github.com/miketweaver/mvgrind/pull/2) +lands.** Upstream probes for `getrandom(2)` with `__has_include()`; +macOS ships that header but declares only `getentropy()` in it, so a stock +`make` dies with *"call to undeclared function 'getrandom'"*. Seed from +`getentropy()` there instead — same fails-closed CSPRNG guarantee, no +`/dev/urandom` dependency: + +```c +#if __has_include() +#include +#if defined(__APPLE__) /* the header is there; getrandom(2) is not */ +#define MV_HAVE_GETENTROPY 1 +#else +#define MV_HAVE_GETRANDOM 1 +#endif +#endif +``` + +```c +#ifdef MV_HAVE_GETENTROPY /* in fill_random(), beside the getrandom block */ + size_t got_off = 0; + while (got_off < n) { + size_t chunk = (n - got_off) > 256 ? 256 : (n - got_off); + if (getentropy(buf + got_off, chunk) != 0) + break; + got_off += chunk; + } + have = (got_off == n); +#endif +``` + +With that, it builds and `--selftest` passes against Apple's OpenCL. + +## Handling keys + +Everything a grind produces is **private-key material**: + +- Hits land in `/grinds/.keys`, mode `0600`. +- The job log holds the same keys — mvgrind prints hits to stdout — also `0600`. +- `vanity_grind_poll` returns keys inline, because that is what `vanity_apply` + consumes. They pass through the model's context; treat the transcript + accordingly. +- Never run `mvgrind` by hand in a repo checkout without `-o`: it appends to + `found.txt` in the current directory, which is the sort of file that gets + committed by accident. diff --git a/llms.txt b/llms.txt index 0441a66..c20d154 100644 --- a/llms.txt +++ b/llms.txt @@ -14,6 +14,7 @@ - [CONTRIBUTING](CONTRIBUTING.md): setup + gates - [docs/local-models.md](docs/local-models.md): local-model offload (tools, backends, config) - [docs/sdk-cli-bridge.md](docs/sdk-cli-bridge.md): experimental Kotlin-SDK device-IO backend +- [docs/vanity.md](docs/vanity.md): vanity NodeNums / app colours — grind (mvgrind), verify, apply - [docs/discord.md](docs/discord.md): read-only Discord source tools (`discord_*`) and token setup - [tests/README.md](tests/README.md): tiered hardware test suite + bench roles - [docs/bench-setup.md](docs/bench-setup.md): set up your own hardware bench diff --git a/src/meshtastic_mcp/capabilities.py b/src/meshtastic_mcp/capabilities.py index e1c273d..d1fbebc 100644 --- a/src/meshtastic_mcp/capabilities.py +++ b/src/meshtastic_mcp/capabilities.py @@ -15,6 +15,8 @@ Needs the ``android`` CLI and ``adb`` on PATH. - ``apple`` — iOS Simulator / macOS-app + native-node orchestration. Needs ``xcrun`` (``idb`` for UI drive). +- ``mvgrind`` — GPU grinding of vanity NodeNums / app colours. Needs the ``mvgrind`` + binary + an OpenCL driver. Tool registration in ``server.py`` consults these so a ``pip install meshtastic-mcp`` with no firmware tree still exposes the full device/admin/recorder surface. @@ -145,6 +147,20 @@ def has_discord() -> bool: return discord.available() +def has_mvgrind() -> bool: + """True when the ``mvgrind`` GPU vanity grinder is resolvable. + + Gates the vanity-identity *grind* tools (``vanity_grind_start`` / + ``vanity_grind_poll`` / ``vanity_grind_stop``). ``vanity_preview`` and + ``vanity_apply`` are core — a key ground elsewhere can always be inspected + and written here. Resolution is path-only (``$MESHTASTIC_MCP_MVGRIND`` → + PATH); needs an OpenCL driver at run time. See ``doctor``. + """ + from . import vanity + + return vanity.available() + + @dataclass(frozen=True) class Capabilities: firmware: bool @@ -158,6 +174,7 @@ class Capabilities: tak: bool sdk_cli: bool discord: bool + mvgrind: bool def summary(self) -> str: active = [ @@ -174,6 +191,7 @@ def summary(self) -> str: ("tak", self.tak), ("sdk_cli", self.sdk_cli), ("discord", self.discord), + ("mvgrind", self.mvgrind), ) if on ] @@ -193,4 +211,5 @@ def detect() -> Capabilities: tak=has_tak(), sdk_cli=has_sdk_cli(), discord=has_discord(), + mvgrind=has_mvgrind(), ) diff --git a/src/meshtastic_mcp/doctor.py b/src/meshtastic_mcp/doctor.py index 91b7f01..8ca0210 100644 --- a/src/meshtastic_mcp/doctor.py +++ b/src/meshtastic_mcp/doctor.py @@ -874,6 +874,40 @@ def _uhubctl_check() -> Check: ) +def _mvgrind_check() -> Check: + """Vanity-identity grinding (`vanity_grind_*`): needs the `mvgrind` GPU grinder + plus an OpenCL driver. Optional — `vanity_preview`/`vanity_apply` are core, so a + key ground on another machine can still be inspected and written without this. + """ + from . import vanity + + needed = "grind a vanity NodeNum / app colour (vanity_grind_start / _poll / _stop)" + path = vanity.mvgrind_bin() + if path: + return Check( + "mvgrind", "vanity", STATUS_OK, needed, detail=path, env_override=vanity.MVGRIND_ENV + ) + # Upstream probes `__has_include()` for getrandom(2); macOS ships that + # header but declares only getentropy(), so a stock `make` fails to compile there. + # Fix submitted as miketweaver/mvgrind#2; docs/vanity.md carries the patch meanwhile. + # Keep any note LAST: the whole fix string is meant to be pasted into a shell. + build = ( + "git clone --recursive https://github.com/miketweaver/mvgrind && cd mvgrind && make " + f"&& export {vanity.MVGRIND_ENV}=$PWD/mvgrind" + ) + if _IS_MAC: + build += " # macOS: patch the getrandom(2) probe first — docs/vanity.md (mvgrind#2)" + return Check( + "mvgrind", + "vanity", + STATUS_MISSING, + needed, + detail="mvgrind not on PATH", + fix=build, + env_override=vanity.MVGRIND_ENV, + ) + + def run() -> DoctorReport: """Probe everything and return a structured report (never raises).""" caps = capabilities.detect() @@ -959,6 +993,8 @@ def run() -> DoctorReport: _sdk_cli_check(), # discord capability (read-only community-server source) _discord_check(), + # vanity capability (GPU NodeNum/colour grinding) + _mvgrind_check(), ] return DoctorReport( platform=f"{platform.system()} {platform.machine()} / Python {platform.python_version()}", diff --git a/src/meshtastic_mcp/flash.py b/src/meshtastic_mcp/flash.py index fe9b89e..755a1f8 100644 --- a/src/meshtastic_mcp/flash.py +++ b/src/meshtastic_mcp/flash.py @@ -23,7 +23,17 @@ import serial -from . import boards, config, connection, devices, pio, port_recovery, registry, userprefs +from . import ( + boards, + config, + connection, + devices, + jobs, + pio, + port_recovery, + registry, + userprefs, +) # Meshtastic variants use both `esp32s3` and `esp32-s3` style names across # variants/*/platformio.ini (no consistency enforced). Accept both spellings. @@ -749,94 +759,9 @@ def touch_1200bps( # Both build and flash exceed the typical 60 s MCP request timeout; run them # in a daemon thread, return a job_id immediately, poll for completion. # --------------------------------------------------------------------------- +# The registry itself lives in `jobs.py` — the vanity grinder shares it. -_active_jobs: dict[str, dict[str, Any]] = {} -_jobs_lock = threading.Lock() - - -def _job_data_dir(kind: str) -> Path: - import os - - from platformdirs import user_data_dir - - root = Path(os.environ.get("MESHTASTIC_MCP_DATA_DIR") or user_data_dir("meshtastic-mcp")) - d = root / kind - d.mkdir(parents=True, exist_ok=True) - return d - - -def _start_job(kind: str, env: str, worker_body) -> dict[str, Any]: - """Launch `worker_body(state, log_path)` in a daemon thread, tracked by job_id. - - `kind` is "builds" or "flashes" (used for the log subdir and id prefix). - `worker_body` receives the mutable `state` dict + the log Path; it runs the - actual pio invocation and updates state under `_jobs_lock`. - """ - import uuid - - job_id = uuid.uuid4().hex[:12] - log_path = _job_data_dir(kind) / f"{job_id}.log" - - state: dict[str, Any] = { - "job_id": job_id, - "kind": kind, - "env": env, - "status": "running", - "started_at": time.time(), - "finished_at": None, - "exit_code": None, - "artifacts": [], - "log_path": str(log_path), - } - with _jobs_lock: - _active_jobs[job_id] = state - - def _run() -> None: - try: - worker_body(state, log_path) - except Exception as exc: - log_path.write_text(f"{kind} worker error: {exc}\n", encoding="utf-8") - with _jobs_lock: - state["status"] = "failed" - state["finished_at"] = time.time() - state["error"] = str(exc) - - threading.Thread(target=_run, daemon=True, name=f"{kind}-{job_id}").start() - return {"job_id": job_id, "status": "running", "log_path": str(log_path)} - - -def _poll_job(job_id: str, tail_lines: int = 50) -> dict[str, Any]: - """Shared poll for build/flash jobs started via `_start_job`.""" - # Snapshot under the lock: the worker publishes exit_code/error/status in one - # critical section, so reading the live dict later can catch a status - # without its reason. - with _jobs_lock: - live = _active_jobs.get(job_id) - state = dict(live) if live is not None else None - if state is None: - return {"error": f"Unknown job_id {job_id!r} (only this session's jobs are tracked)."} - - log_path = Path(state["log_path"]) - log_tail: list[str] = [] - if log_path.exists(): - log_tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-tail_lines:] - - elapsed = round((state["finished_at"] or time.time()) - state["started_at"], 1) - out = { - "job_id": job_id, - "kind": state["kind"], - "env": state["env"], - "status": state["status"], - "elapsed_s": elapsed, - "exit_code": state.get("exit_code"), - "duration_s": state.get("duration_s"), - "artifacts": state.get("artifacts", []), - "log_tail": log_tail, - "log_path": state["log_path"], - } - if state.get("error"): - out["error"] = state["error"] - return out +_jobs_lock = jobs.LOCK def build_start( @@ -867,7 +792,7 @@ def _body(state: dict[str, Any], log_path: Path) -> None: state["artifacts"] = [str(p) for p in _artifacts_for(env)] state["status"] = "done" if result.returncode == 0 else "failed" - out = _start_job("builds", env, _body) + out = jobs.start("builds", env, _body) # Back-compat alias: callers/tests historically read build_id. out["build_id"] = out["job_id"] return out @@ -879,9 +804,10 @@ def build_poll(build_id: str, tail_lines: int = 50) -> dict[str, Any]: Returns status (running/done/failed), elapsed time, artifacts, and the last `tail_lines` lines of build output. """ - out = _poll_job(build_id, tail_lines=tail_lines) + out = jobs.poll(build_id, tail_lines=tail_lines) if "job_id" in out: out["build_id"] = out["job_id"] + out["env"] = out["label"] return out @@ -938,7 +864,7 @@ def _body(state: dict[str, Any], log_path: Path) -> None: state["status"] = "done" if exit_code == 0 else "failed" try: - return _start_job("flashes", env, _body) + return jobs.start("flashes", env, _body) except BaseException: _release_port(lock) raise @@ -946,4 +872,12 @@ def _body(state: dict[str, Any], log_path: Path) -> None: def flash_poll(job_id: str, tail_lines: int = 50) -> dict[str, Any]: """Check status of a background flash started with `flash_start`.""" - return _poll_job(job_id, tail_lines=tail_lines) + out = jobs.poll(job_id, tail_lines=tail_lines) + if "job_id" in out: + out["env"] = out["label"] + # Flash jobs surface a wrong-port / silent-DFU failure as `error`; the + # shared registry renames it so a bare {"error": ...} stays the + # unknown-job reply. + if out.get("worker_error"): + out["error"] = out.pop("worker_error") + return out diff --git a/src/meshtastic_mcp/jobs.py b/src/meshtastic_mcp/jobs.py new file mode 100644 index 0000000..e07fe27 --- /dev/null +++ b/src/meshtastic_mcp/jobs.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Meshtastic contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Background job registry for tools that outlive an MCP request. + +Several operations here run far past the typical ~60 s MCP client timeout: a +PlatformIO build, a firmware upload, a vanity-key grind. They all use the same +shape — start a daemon thread, return a `job_id` immediately, poll for status +and a tail of the job's log. This module is that shape, factored out of +`flash.py` so the grinder shares one registry (and one log root) with +build/flash instead of growing a second one. + +The registry is process-global and in-memory: jobs do not survive a server +restart, which `poll()` says out loud when an id is unknown. +""" + +from __future__ import annotations + +import threading +import time +import uuid +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from . import config + +_active: dict[str, dict[str, Any]] = {} +LOCK = threading.Lock() +"""Guards every mutation of a job's state dict — worker bodies included.""" + + +def data_dir(kind: str) -> Path: + """Log/output directory for jobs of `kind` ("builds", "flashes", "grinds").""" + d = config.mcp_data_dir() / kind + d.mkdir(parents=True, exist_ok=True) + return d + + +def start( + kind: str, + label: str, + worker_body: Callable[[dict[str, Any], Path], None], +) -> dict[str, Any]: + """Launch `worker_body(state, log_path)` in a daemon thread, tracked by job_id. + + `kind` names the log subdir; `label` is the human-readable subject of the + job (a pio env, a grind pattern). `worker_body` owns the actual work and + updates `state` under `LOCK`. + """ + job_id = uuid.uuid4().hex[:12] + log_path = data_dir(kind) / f"{job_id}.log" + + state: dict[str, Any] = { + "job_id": job_id, + "kind": kind, + "label": label, + "status": "running", + "started_at": time.time(), + "finished_at": None, + "exit_code": None, + "artifacts": [], + "log_path": str(log_path), + } + with LOCK: + _active[job_id] = state + + def _run() -> None: + try: + worker_body(state, log_path) + except Exception as exc: + log_path.write_text(f"{kind} worker error: {exc}\n", encoding="utf-8") + with LOCK: + state["status"] = "failed" + state["finished_at"] = time.time() + state["error"] = str(exc) + + threading.Thread(target=_run, daemon=True, name=f"{kind}-{job_id}").start() + return {"job_id": job_id, "status": "running", "log_path": str(log_path)} + + +def poll(job_id: str, tail_lines: int = 50) -> dict[str, Any]: + """Status, elapsed time, artifacts and a log tail for a job from `start`.""" + with LOCK: + state = _active.get(job_id) + if state is None: + return {"error": f"Unknown job_id {job_id!r} (only this session's jobs are tracked)."} + + log_path = Path(state["log_path"]) + log_tail: list[str] = [] + if log_path.exists(): + log_tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-tail_lines:] + + with LOCK: + elapsed = round((state["finished_at"] or time.time()) - state["started_at"], 1) + return { + "job_id": job_id, + "kind": state["kind"], + "label": state["label"], + "status": state["status"], + "elapsed_s": elapsed, + "exit_code": state.get("exit_code"), + "duration_s": state.get("duration_s"), + "artifacts": state.get("artifacts", []), + # Deliberately NOT "error": a bare {"error": ...} is this module's + # "unknown job_id" reply, and callers branch on that key's presence. + "worker_error": state.get("error"), + "log_tail": log_tail, + "log_path": state["log_path"], + } + + +def state_of(job_id: str) -> dict[str, Any] | None: + """The raw mutable state dict for `job_id` (read/update under `LOCK`).""" + with LOCK: + return _active.get(job_id) diff --git a/src/meshtastic_mcp/server.py b/src/meshtastic_mcp/server.py index e0d2be3..1a7c9ea 100644 --- a/src/meshtastic_mcp/server.py +++ b/src/meshtastic_mcp/server.py @@ -38,6 +38,7 @@ registry, rf_oracle, serial_session, + vanity, ) from . import ( config_snapshot as config_snapshot_mod, @@ -198,6 +199,21 @@ def deco(fn): return deco +def vanity_tool(*args: Any, **kwargs: Any): + """Like `@app.tool()` but only registers when the `mvgrind` grinder is present. + + Gates the GPU grind tools only. `vanity_preview` and `vanity_apply` are core: + a key ground on another machine is still inspectable and applicable here. + """ + + def deco(fn): + if CAPS.mvgrind: + return app.tool(*args, **kwargs)(fn) + return fn + + return deco + + def _start_recorder() -> None: # Persistent device-log capture. Starts on first import — pubsub fan-out # is process-global, so subscribing here captures every active interface @@ -244,6 +260,13 @@ def _start_recorder() -> None: "rf_confirm_tx", ) +# mvgrind-coupled tools, gated by `vanity_tool` on the mvgrind capability. +_VANITY_TOOLS = ( + "vanity_grind_start", + "vanity_grind_poll", + "vanity_grind_stop", +) + # Firmware-coupled tools, gated by `firmware_tool` on the firmware capability. _FIRMWARE_TOOLS = ( "list_boards", @@ -282,6 +305,14 @@ def _log_capabilities() -> None: len(_FIRMWARE_TOOLS), ", ".join(_FIRMWARE_TOOLS), ) + if not CAPS.mvgrind: + log.info( + "mvgrind capability inactive: %d vanity-grind tools not registered " + "(build https://github.com/miketweaver/mvgrind, or set " + "$MESHTASTIC_MCP_MVGRIND): %s — vanity_preview/vanity_apply still work", + len(_VANITY_TOOLS), + ", ".join(_VANITY_TOOLS), + ) if not CAPS.sdr: log.info( "sdr capability inactive: %d RF-compliance tools not registered " @@ -3467,6 +3498,104 @@ def push_fake_nodedb( ) +# ---------- Vanity identities (NodeNum / app colour) ----------------------- +# A PKI node's number is crc32(x25519_public_key) and the apps paint it with the +# low 24 bits read as RGB — so a chosen id or colour means grinding the keyspace +# (mvgrind, GPU) and then writing the winning private key. Grinding is gated on +# the binary; preview/apply are core. See `vanity.py` for the derivation. + + +@app.tool() +def vanity_preview(private_key: str) -> dict[str, Any]: + """What node id and colour a private key produces. No device, no grinder. + + Takes hex or base64. Returns the node id, NodeNum, the RGB the apps paint it + (and the black/white they put on top), and whether the scalar is clamped. + Use it to check a key someone else ground before `vanity_apply` writes it. + """ + return vanity.describe_key(private_key) + + +@vanity_tool() +def vanity_grind_start( + pattern: str | None = None, + color: str | None = None, + tol: int = 0, + count: int = 1, + device: int | None = None, + timeout_s: float = vanity.DEFAULT_TIMEOUT_S, +) -> dict[str, Any]: + """Grind for a keypair whose node id / app colour matches, in the background. + + Returns a `job_id` in under a second; poll with `vanity_grind_poll`. Ask for + an id pattern (`"dc80"` prefix, `"dc80****"` wildcards, `"dc80,801f"` a set, + 8 hex digits for an exact id), a colour (`"crimson"`, `"#dc143c"`), or both. + + `tol` widens the colour to +/-N per channel — it costs nothing to check and + lands a hit far sooner (`crimson` alone is ~17 M keys; `--tol 6` is ~8 K). + The two constraints share bits (id nibbles 3-8 *are* the colour channels), so + an impossible pair is rejected up front, in the job log. + + The result is private-key material: hits land in a 0600 file and come back + inline from the poll. Treat them as secrets. + """ + return vanity.grind_start( + pattern=pattern, + color=color, + tol=tol, + count=count, + device=device, + timeout_s=timeout_s, + ) + + +@vanity_tool() +def vanity_grind_poll(job_id: str, tail_lines: int = 12) -> dict[str, Any]: + """Check a background grind: status, progress tail, and any hits so far. + + Every hit is re-derived on the CPU by an implementation that shares no code + with the grinder; `verified: false` means the key does not actually produce + the id it claims, and must not be applied. **Hits contain private keys.** + """ + return vanity.grind_poll(job_id, tail_lines=tail_lines) + + +@vanity_tool() +def vanity_grind_stop(job_id: str) -> dict[str, Any]: + """Stop a running grind. Anything already found is kept and returned.""" + return vanity.grind_stop(job_id) + + +@app.tool() +def vanity_apply( + private_key: str, + port: str | None = None, + confirm: bool = False, + verify: bool = True, + verify_timeout_s: float = 75.0, +) -> dict[str, Any]: + """Write a vanity private key to a device, moving it to the matching NodeNum. + + Destructive: this replaces the node's identity. The old NodeNum is dropped + from its own DB, peers keep using the old public key until they see the new + NodeInfo, and anything that named the old node (admin keys, another node's + DM history) has to be re-pointed. Keep the old key if you want a way back. + + Requires `confirm=True`, a clamped key, and a device whose `lora.region` is + set — the firmware refuses key derivation while the region is UNSET, which + would make this a silent no-op. The board reboots itself (~7 s) to commit; + with `verify` we reconnect and confirm it came back on the expected number, + which is also the empirical check that this build has PKI keygen at all. + """ + return vanity.apply_key( + private_key=private_key, + port=port, + confirm=confirm, + verify=verify, + verify_timeout_s=verify_timeout_s, + ) + + # ---------- MCP tool annotations (modern hint metadata) ------------------- # Annotations let clients reason about each tool without calling it: surface # read-only vs destructive in the UI, auto-approve safe reads, warn before @@ -3494,6 +3623,8 @@ def push_fake_nodedb( "get_board", "build_poll", # reads background-build state; never mutates "flash_poll", # reads background-flash state; never mutates + "vanity_preview", # pure key -> id/colour derivation; no device, no host state + "vanity_grind_poll", # reads grind-job state; never mutates "serial_list", "serial_read", # reads buffered bytes; no write side-effect "device_info", @@ -3541,6 +3672,9 @@ def push_fake_nodedb( _DESTRUCTIVE = { "build_start", # launches a pio subprocess; cannot be undone mid-flight "flash_start", # launches a pio upload subprocess + "vanity_grind_start", # spawns a GPU grinder; writes private-key material to disk + "vanity_grind_stop", # terminates that subprocess + "vanity_apply", # replaces the node identity (NodeNum + keypair); not reversible "build", "clean", "pio_flash", @@ -3648,6 +3782,7 @@ def push_fake_nodedb( "set_channel_url", "config_snapshot", # reads live device config "config_diff", # may read live device config (name_b=None) + "vanity_apply", # writes the security config to a live device and reboots it "set_owner", "set_debug_log_api", "send_text", @@ -3740,6 +3875,11 @@ def push_fake_nodedb( "pa_meter_status": "PA Meter Status", "pa_measure": "PA Meter Measure", "pa_sweep": "PA Power Sweep (Closed-Loop)", + "vanity_preview": "Vanity Identity Preview", + "vanity_grind_start": "Vanity Grind (Async Start)", + "vanity_grind_poll": "Vanity Grind (Poll Status)", + "vanity_grind_stop": "Vanity Grind (Stop)", + "vanity_apply": "Adopt Vanity Identity", } diff --git a/src/meshtastic_mcp/skills/meshtastic-device-ops/SKILL.md b/src/meshtastic_mcp/skills/meshtastic-device-ops/SKILL.md index 884137d..1371640 100644 --- a/src/meshtastic_mcp/skills/meshtastic-device-ops/SKILL.md +++ b/src/meshtastic_mcp/skills/meshtastic-device-ops/SKILL.md @@ -62,6 +62,25 @@ After a write, **reboot then re-read** to prove it persisted to NVS, not just RA (`reboot ` → `get_config`). Region (`lora.region`) and `network.enabled_protocols` are the two that bite — see `meshtastic-e2e` `topology.md`. +## Vanity identity (chosen node id / app colour) + +A node's number is `crc32(x25519_public_key)` and every app paints it with the low 24 bits +read as RGB, so both are chosen by grinding keys, not by setting a field. + +``` +vanity_grind_start color=crimson tol=6 # or pattern=dc80, or both; returns job_id +vanity_grind_poll # hits[]; NEVER use one with verified=false +vanity_preview # what any key gives you — no device, no GPU +vanity_apply confirm=true +``` + +- `tol` is free and finds a hit orders of magnitude sooner — always offer it. +- Grinding needs `mvgrind` (`doctor` prints how to build it); preview/apply do not. +- `vanity_apply` **replaces the identity**, drops the old NodeNum from the node's own DB, and + reboots the board. It needs `lora.region` set (keygen is skipped while UNSET) and refuses an + unclamped key. Keep the previous key if a way back matters. +- Hits are private keys. Don't echo them further than needed; see `docs/vanity.md`. + ## Message + observe ``` @@ -221,3 +240,5 @@ optional — see `doctor` for the `[ui]` extra). This is device-only; for app UI 2. Mutations are confirm-gated and reversible-by-reboot only for RAM writes — re-read after reboot. 3. `factory_reset(full=true)` wipes BLE bonds + the identity key; `full=false` keeps them. 4. Prefer the recorder windows over ad-hoc reads — they're timestamped and align with app snapshots. +5. Never apply a ground key whose `verified` is false, and never apply one without telling the + operator the old node id first — the change is not reversible without the previous key. diff --git a/src/meshtastic_mcp/vanity.py b/src/meshtastic_mcp/vanity.py new file mode 100644 index 0000000..b3a5496 --- /dev/null +++ b/src/meshtastic_mcp/vanity.py @@ -0,0 +1,557 @@ +# SPDX-FileCopyrightText: Meshtastic contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Vanity node identities: grind a NodeNum / app colour, then adopt it. + +On a PKI firmware build a node's identity is not assigned, it is *derived*:: + + public_key = X25519(private_key, 9) + my_node_num = crc32(public_key) # NodeDB.cpp::createNewIdentity + node id = "!%08x" % my_node_num + app colour = the low 24 bits of that number, read straight as RGB + +Both steps are one-way, so a chosen id (or colour) means searching the +keypair space. That search is what `mvgrind `_ +does on the GPU; this module drives it, verifies every hit independently on +the CPU, and writes the winning key to a device. + +Two halves, deliberately split: + +- **grind** (`grind_start`/`grind_poll`/`grind_stop`) needs the ``mvgrind`` + binary, so it is a gated capability. +- **describe/apply** (`describe_key`, `apply_key`) need nothing but the core + deps — a key ground on some other machine (or on a friend's GPU) can be + inspected and adopted here. + +The X25519 and CRC-32 used for verification are computed here from scratch +(RFC 7748 ladder + `zlib.crc32`), sharing no code with the grinder — so a +broken kernel cannot talk this module into writing a key that does not +actually produce the advertised node id. + +**Every result of a grind is private-key material.** Hits land in a 0600 file +under the MCP data dir and are returned inline so they can be applied; treat +both as secrets. See SECURITY.md. +""" + +from __future__ import annotations + +import base64 +import binascii +import os +import re +import shutil +import subprocess +import time +import zlib +from pathlib import Path +from typing import Any + +from . import config, connection, jobs + +# --------------------------------------------------------------------------- +# Curve25519 (RFC 7748 §5) — verification only, one scalarmult per call. +# Deliberately dependency-free: the core must not grow a crypto dep, and an +# independent implementation is the point (see the module docstring). +# --------------------------------------------------------------------------- +_P = 2**255 - 19 +_A24 = 121665 + + +class VanityError(RuntimeError): + """A vanity grind or key-apply could not proceed.""" + + +def clamp(private_key: bytes) -> bytes: + """Return `private_key` with the X25519 clamp bits forced.""" + b = bytearray(private_key) + b[0] &= 248 + b[31] &= 127 + b[31] |= 64 + return bytes(b) + + +def is_clamped(private_key: bytes) -> bool: + """True when the scalar is already clamped. + + The firmware signs with a clamped copy of the scalar, so an unclamped key + yields a node whose signatures do not verify against its own public key. + """ + return private_key[0] & 7 == 0 and private_key[31] & 0xC0 == 0x40 + + +def x25519_public_key(private_key: bytes) -> bytes: + """Derive the X25519 public key for `private_key` (the base-point mult). + + The scalar is clamped on the way in exactly as RFC 7748 decodeScalar25519 + specifies, so this matches what the firmware's monocypher call produces. + """ + if len(private_key) != 32: + raise VanityError(f"private key must be 32 bytes, got {len(private_key)}") + k = int.from_bytes(clamp(private_key), "little") + x1 = 9 + x2, z2, x3, z3, swap = 1, 0, x1, 1, 0 + for t in range(254, -1, -1): + bit = (k >> t) & 1 + swap ^= bit + if swap: + x2, x3 = x3, x2 + z2, z3 = z3, z2 + swap = bit + a = (x2 + z2) % _P + aa = a * a % _P + b = (x2 - z2) % _P + bb = b * b % _P + e = (aa - bb) % _P + c = (x3 + z3) % _P + d = (x3 - z3) % _P + da = d * a % _P + cb = c * b % _P + x3 = pow(da + cb, 2, _P) + z3 = x1 * pow(da - cb, 2, _P) % _P + x2 = aa * bb % _P + z2 = e * ((aa + _A24 * e) % _P) % _P + if swap: + x2, x3 = x3, x2 + z2, z3 = z3, z2 + return (x2 * pow(z2, _P - 2, _P) % _P).to_bytes(32, "little") + + +# --------------------------------------------------------------------------- +# Identity derivation — the firmware's own formula, mirrored +# --------------------------------------------------------------------------- +def nodenum_of_public_key(public_key: bytes) -> int: + """`crc32(public_key)` — the firmware's NodeNum (NodeDB.cpp::createNewIdentity). + + `crc32Buffer()` there is ErriezCRC32, i.e. plain CRC-32/IEEE — `zlib.crc32`. + """ + if len(public_key) != 32: + raise VanityError(f"public key must be 32 bytes, got {len(public_key)}") + return zlib.crc32(public_key) & 0xFFFFFFFF + + +def node_id(nodenum: int) -> str: + """The `!8adc143c` form the apps and the firmware print.""" + return f"!{nodenum & 0xFFFFFFFF:08x}" + + +def node_color(nodenum: int) -> dict[str, Any]: + """The colour the apps paint this node, from the low 24 bits read as RGB. + + Mirrors `nodeColorsFromNum` (Meshtastic-Android `NodeColors.kt`); the Apple + client agrees. `foreground` is the black/white the apps pick for legibility + against that background — worth knowing before committing to a colour. + """ + r = (nodenum >> 16) & 0xFF + g = (nodenum >> 8) & 0xFF + b = nodenum & 0xFF + brightness = (r * 0.299 + g * 0.587 + b * 0.114) / 255 + return { + "hex": f"#{r:02x}{g:02x}{b:02x}", + "rgb": [r, g, b], + "brightness": round(brightness, 4), + "foreground": "black" if brightness > 0.5 else "white", + } + + +def parse_private_key(text: str) -> bytes: + """Accept a 64-char hex or base64 private key (mvgrind prints both).""" + s = text.strip() + if re.fullmatch(r"[0-9a-fA-F]{64}", s): + return bytes.fromhex(s) + try: + raw = base64.b64decode(s, validate=True) + except (binascii.Error, ValueError) as exc: + raise VanityError("private key must be 64 hex chars or base64 of 32 bytes") from exc + if len(raw) != 32: + raise VanityError(f"private key must decode to 32 bytes, got {len(raw)}") + return raw + + +def describe_key(private_key: str) -> dict[str, Any]: + """What node id and colour this private key produces. Pure, no device I/O. + + Use it to check a key before `apply_key` burns it into a radio, or to + inspect a key someone else ground. + """ + sk = parse_private_key(private_key) + pk = x25519_public_key(sk) + num = nodenum_of_public_key(pk) + return { + "node_id": node_id(num), + "nodenum": num, + "color": node_color(num), + "public_key_hex": pk.hex(), + "public_key_b64": base64.b64encode(pk).decode(), + "clamped": is_clamped(sk), + } + + +# --------------------------------------------------------------------------- +# mvgrind — the GPU grinder (capability-gated) +# --------------------------------------------------------------------------- +MVGRIND_ENV = "MESHTASTIC_MCP_MVGRIND" +DEFAULT_TIMEOUT_S = 900.0 + +# A pattern is hex nibbles, wildcards, and comma-separated alternatives; a +# colour is #rgb/#rrggbb or a CSS name. Both are validated before they reach +# argv so a caller-supplied string can never be read as an mvgrind flag. +_PATTERN_RE = re.compile(r"^!?[0-9a-fA-F*?.]{1,8}(,!?[0-9a-fA-F*?.]{1,8})*$") +_COLOR_RE = re.compile(r"^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|[a-zA-Z]{3,24})$") +_PROGRESS_RE = re.compile(r"^\s*\d+ keys\s") + + +def mvgrind_bin() -> str | None: + """Resolve the `mvgrind` binary: `$MESHTASTIC_MCP_MVGRIND` → PATH.""" + override = os.environ.get(MVGRIND_ENV) + if override: + p = Path(override).expanduser() + return str(p) if p.is_file() and os.access(p, os.X_OK) else None + return shutil.which("mvgrind") + + +def available() -> bool: + """True when the grinder is usable (the `mvgrind` capability).""" + return mvgrind_bin() is not None + + +def _require_mvgrind() -> str: + binary = mvgrind_bin() + if binary is None: + raise VanityError( + "mvgrind not found. Build it " + "(git clone --recursive https://github.com/miketweaver/mvgrind && cd mvgrind && make), " + f"then put it on PATH or set ${MVGRIND_ENV} to the binary. Run `doctor` for the " + "platform-specific command." + ) + return binary + + +def parse_hits(out_path: Path) -> list[dict[str, Any]]: + """Parse mvgrind's `--out` file into hits, verifying each one here. + + The file is blank-line-separated `key=value` blocks. Every hit is + re-derived with this module's own X25519 + CRC-32: `verified` is false + when the key does not actually produce the advertised id, which is a + grinder bug, not a near miss — such a hit must not be applied. + """ + if not out_path.exists(): + return [] + hits: list[dict[str, Any]] = [] + for block in out_path.read_text(encoding="utf-8", errors="replace").split("\n\n"): + fields = dict(line.split("=", 1) for line in block.splitlines() if "=" in line) + raw_sk = fields.get("private_key_hex") + if not raw_sk: + continue + try: + sk = parse_private_key(raw_sk) + desc = describe_key(raw_sk) + except VanityError: + continue + claimed_id = fields.get("node_id", "") + hits.append( + { + **desc, + "private_key_hex": sk.hex(), + "private_key_b64": base64.b64encode(sk).decode(), + "verified": claimed_id == desc["node_id"] and desc["clamped"], + "reported_node_id": claimed_id, + } + ) + return hits + + +def _validate(pattern: str | None, color: str | None, tol: int) -> None: + if pattern is None and color is None: + raise VanityError("give a pattern (e.g. 'dc80'), a color, or both.") + if pattern is not None and not _PATTERN_RE.match(pattern): + raise VanityError( + f"invalid pattern {pattern!r}: expected hex nibbles with optional " + "'*'/'?'/'.' wildcards, or a comma-separated set (e.g. 'dc80,801f')." + ) + if color is not None and not _COLOR_RE.match(color): + raise VanityError( + f"invalid color {color!r}: expected '#rgb', '#rrggbb', or a CSS color name." + ) + if not 0 <= tol <= 255: + raise VanityError(f"tol must be 0-255, got {tol}") + + +def grind_start( + pattern: str | None = None, + color: str | None = None, + tol: int = 0, + count: int = 1, + device: int | None = None, + timeout_s: float = DEFAULT_TIMEOUT_S, +) -> dict[str, Any]: + """Launch a GPU grind in the background and return a `job_id` immediately. + + A grind runs far past the MCP request timeout (a full 8-digit id is ~25 s + on a discrete GPU but minutes on weaker OpenCL), so it follows the + `build_start`/`build_poll` pattern. Poll with `grind_poll`. + + `pattern` constrains the node id (`"dc80"` prefix, `"dc80****"` wildcards, + `"dc80,801f"` a set, a full 8 digits for an exact id); `color` constrains + the app colour (`"crimson"`, `"#dc143c"`); `tol` widens the colour by ±N + per channel, which costs nothing and lands a hit far sooner. The two share + bits — id nibbles 3-8 *are* the colour channels — so mvgrind rejects an + impossible combination up front rather than grinding forever; that message + is surfaced verbatim in the job log. + + `timeout_s=0` runs unbounded (stop it with `grind_stop`). + """ + binary = _require_mvgrind() + _validate(pattern, color, tol) + if count < 1: + raise VanityError("count must be >= 1 (an unbounded grind is what timeout_s=0 is for)") + + label = " ".join(x for x in [pattern, f"--color {color}" if color else None] if x) + out_dir = jobs.data_dir("grinds") + argv = [binary] + if pattern: + argv.append(pattern) + if color: + argv.extend(["--color", color]) + if tol: + argv.extend(["--tol", str(tol)]) + if device is not None: + argv.extend(["--device", str(device)]) + argv.extend(["--count", str(count)]) + + def _body(state: dict[str, Any], log_path: Path) -> None: + out_path = out_dir / f"{state['job_id']}.keys" + _touch_private(out_path) + _touch_private(log_path) + with jobs.LOCK: + state["out_path"] = str(out_path) + started = time.time() + with log_path.open("a", encoding="utf-8") as log: + log.write(f"$ {' '.join(argv)} --out {out_path}\n") + log.flush() + # argv is validated above and passed as a list — no shell. + proc = subprocess.Popen( + [*argv, "--out", str(out_path)], + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + with jobs.LOCK: + state["pid"] = proc.pid + try: + rc = proc.wait(timeout=timeout_s or None) + status = "done" if rc == 0 else "failed" + with jobs.LOCK: + stop_requested = bool(state.get("stop_requested")) + if rc != 0 and stop_requested: + status = "stopped" + except subprocess.TimeoutExpired: + proc.terminate() + try: + rc = proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + rc = proc.wait() + status = "timeout" + hits = parse_hits(out_path) + if not hits: # don't leave an empty key file behind for a failed grind + out_path.unlink(missing_ok=True) + with jobs.LOCK: + state["status"] = "done" if (status == "timeout" and hits) else status + state["exit_code"] = rc + state["finished_at"] = time.time() + state["duration_s"] = round(time.time() - started, 2) + state["artifacts"] = [str(out_path)] + state["hits"] = hits + if status == "timeout": + state["timed_out"] = True + + out = jobs.start("grinds", label, _body) + out["pattern"] = pattern + out["color"] = color + out["tol"] = tol + return out + + +def _touch_private(path: Path) -> None: + """Create `path` mode 0600 — it will hold private-key material.""" + fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600) + os.close(fd) + + +def grind_poll(job_id: str, tail_lines: int = 12) -> dict[str, Any]: + """Status of a background grind, plus every verified hit found so far. + + **The `hits` carry private keys.** Feed one to `apply_key` (or save it); + do not paste it anywhere public. mvgrind draws progress with carriage + returns, so `log_tail` is normalised to lines here. + """ + out = jobs.poll(job_id, tail_lines=1) + if "job_id" not in out: # unknown id — jobs.poll returns a bare {"error": ...} + return out + state = jobs.state_of(job_id) or {} + log_path = Path(out["log_path"]) + if log_path.exists(): + text = log_path.read_text(encoding="utf-8", errors="replace").replace("\r", "\n") + lines = [ln for ln in text.splitlines() if ln.strip()] + # Progress is redrawn thousands of times; keep only the newest one. + keep = [ln for ln in lines if not _PROGRESS_RE.match(ln)] + last_progress = next((ln for ln in reversed(lines) if _PROGRESS_RE.match(ln)), None) + if last_progress: + keep.append(last_progress.strip()) + out["log_tail"] = keep[-tail_lines:] + with jobs.LOCK: + out_path = state.get("out_path") + out["hits"] = state.get("hits") + out["timed_out"] = state.get("timed_out", False) + # A running grind can already have written a hit (--count > 1); read the + # file rather than making the caller wait for the whole job to finish. + if out["hits"] is None: + out["hits"] = parse_hits(Path(out_path)) if out_path else [] + out["out_path"] = out_path + out["spec"] = out.pop("label", None) # what was asked for, as passed to mvgrind + return out + + +def grind_stop(job_id: str) -> dict[str, Any]: + """Stop a running grind. Hits already written are kept and returned.""" + state = jobs.state_of(job_id) + if state is None: + return {"error": f"Unknown job_id {job_id!r} (only this session's jobs are tracked)."} + with jobs.LOCK: + pid = state.get("pid") + running = state.get("status") == "running" + if not running or not pid: + return {"ok": True, "stopped": False, **grind_poll(job_id)} + with jobs.LOCK: + state["stop_requested"] = True + try: + os.kill(pid, 15) + except ProcessLookupError: + pass + # The worker thread owns the state transition; give it a moment to land. + for _ in range(20): + if jobs.poll(job_id, tail_lines=0).get("status") != "running": + break + time.sleep(0.25) + return {"ok": True, "stopped": True, **grind_poll(job_id)} + + +# --------------------------------------------------------------------------- +# Adopting a ground key — the identity change +# --------------------------------------------------------------------------- +def apply_key( + private_key: str, + port: str | None = None, + confirm: bool = False, + verify: bool = True, + verify_timeout_s: float = 75.0, +) -> dict[str, Any]: + """Write a vanity private key to a device, moving it to the matching NodeNum. + + This **changes the node's identity**. The old NodeNum is removed from its + own DB, peers keep DMing the old key until they see the new NodeInfo, and + any admin key or channel binding that named the old node has to be + re-pointed. It is not a reversible edit unless you kept the old key. + + The write is a `security` config set carrying the new `private_key` with + `public_key` **cleared** — that is what makes the firmware re-derive the + public key (`AdminModule.cpp`: it only calls `generateCryptoKeyPair()` when + the public key is empty) and so recompute `my_node_num`. Echoing back the + old 32-byte public key silently skips both. The firmware reboots itself + (~7 s) to commit; with `verify` we reconnect afterwards and confirm the + node actually landed on the expected number. + """ + if not confirm: + raise VanityError( + "apply_key changes the node's identity (NodeNum, public key, and the " + "colour every app paints it) and requires confirm=True." + ) + sk = parse_private_key(private_key) + if not is_clamped(sk): + raise VanityError( + "private key is not clamped. The firmware signs with a clamped copy of " + "the scalar, so an unclamped key produces a node whose signatures do not " + "verify. mvgrind only emits clamped keys — re-check where this one came from." + ) + expected = describe_key(private_key) + + with connection.connect(port=port) as iface: + node = iface.localNode + region = node.localConfig.lora.region + if region == 0: # RegionCode.UNSET + raise VanityError( + "lora.region is UNSET: the firmware refuses key generation until a " + "region is set (NodeDB.cpp::generateCryptoKeyPair), so this write " + "would silently leave the identity unchanged. Set the region first." + ) + before = getattr(iface, "myInfo", None) + before_num = int(getattr(before, "my_node_num", 0) or 0) + if before_num == expected["nodenum"]: + return { + "ok": True, + "changed": False, + "reason": "device already holds this identity", + **expected, + } + sec = node.localConfig.security + sec.private_key = sk + sec.public_key = b"" # forces the firmware to re-derive it — see docstring + node.writeConfig("security") + + result: dict[str, Any] = { + "ok": True, + "changed": True, + "previous_node_id": node_id(before_num) if before_num else None, + "rebooting": True, + **expected, + } + if not verify: + result["verified"] = None + result["note"] = "device reboots in ~7 s; re-read device_info to confirm the new node id." + return result + + result.update(_verify_identity(port, expected["nodenum"], verify_timeout_s)) + return result + + +def _verify_identity(port: str | None, expected_num: int, timeout_s: float) -> dict[str, Any]: + """Reconnect after the self-reboot and read back `my_node_num`. + + Also the empirical PKI check: a build without PKI keygen never moves its + NodeNum, and shows up here as a mismatch rather than as version archaeology. + """ + deadline = time.time() + timeout_s + time.sleep(min(10.0, timeout_s)) # the firmware reboots ~7 s after saveChanges + last_error = "" + while time.time() < deadline: + try: + with connection.connect(port=port) as iface: + actual = int(getattr(iface.myInfo, "my_node_num", 0) or 0) + if actual == expected_num: + return {"verified": True, "node_id_on_device": node_id(actual)} + if actual: + return { + "verified": False, + "node_id_on_device": node_id(actual), + "note": ( + "the node came back on a different NodeNum. Either this build " + "excludes PKI keygen (MESHTASTIC_EXCLUDE_PKI_KEYGEN), or the " + "security write did not take." + ), + } + except Exception as exc: # port is gone while it reboots — expected + last_error = str(exc) + time.sleep(3.0) + return { + "verified": None, + "note": ( + f"could not reconnect within {timeout_s:.0f}s to confirm the new node id" + + (f" (last error: {last_error})" if last_error else "") + + ". Re-run device_info once the board is back." + ), + } + + +def data_dir() -> Path: + """Where ground keys land (0600 files). Exposed for `doctor`/docs.""" + return config.mcp_data_dir() / "grinds" diff --git a/tests/unit/test_upload_port_guard.py b/tests/unit/test_upload_port_guard.py index 26f7d6e..e1fe2e7 100644 --- a/tests/unit/test_upload_port_guard.py +++ b/tests/unit/test_upload_port_guard.py @@ -304,53 +304,3 @@ def _stub_script(script, port, binary): lock.release() finally: registry.clear_port_lock(REQUESTED) - - -def test_poll_snapshots_job_state_before_reading_the_log(tmp_path, monkeypatch) -> None: - """A job's terminal status and its reason are published in one critical - section, so a poll must read every field under `_jobs_lock` — reading them - after the log tail can catch `failed` before the `error` naming the wrong - port (or the silent DFU failure) has landed.""" - job_id = "pollsnapshot0" - log_path = tmp_path / f"{job_id}.log" - log_path.write_text("", encoding="utf-8") - state: dict = { - "job_id": job_id, - "kind": "flashes", - "env": "meshnology_w10", - "status": "running", - "started_at": time.time(), - "finished_at": None, - "exit_code": None, - "artifacts": [], - "log_path": str(log_path), - } - with flash._jobs_lock: - flash._active_jobs[job_id] = state - - real_path = flash.Path - - class _TearingPath: - """Publishes a half-written terminal state while the poll reads the log.""" - - def __init__(self, raw) -> None: - self._p = real_path(raw) - - def exists(self) -> bool: - return self._p.exists() - - def read_text(self, *args, **kwargs) -> str: - state["status"] = "failed" - state["exit_code"] = 1 - return self._p.read_text(*args, **kwargs) - - try: - monkeypatch.setattr(flash, "Path", _TearingPath) - polled = flash.flash_poll(job_id) - assert polled["status"] == "running", ( - "poll reported a terminal status it read after the log — the reason " - "may not be published yet" - ) - finally: - with flash._jobs_lock: - flash._active_jobs.pop(job_id, None) diff --git a/tests/unit/test_vanity.py b/tests/unit/test_vanity.py new file mode 100644 index 0000000..505222a --- /dev/null +++ b/tests/unit/test_vanity.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: Meshtastic contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Vanity NodeNum / colour derivation, hit parsing, and the identity write. + +No GPU and no radio: the X25519 ladder is checked against the RFC 7748 vectors, +the identity formula against a real mvgrind hit captured on an Apple M4 +(`!dead5d54`, cross-checked at the time against mvgrind's own CPU re-derivation), +and `apply_key` against a fake node built on the real protobufs — which is where +the load-bearing detail lives: the write must CLEAR `public_key`, or the firmware +takes neither keygen branch and the NodeNum silently never moves. +""" + +from __future__ import annotations + +import base64 +from contextlib import contextmanager + +import pytest + +from meshtastic_mcp import vanity + +# RFC 7748 §6.1 — Alice's and Bob's keypairs. +RFC7748 = [ + ( + "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", + ), + ( + "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb", + "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", + ), +] + +# A real mvgrind hit: `mvgrind dead`. The full chain in one vector — +# private key -> public key -> crc32 -> node id -> the colour the apps paint. +HIT_SK = "78add2dbefef3cc4adb4b93e7f0e25cc72101c995707ab05055528ea8854116e" +HIT_PK = "66a3554c5f5ed575c0745a41bcb3b30de05d5f68438b6b9f46c8715902ed2145" +HIT_ID = "!dead5d54" +HIT_COLOR = "#ad5d54" + +FOUND_FILE = f"""node_id={HIT_ID} +app_color={HIT_COLOR} +private_key_hex={HIT_SK} +public_key_hex={HIT_PK} +private_key_b64={base64.b64encode(bytes.fromhex(HIT_SK)).decode()} +public_key_b64={base64.b64encode(bytes.fromhex(HIT_PK)).decode()} + +""" + + +# --------------------------------------------------------------------------- +# Curve + identity derivation +# --------------------------------------------------------------------------- +@pytest.mark.parametrize(("sk", "pk"), RFC7748) +def test_x25519_matches_rfc7748(sk: str, pk: str) -> None: + assert vanity.x25519_public_key(bytes.fromhex(sk)).hex() == pk + + +def test_identity_chain_matches_a_real_grind() -> None: + desc = vanity.describe_key(HIT_SK) + assert desc["public_key_hex"] == HIT_PK + assert desc["node_id"] == HIT_ID + assert desc["nodenum"] == int(HIT_ID[1:], 16) + assert desc["color"]["hex"] == HIT_COLOR + assert desc["clamped"] is True + + +def test_nodenum_is_crc32_of_the_public_key() -> None: + # The firmware's formula (NodeDB.cpp::createNewIdentity), spelled out so a + # change to either side of it fails here rather than on a radio. + import zlib + + pk = bytes.fromhex(HIT_PK) + assert vanity.nodenum_of_public_key(pk) == zlib.crc32(pk) & 0xFFFFFFFF + assert vanity.node_id(vanity.nodenum_of_public_key(pk)) == HIT_ID + + +def test_color_reads_the_low_24_bits_as_rgb() -> None: + # Mirrors Meshtastic-Android's nodeColorsFromNum, foreground included. + color = vanity.node_color(0x8ADC143C) + assert color["hex"] == "#dc143c" # crimson + assert color["rgb"] == [0xDC, 0x14, 0x3C] + assert color["foreground"] == "white" + assert vanity.node_color(0x00FFFFFF)["foreground"] == "black" + + +def test_clamp_round_trip() -> None: + raw = bytes([0xFF] * 32) + assert not vanity.is_clamped(raw) + clamped = vanity.clamp(raw) + assert vanity.is_clamped(clamped) + assert vanity.clamp(clamped) == clamped + + +def test_unclamped_key_is_reported_not_silently_fixed() -> None: + raw = bytes([0xFF] * 32) + assert vanity.describe_key(raw.hex())["clamped"] is False + + +@pytest.mark.parametrize( + "text", + [HIT_SK, HIT_SK.upper(), base64.b64encode(bytes.fromhex(HIT_SK)).decode()], +) +def test_parse_private_key_accepts_hex_and_base64(text: str) -> None: + assert vanity.parse_private_key(text) == bytes.fromhex(HIT_SK) + + +@pytest.mark.parametrize("text", ["", "zz", HIT_SK[:-2], base64.b64encode(b"short").decode()]) +def test_parse_private_key_rejects_junk(text: str) -> None: + with pytest.raises(vanity.VanityError): + vanity.parse_private_key(text) + + +# --------------------------------------------------------------------------- +# mvgrind output parsing — every hit is re-derived here, not trusted +# --------------------------------------------------------------------------- +def test_parse_hits_verifies_against_its_own_derivation(tmp_path) -> None: + out = tmp_path / "found.txt" + out.write_text(FOUND_FILE, encoding="utf-8") + (hit,) = vanity.parse_hits(out) + assert hit["node_id"] == HIT_ID + assert hit["verified"] is True + assert hit["private_key_hex"] == HIT_SK + + +def test_parse_hits_flags_a_hit_that_lies_about_its_id(tmp_path) -> None: + out = tmp_path / "found.txt" + out.write_text(FOUND_FILE.replace(HIT_ID, "!deadbeef"), encoding="utf-8") + (hit,) = vanity.parse_hits(out) + assert hit["verified"] is False + assert hit["node_id"] == HIT_ID # what the key ACTUALLY produces + assert hit["reported_node_id"] == "!deadbeef" + + +def test_parse_hits_on_a_missing_or_empty_file(tmp_path) -> None: + assert vanity.parse_hits(tmp_path / "nope.txt") == [] + empty = tmp_path / "empty.txt" + empty.write_text("", encoding="utf-8") + assert vanity.parse_hits(empty) == [] + + +# --------------------------------------------------------------------------- +# Argument validation — a pattern must never be readable as an mvgrind flag +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("pattern", ["-h", "--color", "dc80;rm -rf /", "dc80 --bench 9", "xyz"]) +def test_grind_rejects_a_pattern_that_is_not_a_pattern(pattern: str) -> None: + with pytest.raises(vanity.VanityError): + vanity.grind_start(pattern=pattern) + + +@pytest.mark.parametrize("pattern", ["dc80", "!dc801051", "dc80****", "dc80,801f,d0f0", "d?.0"]) +def test_valid_patterns_pass_validation(pattern: str) -> None: + vanity._validate(pattern, None, 0) + + +@pytest.mark.parametrize("color", ["-crimson", "#gg0000", "#dc143", "rgb(1,2,3)"]) +def test_grind_rejects_a_bad_color(color: str) -> None: + with pytest.raises(vanity.VanityError): + vanity.grind_start(color=color) + + +def test_grind_needs_something_to_grind_for() -> None: + with pytest.raises(vanity.VanityError): + vanity.grind_start() + + +# --------------------------------------------------------------------------- +# apply_key — the identity write +# --------------------------------------------------------------------------- +class FakeNode: + def __init__(self, region: int) -> None: + from meshtastic.protobuf import localonly_pb2 + + self.localConfig = localonly_pb2.LocalConfig() + self.localConfig.lora.region = region + self.localConfig.security.private_key = bytes(32) + self.localConfig.security.public_key = bytes(32) + self.written: list[str] = [] + + def writeConfig(self, name: str) -> None: + self.written.append(name) + + +class FakeIface: + def __init__(self, region: int, node_num: int) -> None: + self.localNode = FakeNode(region) + self.myInfo = type("MyInfo", (), {"my_node_num": node_num})() + + +@contextmanager +def _fake_connect(iface: FakeIface): + yield iface + + +def _patch_connect(monkeypatch, iface: FakeIface) -> None: + monkeypatch.setattr( + vanity.connection, "connect", lambda *a, **k: _fake_connect(iface), raising=True + ) + + +def test_apply_requires_confirm() -> None: + with pytest.raises(vanity.VanityError, match="confirm=True"): + vanity.apply_key(HIT_SK, confirm=False) + + +def test_apply_refuses_an_unclamped_key() -> None: + with pytest.raises(vanity.VanityError, match="not clamped"): + vanity.apply_key(bytes([0xFF] * 32).hex(), confirm=True) + + +def test_apply_refuses_when_the_region_is_unset(monkeypatch) -> None: + iface = FakeIface(region=0, node_num=0x11111111) + _patch_connect(monkeypatch, iface) + with pytest.raises(vanity.VanityError, match="UNSET"): + vanity.apply_key(HIT_SK, confirm=True, verify=False) + assert iface.localNode.written == [] + + +def test_apply_clears_the_public_key_so_the_firmware_re_derives_it(monkeypatch) -> None: + # The whole trap: AdminModule only calls generateCryptoKeyPair(private_key) + # when the incoming public_key is EMPTY. Echo the old 32-byte public key + # back and neither keygen branch fires — the node keeps its old NodeNum. + iface = FakeIface(region=1, node_num=0x11111111) + _patch_connect(monkeypatch, iface) + result = vanity.apply_key(HIT_SK, confirm=True, verify=False) + + sec = iface.localNode.localConfig.security + assert iface.localNode.written == ["security"] + assert sec.private_key == bytes.fromhex(HIT_SK) + assert len(sec.public_key) == 0 + assert result["changed"] is True + assert result["node_id"] == HIT_ID + assert result["previous_node_id"] == "!11111111" + + +def test_apply_is_a_no_op_when_the_device_already_has_that_identity(monkeypatch) -> None: + iface = FakeIface(region=1, node_num=int(HIT_ID[1:], 16)) + _patch_connect(monkeypatch, iface) + result = vanity.apply_key(HIT_SK, confirm=True, verify=False) + assert result["changed"] is False + assert iface.localNode.written == []