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:
1 parent
03b3c0926b
commit
1c678d43f3
7 files changed
+82
-29
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user