Files
Michael Sauer 78cf01ca6c fix(tools): SignError statt roher ValueError bei kaputtem Schluesselmaterial
nl_sign.py verify/sign gaben bei syntaktisch ungueltigem --pub/--key-Inhalt
(falsches DER/PEM-Format) bisher eine unbehandelte ValueError (pycryptodome
teils IndexError/TypeError) an den Aufrufer durch - die CLI brach dadurch
mit Python-Traceback ab statt mit der vorgesehenen "error: ..."-Meldung und
Exitcode 1. Beide Backends (cryptography und pycryptodome/pycryptodomex)
kapseln das Einlesen des privaten wie des oeffentlichen Schluessels jetzt in
try/except und werfen SignError mit einer festen, schluesselinhaltsfreien
Meldung (via "from None", damit die urspruengliche Bibliotheksausnahme nicht
implizit mitgeschleppt wird). Ein ungueltiger Schluessel kann dadurch nie zu
verify_image() == True fuehren.

Vier neue Tests in test_nl_sign.py: je ein Modul- und CLI-Test fuer kaputtes
--pub und kaputtes --key, die SignError bzw. Exitcode != 0 ohne Traceback auf
stderr festnageln.
2026-09-13 03:24:02 +02:00

228 lines
8.4 KiB
Python

#!/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:
try:
key = serialization.load_pem_private_key(private_pem, password=None)
except (ValueError, TypeError):
raise SignError(
"privater Schluessel ist kein gueltiger PEM-Schluessel") from 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:
try:
key = serialization.load_pem_public_key(public_key_bytes)
except (ValueError, TypeError):
raise SignError(
"oeffentlicher Schluessel ist weder gueltiges DER noch PEM") from None
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:
try:
key = RSA.import_key(private_pem)
except (ValueError, TypeError, IndexError):
raise SignError(
"privater Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
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:
try:
key = RSA.import_key(public_der)
except (ValueError, TypeError, IndexError):
raise SignError(
"oeffentlicher Schluessel ist kein gueltiger PEM/DER-Schluessel") from None
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()