# 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:<plattform>:<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` | `<v1-Hash>` | 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/<name>/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

`<StorageDirectory>/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 = <v2> suchen
   → gefunden: normaler Weg (last_seen, hostname, app_version aktualisieren)

2. nicht gefunden, und legacy_hardware_id ist gesetzt:
   Aktivierung mit hardware_id = <v1> suchen
   → gefunden: UPDATE activations SET hardware_id = <v2>, hwid_version = 2,
               hwid_source = <quelle> 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. **`<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>`** —
   *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/<slug>/license`, sonst `$HOME/.config/<slug>/license`.
4. Windows: `SpecialFolder.ApplicationData` wie heute.
5. Letzter Ausweg: `<AppContext.BaseDirectory>/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.
