From 1c678d43f39d673841a7960a8a68a909eb057bd6 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 13 Oct 2026 18:10:40 -0400 Subject: [PATCH] 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) --- CHANGELOG.md | 4 ++++ docs/devices/uconsole.md | 3 ++- meshterm/core/connection.py | 13 +++++++++---- meshterm/core/privatekey.py | 39 +++++++++++++++++++++++++------------ meshterm/core/spiradio.py | 16 +++++++++------ meshterm/tools/config.py | 8 ++++---- tests/test_privatekey.py | 28 ++++++++++++++++++++++++-- 7 files changed, 82 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f2c47..abe2a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/devices/uconsole.md b/docs/devices/uconsole.md index aac6870..39756cb 100644 --- a/docs/devices/uconsole.md +++ b/docs/devices/uconsole.md @@ -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:** diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index 6eb67de..1dde0c6 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -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() diff --git a/meshterm/core/privatekey.py b/meshterm/core/privatekey.py index e4bd6e8..d62bc2c 100644 --- a/meshterm/core/privatekey.py +++ b/meshterm/core/privatekey.py @@ -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." diff --git a/meshterm/core/spiradio.py b/meshterm/core/spiradio.py index c7224a1..e2c0d56 100644 --- a/meshterm/core/spiradio.py +++ b/meshterm/core/spiradio.py @@ -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 ) diff --git a/meshterm/tools/config.py b/meshterm/tools/config.py index 06e8f62..501dbcb 100644 --- a/meshterm/tools/config.py +++ b/meshterm/tools/config.py @@ -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: diff --git a/tests/test_privatekey.py b/tests/test_privatekey.py index 600140f..efcf4a7 100644 --- a/tests/test_privatekey.py +++ b/tests/test_privatekey.py @@ -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