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>
144 lines
5.4 KiB
Python
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."
|
|
)
|