diff --git a/tools/release.sh b/tools/release.sh index 1e958da..4c1c2f0 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -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 diff --git a/tools/tests/test_release.sh b/tools/tests/test_release.sh index fe1ebf5..ed9dfcc 100755 --- a/tools/tests/test_release.sh +++ b/tools/tests/test_release.sh @@ -11,6 +11,13 @@ # wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das # Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug. # +# Und zwar an der Stelle, an der sie beim echten Bau auch landen: IM KOMPRIMIERTEN +# RUMPF. Ein Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann - beim _ota.img - 512 +# Byte Trailer. Frueher schrieb der Stub reinen Klartext in eine Datei, die er nur +# "..._ota.img" nannte; damit fand die rohe Bytesuche in release.sh die Werte, und Fall +# 5 war gruen, obwohl der Pruefkoerper an echten Abbildern nie irgendetwas gefunden +# haette. Ein Testartefakt, das dem Pruefling recht gibt, ist schlimmer als kein Test. +# # Aufruf: tools/tests/test_release.sh set -euo pipefail @@ -75,6 +82,9 @@ EOF # (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf # STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn # config.local.cmake beiseite liegt (prueft den doppelten Boden) +# STUB_TRUNC_TARGET schneidet beim _ota.img dieses Ziels den gzip-Strom ab - das +# Abbild laesst sich dann nicht entpacken. Das _gz.img bleibt heil, +# damit das Signieren davor nicht schon abbricht. setup_stub_docker() { local stub=$1 sandbox=$2 cat >"$stub" <<'STUB' @@ -115,22 +125,56 @@ fi if [ "${STUB_LEAK:-}" = "1" ]; then creds="$STUB_LEAK_VALUE" fi -CREDS="$creds" TARGET="$target" OUTDIR="$outdir" python3 - <<'PY' +CREDS="$creds" TARGET="$target" OUTDIR="$outdir" TRUNC="${STUB_TRUNC_TARGET:-}" python3 - <<'PY' +import gzip +import io import os target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"] -body = bytearray(("firmware-%s-" % target).encode() * 200) -body = body[:4096] + b"\0" * (4096 - len(body[:4096])) + +# Der Rumpf ist ~100 kB gleichfoermiger Text - so wie echte Firmware gross genug, dass +# deflate ihn WIRKLICH komprimiert. Zwei Klippen liegen hier dicht nebeneinander: +# zu kleiner Rumpf -> gzip-Strom unter 1024 Byte, und nl_sign.py lehnt ab; +# unkomprimierbarer Rumpf (etwa Zufallsbytes) -> deflate legt ihn als "stored block" +# UNVERAENDERT ab, der Klartext stuende wieder roh in der Datei und der Test prueft +# erneut eine Welt, die es nicht gibt. +body = bytearray( + b"".join(b"ninlight-%s-block-%06d\n" % (target.encode(), i) for i in range(4000))) +tag = ("firmware-%s-" % target).encode() +body[0:len(tag)] = tag blob = creds.encode() if blob: + # Die Zugangsdaten liegen IM Rumpf - also nach dem Packen im komprimierten Strom + # und nicht im Klartext in der Datei. Genau wie beim echten Compiler. body[100:100 + len(blob)] = blob -gz = bytes(body) + +# Aufbau wie beim echten Bau: 56 Byte Kopf (wm_tool), dann der gzip-Strom. +header = bytearray(b"\x9f\xff\xff\xa0" + b"\x00" * 52) +header[4:8] = b"STUB" +buf = io.BytesIO() +# mtime=0: sonst haengt der Dateiinhalt an der Uhrzeit und Vergleiche werden wacklig. +with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gf: + gf.write(bytes(body)) +stream = buf.getvalue() +raw = bytes(header) + stream +# nl_sign.py verlangt eine durch 4 teilbare Laenge von mindestens 1024 Byte. Die +# Fuellbytes stehen hinter dem gzip-Strom und stoeren das Entpacken nicht. +raw += b"\x00" * (-len(raw) % 4) +assert len(raw) >= 1024, "Stub-Abbild zu kurz fuer nl_sign.py" +# Selbstkontrolle gegen genau den Fehler, der diese Suite jahrelang wertlos machte. +assert not blob or blob not in raw, \ + "Zugangsdaten stehen roh in der Datei - der Rumpf wurde nicht wirklich komprimiert" with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh: - fh.write(gz) # roh, ohne NINL-Trailer - das signiert nl_sign.py + fh.write(raw) # roh, ohne NINL-Trailer - das signiert nl_sign.py trailer = bytearray(b"\xff" * 512) trailer[0:4] = b"NINL" +ota_body = raw +if os.environ.get("TRUNC") == target: + # Abgeschnittener gzip-Strom: decompressobj liefert dafuer stillschweigend nur den + # lesbaren Teil, wenn niemand dec.eof abfragt. + ota_body = bytes(header) + stream[:len(stream) // 2] with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh: - fh.write(gz + bytes(trailer)) + fh.write(ota_body + bytes(trailer)) PY STUB chmod +x "$stub" @@ -171,29 +215,50 @@ run_release() { STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \ STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \ STUB_LEAK="${STUB_LEAK:-}" \ + STUB_TRUNC_TARGET="${STUB_TRUNC_TARGET:-}" \ STUB_LEAK_VALUE="$SECRET_SSID" \ "$sandbox/tools/release.sh" "$version" } -# Sucht die Zugangsdaten BINAER in jeder Datei unterhalb eines Verzeichnisses. +# Sucht die Zugangsdaten in jeder Datei unterhalb eines Verzeichnisses - in den +# Rohbytes UND im entpackten Rumpf jedes Abbilds. Nur roh zu suchen hiesse, die +# Zusage "keine Zugangsdaten im Archiv" gar nicht zu pruefen: im gzip-Strom steht +# kein Klartext. Ein Abbild, das sich nicht entpacken laesst, wird hier gemeldet und +# nicht verschwiegen - sonst haette die Gegenprobe ein stilles Loch. +# $2 (optional) = "roh": nur die Rohbytes durchsuchen, nichts entpacken. grep_secrets() { - local dir=$1 - SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 - "$dir" <<'PY' + local dir=$1 mode=${2:-entpackt} + SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \ + python3 - "$dir" "$mode" <<'PY' import os import sys +import zlib -root = sys.argv[1] +HEADER_LEN = 56 + +root, mode = sys.argv[1], sys.argv[2] needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")} for dirpath, _dirs, files in os.walk(root): for fn in sorted(files): p = os.path.join(dirpath, fn) + rel = os.path.relpath(p, root) if os.path.islink(p): continue with open(p, "rb") as fh: data = fh.read() + views = [data] + if mode != "roh" and fn.endswith(".img"): + dec = zlib.decompressobj(zlib.MAX_WBITS | 16) + try: + plain = dec.decompress(data[HEADER_LEN:]) + if not dec.eof: + raise ValueError("gzip-Strom unvollstaendig") + views.append(plain) + except Exception as exc: + print("NICHT ENTPACKBAR %s (%s)" % (rel, exc)) for name, needle in needles.items(): - if needle in data: - print("%s in %s" % (name, os.path.relpath(p, root))) + if any(needle in view for view in views): + print("%s in %s" % (name, rel)) PY } @@ -283,12 +348,12 @@ else fail "Pruefsummenpruefung fehlgeschlagen" fi -echo "--- Fall 5: keine Zugangsdaten im Archiv (binaer gesucht) ---" +echo "--- Fall 5: keine Zugangsdaten im Archiv (roh UND im entpackten Rumpf gesucht) ---" HITS=$(grep_secrets "$A") if [ -z "$HITS" ]; then pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs" else - fail "Zugangsdaten im Archiv gefunden: + fail "Zugangsdaten im Archiv gefunden (oder ein Abbild liess sich nicht entpacken): $HITS" fi # Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine @@ -312,6 +377,46 @@ if grep_secrets "$TMPROOT/probe" | grep -q SSID; then else fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos" fi +# Und zwar realistisch: IM komprimierten Rumpf, nicht als Klartext in der Datei. Faende +# die rohe Suche sie, prueften alle Faelle oben eine Welt, die es nicht gibt - an echten +# Abbildern findet eine rohe Suche nie etwas. +if grep_secrets "$TMPROOT/probe" roh | grep -q .; then + fail "Gegenprobe: der Stub legt die Zugangsdaten im Klartext ab - unrealistisches Abbild" +else + pass "Gegenprobe: im Rohbyteinhalt des Stub-Abbilds steht kein Klartext (nur im gzip-Rumpf)" +fi +# Und das Abbild hat wirklich den Aufbau des echten: 56 Byte Kopf, gzip, 512 Byte +# Trailer mit NINL am Anfang. +if python3 - "$TMPROOT/probe/evk" <<'PY' +import os +import sys +import zlib + +d = sys.argv[1] +ota = open(os.path.join(d, "ninlight_evk_ota.img"), "rb").read() +gz = open(os.path.join(d, "ninlight_evk_gz.img"), "rb").read() +assert ota[-512:][:4] == b"NINL", "kein NINL am Trailer-Anfang" +assert ota[:-512] == gz, "_ota.img ist nicht _gz.img + 512 Byte Trailer" +assert gz[56:58] == b"\x1f\x8b", "kein gzip-Strom ab Byte 56" +for raw in (ota, gz): + dec = zlib.decompressobj(zlib.MAX_WBITS | 16) + dec.decompress(raw[56:]) + assert dec.eof, "gzip-Strom unvollstaendig" +PY +then + pass "Gegenprobe: Stub-Abbild hat den echten Aufbau (56 Byte Kopf, gzip, NINL-Trailer)" +else + fail "Gegenprobe: Stub-Abbild hat nicht den Aufbau eines echten Abbilds" +fi + +echo "--- Meldung der Zugangsdatenpruefung nennt Dateien und entpackte Abbilder ---" +# 8 ota + 8 flash + 16 migrate + bruno/ninlight.yml + README.md + onboard.sh = 35, +# davon 32 Abbilder. SHA256SUMS entsteht erst nach der Pruefung. +if echo "$OUT" | grep -qF "sauber (35 Dateien geprueft, davon 32 Abbilder entpackt)"; then + pass "Meldung nennt Dateizahl und Zahl der entpackten Abbilder" +else + fail "Meldung der Zugangsdatenpruefung unerwartet: $(echo "$OUT" | grep -i sauber || echo '(keine sauber-Zeile)')" +fi echo "--- Fall 6a: config.local.cmake nach dem Erfolgslauf unveraendert ---" if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then @@ -413,7 +518,10 @@ else fail "andere Version schlaegt fehl: $OUT" fi -echo "=== Szenario 6: doppelter Boden - Zugangsdaten im Abzug brechen den Lauf ab ===" +echo "=== Szenario 6: doppelter Boden - Zugangsdaten im KOMPRIMIERTEN Rumpf brechen den Lauf ab ===" +# Das ist der Kern: der Stub legt die SSID im gzip-Rumpf jedes Abbilds ab, wie es der +# echte Compiler taete. Ein Pruefkoerper, der nur die Rohbytes durchsucht, laesst diesen +# Lauf anstandslos durch und packt Michaels WLAN-Daten ins Archiv. SANDBOX="$TMPROOT/s6" setup_sandbox "$SANDBOX" setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX" @@ -433,6 +541,60 @@ else pass "die Meldung druckt den Wert nicht, nur den Namen" fi if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi +if echo "$OUT" | grep -q "NINLIGHT_WIFI_SSID in ota/ninlight_evk_ota.img"; then + pass "Meldung nennt den Namen des Wertes und die Datei" +else + fail "Meldung nennt Name und Datei nicht: $OUT" +fi + +echo "=== Szenario 6b: Klartext in einer Nicht-Abbild-Datei bricht den Lauf ab ===" +# Nicht jede Datei im Archiv ist ein Abbild: README, onboard.sh und die Bruno-Collection +# sind Text. Fuer sie bleibt die rohe Bytesuche die einzige Pruefung - sie darf durch das +# Entpacken nicht verlorengehen. +SANDBOX="$TMPROOT/s6b" +setup_sandbox "$SANDBOX" +setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX" +printf '\n# versehentlich notiert: %s\n' "$SECRET_PASS" >>"$SANDBOX/bruno/ninlight.yml" +set +e +OUT=$(TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1) +RC=$? +set -e +if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf war trotz Klartext in bruno/ninlight.yml erfolgreich"; fi +if echo "$OUT" | grep -q "NINLIGHT_WIFI_PASSWORD in bruno/ninlight.yml"; then + pass "Meldung nennt NINLIGHT_WIFI_PASSWORD und die Datei" +else + fail "Meldung nennt den Fund in bruno/ninlight.yml nicht: $OUT" +fi +if echo "$OUT" | grep -qF "$SECRET_PASS"; then + fail "die Meldung druckt das Passwort selbst" +else + pass "die Meldung druckt das Passwort nicht, nur den Namen" +fi +if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi + +echo "=== Szenario 6c: ein nicht entpackbares Abbild bricht den Lauf ab ===" +# Ein abgeschnittener gzip-Strom ist der heimtueckische Fall: decompressobj liefert +# stillschweigend nur den lesbaren Teil. Was dahinter steht, bliebe ungeprueft - und +# genau dort koennte das Passwort liegen. Ungeprueft durchlassen ist keine Option. +SANDBOX="$TMPROOT/s6c" +setup_sandbox "$SANDBOX" +setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX" +set +e +OUT=$(STUB_TRUNC_TARGET=e14_cw TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1) +RC=$? +set -e +if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf mit unentpackbarem Abbild war erfolgreich"; fi +if echo "$OUT" | grep -q "ota/ninlight_e14_cw_ota.img"; then + pass "Meldung nennt die betroffene Datei" +else + fail "Meldung nennt die betroffene Datei nicht: $OUT" +fi +if echo "$OUT" | grep -q "laesst sich nicht entpacken"; then + pass "Meldung sagt, dass das Abbild nicht entpackbar ist" +else + fail "Meldung erklaert den Grund nicht: $OUT" +fi +if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz unentpackbarem Abbild gepackt"; fi echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ===" ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"