Der Stand lag ueber eine Bestandsaufnahme, drei Konzeptpapiere, vier Umsetzungsplaene und zwei Deploymentcenter-Dokumente verteilt - jedes mit eigener Reihenfolge, teils widersprueglich. Alle offenen Punkte daraus sind in docs/Roadmap.md zusammengefuehrt. Aufbau der neuen Roadmap - Statusvokabular: erledigt / beschlossen-offen / Entscheidung noetig / zurueckgestellt / Idee. Damit steht das Geparkte sichtbar drin, statt in einem Konzeptpapier zu verschwinden. - Herkunft-Spalte traegt das alte Kuerzel (S4, K2, B9, T4, F-A1, C1, DC1), damit die archivierten Papiere auffindbar bleiben, ohne sie zu lesen. - Abschnitt 1 Reihenfolge, 2 offene Entscheidungen, 3 die Vorhaben nach Bereich, 4 Ideenspeicher, 5 Chronik des Erledigten, 6 Modell-Einstufung, 7 Herkunftskarte. Was dabei sichtbar wurde - Neun Entscheidungen blockieren Arbeit, ohne dass sie Aufwand kosten - allen voran Matrix oder Rocket.Chat. Sie stehen jetzt gesammelt in Abschnitt 2 statt verstreut in den Diskussionsteilen der Konzepte. - B8 (keine Tiefenbegrenzung bei AgentComm) ist unveraendert offen. Das ist keine Theorie: A haelt sein Gate, waehrend es auf B wartet - ruft B nun A, warten beide bis zum Timeout. Der Testfall A13 dafuer fehlt bis heute. - Die vier Umsetzungsplaene vom 2026-08-05 sind alle unumgesetzt und waren in keiner Roadmap verzeichnet. Archiv Sechs Dokumente ziehen nach docs/archiv/: Bestandsaufnahme, Konzepte Backup/Finanz/Analyse, Linux-Portierung-Analyse, Lizenz-HardwareId-v2 (gegenstandslos - LicenseLabrador ist ersetzt), Deploymentcenter-Review und der 2.4-Integrationsplan. Sie bleiben als Begruendung lesbar, werden aber nicht mehr fortgeschrieben; das README ordnet jedes einzeln ein und warnt, dass ihre Quelltext-Verweise ins Leere gehen koennen. Bauplan bleibt Bauplan Taskboard, Audit, Staging, Memory, Agentenkommunikation, RocketChat und Deploymentcenter-Integration bleiben in docs/ - sie sind die Detailvorgabe fuer die Umsetzung, nicht Vorhabenlisten. Jedes bekommt oben eine Zeile, die seine Rolle und den Umsetzungsstand nennt und auf die Roadmap zeigt. Dasselbe fuer die vier Umsetzungsplaene: die Reihenfolge gilt in der Roadmap, nicht im Plan. Nebenbei repariert: Taskboard-Konzept und Teststrategie verwiesen auf AgentScheduler und ToolJobScheduler, die seit der Scanner-Konsolidierung geloescht sind. Alle Dokument-Verweise in docs/ sind geprueft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
521 lines
23 KiB
Markdown
521 lines
23 KiB
Markdown
# 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.
|