# Hardware-ID v2 — Implementierungsvorschlag Stand: 2026-08-06. Betrifft `LicenseLabrador` (Client + Server) und die Aufrufseite in ClawdDotNet ([Services/LicenseGate.cs](Services/LicenseGate.cs)). Anlass: Für den [Linux-Umzug](Linux-Portierung-Analyse.md) muss die Hardware-Bindung auf beiden Plattformen funktionieren. Bei der Durchsicht sind dabei zwei Probleme aufgefallen, die **nichts mit Linux zu tun haben**, aber denselben Code betreffen — die sollten in einem Zug mit erledigt werden. --- ## 1. Befund ### 1.1 Der Rechnername steckt im Hash — das ist das eigentliche Problem [HardwareId.cs:43](../../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs): ```csharp rawBuilder.Append(Environment.MachineName); // "for system isolation" ``` Folge: **Ein umbenannter Rechner ist eine neue Maschine.** Er verbraucht einen weiteren Aktivierungsplatz, und der alte bleibt für immer belegt (`max_activations` ist standardmäßig 2 — nach zwei Umbenennungen ist die Lizenz dicht). Das gilt bereits heute unter Windows. Unter Linux wird daraus ein Totalausfall: In einem Container ist der Hostname standardmäßig die gekürzte Container-ID, also **bei jedem Start ein anderer**. Die Lizenz wäre nach dem zweiten `docker run` verbraucht. Die Absicht („system isolation") ist auch nicht erfüllt: Der Rechnername steht ohnehin im Feld `hostname`, das der Server bei jeder Prüfung mitschreibt ([LicenseService.php:112](../../../LicenseLabrador/server/src/LicenseService.php)). Diagnostisch verlieren wir nichts, wenn er aus dem Hash verschwindet. ### 1.2 Die MAC-Ausweichlösung ist unter Linux instabil [HardwareId.cs:86](../../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs) nimmt die alphabetisch erste physische MAC. Unter Linux: - Die Stoppwortliste kennt `docker` und `veth`, aber **nicht** `br-` (Bridges), `virbr` (libvirt), `cni`, `flannel`, `cali` (Kubernetes), `zt` (ZeroTier). - `NetworkInterfaceType` meldet unter Linux für die meisten virtuellen Geräte schlicht `Ethernet` — die Typprüfung greift also nicht. - Bridge- und veth-MACs werden von systemd **je Boot neu zufällig** vergeben. Sortiert man solche Adressen mit, wechselt die Hardware-ID beim Neustart. Die Ausweichlösung ist damit unter Linux schlimmer als keine. ### 1.3 Der Zustandsspeicher fällt still auf Klartext zurück [StateStore.cs:41](../../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs) beim Lesen und `:90` beim Schreiben: ```csharp try { decryptedData = ProtectedData.Unprotect(rawData, null, ...); } catch { decryptedData = rawData; } // ← Klartext wird akzeptiert ``` Unter Linux wirft DPAPI immer, also läuft alles über den Klartextzweig. Zwei Folgen: - `SECURITY.md` behauptet, der Cache sei „strikt an die `hardware_id` gebunden". Das stimmt für die *Hülle* (die Prüfung in [LicenseClient.cs:198](../../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs)), nicht für die Cache-Datei selbst. - Ernster: `max_seen_time` ist die Uhr-Rückdreh-Sperre ([StateStore.cs:107](../../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)). Wer eine `state.dat` von Hand schreiben kann, setzt den Wert auf 0 und stellt die Systemuhr zurück. Der Klartext-Rückfall beim **Lesen** macht das möglich, und zwar auf jeder Plattform, auf der DPAPI nicht greift. ### 1.4 Ablageort bricht bei einem systemd-Dienst weg [LicenseConfig.cs:22](../../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseConfig.cs) verwendet `Environment.GetFolderPath(SpecialFolder.ApplicationData)`. Läuft der Dienst unter `User=clawd` ohne Heimatverzeichnis, ist `HOME` nicht gesetzt und `GetFolderPath` liefert einen **leeren String**. `Path.Combine("", slug, "license")` ergibt einen relativen Pfad — die Lizenz landet im Arbeitsverzeichnis oder gar nicht. ### 1.5 Kein Formatkennzeichen, keine Plattformangabe Die Hardware-ID ist heute ein nackter SHA-256-Hex-String. Es gibt keine Möglichkeit, im Server zu erkennen, aus welcher Quelle oder von welchem Betriebssystem eine Aktivierung stammt — und keinen Weg, das Format je zu wechseln, ohne alle bestehenden Aktivierungen zu verlieren. **Randnotiz:** `OperatingSystemHelpers.IsWindows()` nutzt `Environment.OSVersion.Platform == PlatformID.Win32NT`. Das funktioniert zufällig richtig (Linux liefert `Unix`), ist aber die veraltete API. `RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` ist in netstandard2.0 verfügbar und der korrekte Weg. --- ## 2. Zielbild: das Format ``` 2::<64 Hex-Zeichen> Beispiele: 2:win:9f3ab7c1… (Windows, MachineGuid) 2:lin:41e0d5aa… (Linux, /etc/machine-id) 2:lin:7c9182ff… (Linux, Vorgabe per Umgebungsvariable) ``` 68 Zeichen — passt in `activations.hardware_id VARCHAR(128)` ohne Schemaänderung. Der Doppelpunkt ist unproblematisch, die Spalte ist `utf8mb4_unicode_ci` und wird nur verglichen. Der Hash selbst: ``` sha256( "LicenseLabrador-HWID-v2" ‖ "\n" ‖ plattform ‖ "\n" ‖ quelle ‖ "\n" ‖ rohwert ) ``` - **Kein `MachineName`.** (1.1) - Die Domänenzeichenkette verhindert, dass derselbe Rohwert in anderem Zusammenhang wiederverwendbar ist. - `quelle` geht mit in den Hash: Findet der Client später eine bessere Quelle, ändert sich die ID bewusst und nachvollziehbar, statt zufällig. Zusätzlich gehen drei neue Felder mit in die Anfrage — **nicht** in den Hash, nur zur Diagnose und für die Migration: | Feld | Beispiel | Zweck | |---|---|---| | `hwid_version` | `2` | Formaterkennung serverseitig | | `hwid_source` | `machine-id` | Admin sieht, wie stabil die Bindung ist | | `legacy_hardware_id` | `` | Migration ohne Platzverlust (Abschnitt 4) | --- ## 3. Quellen je Plattform Reihenfolge = Priorität. Die erste Quelle, die einen nichtleeren, plausiblen Wert liefert, gewinnt. ### 3.1 Vorgabe (alle Plattformen, höchste Priorität) ``` LicenseConfig.HardwareIdOverride (Code) LICENSELABRADOR_HWID (Umgebungsvariable) ``` Quelle: `override`. Der Rohwert wird trotzdem gehasht, damit das Format einheitlich bleibt. **Das ist der ehrliche Weg für Container und Serverbetrieb.** Heuristik kann dort nicht gewinnen — in einem Container gibt es keine Hardware, an die man binden könnte. Der Betreiber setzt einen stabilen Wert, hinterlegt ihn im Deployment-Geheimnis, und die Bindung ist so verlässlich wie dieser Wert. Eine Zeile in der systemd-Unit statt eines Ratespiels. ### 3.2 Windows | # | Quelle | `hwid_source` | |---|---|---| | 1 | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (Registry64) | `machine-guid` | | 2 | Stabile physische MAC (Abschnitt 3.4) | `mac` | | 3 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` | Unverändert zu heute — nur ohne `MachineName` im Hash. ### 3.3 Linux | # | Quelle | `hwid_source` | Anmerkung | |---|---|---|---| | 1 | `/etc/machine-id` | `machine-id` | Von systemd bei der Installation erzeugt, überlebt Neustarts und Kernel-Updates. Die richtige Wahl auf einem echten System. | | 2 | `/var/lib/dbus/machine-id` | `dbus-machine-id` | Ältere Systeme ohne systemd. | | 3 | `/sys/class/dmi/id/product_uuid` | `dmi-uuid` | SMBIOS-UUID, echte Hardware-Bindung. **Meist nur für root lesbar** (`0400`) — Versuch in `try` einpacken, kein Fehler wenn nicht lesbar. Bei VMs vom Hypervisor gesetzt und dort stabil. | | 4 | Stabile physische MAC (Abschnitt 3.4) | `mac` | | | 5 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` | | Zwei Fallen bei `/etc/machine-id`, die geprüft werden müssen: - **Leer oder nur Zeilenumbruch.** Auf Systemen mit `systemd-firstboot` oder in manchen Images existiert die Datei, ist aber leer. Muss als „nicht vorhanden" behandelt werden, nicht als gültiger Wert — sonst haben *alle* diese Installationen dieselbe ID. - **Der Wert `uninitialized`.** Genau diese Zeichenkette schreibt systemd, wenn die ID im laufenden Betrieb noch nicht festgelegt ist. Ebenfalls verwerfen. ```csharp private static bool IsPlausibleMachineId(string? v) => !string.IsNullOrWhiteSpace(v) && v.Trim().Length >= 16 && !v.Trim().Equals("uninitialized", StringComparison.OrdinalIgnoreCase) && v.Trim().Trim('0').Length > 0; // nicht alles Nullen ``` ### 3.4 MAC-Ausweichlösung, überarbeitet Die heutige Fassung nimmt `FirstOrDefault()` der sortierten Liste. Wenn eine Schnittstelle dazukommt oder wegfällt, kann sich damit die gewählte MAC ändern. Besser: **alle** gültigen MACs sortiert verketten — dann ändert sich der Wert nur, wenn sich die Netzwerkausstattung wirklich ändert, und nicht schon, weil eine Adresse hinzukommt, die vorher sortiert davor lag. Stoppwortliste erweitern um: `br-`, `virbr`, `cni`, `flannel`, `cali`, `weave`, `zt`, `tailscale`, `ipsec`, `sit`, `gre`, `dummy`, `bond`, `macvlan`, `ovs`. Zusätzlich hart ausschließen (unabhängig vom Namen): - Schnittstellen mit gesetztem **„locally administered"-Bit** (zweites Bit des ersten Oktetts, `mac[0] & 0x02`). Genau das setzt systemd bei zufällig erzeugten MACs für veth und Bridges. Ein sauberer, namensunabhängiger Filter — und der wirksamste von allen. - Unter Linux zusätzlich prüfen: existiert `/sys/class/net//device`? Fehlt das Verzeichnis, hat die Schnittstelle kein physisches Gerät und ist virtuell. Das ist zuverlässiger als jede Namensliste. ```csharp // Namensunabhängig: zufällig erzeugte MACs tragen dieses Bit. private static bool IsLocallyAdministered(PhysicalAddress addr) { var b = addr.GetAddressBytes(); return b.Length > 0 && (b[0] & 0x02) != 0; } ``` ### 3.5 Erzeugte Datei als letzte Stufe `/machine.key` — 32 Zufallsbytes, Base64, Dateirechte `0600`. Wird nur angelegt, wenn keine Quelle davor greift. Das ist eine **Installations-** und keine Hardware-Bindung. Für einen Container ohne Vorgabe ist das aber die Wahrheit, und mit einem gemounteten Datenverzeichnis bleibt sie über Container-Neustarts stabil. `hwid_source` = `keyfile` macht dem Admin sichtbar, dass diese Aktivierung schwächer gebunden ist als die anderen. Wichtig: Die Datei gehört ins **Datenverzeichnis**, nicht neben die Programmdatei. Sonst ist sie bei jedem Deployment weg. --- ## 4. Migration v1 → v2 ohne Platzverlust Der Kern: Der Client kennt **beide** IDs und schickt beide mit. Der Server zieht die alte Aktivierung auf die neue ID um, statt eine zweite anzulegen. **Client** — `HardwareId` bekommt neben `GetHardwareId()` (v2) ein `GetLegacyHardwareId()`, das die heutige v1-Berechnung *unverändert* beibehält (inklusive `MachineName`, damit sie zu bestehenden Aktivierungen passt). Beides geht in die Anfrage: ```csharp hardware_id = "2:lin:41e0…", legacy_hardware_id = "8fa2…", // nur solange v1-Aktivierungen existieren hwid_version = 2, hwid_source = "machine-id", ``` **Server** — in `LicenseService::validate`, an der Stelle der heutigen Suche ([LicenseService.php:100](../../../LicenseLabrador/server/src/LicenseService.php)): ``` 1. Aktivierung mit hardware_id = suchen → gefunden: normaler Weg (last_seen, hostname, app_version aktualisieren) 2. nicht gefunden, und legacy_hardware_id ist gesetzt: Aktivierung mit hardware_id = suchen → gefunden: UPDATE activations SET hardware_id = , hwid_version = 2, hwid_source = WHERE id = … + audit_log-Eintrag 'hwid_migrated' → weiter wie unter 1. KEIN neuer Platz verbraucht. 3. weder noch: neue Aktivierung anlegen, max_activations prüfen (wie heute) ``` Damit wandern alle bestehenden Windows-Installationen beim ersten Start nach dem Update lautlos auf v2 — niemand merkt etwas, kein Aktivierungsplatz geht verloren. Das `legacy_hardware_id`-Feld kann nach einer Übergangszeit (etwa zwei Veröffentlichungen) aus dem Client fallen. ### 4.1 Der lokale Cache muss einmal verworfen werden Nicht übersehen: Die Hardware-ID geht in zwei weitere Berechnungen ein — `CalculateHmac(state, licenseKey, _hardwareId)` für die Prüfsumme in `LicenseResult` ([LicenseClient.cs:272](../../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs)) und den Seed des Speicherschutzes (`:287`). Nach dem Formatwechsel schlägt `VerifyChecksum` für jede zwischengespeicherte Hülle fehl. Das ist kein Fehler, sondern erwartet — muss aber als **Cache-Fehltreffer** behandelt werden (einmal online neu prüfen), nicht als `TamperSuspected`. Sonst sperrt sich jede bestehende Installation beim ersten Start nach dem Update selbst aus. Der Weg dorthin: Cache-Version im `LocalCacheData` mitführen (`schema_version: 2`) und einen Datensatz mit abweichender Version verwerfen, bevor die Prüfsumme überhaupt geprüft wird. ### 4.2 Schemaerweiterung ```sql ALTER TABLE activations ADD COLUMN hwid_version TINYINT NOT NULL DEFAULT 1 AFTER hardware_id, ADD COLUMN hwid_source VARCHAR(32) NULL AFTER hwid_version, ADD COLUMN platform VARCHAR(8) NULL AFTER hwid_source; ``` Alles mit Vorgabewerten, also rückwärtskompatibel — ein alter Client, der die Felder nicht schickt, funktioniert unverändert weiter. --- ## 5. Umzug Windows → Linux Das ist etwas anderes als die Formatmigration: hier wechselt die Maschine wirklich, die ID muss sich also ändern. Drei Wege, alle drei sinnvoll parallel: ### 5.1 Der Normalfall braucht gar nichts `max_activations` ist standardmäßig **2**. Ein Windows-Entwicklungsrechner und ein Linux-Server passen also ohne jeden Eingriff hinein. Für den anstehenden Umzug ist das wahrscheinlich die ganze Antwort — die anderen beiden Punkte sind für den Fall danach. ### 5.2 Abmelden vor dem Umzug (existiert, aber nicht erreichbar) `LicenseService::deactivate` löscht die Aktivierungszeile und gibt den Platz frei ([LicenseService.php:153](../../../LicenseLabrador/server/src/LicenseService.php)), und `LicenseClient.DeactivateAsync` ruft es auf. In ClawdDotNet ist die Methode aber nur über [LicenseGate.cs:39](Services/LicenseGate.cs) erreichbar und dort an die GUI gebunden. Nachzuliefern: ein Kommandozeilenschalter am Host, damit das auch ohne Oberfläche geht. ```bash clawddotnet --license-deactivate ``` Das braucht der kopflose Betrieb ohnehin (siehe [Linux-Portierung-Analyse.md](Linux-Portierung-Analyse.md), 2.6 — der Lizenzdialog ist ein `MessageBox`, der einen Dienst blockieren würde). ### 5.3 Umbinden aus der Verwaltung (fehlt noch) Für den Fall, dass die alte Maschine schon weg ist: In `public/admin/license_detail.php` je Aktivierungszeile eine Schaltfläche **„Aktivierung freigeben"** (löscht die Zeile, gibt den Platz frei). Ein echtes „Umbinden" auf eine bekannte neue ID ist unnötig — Freigeben plus Neuaktivierung auf dem Zielsystem ist derselbe Vorgang mit weniger Code und einer klareren Spur im Prüfprotokoll. Beides sollte in `audit_log` landen, mit altem und neuem Wert. --- ## 6. Zustandsspeicher härten Zusammen mit dem HW-ID-Umbau, weil dieselbe Datei betroffen ist und die Verschlüsselung den HW-ID als Schlüsselmaterial braucht. **Format** — feste Hülle statt „mal so, mal so": ``` Magic "LLS2" (4 Byte) │ Nonce (12) │ Ciphertext │ GCM-Tag (16) ``` - **AES-GCM**, Schlüssel abgeleitet aus HW-ID + `ProductSlug` per HKDF-SHA256. - Auf Windows das Ergebnis **zusätzlich** in DPAPI wickeln (Gürtel und Hosenträger, kostet nichts). - Dateirechte `0600` auf Unix. **Der entscheidende Punkt: den Klartext-Rückfall beim Lesen entfernen.** Eine Datei, die sich nicht entschlüsseln oder nicht authentifizieren lässt, ist **kein Cache** — sie wird verworfen und der Client prüft online. Nicht als Klartext akzeptieren. Genau dieser Rückfall macht heute die Uhr-Rückdreh-Sperre umgehbar (1.3). Einmalig weiterhin lesbar bleiben muss das alte Format (Datei ohne `LLS2`-Magic): einlesen, in v2 neu schreiben, fertig. Nach einer Veröffentlichung kann der Pfad weg. ### 6.1 netstandard2.0 hat kein AesGcm — Empfehlung: mehrfach zielen `System.Security.Cryptography.AesGcm` gibt es erst ab .NET Core 3.0, `HKDF` erst ab .NET 5, `File.SetUnixFileMode` erst ab .NET 7. Das Projekt zielt heute auf `netstandard2.0`. Zwei Wege: 1. **`netstandard2.0;net8.0`** — *empfohlen*. ClawdDotNet (net10.0) zieht automatisch das net8.0-Ziel und bekommt `AesGcm`, `HKDF` und `File.SetUnixFileMode` ohne Umwege. Der netstandard2.0-Zweig bleibt für andere Abnehmer erhalten und nutzt dort BouncyCastle. Kosten: ein paar `#if NET8_0_OR_GREATER`-Blöcke an genau drei Stellen. 2. **Durchgängig BouncyCastle** (`GcmBlockCipher`, `HkdfBytesGenerator`) — die Bibliothek ist mit `BouncyCastle.Cryptography` bereits als Abhängigkeit da, also kein neues Paket. Kein Mehrfachziel nötig, aber die Dateirechte bleiben ein Problem: `chmod` müsste per P/Invoke laufen. Weg 1 ist sauberer, weil er nebenbei das Dateirechte-Problem löst. --- ## 7. Ablageort (1.4) Auflösungskette in `LicenseConfig.StorageDirectory`, erste nutzbare gewinnt: 1. Explizit gesetzter Wert (ClawdDotNet setzt ihn künftig — der Host hat ohnehin eine eigene XDG-Auflösung). 2. `LICENSELABRADOR_STORAGE_DIR`. 3. Unix: `$XDG_CONFIG_HOME//license`, sonst `$HOME/.config//license`. 4. Windows: `SpecialFolder.ApplicationData` wie heute. 5. Letzter Ausweg: `/license`. **Und in jedem Fall: nie einen leeren Pfad durchlassen.** Der heutige Code kann `Path.Combine("", …)` erzeugen, ohne dass es auffällt. Ein `if (string.IsNullOrEmpty(...)) throw` an dieser Stelle ist besser als eine Lizenzdatei, die im Arbeitsverzeichnis landet und beim nächsten Start nicht mehr gefunden wird. --- ## 8. Änderungsliste ### LicenseLabrador — Client | Datei | Was | |---|---| | `HardwareId.cs` | Neuschreiben: v2-Format, Quellenkette je Plattform, `GetLegacyHardwareId()`, `HwidSource`/`Platform` als Eigenschaften, MAC-Filter (locally-administered-Bit, `/sys/class/net/*/device`), Plausibilitätsprüfung für machine-id, `machine.key`-Erzeugung | | `LicenseConfig.cs` | `HardwareIdOverride`, Auflösungskette für `StorageDirectory`, leeren Pfad ausschließen | | `StateStore.cs` | `LLS2`-Hülle, AES-GCM, `schema_version`, **Klartext-Rückfall beim Lesen entfernen**, v1-Einmalmigration, `0600` | | `LicenseClient.cs` | Neue Felder in `validate`/`deactivate` senden; Cache mit abweichender `schema_version` als Fehltreffer behandeln, **nicht** als `TamperSuspected` | | `OperatingSystemHelpers` | `RuntimeInformation.IsOSPlatform`, dazu `IsLinux()`/`IsMacOs()` | | `LicenseLabrador.Client.csproj` | `netstandard2.0;net8.0` | ### LicenseLabrador — Server | Datei | Was | |---|---| | `sql/schema.sql` + Migrationsskript | `hwid_version`, `hwid_source`, `platform` | | `src/LicenseService.php` | `legacy_hardware_id` entgegennehmen; Migrationssuche (Abschnitt 4); neue Felder speichern | | `src/Audit.php` | Ereignisart `hwid_migrated`, `activation_released` | | `public/admin/license_detail.php` | Quelle/Plattform je Aktivierung anzeigen, „Aktivierung freigeben" | | `docs/SECURITY.md` | Aussage zur Cache-Bindung korrigieren (1.3) | ### ClawdDotNet | Datei | Was | |---|---| | [Services/LicenseGate.cs](Services/LicenseGate.cs) | `StorageDirectory` explizit setzen; `MessageBox`/`frm_License` hinter eine Schnittstelle (`ILicensePrompt`) legen, damit der kopflose Host eine Konsolenfassung einsetzen kann | | Host (neu) | `--license-deactivate`, `--license-set-key`, `--license-status` | | `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Format v2 und Migrationsweg nachtragen | --- ## 9. Testplan Das Wichtigste zuerst — die Fälle, die heute schiefgehen würden: | Fall | Erwartung | |---|---| | Rechner umbenennen | **ID unverändert** (Kern von 1.1) | | Container zweimal starten, `/etc/machine-id` im Abbild | beide Male dieselbe ID | | Container ohne `machine-id`, Datenverzeichnis gemountet | ID über Neustarts stabil, `hwid_source = keyfile` | | Container ohne `machine-id`, **ohne** Mount | ID wechselt — muss so sein, und im Protokoll erkennbar | | `LICENSELABRADOR_HWID` gesetzt | gewinnt gegen alles, `hwid_source = override` | | `/etc/machine-id` leer bzw. `uninitialized` | wird verworfen, nächste Quelle greift | | Docker-Bridge und veth vorhanden, keine machine-id | MAC-Wahl ignoriert sie, ID über Neustart stabil | | Bestehende v1-Windows-Aktivierung, Client aktualisiert | Zeile wird auf v2 umgeschrieben, `max_activations` unverändert, Prüfprotokolleintrag | | v1-Cache-Datei nach dem Update | einmal online geprüft, dann v2-Cache — **kein** `TamperSuspected` | | `state.dat` von Hand mit `max_seen_time = 0` | Datei wird verworfen, Uhr-Rückdreh-Sperre bleibt wirksam | | systemd-Dienst ohne `HOME` | Ablageort auflösbar, keine Datei im Arbeitsverzeichnis | | Alter Client gegen neuen Server | funktioniert unverändert (Felder haben Vorgabewerte) | | Neuer Client gegen alten Server | funktioniert, Zusatzfelder werden ignoriert | Die letzten beiden Zeilen sind nicht optional: Client und Server werden nicht gleichzeitig ausgerollt. --- ## 10. Aufwand | Block | PT | |---|---:| | `HardwareId` v2 samt Quellenkette, MAC-Filter, `machine.key` | 2–3 | | Client mehrfach zielen + `StateStore`-Härtung | 2–3 | | Server: Migrationssuche, Schema, Prüfprotokoll, Verwaltungsansicht | 2–3 | | ClawdDotNet: `ILicensePrompt`, Lizenz-Kommandozeile | 1–2 | | Tests (Container-Fälle brauchen echtes Docker) und Abnahme | 1–2 | | **Summe** | **8–13** | Das ist mehr als die 3–5 PT, die in der Linux-Analyse für „Lizenz" standen — weil dort nur die Plattformverträglichkeit gerechnet war. Die Punkte 1.1 und 1.3 sind bestehende Fehler, die unabhängig vom Umzug behoben werden sollten; sie machen den Unterschied aus. Der Block ist **unabhängig vom übrigen Linux-Umzug** und kann sofort beginnen — er hängt an keiner der offenen GUI-Entscheidungen. --- ## 11. Was ich anders machen würde als heute — kurz begründet Drei Entscheidungen im Vorschlag verdienen eine Begründung, weil sie vom bisherigen Ansatz abweichen: **Rechnername raus.** Er ist der Grund, warum die heutige Bindung fragiler ist als nötig, und er trägt nichts bei, was `activations.hostname` nicht schon festhält. Eine Bindung, die bei einer Umbenennung bricht, bindet nicht an Hardware, sondern an eine Konfiguration. **Vorgabe per Umgebungsvariable statt besserer Heuristik für Container.** Man kann eine Container-Umgebung nicht sinnvoll erraten — es gibt dort keine Hardware. Jede zusätzliche Heuristik verschiebt nur, wo es falsch wird. Eine explizite Vorgabe ist ein bewusster Betreiberentscheid, in der Unit-Datei sichtbar, im Prüfprotokoll nachvollziehbar. **Kein Klartext-Rückfall, auch nicht „zur Sicherheit".** Der heutige Rückfall sollte Robustheit bringen, kostet aber genau die Eigenschaft, für die der Cache existiert. Ein verworfener Cache bedeutet: einmal online prüfen. Das ist der mildere Schaden — und wer keine Verbindung hat, hat immer noch die Offline-Gnadenfrist aus der signierten Hülle, die von dieser Datei nicht abhängt.