Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -287,6 +303,17 @@ App/AVD connects to `10.0.2.2:<port>` (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=<hit>, port=<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`)
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<user-config-dir>/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: …`).

Expand Down
11 changes: 11 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<MCP data dir>/grinds/<job_id>.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`.
181 changes: 181 additions & 0 deletions docs/vanity.md
Original file line number Diff line number Diff line change
@@ -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(<sys/random.h>)`;
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(<sys/random.h>)
#include <sys/random.h>
#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 `<MCP data dir>/grinds/<job_id>.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.
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/meshtastic_mcp/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -158,6 +174,7 @@ class Capabilities:
tak: bool
sdk_cli: bool
discord: bool
mvgrind: bool

def summary(self) -> str:
active = [
Expand All @@ -174,6 +191,7 @@ def summary(self) -> str:
("tak", self.tak),
("sdk_cli", self.sdk_cli),
("discord", self.discord),
("mvgrind", self.mvgrind),
)
if on
]
Expand All @@ -193,4 +211,5 @@ def detect() -> Capabilities:
tak=has_tak(),
sdk_cli=has_sdk_cli(),
discord=has_discord(),
mvgrind=has_mvgrind(),
)
Loading
Loading