Files
Jean-Pierre MartineauandClaude Opus 5.5 1c678d43f3 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>
2026-10-13 18:10:40 -04:00

144 lines
5.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""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
32-byte nonce). In hex, this key has 128 digits.
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, 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
import hashlib
import re
from pathlib import Path
__all__ = [
"KEY_BYTES",
"SEED_BYTES",
"checked",
"expand_seed",
"normalize",
"parse_private_key",
"read_private_key_file",
]
#: The length of a private key in the form that a companion exports and imports.
KEY_BYTES = 64
#: The length of an Ed25519 seed, the short form from which a key comes.
SEED_BYTES = 32
#: The largest file that :func:`read_private_key_file` reads. A key in hex, with a line
#: break, is 129 bytes. Thus a larger file is not a key, and the function does not read a
#: large file that the user selected by mistake.
_MAX_FILE_BYTES = 4096
_HEX = re.compile(r"[0-9a-fA-F]*")
def expand_seed(seed: bytes) -> bytes:
"""The 64-byte private key that a 32-byte Ed25519 seed makes.
This is the key generation of the orlp Ed25519 library, which the MeshCore firmware
uses: the SHA-512 digest of the seed, with its first half clamped to make the scalar.
The result has the same public key as the seed, and a firmware companion accepts it.
Raises:
ValueError: If ``seed`` does not have 32 bytes.
"""
if len(seed) != SEED_BYTES:
raise ValueError(f"A seed has {SEED_BYTES} bytes, not {len(seed)}.")
digest = hashlib.sha512(seed).digest()
scalar = bytearray(digest[:32])
scalar[0] &= 0xF8
scalar[31] &= 0x3F
scalar[31] |= 0x40
return bytes(scalar) + digest[32:]
def normalize(raw: bytes) -> bytes:
"""``raw`` as a 64-byte key: a key stays as it is, and a seed is expanded.
Raises:
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
"""
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)
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)}."
)
def parse_private_key(text: str) -> bytes:
"""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.
Raises:
ValueError: If ``text`` is not hex, or has the wrong length. The message is for the
user.
"""
digits = "".join(text.split())
if not digits or not _HEX.fullmatch(digits):
raise ValueError("A private key has hex digits only (0-9 and a-f).")
if len(digits) % 2:
raise ValueError(
f"A private key has an even number of hex digits. This one has {len(digits)}."
)
return checked(bytes.fromhex(digits))
def read_private_key_file(path: Path) -> bytes:
"""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
key is random bytes, and the chance that all of them are hex digits is too small to
matter. But a hex seed that someone wrote into a key file as text is a real mistake,
and as raw bytes it would be read as a different key.
Raises:
OSError: If the file cannot be read.
ValueError: If the file does not hold a key. The message is for the user.
"""
if path.stat().st_size > _MAX_FILE_BYTES:
raise ValueError(f"{path.name} is too large to be a private key.")
data = path.read_bytes()
try:
digits = "".join(data.decode("ascii").split())
except UnicodeDecodeError:
digits = ""
if digits and _HEX.fullmatch(digits):
return parse_private_key(digits)
if len(data) in (KEY_BYTES, SEED_BYTES):
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."
)