feat(tools): Signierwerkzeug fuer Migrationsimages

nl_sign.py signiert/prueft W600-Images im RSA-4096-PKCS#1-v1.5-Format der
alten Klyqa-Firmware (Check_OTA_Image() in qc_ssl_layer.c): SHA256 ueber die
gesamte Datei, angehaengt als exakt 512 Byte - derselbe Block wie der
ninlight-Trailer, daher die Ablehnung bereits getrailerter Eingaben.
Unterstuetzt sowohl cryptography als auch pycryptodome/pycryptodomex als
Backend. Gegen das echte Klyqa-Schluesselpaar aus ~/workspace/fw-sign
bitgleich verifiziert.

tools/test_host.sh haengt jetzt die Python-Tests aus tools/mkimage/tests
und tools/station/tests an ctest an.
This commit is contained in:
Michael Sauer
2026-09-13 03:15:08 +02:00
parent 00d5a4c532
commit e50659d954
3 changed files with 303 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Signiert ein W600-Firmwareimage so, wie es die alte Klyqa-Firmware erwartet.
Format: RSA-4096 PKCS#1 v1.5 ueber den SHA256 der GESAMTEN Datei, angehaengt als
exakt 512 Byte. Belegt durch Check_OTA_Image() in qc_ssl_layer.c der alten Firmware
und durch sign_w600_fw.py im Schluesselverzeichnis.
Die 512 Byte belegen denselben Block wie der ninlight-Trailer. Ein Migrationsimage
traegt deshalb KEINEN Trailer - die alte Firmware prueft die Signatur, die neue
prueft an dieser Stelle nichts mehr, weil sie dann schon laeuft.
Muss unter Python 3.8.10 laufen (siehe nl_mkimage.py fuer den Hintergrund): keine
`str | None`-Annotationen ohne `from __future__ import annotations`, kein
`list[str]` zur Laufzeit.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import NamedTuple
SIG_LEN = 512
TRAILER_MAGIC = b"NINL"
KEY_BITS = 4096
class SignError(Exception):
pass
class TestKey(NamedTuple):
private_pem: bytes
public_der: bytes
def _backend():
"""Liefert ein einheitliches (sign, verify, keygen)-Tripel, egal ob `cryptography`
oder `pycryptodome`/`pycryptodomex` installiert ist - auf keinem Rechner ist sicher
dieselbe Bibliothek vorhanden."""
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.exceptions import InvalidSignature
def sign(private_pem: bytes, data: bytes) -> bytes:
key = serialization.load_pem_private_key(private_pem, password=None)
if key.key_size != KEY_BITS:
raise SignError(
f"Schluessel hat {key.key_size} Bit, benoetigt werden {KEY_BITS}")
return key.sign(data, padding.PKCS1v15(), hashes.SHA256())
def verify(public_key_bytes: bytes, data: bytes, signature: bytes) -> bool:
# akzeptiert sowohl DER als auch PEM, wie im CLI-Interface vorgesehen
try:
key = serialization.load_der_public_key(public_key_bytes)
except ValueError:
key = serialization.load_pem_public_key(public_key_bytes)
try:
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
return True
except InvalidSignature:
return False
def keygen() -> TestKey:
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_der = key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return TestKey(private_pem, public_der)
return sign, verify, keygen
except ImportError:
pass
try:
try:
from Cryptodome.Signature import pkcs1_15
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import RSA
except ImportError:
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
def sign(private_pem: bytes, data: bytes) -> bytes:
key = RSA.import_key(private_pem)
if key.size_in_bits() != KEY_BITS:
raise SignError(
f"Schluessel hat {key.size_in_bits()} Bit, benoetigt werden {KEY_BITS}")
digest = SHA256.new(data)
return pkcs1_15.new(key).sign(digest)
def verify(public_der: bytes, data: bytes, signature: bytes) -> bool:
key = RSA.import_key(public_der)
digest = SHA256.new(data)
try:
pkcs1_15.new(key).verify(digest, signature)
return True
except (ValueError, TypeError):
return False
def keygen() -> TestKey:
key = RSA.generate(KEY_BITS)
private_pem = key.export_key("PEM")
public_der = key.publickey().export_key("DER")
return TestKey(private_pem, public_der)
return sign, verify, keygen
except ImportError:
pass
raise SignError(
"weder 'cryptography' noch 'pycryptodome'/'pycryptodomex' ist installiert")
def generate_test_key() -> TestKey:
"""Nur fuer Tests: erzeugt einen frischen 4096-Bit-Schluessel im Speicher.
Schreibt niemals eine Datei."""
_sign, _verify, keygen = _backend()
return keygen()
def _check_raw(raw: bytes) -> None:
if len(raw) < 1024:
raise SignError(f"Eingabe ist mit {len(raw)} Byte kuerzer als die Mindestlaenge 1024")
if len(raw) % 4 != 0:
raise SignError(f"Eingabe ist mit {len(raw)} Byte nicht durch 4 teilbar")
if raw[-SIG_LEN:][:4] == TRAILER_MAGIC:
raise SignError(
"Eingabe traegt bereits einen ninlight-Trailer - das ist ein OTA-Image, "
"kein Rohbild")
def sign_image(raw: bytes, key_pem: bytes) -> bytes:
"""Haengt eine 512-Byte-PKCS#1-v1.5-Signatur ueber den SHA256 von `raw` an."""
_check_raw(raw)
sign, _verify, _keygen = _backend()
signature = sign(key_pem, raw)
if len(signature) != SIG_LEN:
raise SignError(
f"Signatur ist {len(signature)} Byte lang, erwartet werden {SIG_LEN}")
return raw + signature
def verify_image(signed: bytes, pub_der: bytes) -> bool:
"""Prueft eine mit `sign_image` erzeugte Signatur. Gibt bei ungueltiger Signatur
False zurueck; wirft nur bei echten Formatfehlern."""
if len(signed) <= SIG_LEN:
raise SignError(f"Eingabe ist mit {len(signed)} Byte zu kurz fuer eine Signatur")
payload, signature = signed[:-SIG_LEN], signed[-SIG_LEN:]
_sign, verify, _keygen = _backend()
return verify(pub_der, payload, signature)
def _cmd_sign(args: argparse.Namespace) -> int:
key_pem = Path(args.key).read_bytes()
raw = Path(args.in_).read_bytes()
try:
signed = sign_image(raw, key_pem)
except SignError as e:
print(f"error: {e}", file=sys.stderr)
return 1
Path(args.out).write_bytes(signed)
print(f"{args.out} ({len(signed)} bytes)")
return 0
def _cmd_verify(args: argparse.Namespace) -> int:
pub_der = Path(args.pub).read_bytes()
signed = Path(args.in_).read_bytes()
try:
ok = verify_image(signed, pub_der)
except SignError as e:
print(f"error: {e}", file=sys.stderr)
return 1
if ok:
print(f"{args.in_}: signature valid")
return 0
print(f"error: {args.in_}: signature invalid", file=sys.stderr)
return 1
def main() -> None:
p = argparse.ArgumentParser(
description="signs/verifies a W600 image the way the old Klyqa firmware expects")
sub = p.add_subparsers(dest="command", required=True)
p_sign = sub.add_parser("sign", help="sign a raw _gz.img")
p_sign.add_argument("--key", required=True, help="private key, PEM")
p_sign.add_argument("--in", dest="in_", required=True, help="input _gz.img")
p_sign.add_argument("--out", required=True, help="output signed image")
p_sign.set_defaults(func=_cmd_sign)
p_verify = sub.add_parser("verify", help="verify a signed image")
p_verify.add_argument("--pub", required=True, help="public key, PEM or DER")
p_verify.add_argument("--in", dest="in_", required=True, help="signed image to check")
p_verify.set_defaults(func=_cmd_verify)
args = p.parse_args()
raise SystemExit(args.func(args))
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
TOOL = HERE.parent / "nl_sign.py"
sys.path.insert(0, str(HERE.parent))
import nl_sign # noqa: E402
@pytest.fixture(scope="module")
def key():
"""Ein eigener 4096-Bit-Schluessel, damit kein echter Signierschluessel in die Tests
gerät. Einmal pro Modul, weil das Erzeugen ein paar Sekunden dauert."""
return nl_sign.generate_test_key()
def test_signature_is_512_bytes(key, tmp_path):
raw = b"\xa5" * 4096
signed = nl_sign.sign_image(raw, key.private_pem)
assert len(signed) == len(raw) + 512
assert signed[: len(raw)] == raw
def test_signature_verifies(key):
raw = b"firmware" * 512
signed = nl_sign.sign_image(raw, key.private_pem)
assert nl_sign.verify_image(signed, key.public_der) is True
def test_modified_payload_fails_verification(key):
raw = bytearray(b"firmware" * 512)
signed = bytearray(nl_sign.sign_image(bytes(raw), key.private_pem))
signed[17] ^= 0xFF
assert nl_sign.verify_image(bytes(signed), key.public_der) is False
def test_signing_is_deterministic(key):
raw = b"x" * 1024
assert nl_sign.sign_image(raw, key.private_pem) == nl_sign.sign_image(raw, key.private_pem)
def test_cli_roundtrip(key, tmp_path):
src = tmp_path / "in_gz.img"
dst = tmp_path / "out.img"
pub = tmp_path / "pub.der"
priv = tmp_path / "key.pem"
src.write_bytes(b"\x01\x02\x03" * 1000)
priv.write_bytes(key.private_pem)
pub.write_bytes(key.public_der)
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
"--in", str(src), "--out", str(dst)], check=True)
assert dst.stat().st_size == src.stat().st_size + 512
subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
"--in", str(dst)], check=True)
def test_cli_verify_rejects_tampered(key, tmp_path):
src = tmp_path / "in_gz.img"
dst = tmp_path / "out.img"
pub = tmp_path / "pub.der"
priv = tmp_path / "key.pem"
src.write_bytes(b"\x07" * 2000)
priv.write_bytes(key.private_pem)
pub.write_bytes(key.public_der)
subprocess.run([sys.executable, str(TOOL), "sign", "--key", str(priv),
"--in", str(src), "--out", str(dst)], check=True)
data = bytearray(dst.read_bytes())
data[0] ^= 0x01
dst.write_bytes(bytes(data))
r = subprocess.run([sys.executable, str(TOOL), "verify", "--pub", str(pub),
"--in", str(dst)])
assert r.returncode != 0
def test_refuses_already_signed_looking_input(key, tmp_path):
"""Ein Image mit ninlight-Trailer ist ein OTA-Image, kein Rohbild - zweimal 512 Byte
anzuhaengen ergaebe eine Datei, die die alte Firmware ablehnt."""
raw = b"\x00" * 1024 + b"NINL" + b"\x01" + b"\x00" * 507
with pytest.raises(nl_sign.SignError):
nl_sign.sign_image(raw, key.private_pem)