diff --git a/tools/mkimage/nl_sign.py b/tools/mkimage/nl_sign.py new file mode 100644 index 0000000..5b5e6e4 --- /dev/null +++ b/tools/mkimage/nl_sign.py @@ -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() diff --git a/tools/mkimage/tests/test_nl_sign.py b/tools/mkimage/tests/test_nl_sign.py new file mode 100644 index 0000000..f1adbe0 --- /dev/null +++ b/tools/mkimage/tests/test_nl_sign.py @@ -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) diff --git a/tools/test_host.sh b/tools/test_host.sh index 62edf66..bd09054 100755 --- a/tools/test_host.sh +++ b/tools/test_host.sh @@ -5,3 +5,9 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd) cmake -S "$ROOT/test" -B "$ROOT/build/host" -G Ninja >/dev/null cmake --build "$ROOT/build/host" ctest --test-dir "$ROOT/build/host" --output-on-failure "$@" + +if python3 -c 'import pytest' 2>/dev/null; then + python3 -m pytest "$ROOT/tools/mkimage/tests" "$ROOT/tools/station/tests" -q +else + echo "Hinweis: pytest fehlt - Python-Tests uebersprungen" >&2 +fi