#!/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()