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:
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user