fix(station): Zugangsdatenpruefung entpackt die Abbilder und prueft beide Rettungssaetze

Die Pruefung des Bildsatzes "ohne-wlan" suchte den Klartext der Werte aus
config.local.cmake in der Abbilddatei, wie sie auf der Platte liegt. Der Rumpf
eines Abbilds ist aber gzip (ab Byte 56, die letzten 512 Byte sind Trailer bzw.
Signatur) - im komprimierten Strom steht der Klartext nicht. Derselbe
Pruefkoerper meldete deshalb auch fuer den Satz aus build/, der die
Zugangsdaten nachweislich traegt, "sauber". Er haette nie Alarm geschlagen,
egal was im Bildsatz lag.

Jetzt wird jedes Abbild entpackt und erst dann durchsucht - im Rumpf wie in den
Rohbytes, denn Kopf, Trailer und Beidateien wie manifest.json sind
unkomprimiert. Ein Abbild, das sich nicht entpacken laesst, ist ein Fehler und
bricht den Deploy ab, statt ungeprueft durchzugehen; das gilt auch fuer einen
abgeschnittenen gzip-Strom, den decompressobj sonst stillschweigend annimmt.

Zweitens hing der ganze Block an der Existenz von migrate-nocreds/. Der
Rettungssatz rescue-nocreds/ lief ungeprueft durch, obwohl er der gefaehrlichere
von beiden ist: er geht ohne Signatur an die Lampe. Geprueft werden jetzt beide
Saetze "ohne-wlan"; migrate-wlan/ und rescue-wlan/ bleiben ungeprueft, die
sollen die Daten enthalten.

Die Meldung nennt jetzt Zahl der Abbilder und der Saetze statt der Zahl der
Suchbegriffe. Gefundene Werte werden weiterhin nie ausgegeben, nur ihr Name und
die Datei.

Die Testattrappen haben jetzt den echten Aufbau (56 Byte Kopf, gzip-Rumpf,
512 Byte Trailer) und tragen die Zugangsdaten im komprimierten Rumpf; die
Gegenproben-Hilfe tree_contains entpackt mit. Neu sind Szenarien fuer
Zugangsdaten im Rettungssatz, die Abgrenzung zu rescue-wlan, ein nicht
entpackbares Abbild und den Meldungstext. Szenario 33, 39, 41 und 42 sind gegen
den alten Pruefkoerper rot.
This commit is contained in:
Michael Sauer
2026-09-23 23:55:23 +02:00
parent bd467b5e52
commit d7810c6cff
2 changed files with 312 additions and 41 deletions
+85 -16
View File
@@ -329,46 +329,115 @@ fi
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
# Fehler. Lieber ein abgebrochener Deploy.
#
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck.
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh.
if [ -d "$STAGE/migrate-nocreds" ]; then
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen =="
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen.
# Geprueft werden BEIDE Saetze "ohne-wlan" - der Migrationssatz migrate-nocreds/ UND der
# Rettungssatz rescue-nocreds/. Der Rettungssatz ist dabei der gefaehrlichere von beiden:
# er geht ohne Signatur an die Lampe. Frueher hing dieser ganze Block allein an
# migrate-nocreds/, der Rettungssatz lief ungeprueft durch.
# migrate-wlan/ und rescue-wlan/ bleiben ungeprueft: die SOLLEN die Zugangsdaten
# enthalten, das ist ihr Zweck.
#
# Gesucht wird im ENTPACKTEN Rumpf, nicht in der Datei, wie sie auf der Platte liegt:
# jedes Abbild (_ota.img wie die signierten _prod.img/_dev.img) ist 56 Byte Kopf, dann
# ein gzip-Strom, dann 512 Byte Trailer bzw. Signatur. Im komprimierten Strom steht der
# Klartext des Passworts nicht - eine Suche im Rohbyteinhalt meldete deshalb IMMER
# "sauber", auch fuer den Satz aus build/, der die Zugangsdaten nachweislich traegt. Die
# Pruefung hat bis hierhin also nie irgendetwas geprueft.
#
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
# "dann eben ungeprueft durchlassen". Genau diese Nachsicht waere der Weg, auf dem das
# Passwort am Ende doch hinausgeht.
#
# Der Pruefkoerper in tools/release.sh ist verwandt, aber nicht mehr identisch: dort
# wird ein fertiges Archiv aus lauter verschiedenen Dateiarten geprueft, hier ein
# Bildsatz aus Firmware-Abbildern.
# Als if-Zweige und nicht als "[ -d … ] && CHECK_SETS+=(…)": unter "set -e" waere eine
# solche Zeile, wenn das Verzeichnis fehlt, ein fehlgeschlagenes letztes Kommando und
# risse den Deploy ab.
declare -a CHECK_SETS=()
if [ -d "$STAGE/migrate-nocreds" ]; then CHECK_SETS+=(migrate-nocreds); fi
if [ -d "$STAGE/rescue-nocreds" ]; then CHECK_SETS+=(rescue-nocreds); fi
if [ ${#CHECK_SETS[@]} -gt 0 ]; then
echo "== Bildsaetze 'ohne-wlan' auf Zugangsdaten pruefen =="
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || exit 1
python3 - "$LOCAL" "$STAGE" "${CHECK_SETS[@]}" <<'CHECKPY' || exit 1
import os
import re
import sys
import zlib
stage, local = sys.argv[1], sys.argv[2]
local, stage, sets = sys.argv[1], sys.argv[2], sys.argv[3:]
text = open(local, encoding="utf-8", errors="replace").read()
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
# haette nur falsche Alarme erzeugt.
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
# Aufbau eines Abbilds: 56 Byte Kopf (vom wm_tool erzeugt), dann der gzip-Strom mit der
# eigentlichen Firmware, dann 512 Byte ninlight-Trailer bzw. RSA-Signatur.
HEADER_LEN = 56
TRAILER_LEN = 512
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 + TRAILER_LEN:
raise ValueError("Datei ist kuerzer als Kopf + Trailer")
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
body = dec.decompress(raw[HEADER_LEN:-TRAILER_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 = []
for root, _dirs, files in os.walk(stage):
for fn in sorted(files):
path = os.path.join(root, fn)
with open(path, "rb") as fh:
data = fh.read()
for name, needle in needles:
if needle in data:
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
broken = []
images = 0
for image_set in sets:
root_dir = os.path.join(stage, image_set)
for root, _dirs, files in os.walk(root_dir):
for fn in sorted(files):
path = os.path.join(root, fn)
rel = os.path.relpath(path, stage)
with open(path, "rb") as fh:
raw = fh.read()
# Durchsucht werden beide Sichten: der entpackte Rumpf (dort steht die
# Firmware) UND die Rohbytes (Kopf und Trailer werden nicht komprimiert,
# und Beidateien wie manifest.json gibt es nur roh).
views = [raw]
if fn.endswith(".img"):
images += 1
try:
views.append(unpack(raw))
except Exception as exc:
broken.append("%s (%s)" % (rel, exc))
continue
for name, needle in needles:
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 im Bildsatz 'ohne-wlan' laesst sich nicht entpacken, also auch "
"nicht auf Zugangsdaten pruefen - nichts uebertragen:\n "
+ "\n ".join(broken) + "\n")
sys.exit(1)
if hits:
sys.stderr.write(
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
sys.exit(1)
print(" sauber (%d geprueft)" % len(needles))
print(" sauber (%d Abbilder in %d Saetzen geprueft: %s)"
% (images, len(sets), ", ".join(sets)))
CHECKPY
else
echo " uebersprungen - $LOCAL nicht vorhanden" >&2