An imported seed stays a seed on the uConsole and the Cardputer Zero

The key parsers now return the key in the form that the user gave. Each
device takes the form that it keeps: a firmware companion expands a seed
to the 64 bytes that it accepts, and the node of an SPI radio saves the
seed as it is, the same form as a key that it makes itself. A seed is the
more complete form, and the radio library signs with it through PyNaCl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-13 18:10:40 -04:00
1 parent 03b3c0926b
commit 1c678d43f3
7 files changed
+82 -29

No files matched your search

+4
View File
@@ -18,6 +18,10 @@ can change how things behave. It does not only add new things.
message has the same mark, so a message that names you is easy to see. On the PicoCalc,
the console cannot draw a light fill, so there the `★` alone marks your messages. Before,
your messages showed only the `★`.
- **On the uConsole and the Cardputer Zero, an imported seed stays a seed.** MeshTerm saves
a 32-byte seed in the node's `identity.key` as you give it. This is the same form that
the node uses when it makes its own key. A firmware companion still gets the full 64-byte
key, because it accepts only that form.
- **In a chat, a blank line comes after the messages of each author.** Thus the
conversation is easier to follow when many people write. The Cardputer Zero has no
blank line there, because its screen has only 14 rows and each row shows a message.
+2 -1
View File
@@ -227,7 +227,8 @@ the mesh. On Device config, **Identity key…** shows the key, exports it, and i
one, as for each companion. An export has the form that a firmware companion uses: 64
bytes, or 128 hex digits. When the node makes its own key, it keeps a shorter form in
`identity.key`: a 32-byte seed. The two forms are the same identity, and an import accepts
each of them. The node reads its key only when it starts. Thus, after an import, MeshTerm
each of them. MeshTerm saves an imported key in the form that you give, so an imported
seed stays a seed. The node reads its key only when it starts. Thus, after an import, MeshTerm
restarts the node, and the reconnect dialog shows until the node is ready again.
**A profile, if you want a name for the connection:**
+9 -4
View File
@@ -52,6 +52,7 @@ from .models import (
advert_time,
utcnow,
)
from .privatekey import normalize as normalize_key
from .region_sim import SimulatedRegionMap
from .regions import WILDCARD as REGION_WILDCARD
from .regions import normalize as normalize_region
@@ -2003,9 +2004,12 @@ class Device(ABC):
async def import_private_key(self, key_hex: str) -> None:
"""Import a private key, and replace the identity of the device with it.
Each device takes the form that it keeps. A firmware companion accepts only the
64-byte key, so it expands a seed first (:func:`meshterm.core.privatekey.normalize`).
The node of an SPI radio keeps the form that it gets.
Args:
key_hex: The 64-byte key in hex, as :func:`meshterm.core.privatekey.normalize`
makes it. A caller that has a seed expands it first.
key_hex: The key in hex: the 64-byte key, or a 32-byte seed.
"""
@abstractmethod
@@ -4573,7 +4577,7 @@ class MeshCoreDevice(Device):
# error, thus it waited for its full timeout and returned a refusal with no cause.
# This call waits for the third answer too, and names it.
event = await self._require().commands.send(
b"\x18" + bytes.fromhex(key_hex),
b"\x18" + normalize_key(bytes.fromhex(key_hex)),
[EventType.OK, EventType.ERROR, EventType.DISABLED],
)
if getattr(event, "type", None) == EventType.DISABLED:
@@ -5418,7 +5422,8 @@ class MockDevice(Device):
return self._private_key
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
self._private_key = bytes.fromhex(key_hex).hex() # checks the hex, and normalizes it
# As a firmware does: it keeps only the 64-byte key, so a seed is expanded.
self._private_key = normalize_key(bytes.fromhex(key_hex)).hex()
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
self._custom_vars.clear()
+27 -12
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""The private key of our node: the forms that MeshTerm accepts, and the one form that it sends.
"""The private key of our node: the two forms that MeshTerm accepts, and where each one goes.
A MeshCore companion exports and imports its private key in one form only: 64 bytes, the
expanded Ed25519 key of the orlp library that the firmware uses (a 32-byte scalar, then a
@@ -7,10 +7,16 @@ expanded Ed25519 key of the orlp library that the firmware uses (a 32-byte scala
A key can also exist in a shorter form: the 32-byte Ed25519 seed from which the 64 bytes
come. The node that MeshTerm runs on an SPI radio keeps such a seed in its ``identity.key``
when it makes its own key. A seed is the same identity, but a firmware companion does not
accept it. Thus MeshTerm expands a seed before it sends it (:func:`expand_seed`), in the
same way that the firmware makes its own key. The user can give either form, and the
device always gets the 64 bytes.
when it makes its own key, because its radio library keeps keys in that form. A seed is
the same identity, and it is the more complete form: a seed always gives the 64 bytes,
and the 64 bytes never give back the seed.
The user can give either form, and the parsers here return it as given
(:func:`parse_private_key`, :func:`read_private_key_file`). Each device then takes the
form that it keeps. A firmware companion accepts only the 64 bytes, so it gets a seed
expanded (:func:`normalize`, in the same way that the firmware makes its own key). The
node of an SPI radio keeps the form that it gets, so an imported seed stays a seed there
(JP, 2026-10-13).
"""
from __future__ import annotations
@@ -22,6 +28,7 @@ from pathlib import Path
__all__ = [
"KEY_BYTES",
"SEED_BYTES",
"checked",
"expand_seed",
"normalize",
"parse_private_key",
@@ -68,10 +75,18 @@ def normalize(raw: bytes) -> bytes:
Raises:
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
"""
if len(raw) == KEY_BYTES:
raw = checked(raw)
return expand_seed(raw) if len(raw) == SEED_BYTES else raw
def checked(raw: bytes) -> bytes:
"""``raw`` itself, if it is a 64-byte key or a 32-byte seed.
Raises:
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
"""
if len(raw) in (KEY_BYTES, SEED_BYTES):
return bytes(raw)
if len(raw) == SEED_BYTES:
return expand_seed(raw)
raise ValueError(
f"A private key has {KEY_BYTES} bytes ({KEY_BYTES * 2} hex digits), or "
f"{SEED_BYTES} for a seed. This one has {len(raw)}."
@@ -79,7 +94,7 @@ def normalize(raw: bytes) -> bytes:
def parse_private_key(text: str) -> bytes:
"""The 64-byte key in ``text``, which the user typed or pasted as hex.
"""The key in ``text``, which the user typed or pasted as hex: 64 bytes, or a 32-byte seed.
The function ignores spaces and line breaks, because a key that the user copies from a
terminal often has them where the terminal wrapped the line.
@@ -95,11 +110,11 @@ def parse_private_key(text: str) -> bytes:
raise ValueError(
f"A private key has an even number of hex digits. This one has {len(digits)}."
)
return normalize(bytes.fromhex(digits))
return checked(bytes.fromhex(digits))
def read_private_key_file(path: Path) -> bytes:
"""The 64-byte key in the file at ``path``: hex text, or the raw bytes of a key or a seed.
"""The key in the file at ``path``, in its own form: hex text, or the raw bytes of a key.
``config export-key --out`` writes hex text. The ``identity.key`` of a node keeps raw
bytes. The function accepts the two forms. It reads the file as hex text first. A raw
@@ -121,7 +136,7 @@ def read_private_key_file(path: Path) -> bytes:
if digits and _HEX.fullmatch(digits):
return parse_private_key(digits)
if len(data) in (KEY_BYTES, SEED_BYTES):
return normalize(data)
return checked(data)
raise ValueError(
f"{path.name} does not hold a private key. A key file holds the key as hex "
f"text, or {KEY_BYTES} or {SEED_BYTES} raw bytes."
+10 -6
View File
@@ -53,7 +53,7 @@ from .atomicwrite import write_atomically
from .config import DeviceProfile, SpiWiring
from .connection import DeviceCommandError, MeshCoreDevice
from .discovery import TRANSPORT_SERIAL, DiscoveredDevice, spi_device
from .privatekey import KEY_BYTES, SEED_BYTES
from .privatekey import checked
_log = logging.getLogger(__name__)
@@ -949,15 +949,19 @@ class SpiRadioDevice(MeshCoreDevice):
menu announces a reconnect, which stops this node and starts a new one, and a run
on the command line stops the node when it ends.
The file gets the key in the form that the user gave. An imported seed stays a seed,
because the radio library keeps keys in that form, and a seed is the more complete
form (JP, 2026-10-13). A 64-byte key from a firmware companion has no seed, and the
library reads it in that form too.
The method does not restart the node itself. The services of the session subscribe
to the events of this connection, and a new connection under them would leave them
deaf. The reconnect of the session is the one path that rebuilds them.
"""
key = bytes.fromhex(key_hex)
if len(key) not in (KEY_BYTES, SEED_BYTES):
raise DeviceCommandError(
f"a private key has {KEY_BYTES} or {SEED_BYTES} bytes, not {len(key)}"
)
try:
key = checked(bytes.fromhex(key_hex))
except ValueError as exc:
raise DeviceCommandError(str(exc)) from None
await asyncio.to_thread(
write_atomically, self._state / "identity.key", key, owner_only=True
)
+4 -4
View File
@@ -31,7 +31,7 @@ from ..core.device_config import (
parse_value,
settings_by_category,
)
from ..core.privatekey import normalize, parse_private_key, read_private_key_file
from ..core.privatekey import checked, parse_private_key, read_private_key_file
from ..services.clock_sync import set_clock
from .base import Secret, Tool, ToolResult, register, reveal
@@ -320,9 +320,9 @@ async def apply_ops(
elif kind == "export_key":
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
elif kind == "import_key":
# The callers check the key before they confirm. This expands a seed that a
# caller gives as it is, because a firmware companion accepts only 64 bytes.
await device.import_private_key(normalize(bytes.fromhex(reveal(op[1]))).hex())
# The callers check the key before they confirm. The key goes on in the form
# that the user gave: each device takes the form that it keeps.
await device.import_private_key(checked(bytes.fromhex(reveal(op[1]))).hex())
if device.import_restarts:
ctx.ui.ack("[ok]✓[/ok] private key imported — the node uses it from its next start")
else:
+26 -2
View File
@@ -65,6 +65,11 @@ def test_a_key_passes_as_it_is_and_a_seed_is_expanded() -> None:
assert normalize(key[:32]) == expand_seed(key[:32])
def test_a_pasted_seed_stays_a_seed() -> None:
"""The parser returns the form that the user gave. Each device takes the form it keeps."""
assert parse_private_key("ab" * 32) == bytes.fromhex("ab" * 32)
def test_a_pasted_key_can_have_line_breaks_and_capitals() -> None:
"""A key that the user copies from a terminal has the breaks where the terminal wrapped it."""
pasted = "CD" * 32 + "\n " + "cd" * 32 + "\n"
@@ -92,8 +97,8 @@ def test_a_bad_key_says_why(text: str, problem: str) -> None:
[
(("cd" * 64 + "\n").encode(), bytes.fromhex("cd" * 64)),
(bytes.fromhex("cd" * 64), bytes.fromhex("cd" * 64)),
(bytes(range(32)), expand_seed(bytes(range(32)))),
(("ab" * 32).encode(), expand_seed(bytes.fromhex("ab" * 32))),
(bytes(range(32)), bytes(range(32))),
(("ab" * 32).encode(), bytes.fromhex("ab" * 32)),
],
ids=["hex-text", "raw-key", "raw-seed", "seed-as-hex-text"],
)
@@ -102,6 +107,8 @@ def test_a_key_file_can_hold_hex_text_or_raw_bytes(
) -> None:
"""``export-key --out`` writes hex text, and the ``identity.key`` of a node holds raw bytes.
The key comes back in its own form: a seed stays a seed, for the device to place.
A seed that someone wrote into a file as hex text is 64 bytes on disk. As raw bytes, it
would be a different key with no error, so the file is read as text first.
"""
@@ -145,6 +152,13 @@ async def test_a_firmware_import_sends_the_key() -> None:
assert sent == [b"\x18" + bytes.fromhex("cd" * 64)]
async def test_a_firmware_import_expands_a_seed() -> None:
"""A firmware companion accepts only 64 bytes, so it gets a seed expanded."""
device, sent = _device_that_answers(Event(EventType.OK, {}))
await device.import_private_key("ab" * 32)
assert sent == [b"\x18" + expand_seed(bytes.fromhex("ab" * 32))]
async def test_a_firmware_with_the_import_off_says_so() -> None:
"""The firmware answers "disabled". Before, MeshTerm waited for its timeout and said nothing."""
device, _sent = _device_that_answers(Event(EventType.DISABLED, {}))
@@ -168,6 +182,16 @@ async def test_an_spi_node_gets_its_imported_key_as_a_file(tmp_path: Path) -> No
assert stat.S_IMODE(path.stat().st_mode) == 0o600
async def test_an_spi_node_keeps_an_imported_seed_as_a_seed(tmp_path: Path) -> None:
"""The radio library keeps keys as seeds, and a seed is the more complete form.
Thus the node saves an imported seed as it is, and does not expand it (JP, 2026-10-13).
"""
device = spiradio.SpiRadioDevice(SpiWiring(), tmp_path / "state")
await device.import_private_key("ab" * 32)
assert (tmp_path / "state" / "identity.key").read_bytes() == bytes.fromhex("ab" * 32)
def test_a_firmware_companion_uses_its_new_key_at_once() -> None:
"""Only a node that MeshTerm runs must start again to use the key."""
assert MeshCoreDevice.import_restarts is False