fix(release): Zugangsdatenpruefung entpackt die Abbilder, Teststub baut echte

Die Pruefung vor dem Packen suchte die Werte aus config.local.cmake in den
Rohbytes jeder Archivdatei. Ein Abbild ist aber 56 Byte Kopf, dann ein
gzip-Strom, dann - je nach Bildart - 512 Byte Trailer bzw. Signatur; im
komprimierten Strom steht kein Klartext. Derselbe Pruefkoerper meldete deshalb
auch fuer einen Bildsatz aus build/, der die Zugangsdaten nachweislich traegt,
"sauber (2 geprueft)". Er haette nie Alarm geschlagen.

Jetzt wird jede Datei weiterhin roh geprueft (Kopf, Trailer und die
Nicht-Abbilder wie README.md, onboard.sh und bruno/ sind unkomprimiert) und
jedes Abbild zusaetzlich entpackt. Ein Abbild, das sich nicht entpacken laesst,
bricht den Release ab, statt ungeprueft durchzugehen; das gilt auch fuer einen
abgeschnittenen gzip-Strom, den decompressobj sonst stillschweigend annimmt.

Entpackt wird ab Byte 56 ohne festes Abschneiden des Dateiendes. Anders als in
tools/station/deploy.sh liegen hier drei Bildarten nebeneinander: flash/_gz.img
traegt gar keinen Trailer, ota/_ota.img einen 512-Byte-Trailer, migrate/*.img
eine 512-Byte-Signatur. Ein raw[56:-512] wie dort haette jedes _gz.img fuer
unentpackbar gehalten und jeden Release abgebrochen.

Der Teststub war der zweite Fehler und verdeckte den ersten: er legte die
Zugangsdaten als Klartext in eine Datei, die er nur "..._ota.img" nannte. Damit
fand die rohe Suche sie, und der Fall war gruen, obwohl er eine Welt prueft, die
es nicht gibt. Der Stub baut jetzt Abbilder wie der echte Bau - 56 Byte Kopf,
gzip des Rumpfes mit den Zugangsdaten INNERHALB des komprimierten Rumpfes, beim
_ota.img 512 Byte Trailer mit NINL - und kontrolliert selbst, dass der Klartext
nicht roh in der Datei steht. Der Rumpf ist gross und gleichfoermig genug, dass
deflate ihn wirklich komprimiert statt ihn als "stored block" durchzureichen.

Neu sind Szenarien fuer Klartext in einer Nicht-Abbild-Datei, ein nicht
entpackbares Abbild und den Meldungstext; die Gegenprobe prueft jetzt auch den
Aufbau des Stub-Abbilds. Szenario 6 (Zugangsdaten im komprimierten Rumpf) und 6c
sind gegen den alten Pruefkoerper rot.

Die Meldung nennt jetzt die Zahl der Dateien und der entpackten Abbilder statt
der Zahl der Suchbegriffe. Gefundene Werte werden weiterhin nie ausgegeben, nur
ihr Name und die Datei.
This commit is contained in:
Michael Sauer
2026-09-24 00:25:49 +02:00
parent d7810c6cff
commit 5cbf425e49
2 changed files with 241 additions and 21 deletions
+64 -6
View File
@@ -254,15 +254,37 @@ README
echo "== Auf Zugangsdaten pruefen =="
# Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt
# wird, wird JEDE Datei des Archivs binaer gegen die echten Werte aus
# config.local.cmake geprueft. Lieber ein abgebrochener Release als ein Archiv mit
# Michaels WLAN-Passwort bei Dritten.
# wird, wird JEDE Datei des Archivs gegen die echten Werte aus config.local.cmake
# geprueft. Lieber ein abgebrochener Release als ein Archiv mit Michaels WLAN-Passwort
# bei Dritten - dieses Archiv geht an Fremde, und wiederholen laesst sich das nicht.
#
# Gesucht wird in ZWEI Sichten:
# roh - Kopf, Trailer und die Nicht-Abbilder (README.md, onboard.sh, bruno/) sind
# unkomprimiert; eine versehentlich notierte Zeile faellt nur so auf.
# entpackt- jedes Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann (beim _ota.img
# bzw. den signierten migrate/-Bildern) 512 Byte Trailer bzw. Signatur. Im
# komprimierten Strom steht kein Klartext. Die rohe Suche allein meldete
# deshalb IMMER "sauber" - auch fuer Abzuege aus build/, die die
# Zugangsdaten nachweislich tragen. Sie hat nie etwas geprueft.
#
# Entpackt wird ab Byte 56 OHNE das Dateiende abzuschneiden: die drei Bildarten im
# Archiv haben unterschiedliche Enden (flash/_gz.img traegt gar keinen Trailer,
# ota/_ota.img einen 512-Byte-Trailer, migrate/*.img eine 512-Byte-Signatur). Ein
# festes raw[56:-512] wie in tools/station/deploy.sh - wo nur Abbilder MIT Trailer
# vorkommen - wuerde hier jedes _gz.img fuer unentpackbar halten und jeden Release
# abbrechen. decompressobj hoert am Ende des gzip-Stroms von selbst auf; was danach
# kommt, ist ohnehin schon roh geprueft.
#
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
# "dann eben ungeprueft durchlassen". Das gilt auch fuer einen abgeschnittenen
# gzip-Strom, den decompressobj sonst stillschweigend annimmt.
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
import os
import re
import sys
import zlib
stage, local = sys.argv[1], sys.argv[2]
text = open(local, encoding="utf-8", errors="replace").read()
@@ -271,27 +293,63 @@ text = open(local, encoding="utf-8", errors="replace").read()
# haette nur falsche Alarme erzeugt.
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
# Kopflaenge des vom wm_tool erzeugten Abbilds; danach beginnt der gzip-Strom.
HEADER_LEN = 56
needles = []
for name in SECRETS:
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
if m and m.group(1) not in PUBLIC:
needles.append((name, m.group(1).encode()))
def unpack(raw):
"""Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt."""
if len(raw) <= HEADER_LEN:
raise ValueError("Datei ist kuerzer als der Kopf")
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
body = dec.decompress(raw[HEADER_LEN:])
# Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj
# liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe
# ungeprueft - und genau dort koennte das Passwort stehen.
if not dec.eof:
raise ValueError("gzip-Strom ist unvollstaendig")
return body
hits = []
broken = []
checked = 0
images = 0
for root, _dirs, files in os.walk(stage):
for fn in sorted(files):
path = os.path.join(root, fn)
rel = os.path.relpath(path, stage)
with open(path, "rb") as fh:
data = fh.read()
checked += 1
views = [data]
if fn.endswith(".img"):
try:
views.append(unpack(data))
except Exception as exc:
broken.append("%s (%s)" % (rel, exc))
continue
images += 1
for name, needle in needles:
if needle in data:
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
if any(needle in view for view in views):
# NUR der Name des Wertes und die Datei - niemals der Wert selbst.
hits.append("%s in %s" % (name, rel))
if broken:
sys.stderr.write(
"Fehler: Abbild laesst sich nicht entpacken, also auch nicht auf Zugangsdaten "
"pruefen - nichts gepackt:\n " + "\n ".join(broken) + "\n")
sys.exit(1)
if hits:
sys.stderr.write(
"Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n "
+ "\n ".join(hits) + "\n")
sys.exit(1)
print(" sauber (%d geprueft)" % len(needles))
print(" sauber (%d Dateien geprueft, davon %d Abbilder entpackt)" % (checked, images))
PY
fi