"""Browserlauf zu Paket W: echter Chromium mit virtuellem Authenticator gegen die Probe-Instanz. Koordinator-Pruefcode. Laeuft unter Windows mit Playwright. Ablauf: mit Passwort und Code anmelden, in "Zugang" einen Schluessel registrieren, abmelden, mit dem Schluessel anmelden (der Zaehler des virtuellen Authenticators muss steigen -- sonst waere er nie gefragt worden), abmelden, wieder mit Code anmelden, Schluessel entfernen. Dazu die Gegenprobe unter 127.0.0.1 und ein Blick in Schluesseldatei und Anmeldeprotokoll der Probe-Instanz. Aufruf: python browser_w.py """ import base64 import hashlib import hmac import json import struct import sys import time from pathlib import Path from playwright.sync_api import sync_playwright PORT = int(sys.argv[1]) ORDNER = Path(sys.argv[2]) ZUGANG = json.loads((ORDNER / "zugang.json").read_text(encoding="utf-8")) KNOPF_SCHLUESSEL = "Mit Sicherheitsschlüssel anmelden" befunde: list[str] = [] schritte: list[str] = [] def totp(geheimnis: str) -> tuple[str, int]: schluessel = base64.b32decode(geheimnis) zaehler = int(time.time() // 30) mac = hmac.new(schluessel, struct.pack(">Q", zaehler), hashlib.sha1).digest() versatz = mac[-1] & 0x0F wert = (struct.unpack(">I", mac[versatz:versatz + 4])[0] & 0x7FFFFFFF) % 1_000_000 return f"{wert:06d}", zaehler def schritt(text: str, ok: bool) -> None: schritte.append(("OK " if ok else "BEFUND ") + text) if not ok: befunde.append(text) def melde_mit_code_an(seite, letzter_zaehler: int) -> int: # Ein Code gilt je Zeitschritt einmal; vor der zweiten Anmeldung den naechsten Schritt abwarten. while int(time.time() // 30) <= letzter_zaehler: time.sleep(1.0) code, zaehler = totp(ZUGANG["geheimnis"]) seite.fill("#anmeldung-benutzer", ZUGANG["benutzer"]) seite.fill("#anmeldung-passwort", ZUGANG["passwort"]) seite.fill("#anmeldung-code", code) seite.get_by_role("button", name="Anmelden", exact=True).click() seite.get_by_role("button", name="Abmelden").wait_for(timeout=15000) return zaehler def main() -> int: with sync_playwright() as p: browser = p.chromium.launch(headless=True) kontext = browser.new_context() seite = kontext.new_page() konsole: list[str] = [] api: list[str] = [] seite.on("console", lambda m: konsole.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None) seite.on("pageerror", lambda e: konsole.append(f"pageerror: {e}")) seite.on("response", lambda r: api.append(f"{r.status} {r.request.method} {r.url.split(str(PORT), 1)[-1]}") if "/api/" in r.url else None) cdp = kontext.new_cdp_session(seite) cdp.send("WebAuthn.enable") authenticator = cdp.send("WebAuthn.addVirtualAuthenticator", {"options": { "protocol": "ctap2", "transport": "usb", "hasResidentKey": False, "hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True, }})["authenticatorId"] def zugaenge() -> list[dict]: return cdp.send("WebAuthn.getCredentials", {"authenticatorId": authenticator})["credentials"] seite.goto(f"http://localhost:{PORT}/") seite.locator("#anmeldung-benutzer").wait_for(timeout=15000) schritt("Anmeldemaske unter http://localhost erreichbar", True) knopf = seite.get_by_role("button", name=KNOPF_SCHLUESSEL) schritt("Knopf fuer den Schluessel ist da und bedienbar", knopf.count() == 1 and knopf.is_enabled()) zaehler = melde_mit_code_an(seite, -1) schritt("Anmeldung mit Passwort und Code", True) seite.get_by_text("Zugang", exact=True).first.click() seite.locator("#zugang-bezeichnung").wait_for(timeout=10000) seite.fill("#zugang-bezeichnung", "Virtueller Schluessel") seite.fill("#zugang-passwort", ZUGANG["passwort"]) seite.get_by_role("button", name="Schlüssel hinzufügen").click() liste = seite.get_by_role("list", name="Sicherheitsschlüssel") liste.wait_for(timeout=15000) schritt("Registrierung: der Schluessel steht in der Liste", "Virtueller Schluessel" in liste.inner_text()) vorher = zugaenge() schritt("Der virtuelle Authenticator haelt genau einen Zugang", len(vorher) == 1) zaehler_vorher = vorher[0]["signCount"] if vorher else -1 # Schluesseldatei ansehen, solange der Schluessel hinterlegt ist. datei = ORDNER / "zugang-schluessel.json" kandidaten = [pfad for pfad in ORDNER.glob("*schluessel*")] schritt(f"Schluesseldatei liegt neben der Zugangsdatei ({[k.name for k in kandidaten]})", bool(kandidaten)) if kandidaten: inhalt = kandidaten[0].read_text(encoding="utf-8") schritt("Schluesseldatei traegt weder Passwort noch TOTP-Geheimnis", ZUGANG["passwort"] not in inhalt and ZUGANG["geheimnis"] not in inhalt) daten = json.loads(inhalt) print("Schluesseldatei, Form:", json.dumps( {k: (sorted(v[0]) if isinstance(v, list) and v and isinstance(v[0], dict) else type(v).__name__) for k, v in (daten.items() if isinstance(daten, dict) else {"liste": daten}.items())}, ensure_ascii=False)[:600]) seite.get_by_role("button", name="Abmelden").click() seite.locator("#anmeldung-benutzer").wait_for(timeout=10000) seite.fill("#anmeldung-benutzer", ZUGANG["benutzer"]) seite.fill("#anmeldung-passwort", ZUGANG["passwort"]) seite.get_by_role("button", name=KNOPF_SCHLUESSEL).click() seite.get_by_role("button", name="Abmelden").wait_for(timeout=15000) schritt("Anmeldung mit Passwort und Sicherheitsschluessel, ohne Code", True) nachher = zugaenge() zaehler_nachher = nachher[0]["signCount"] if nachher else -1 schritt(f"Der Authenticator wurde wirklich gefragt (Zaehler {zaehler_vorher} -> {zaehler_nachher})", zaehler_nachher > zaehler_vorher) # Falsches Passwort auf dem Schluessel-Weg: Ablehnung, keine Sitzung. seite.get_by_role("button", name="Abmelden").click() seite.locator("#anmeldung-benutzer").wait_for(timeout=10000) seite.fill("#anmeldung-benutzer", ZUGANG["benutzer"]) seite.fill("#anmeldung-passwort", "ganz-sicher-falsch-2026") seite.get_by_role("button", name=KNOPF_SCHLUESSEL).click() hinweis = seite.get_by_role("alert") hinweis.wait_for(timeout=10000) schritt("Falsches Passwort auf dem Schluessel-Weg wird abgelehnt", "stimmt nicht" in hinweis.inner_text()) zaehler = melde_mit_code_an(seite, zaehler) schritt("Der Code-Weg geht danach unveraendert", True) seite.get_by_text("Zugang", exact=True).first.click() seite.locator("#zugang-passwort").wait_for(timeout=10000) seite.fill("#zugang-passwort", ZUGANG["passwort"]) seite.get_by_role("button", name="Entfernen").click() seite.get_by_text("Es ist kein Sicherheitsschlüssel hinterlegt").wait_for(timeout=10000) schritt("Entfernen: die Liste ist wieder leer", True) # Gegenprobe unter 127.0.0.1: beginn antwortet mit 409, kein 500, kein leerer Schirm. zweite = kontext.new_page() zweite.goto(f"http://127.0.0.1:{PORT}/") zweite.locator("#anmeldung-benutzer").wait_for(timeout=15000) antwort = zweite.evaluate( """async ([benutzer, passwort]) => { const r = await fetch('/api/anmeldung/schluessel/beginn', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({benutzer, passwort})}); return [r.status, await r.text()]; }""", [ZUGANG["benutzer"], ZUGANG["passwort"]]) schritt(f"Unter 127.0.0.1 antwortet beginn mit 409 ({antwort[0]})", antwort[0] == 409) print("Satz unter 127.0.0.1:", antwort[1][:260]) browser.close() protokoll = ORDNER / "anmeldungen.jsonl" zeilen = [json.loads(z) for z in protokoll.read_text(encoding="utf-8").splitlines() if z.strip()] wege = [z.get("weg") for z in zeilen] print(f"Anmeldeprotokoll: {len(zeilen)} Zeilen, Ausgaenge {[z.get('ausgang') for z in zeilen]}, Wege {wege}") roh = protokoll.read_text(encoding="utf-8") schritt("Anmeldeprotokoll traegt eine Zeile mit weg=schluessel", "schluessel" in wege) schritt("Anmeldeprotokoll traegt weder Passwort noch Geheimnis", ZUGANG["passwort"] not in roh and ZUGANG["geheimnis"] not in roh and "ganz-sicher-falsch" not in roh) boese = [z for z in konsole if "Content Security Policy" in z or "Permissions" in z or "pageerror" in z] schritt(f"Keine CSP-, Permissions-Policy- oder Seitenfehler in der Konsole ({len(boese)})", not boese) fuenfhunderter = [z for z in api if z.startswith("5")] schritt(f"Keine 5xx-Antwort der Schnittstelle ({len(fuenfhunderter)})", not fuenfhunderter) print("--- Schritte") for zeile in schritte: print(zeile) print("--- API-Antworten der Schluessel-Wege") for zeile in api: if "schluessel" in zeile or "anmeldung" in zeile: print(" " + zeile) print("--- Konsole (Fehler und Warnungen)") for zeile in konsole[:12]: print(" " + zeile[:220]) print(f"{len(befunde)} Befund(e): {befunde}") return 2 if befunde else 0 if __name__ == "__main__": sys.exit(main())