commit fd3fe19539b33ff15b04b3c41dc15942707973e1 Author: duffyduck Date: Thu Oct 22 12:32:47 2026 +0200 first commit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e96514f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert. +Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), +Versionierung nach [SemVer](https://semver.org/lang/de/). + +## [Unreleased] + +### Added +- GUI (tkinter) zum Auflisten, Mounten und Aushängen von Partitionen. +- Linux-Backend: Passwort leeren, neues SHA-512-Passwort setzen, Konto entsperren. +- Windows-Backend (chntpw): Passwort leeren, Konto entsperren, zu Admin machen. +- OS-Erkennung (Linux via `/etc/shadow`, Windows via SAM-Hive). +- NTFS-Mount mit `remove_hiberfile` für Windows-Fast-Startup. +- Live-ISO auf Basis von Debian live-build mit Autostart in die GUI. +- Docker-basiertes Build-Skript (`build.sh`), kein Hostbedarf außer Docker. +- NTFS-Dirty-Bit-Erkennung beim Mounten mit Rückfrage und automatischem + `ntfsfix` + erzwungenem Schreib-Mount; zusätzlich eigener Button + „NTFS reparieren (Dirty-Bit)". diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7dc155 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# PC-Unlocker + +Grafisches **Offline-Recovery-Tool** für lokale Benutzerkonten unter **Linux** +und **Windows**, ausgeliefert als bootfähige **Live-ISO**. Gedacht für IT-Admins +und das Zurücksetzen vergessener Passwörter auf Maschinen, zu denen man +physischen Zugang und die Berechtigung hat (eigener Rechner, verwaltete +Firmen-PCs, Konten ausgeschiedener Mitarbeiter). + +Gleiche Kategorie wie `chntpw`, Trinity Rescue Kit oder System Rescue CD. + +## Was es kann + +| | Linux | Windows | +|---|---|---| +| Passwort leeren (blank) | ✅ | ✅ | +| Neues Passwort setzen | ✅ (SHA-512) | ❌ siehe Hinweis | +| Konto entsperren | ✅ | ✅ | +| Zu Admin machen | — | ✅ | + +**Hinweis Windows:** Ein *neues* Passwort direkt in die SAM zu schreiben ist auf +modernen Windows-Versionen unzuverlässig (SYSKEY / geänderte Hash-Formate). +Verlässlicher Weg: **Passwort leeren**, anmelden, in Windows neu vergeben. + +## Grenzen (bewusst) + +Verschlüsselte Datenträger (**BitLocker**, **LUKS**) bleiben geschützt — das Tool +kann die Verschlüsselung nicht umgehen. Ohne den jeweiligen Schlüssel/Recovery-Key +ist die Partition nicht les- oder schreibbar. + +## Bedienung + +1. ISO auf USB-Stick schreiben (siehe unten) und Zielrechner davon booten. +2. Das Live-System startet automatisch in die GUI. +3. Partition wählen → **Öffnen / Mounten** → erkannte Konten erscheinen. +4. Konto wählen → gewünschte Aktion. Vor dem Beenden hängt das Tool alles sauber aus. + +## ISO bauen + +Einzige Voraussetzung auf dem Build-Host: **Docker**. Sonst muss nichts +installiert sein — `live-build` und alle Werkzeuge laufen im Container. + +```bash +./build.sh +# Ergebnis: ./out/pc-unlocker.iso +``` + +Auf USB-Stick schreiben: + +```bash +sudo dd if=out/pc-unlocker.iso of=/dev/sdX bs=4M status=progress conv=fsync +``` + +> Der Container läuft mit `--privileged`, weil `live-build` Loop-Devices und +> Mounts benötigt. + +## App direkt testen (ohne ISO) + +Auf einem Linux-Host mit `python3-tk`, `chntpw`, `ntfs-3g`: + +```bash +sudo python3 app/pc_unlocker.py +``` + +## Projektstruktur + +``` +app/pc_unlocker.py GUI (tkinter) +app/puk/disks.py Datenträger auflisten/mounten +app/puk/linux.py /etc/shadow bearbeiten +app/puk/windows.py SAM via chntpw bearbeiten +config/ live-build Paketlisten + Autostart-Hook +build/Dockerfile Build-Container (Debian + live-build) +build/build-iso.sh Build-Logik im Container +build.sh Host-Einstieg: Docker bauen + ISO erzeugen +``` + +## Rechtlicher Hinweis + +Nur auf Systemen einsetzen, die dir gehören oder für die du ausdrücklich +autorisiert bist. Der unbefugte Zugriff auf fremde Computer ist strafbar. diff --git a/app/__pycache__/pc_unlocker.cpython-311.pyc b/app/__pycache__/pc_unlocker.cpython-311.pyc new file mode 100644 index 0000000..05b2dba Binary files /dev/null and b/app/__pycache__/pc_unlocker.cpython-311.pyc differ diff --git a/app/pc_unlocker.py b/app/pc_unlocker.py new file mode 100755 index 0000000..418eb35 --- /dev/null +++ b/app/pc_unlocker.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""PC-Unlocker -- grafisches Offline-Recovery fuer lokale Konten. + +Legitimer Einsatz: Zuruecksetzen von Passwoertern auf Maschinen, zu denen man +physischen Zugang und die Berechtigung hat (eigener Rechner, verwaltete +Firmen-PCs, Konten ausgeschiedener Mitarbeiter). Verschluesselte Datentraeger +(BitLocker/LUKS) bleiben geschuetzt -- dieses Tool kann sie nicht umgehen. + +Benoetigt root und ein X-Display (laeuft im Live-System automatisch). +""" + +from __future__ import annotations + +import os +import sys +import threading +import tkinter as tk +from tkinter import messagebox, simpledialog, ttk + +from puk import __version__, disks, linux, windows + + +class App(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title(f"PC-Unlocker {__version__}") + self.geometry("820x600") + self.minsize(720, 520) + + self.parts: list[disks.Partition] = [] + self.current: disks.Partition | None = None + + self._build_ui() + self.protocol("WM_DELETE_WINDOW", self._on_close) + self.after(200, self.refresh_disks) + + # ---------------------------------------------------------------- UI + def _build_ui(self) -> None: + pad = dict(padx=8, pady=4) + + bar = ttk.Frame(self) + bar.pack(fill="x", **pad) + ttk.Button(bar, text="Datentraeger neu einlesen", + command=self.refresh_disks).pack(side="left") + ttk.Button(bar, text="Oeffnen / Mounten", + command=self.open_selected).pack(side="left", padx=6) + ttk.Button(bar, text="Aushaengen", + command=self.close_selected).pack(side="left") + ttk.Button(bar, text="NTFS reparieren (Dirty-Bit)", + command=self.clean_selected).pack(side="left", padx=6) + + body = ttk.Panedwindow(self, orient="horizontal") + body.pack(fill="both", expand=True, **pad) + + left = ttk.Labelframe(body, text="Partitionen") + self.disk_list = tk.Listbox(left, exportselection=False) + self.disk_list.pack(fill="both", expand=True, padx=4, pady=4) + self.disk_list.bind("<>", lambda e: None) + body.add(left, weight=1) + + right = ttk.Labelframe(body, text="Benutzerkonten") + self.user_list = tk.Listbox(right, exportselection=False) + self.user_list.pack(fill="both", expand=True, padx=4, pady=4) + body.add(right, weight=1) + + act = ttk.Frame(self) + act.pack(fill="x", **pad) + self.btn_blank = ttk.Button(act, text="Passwort leeren (blank)", + command=self.do_blank, state="disabled") + self.btn_set = ttk.Button(act, text="Neues Passwort setzen", + command=self.do_set, state="disabled") + self.btn_unlock = ttk.Button(act, text="Konto entsperren", + command=self.do_unlock, state="disabled") + self.btn_admin = ttk.Button(act, text="Zu Admin machen (Windows)", + command=self.do_admin, state="disabled") + for b in (self.btn_blank, self.btn_set, self.btn_unlock, self.btn_admin): + b.pack(side="left", padx=4) + + logf = ttk.Labelframe(self, text="Protokoll") + logf.pack(fill="both", expand=False, **pad) + self.log = tk.Text(logf, height=8, state="disabled", wrap="word") + self.log.pack(fill="both", expand=True, padx=4, pady=4) + + self._log("PC-Unlocker gestartet. Datentraeger auswaehlen und mounten.") + + def _log(self, msg: str) -> None: + self.log.configure(state="normal") + self.log.insert("end", msg + "\n") + self.log.see("end") + self.log.configure(state="disabled") + + # ------------------------------------------------------------- Disks + def refresh_disks(self) -> None: + disks.unmount_all(self.parts) + self.current = None + self.user_list.delete(0, "end") + self._set_actions(linux_os=False, windows_os=False) + try: + self.parts = disks.list_partitions() + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Fehler", f"lsblk fehlgeschlagen:\n{exc}") + return + self.disk_list.delete(0, "end") + for p in self.parts: + self.disk_list.insert("end", p.display) + self._log(f"{len(self.parts)} Partition(en) gefunden.") + + def _selected_part(self) -> disks.Partition | None: + sel = self.disk_list.curselection() + if not sel: + return None + return self.parts[sel[0]] + + def open_selected(self) -> None: + p = self._selected_part() + if not p: + messagebox.showinfo("Hinweis", "Bitte eine Partition auswaehlen.") + return + try: + mp = disks.mount(p) + except disks.DirtyVolume as exc: + self._log(f"NTFS unsauber: {exc}") + if not messagebox.askyesno( + "NTFS unsauber (Dirty-Bit)", + f"{p.path} wurde nicht ordnungsgemaess ausgehaengt " + "(Dirty-Bit / Windows Fast-Startup).\n\n" + "Dirty-Bit jetzt bereinigen (ntfsfix) und erneut " + "schreibend mounten?"): + return + if not self._fix_and_mount(p): + return + mp = p.our_mount + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Mount fehlgeschlagen", str(exc)) + return + self.current = p + self.disk_list.delete(self.disk_list.curselection()[0]) + self.disk_list.insert(self.parts.index(p), p.display) + self._log(f"Gemountet: {p.path} -> {mp} (OS: {p.os_kind or 'unbekannt'})") + self._load_users() + + def _fix_and_mount(self, p: disks.Partition) -> bool: + """ntfsfix ausfuehren und anschliessend erzwungen RW mounten.""" + try: + out = disks.fix_ntfs(p) + self._log("ntfsfix: " + (out.splitlines()[-1] if out else "ok")) + disks.mount(p, force=True) + self._log(f"Nach Reparatur schreibend gemountet: {p.path} -> {p.our_mount}") + return True + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Reparatur fehlgeschlagen", str(exc)) + return False + + def clean_selected(self) -> None: + """NTFS-Dirty-Bit einer ausgewaehlten (ausgehaengten) Partition bereinigen.""" + p = self._selected_part() + if not p: + messagebox.showinfo("Hinweis", "Bitte eine Partition auswaehlen.") + return + if p.fstype != "ntfs": + messagebox.showinfo("Hinweis", "Nur fuer NTFS-Partitionen noetig.") + return + if p.our_mount: + disks.unmount(p) + self._log(f"Fuer Reparatur ausgehaengt: {p.path}") + if not messagebox.askyesno( + "NTFS reparieren", + f"Dirty-Bit auf {p.path} loeschen (ntfsfix)?"): + return + try: + out = disks.fix_ntfs(p) + self._log("ntfsfix: " + (out or "ok")) + messagebox.showinfo("Fertig", + "Dirty-Bit bereinigt. Partition kann nun " + "schreibend gemountet werden.") + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Reparatur fehlgeschlagen", str(exc)) + self.refresh_disks() + + def close_selected(self) -> None: + p = self._selected_part() + if p: + disks.unmount(p) + self._log(f"Ausgehaengt: {p.path}") + if self.current is p: + self.current = None + self.user_list.delete(0, "end") + self._set_actions(linux_os=False, windows_os=False) + self.refresh_disks() + + # ------------------------------------------------------------- Users + def _load_users(self) -> None: + self.user_list.delete(0, "end") + p = self.current + if not p: + return + try: + if p.os_kind == "linux": + self._users = linux.list_users(p.our_mount) + self._set_actions(linux_os=True, windows_os=False) + elif p.os_kind == "windows": + self._users = windows.list_users(p.our_mount) + self._set_actions(linux_os=False, windows_os=True) + else: + self._users = [] + self._set_actions(linux_os=False, windows_os=False) + self._log("Kein unterstuetztes OS auf dieser Partition erkannt.") + return + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Fehler beim Lesen der Benutzer", str(exc)) + return + for u in self._users: + self.user_list.insert("end", u.display) + self._log(f"{len(self._users)} Benutzerkonto(en) gelesen.") + + def _set_actions(self, linux_os: bool, windows_os: bool) -> None: + self.btn_blank.configure(state="normal" if (linux_os or windows_os) else "disabled") + self.btn_unlock.configure(state="normal" if (linux_os or windows_os) else "disabled") + self.btn_set.configure(state="normal" if linux_os else "disabled") + self.btn_admin.configure(state="normal" if windows_os else "disabled") + + def _selected_user(self): + sel = self.user_list.curselection() + if not sel: + messagebox.showinfo("Hinweis", "Bitte ein Benutzerkonto auswaehlen.") + return None + return self._users[sel[0]] + + # ----------------------------------------------------------- Actions + def _run(self, fn, ok_msg: str) -> None: + """Aktion ausfuehren, UI-Thread sauber halten, danach Benutzer neu laden.""" + def worker(): + try: + fn() + self.after(0, lambda: self._log("OK: " + ok_msg)) + except Exception as exc: # noqa: BLE001 + self.after(0, lambda: messagebox.showerror("Fehler", str(exc))) + finally: + self.after(0, self._load_users) + threading.Thread(target=worker, daemon=True).start() + + def do_blank(self) -> None: + u = self._selected_user() + if not u or not self.current: + return + if not messagebox.askyesno( + "Bestaetigen", + f"Passwort von '{u.name}' wirklich leeren?"): + return + mnt = self.current.our_mount + if self.current.os_kind == "linux": + self._run(lambda: linux.blank_password(mnt, u.name), + f"{u.name}: Passwort geleert.") + else: + self._run(lambda: windows.blank_password(mnt, u.name), + f"{u.name}: Passwort geleert.") + + def do_set(self) -> None: + u = self._selected_user() + if not u or not self.current: + return + if self.current.os_kind != "linux": + return + pw = simpledialog.askstring( + "Neues Passwort", f"Neues Passwort fuer '{u.name}':", show="*") + if pw is None: + return + if not pw: + messagebox.showinfo("Hinweis", "Leer = benutze stattdessen 'Passwort leeren'.") + return + mnt = self.current.our_mount + self._run(lambda: linux.set_password(mnt, u.name, pw), + f"{u.name}: neues Passwort gesetzt.") + + def do_unlock(self) -> None: + u = self._selected_user() + if not u or not self.current: + return + mnt = self.current.our_mount + if self.current.os_kind == "linux": + self._run(lambda: linux.unlock(mnt, u.name), f"{u.name}: entsperrt.") + else: + self._run(lambda: windows.unlock(mnt, u.name), f"{u.name}: entsperrt/aktiviert.") + + def do_admin(self) -> None: + u = self._selected_user() + if not u or not self.current or self.current.os_kind != "windows": + return + if not messagebox.askyesno( + "Bestaetigen", f"'{u.name}' zum Administrator machen?"): + return + mnt = self.current.our_mount + self._run(lambda: windows.promote_admin(mnt, u.name), + f"{u.name}: zur Administratorengruppe hinzugefuegt.") + + # -------------------------------------------------------------- Exit + def _on_close(self) -> None: + disks.unmount_all(self.parts) + self.destroy() + + +def main() -> int: + if os.geteuid() != 0: + msg = "PC-Unlocker benoetigt root-Rechte (z.B. 'sudo pc_unlocker.py')." + try: + r = tk.Tk(); r.withdraw() + messagebox.showerror("Root erforderlich", msg) + except Exception: # noqa: BLE001 + print(msg, file=sys.stderr) + return 1 + App().mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/puk/__init__.py b/app/puk/__init__.py new file mode 100644 index 0000000..2775084 --- /dev/null +++ b/app/puk/__init__.py @@ -0,0 +1,3 @@ +"""PC-Unlocker: Offline-Recovery fuer lokale Linux- und Windows-Konten.""" + +__version__ = "0.1.0" diff --git a/app/puk/__pycache__/__init__.cpython-311.pyc b/app/puk/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6a7ab46 Binary files /dev/null and b/app/puk/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/puk/__pycache__/disks.cpython-311.pyc b/app/puk/__pycache__/disks.cpython-311.pyc new file mode 100644 index 0000000..b487a41 Binary files /dev/null and b/app/puk/__pycache__/disks.cpython-311.pyc differ diff --git a/app/puk/__pycache__/linux.cpython-311.pyc b/app/puk/__pycache__/linux.cpython-311.pyc new file mode 100644 index 0000000..a648e7a Binary files /dev/null and b/app/puk/__pycache__/linux.cpython-311.pyc differ diff --git a/app/puk/__pycache__/windows.cpython-311.pyc b/app/puk/__pycache__/windows.cpython-311.pyc new file mode 100644 index 0000000..20da056 Binary files /dev/null and b/app/puk/__pycache__/windows.cpython-311.pyc differ diff --git a/app/puk/disks.py b/app/puk/disks.py new file mode 100644 index 0000000..5a9bce1 --- /dev/null +++ b/app/puk/disks.py @@ -0,0 +1,186 @@ +"""Blockgeräte auflisten, mounten und wieder aushängen. + +Alle Operationen setzen root-Rechte voraus (Offline-Recovery läuft aus einem +Live-System). NTFS wird über ntfs-3g gemountet; Fast-Startup/Hibernation von +Windows wird mit ``remove_hiberfile`` behandelt, sonst verweigert ntfs-3g das +Schreiben. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field + + +class DirtyVolume(RuntimeError): + """NTFS konnte nicht RW gemountet werden (Dirty-Bit / Fast-Startup / Hibernation). + + Das Volume laesst sich nach einem ``fix_ntfs()`` in aller Regel wieder + schreibbar einhaengen. + """ + + +# Textbausteine, an denen ntfs-3g einen unsauberen Zustand meldet +_DIRTY_MARKERS = ( + "unclean", + "was not safely closed", + "dirty", + "hibernat", + "metadata kept in windows cache", + "falling back to read-only", + "windows is in", +) + + +@dataclass +class Partition: + path: str # z.B. /dev/sda2 + size: str # menschenlesbar, z.B. "256G" + fstype: str # ext4, ntfs, vfat, ... + label: str # Datenträgerbezeichnung, kann leer sein + mountpoint: str # aktueller Mountpunkt laut lsblk (meist leer) + our_mount: str = "" # von uns gesetzter Mountpunkt + os_kind: str = "" # "linux", "windows" oder "" (unbekannt/nicht gemountet) + + @property + def display(self) -> str: + bits = [self.path, self.size or "?", self.fstype or "?"] + if self.label: + bits.append(f"[{self.label}]") + if self.os_kind: + bits.append(f"-> {self.os_kind.upper()}") + return " ".join(bits) + + +def list_partitions() -> list[Partition]: + """Alle mountbaren Partitionen zurueckgeben (ohne Swap/leere FS).""" + out = subprocess.run( + ["lsblk", "-J", "-b", "-o", + "PATH,SIZE,FSTYPE,LABEL,MOUNTPOINT,TYPE"], + capture_output=True, text=True, check=True, + ).stdout + data = json.loads(out) + parts: list[Partition] = [] + + def walk(nodes): + for n in nodes: + fstype = n.get("fstype") or "" + ntype = n.get("type") or "" + if ntype in ("part", "lvm", "crypt") and fstype not in ("", "swap"): + parts.append(Partition( + path=n.get("path") or "", + size=_human(n.get("size")), + fstype=fstype, + label=n.get("label") or "", + mountpoint=n.get("mountpoint") or "", + )) + if n.get("children"): + walk(n["children"]) + + walk(data.get("blockdevices", [])) + return parts + + +def _human(nbytes) -> str: + try: + n = int(nbytes) + except (TypeError, ValueError): + return "" + for unit in ("B", "K", "M", "G", "T", "P"): + if n < 1024 or unit == "P": + return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}" + n /= 1024 + return "" + + +def mount(part: Partition, force: bool = False) -> str: + """Partition read-write mounten und OS-Typ erkennen. Gibt Mountpunkt zurueck. + + Bei NTFS wird ein unsauberer Zustand (Dirty-Bit / Fast-Startup / + Hibernation) als :class:`DirtyVolume` gemeldet, damit die Oberflaeche + nachfragen kann. Mit ``force=True`` wird nach einem ``fix_ntfs()`` ein + erzwungener Schreib-Mount versucht. + """ + if part.our_mount and os.path.ismount(part.our_mount): + return part.our_mount + + mp = tempfile.mkdtemp(prefix="puk-") + if part.fstype == "ntfs": + # remove_hiberfile: raeumt hiberfile.sys weg (Fast-Startup/Hibernation). + # force: Schreib-Mount trotz gesetztem Dirty-Flag erzwingen. + opts = "rw,remove_hiberfile" + if force: + opts += ",force" + cmd = ["mount", "-t", "ntfs-3g", "-o", opts, part.path, mp] + else: + cmd = ["mount", "-o", "rw", part.path, mp] + + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + os.rmdir(mp) + err = (res.stderr or res.stdout or "").strip() + if part.fstype == "ntfs" and _looks_dirty(err): + raise DirtyVolume(err or "NTFS-Volume ist unsauber (Dirty-Bit).") + raise RuntimeError(err or f"mount fehlgeschlagen ({part.path})") + + part.our_mount = mp + part.os_kind = detect_os(mp) + return mp + + +def _looks_dirty(message: str) -> bool: + low = message.lower() + return any(m in low for m in _DIRTY_MARKERS) + + +def fix_ntfs(part: Partition) -> str: + """Dirty-Bit loeschen und NTFS-Journal zuruecksetzen (ntfsfix). + + Darf nur auf einem *nicht* gemounteten Volume laufen. Gibt die Ausgabe + von ntfsfix zurueck. + """ + if part.our_mount and os.path.ismount(part.our_mount): + raise RuntimeError("Volume ist noch gemountet -- erst aushaengen.") + if shutil.which("ntfsfix") is None: + raise RuntimeError("ntfsfix nicht gefunden (Paket ntfs-3g).") + res = subprocess.run(["ntfsfix", "-d", part.path], + capture_output=True, text=True) + out = (res.stdout + res.stderr).strip() + if res.returncode != 0: + raise RuntimeError(out or "ntfsfix fehlgeschlagen") + return out + + +def detect_os(mountpoint: str) -> str: + if os.path.exists(os.path.join(mountpoint, "etc", "shadow")): + return "linux" + # Windows: SAM-Hive, Gross-/Kleinschreibung variiert je nach FS + for cfg in ("Windows/System32/config", "WINDOWS/system32/config"): + if os.path.exists(os.path.join(mountpoint, cfg, "SAM")): + return "windows" + return "" + + +def unmount(part: Partition) -> None: + if part.our_mount and os.path.ismount(part.our_mount): + subprocess.run(["umount", part.our_mount], + capture_output=True, text=True) + if part.our_mount and os.path.isdir(part.our_mount): + try: + os.rmdir(part.our_mount) + except OSError: + pass + part.our_mount = "" + part.os_kind = "" + + +def unmount_all(parts: list[Partition]) -> None: + for p in parts: + try: + unmount(p) + except Exception: + pass diff --git a/app/puk/linux.py b/app/puk/linux.py new file mode 100644 index 0000000..5f937ce --- /dev/null +++ b/app/puk/linux.py @@ -0,0 +1,142 @@ +"""Linux-Konten auf einer gemounteten Partition zuruecksetzen. + +Arbeitet direkt auf ``/etc/shadow``. Vor jeder Aenderung wird eine +Sicherungskopie ``shadow.puk.bak`` abgelegt. Passwort-Hashes werden lokal mit +``crypt`` (SHA-512) erzeugt. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from dataclasses import dataclass + +try: + import crypt # entfernt in Python 3.13 + _HAVE_CRYPT = True +except ImportError: # pragma: no cover + _HAVE_CRYPT = False + + +def _hash_password(password: str) -> str: + """SHA-512 crypt-Hash erzeugen; Fallback ueber openssl, falls crypt fehlt.""" + if _HAVE_CRYPT: + return crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512)) + res = subprocess.run( + ["openssl", "passwd", "-6", "-stdin"], + input=password, capture_output=True, text=True, check=True, + ) + return res.stdout.strip() + + +@dataclass +class LinuxUser: + name: str + uid: int + locked: bool + has_password: bool + shell: str + + @property + def display(self) -> str: + flags = [] + if self.locked: + flags.append("gesperrt") + if not self.has_password: + flags.append("kein PW") + tail = f" ({', '.join(flags)})" if flags else "" + return f"{self.name} uid={self.uid} {self.shell}{tail}" + + +def _shadow_path(mnt: str) -> str: + return os.path.join(mnt, "etc", "shadow") + + +def _passwd_path(mnt: str) -> str: + return os.path.join(mnt, "etc", "passwd") + + +def list_users(mnt: str) -> list[LinuxUser]: + """Login-faehige Konten aus passwd/shadow lesen.""" + shells: dict[str, tuple[int, str]] = {} + with open(_passwd_path(mnt), encoding="utf-8", errors="replace") as fh: + for line in fh: + f = line.rstrip("\n").split(":") + if len(f) >= 7: + shells[f[0]] = (int(f[2] or -1), f[6]) + + users: list[LinuxUser] = [] + with open(_shadow_path(mnt), encoding="utf-8", errors="replace") as fh: + for line in fh: + f = line.rstrip("\n").split(":") + if len(f) < 2: + continue + name, pw = f[0], f[1] + uid, shell = shells.get(name, (-1, "")) + # System-/Dienstkonten ausblenden, echte Logins zeigen + is_login = (uid == 0) or (uid >= 1000) or shell.endswith( + ("/bash", "/sh", "/zsh", "/fish")) + if not is_login: + continue + locked = pw.startswith("!") or pw.startswith("*") + has_pw = bool(pw) and not locked and pw not in ("!", "*", "!!") + users.append(LinuxUser(name, uid, locked, has_pw, shell)) + users.sort(key=lambda u: (u.uid != 0, u.uid)) + return users + + +def _rewrite(mnt: str, username: str, new_field: str) -> None: + path = _shadow_path(mnt) + shutil.copy2(path, path + ".puk.bak") + with open(path, encoding="utf-8", errors="replace") as fh: + lines = fh.readlines() + + now_days = int(time.time() // 86400) + out = [] + found = False + for line in lines: + f = line.rstrip("\n").split(":") + if f and f[0] == username: + found = True + f[1] = new_field + # Feld 3 = letzte Aenderung; aktualisieren, Rest auffuellen + while len(f) < 9: + f.append("") + f[2] = str(now_days) + out.append(":".join(f) + "\n") + else: + out.append(line) + if not found: + raise RuntimeError(f"Benutzer {username} nicht in shadow gefunden") + + tmp = path + ".puk.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + fh.writelines(out) + os.chmod(tmp, 0o640) + os.replace(tmp, path) + + +def blank_password(mnt: str, username: str) -> None: + """Passwortfeld leeren -> Login ohne Passwort moeglich.""" + _rewrite(mnt, username, "") + + +def set_password(mnt: str, username: str, password: str) -> None: + """Neues SHA-512-Passwort setzen.""" + _rewrite(mnt, username, _hash_password(password)) + + +def unlock(mnt: str, username: str) -> None: + """Fuehrendes ! / * entfernen (Konto entsperren) ohne Passwort zu aendern.""" + path = _shadow_path(mnt) + with open(path, encoding="utf-8", errors="replace") as fh: + lines = fh.readlines() + for line in lines: + f = line.rstrip("\n").split(":") + if f and f[0] == username: + pw = f[1].lstrip("!*") + _rewrite(mnt, username, pw) + return + raise RuntimeError(f"Benutzer {username} nicht gefunden") diff --git a/app/puk/windows.py b/app/puk/windows.py new file mode 100644 index 0000000..2233bb0 --- /dev/null +++ b/app/puk/windows.py @@ -0,0 +1,116 @@ +"""Windows-Konten ueber die SAM-Hive bearbeiten (via chntpw). + +Verlaesslich moeglich: Passwort blanken, Konto entsperren/aktivieren, Benutzer +zum Administrator machen. Ein *neues* Passwort direkt zu schreiben ist auf +modernen Windows-Versionen unzuverlaessig (SYSKEY / geaenderte Hash-Formate), +deshalb bietet dieses Tool das bewusst nicht an -- der sichere Weg ist blanken +und das Passwort danach in Windows selbst neu zu vergeben. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from dataclasses import dataclass + + +@dataclass +class WindowsUser: + rid: str + name: str + is_admin: bool + is_locked: bool + is_disabled: bool + pw_blank: bool + + @property + def display(self) -> str: + flags = [] + if self.is_admin: + flags.append("Admin") + if self.is_locked: + flags.append("gesperrt") + if self.is_disabled: + flags.append("deaktiviert") + if self.pw_blank: + flags.append("kein PW") + tail = f" ({', '.join(flags)})" if flags else "" + return f"{self.name} [RID {self.rid}]{tail}" + + +def _config_dir(mnt: str) -> str: + for cfg in ("Windows/System32/config", "WINDOWS/system32/config"): + p = os.path.join(mnt, cfg) + if os.path.exists(os.path.join(p, "SAM")): + return p + raise RuntimeError("SAM-Hive nicht gefunden (kein Windows auf dieser Partition?)") + + +def _sam(mnt: str) -> str: + return os.path.join(_config_dir(mnt), "SAM") + + +def list_users(mnt: str) -> list[WindowsUser]: + """Benutzer aus der SAM lesen (chntpw -l).""" + sam = _sam(mnt) + res = subprocess.run(["chntpw", "-l", sam], + capture_output=True, text=True) + users: list[WindowsUser] = [] + # Zeilen: | 01f4 | Administrator | ADMIN | dis/lock | + row = re.compile(r"\|\s*([0-9a-fA-F]+)\s*\|\s*(.+?)\s*\|\s*(ADMIN)?\s*\|\s*(.*?)\s*\|") + for line in res.stdout.splitlines(): + m = row.match(line) + if not m: + continue + rid, name, admin, status = m.groups() + status = (status or "").lower() + users.append(WindowsUser( + rid=rid, + name=name, + is_admin=bool(admin), + is_locked="lock" in status, + is_disabled="dis" in status, + pw_blank="*blank*" in status or "blank" in status, + )) + if not users: + raise RuntimeError("Keine Benutzer in der SAM gelesen.\n" + + (res.stderr.strip() or res.stdout.strip())) + return users + + +def _drive(mnt: str, username: str, menu_choices: str) -> None: + """chntpw interaktiv fuer einen Benutzer steuern. + + menu_choices: Folge der Menuepunkte (z.B. '1' blanken, '2' entsperren, + '3' Admin). Anschliessend 'q' (Benutzer verlassen) und 'y' (Hive schreiben). + """ + sam = _sam(mnt) + shutil.copy2(sam, sam + ".puk.bak") + + # chntpw liest pro Zeile eine Eingabe. Nach den Auswahlpunkten 'q' und + # zum Schluss 'y' zum Speichern der Aenderungen. + stdin = "".join(c + "\n" for c in menu_choices) + "q\ny\n" + res = subprocess.run( + ["chntpw", "-u", username, sam], + input=stdin, capture_output=True, text=True, + ) + low = (res.stdout + res.stderr).lower() + if "error" in low and "writing" in low: + raise RuntimeError(res.stdout + res.stderr) + # chntpw gibt bei Erfolg typ. "Hives that have changed:" / "writing sam" + if res.returncode != 0 and "edit complete" not in low: + raise RuntimeError(res.stdout + res.stderr or "chntpw fehlgeschlagen") + + +def blank_password(mnt: str, username: str) -> None: + _drive(mnt, username, "1") + + +def unlock(mnt: str, username: str) -> None: + _drive(mnt, username, "2") + + +def promote_admin(mnt: str, username: str) -> None: + _drive(mnt, username, "3") diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..8ab80c9 --- /dev/null +++ b/build.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# PC-Unlocker ISO bauen -- einziger Hostbedarf: Docker. +# +# ./build.sh baut Image und ISO -> ./out/pc-unlocker.iso +# ./build.sh --no-cache erzwingt Neubau des Docker-Images +# +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +IMAGE="pc-unlocker-builder" +OUT="$HERE/out" + +CACHE="" +[ "${1:-}" = "--no-cache" ] && CACHE="--no-cache" + +if ! command -v docker >/dev/null 2>&1; then + echo "Fehler: Docker ist nicht installiert." >&2 + exit 1 +fi + +echo "==> Docker-Build-Image erstellen" +docker build $CACHE -t "$IMAGE" -f "$HERE/build/Dockerfile" "$HERE" + +mkdir -p "$OUT" + +echo "==> ISO im Container bauen (benoetigt --privileged fuer loop/mount)" +docker run --rm \ + --privileged \ + -v "$OUT:/output" \ + "$IMAGE" + +echo +echo "Fertig. ISO liegt unter: $OUT/pc-unlocker.iso" +echo "Auf einen USB-Stick schreiben z.B. mit:" +echo " sudo dd if=$OUT/pc-unlocker.iso of=/dev/sdX bs=4M status=progress conv=fsync" diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..de6a00c --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,23 @@ +# Build-Umgebung fuer die PC-Unlocker Live-ISO. +# Es muss auf dem Host-System NICHTS ausser Docker installiert sein. +FROM debian:bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + live-build \ + ca-certificates \ + debootstrap \ + xorriso \ + isolinux \ + syslinux-common \ + squashfs-tools \ + apt-utils \ + && rm -rf /var/lib/apt/lists/* + +# Projektinhalte in die Image-Vorlage legen +COPY config/ /src/config/ +COPY app/ /src/app/ +COPY build/build-iso.sh /usr/local/bin/build-iso.sh +RUN chmod +x /usr/local/bin/build-iso.sh + +WORKDIR /work +ENTRYPOINT ["/usr/local/bin/build-iso.sh"] diff --git a/build/build-iso.sh b/build/build-iso.sh new file mode 100755 index 0000000..d026d7f --- /dev/null +++ b/build/build-iso.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Laeuft IM Container. Konfiguriert live-build, legt unsere Dateien ein und +# baut die ISO. Ergebnis landet in /output (per Volume vom Host gemountet). +set -euo pipefail + +WORK=/work +OUT=/output +mkdir -p "$WORK" "$OUT" +cd "$WORK" + +echo "==> live-build konfigurieren" +lb config \ + --distribution bookworm \ + --architectures amd64 \ + --binary-images iso-hybrid \ + --debian-installer none \ + --memtest none \ + --apt-indices false \ + --apt-recommends false \ + --archive-areas "main contrib non-free non-free-firmware" \ + --bootappend-live "boot=live components quiet noautologin" + +echo "==> Paketlisten und Hooks einlegen" +cp -r /src/config/package-lists/* config/package-lists/ 2>/dev/null || true +mkdir -p config/hooks/normal +cp -r /src/config/hooks/normal/* config/hooks/normal/ 2>/dev/null || true + +echo "==> Anwendung in das Live-System einlegen (/opt/pc-unlocker)" +mkdir -p config/includes.chroot/opt/pc-unlocker +cp -r /src/app/* config/includes.chroot/opt/pc-unlocker/ + +echo "==> ISO bauen (kann je nach Netzwerk einige Minuten dauern)" +lb build + +echo "==> Ergebnis nach ${OUT} kopieren" +ISO=$(ls -1 *.iso | head -n1) +cp -v "$ISO" "$OUT/pc-unlocker.iso" +echo "==> Fertig: ${OUT}/pc-unlocker.iso" diff --git a/config/hooks/normal/0100-autostart.hook.chroot b/config/hooks/normal/0100-autostart.hook.chroot new file mode 100755 index 0000000..f31b424 --- /dev/null +++ b/config/hooks/normal/0100-autostart.hook.chroot @@ -0,0 +1,47 @@ +#!/bin/sh +# Live-System so einrichten, dass es direkt in die PC-Unlocker-GUI bootet. +# Laeuft im chroot waehrend des live-build. +set -e + +# 1) Autologin als root auf tty1 +mkdir -p /etc/systemd/system/getty@tty1.service.d +cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf <<'EOF' +[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin root --noclear %I $TERM +EOF + +# 2) Nach dem Login auf tty1 automatisch X starten +cat > /root/.bash_profile <<'EOF' +# Nur auf der ersten Konsole und wenn X noch nicht laeuft +if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then + exec startx +fi +EOF + +# 3) X startet openbox, openbox startet die Anwendung +cat > /root/.xinitrc <<'EOF' +#!/bin/sh +exec openbox-session +EOF +chmod +x /root/.xinitrc + +mkdir -p /root/.config/openbox +cat > /root/.config/openbox/autostart <<'EOF' +# Bildschirm nicht abschalten +xset s off -dpms & +# Hintergrund neutral +xsetroot -solid "#20252b" & +# PC-Unlocker; bei Absturz Fallback-Terminal +( pc-unlocker || xterm ) & +EOF + +# 4) Launcher +cat > /usr/local/bin/pc-unlocker <<'EOF' +#!/bin/sh +cd /opt/pc-unlocker +exec python3 pc_unlocker.py "$@" +EOF +chmod +x /usr/local/bin/pc-unlocker + +echo "pc-unlocker autostart hook done." diff --git a/config/package-lists/pc-unlocker.list.chroot b/config/package-lists/pc-unlocker.list.chroot new file mode 100644 index 0000000..804c4f7 --- /dev/null +++ b/config/package-lists/pc-unlocker.list.chroot @@ -0,0 +1,24 @@ +# Pakete im Live-System (Debian live-build, Sektion chroot) + +# Minimales X + leichter Fenstermanager fuer die GUI +xserver-xorg +xinit +openbox + +# Python + Tk fuer die Anwendung +python3 +python3-tk + +# Recovery-Werkzeuge +chntpw +ntfs-3g +dosfstools +util-linux +lvm2 +cryptsetup +gdisk +parted + +# Hilfreiches +xterm +console-setup diff --git a/out/pc-unlocker.iso b/out/pc-unlocker.iso new file mode 100644 index 0000000..5014c7d Binary files /dev/null and b/out/pc-unlocker.iso differ