Files
Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs
T
Deploymentcenter BotandClaude Opus 5 2388b5abe1 feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback
Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen
verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im
Fehlerfall halb aktualisierte Installationen hinterliessen.

Server
- Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das
  zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version,
  weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System
  zog sich damit das Windows-Paket.
- Aufloesungsregel: je Version das plattformgenaue Paket, sonst das
  plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich
  'any' - lieber kein Update als das falsche.
- manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer,
  wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war.
- Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt
  /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf
  fremden Systemen und darf den Signierschluessel nicht besitzen.

Packager
- Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der
  bestehenden latest.json fehl, ersetzte ein leeres catch die komplette
  Historie durch einen einzigen Eintrag - ohne jede Meldung.
- Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der
  mitgelieferten Beispielkonfiguration nie zu.
- preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am
  Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten
  ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems.
- Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen.
- Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer
  Endlosschleife: Clients aktualisieren, melden weiter die alte Version,
  halten das Release erneut fuer neu.
- --platform mit Ableitung aus dem Publish-Pfad.

Agent
- Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als
  "Atomic Replace with Backup" kommentiert und war eine Kopierschleife.
- Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der
  Vorversion. Was nicht aus einem Release stammt, bleibt liegen.
- Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben.
- API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb
  die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte.
- Installierte Version aus --current-version oder manifest.json statt des
  Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen
  liess. Reparatur funktioniert damit auch ohne manifest.json.
- Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden.

SDK
- ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes
  "update-agent.exe" wird unter Linux nie gefunden.
- LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die
  Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit
  dem Herunterfahren) und --platform.

Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz-
Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen
sich nicht getrennt stagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 19:56:35 +02:00

356 lines
12 KiB
C#

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
#if NETSTANDARD2_0
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters;
#endif
namespace Deploymentcenter.Client;
public class LocalCacheData
{
public int SchemaVersion { get; set; } = StateStore.CurrentSchemaVersion;
public string ProductSlug { get; set; } = string.Empty;
public string LicenseKey { get; set; } = string.Empty;
public string HardwareId { get; set; } = string.Empty;
public string Status { get; set; } = "invalid";
public long IssuedAt { get; set; }
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), 0 wenn unbefristet.</summary>
public long ExpiresAt { get; set; }
/// <summary>
/// Ende der Offline-Gnadenfrist (Unix-Zeit). Getrennt von
/// <see cref="ExpiresAt"/>, weil eine Lizenz bis 2040 laufen kann, die
/// Frist ohne Serverkontakt aber nur ueber die vom Server gemeldeten
/// cache_ttl_hours. Schema 2 kannte das Feld nicht; dort wird die Frist
/// aus IssuedAt abgeleitet.
/// </summary>
public long CacheExpiresAt { get; set; }
/// <summary>Vom Server gemeldete Gnadenfrist in Stunden.</summary>
public int CacheTtlHours { get; set; }
public long MaxSeenTime { get; set; }
public string Checksum { get; set; } = string.Empty;
}
public static class StateStore
{
private static readonly byte[] Magic = Encoding.UTF8.GetBytes("LLS2"); // 4 bytes: 0x4C, 0x4C, 0x53, 0x32
/// <summary>Schema, das dieser Client schreibt.</summary>
public const int CurrentSchemaVersion = 3;
/// <summary>
/// Schemata, die noch gelesen werden. Schema 2 hat keine getrennte
/// Cache-Frist; ein Aufsteigen darf keinen Zwang zur Online-Pruefung
/// ausloesen, nur weil das SDK aktualisiert wurde.
/// </summary>
private static readonly int[] SupportedSchemaVersions = { 2, 3 };
public static LocalCacheData? Load(string productSlug, string hardwareId)
{
try
{
string dir = LicenseConfig.GetStorageDirectory(productSlug);
string statePath = Path.Combine(dir, "state.dat");
if (!File.Exists(statePath))
return null;
byte[] rawFileContent = File.ReadAllBytes(statePath);
if (rawFileContent.Length == 0)
return null;
byte[] payloadBytes = rawFileContent;
// Try DPAPI unwrap on Windows
if (OperatingSystemHelpers.IsWindows())
{
try
{
payloadBytes = ProtectedData.Unprotect(rawFileContent, null, DataProtectionScope.CurrentUser);
}
catch
{
payloadBytes = rawFileContent;
}
}
// Check LLS2 Magic Header
if (payloadBytes.Length >= 4 + 12 + 16 && StartsWithMagic(payloadBytes, Magic))
{
byte[] key = DeriveKey(hardwareId, productSlug);
byte[]? jsonBytes = DecryptAesGcmEnvelope(payloadBytes, key);
if (jsonBytes == null)
return null; // Decryption/Auth failed -> Treat strictly as Cache Miss
string jsonStr = Encoding.UTF8.GetString(jsonBytes);
var data = JsonSerializer.Deserialize<LocalCacheData>(jsonStr);
if (data == null || Array.IndexOf(SupportedSchemaVersions, data.SchemaVersion) < 0)
return null; // Incompatible schema -> Treat as Cache Miss
if (!BelongsHere(data, productSlug, hardwareId))
return null;
return data;
}
// Legacy Migration Check (non-LLS2 file)
//
// Vorher wurde hier beliebiges JSON nach LocalCacheData
// deserialisiert und sofort im LLS2-Format zurueckgeschrieben.
// Passte kein einziges Feld, entstand ein Standardobjekt, das die
// urspruengliche Datei ueberschrieb. Da LicenseLabrador denselben
// Pfad und Dateinamen verwendet - GetStorageDirectory beruecksichtigt
// dafuer eigens LICENSELABRADOR_STORAGE_DIR - zerstoerte das den
// fremden Cache still. Uebernommen wird jetzt nur, was sich als
// Cache genau dieses Produkts auf genau dieser Maschine ausweist.
try
{
string legacyJson = Encoding.UTF8.GetString(payloadBytes);
// Ein Ueberbleibsel im Binaerformat ist kein JSON-Objekt.
if (legacyJson.TrimStart().StartsWith("{", StringComparison.Ordinal))
{
var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
if (legacyData != null && BelongsHere(legacyData, productSlug, hardwareId))
{
legacyData.SchemaVersion = CurrentSchemaVersion;
Save(productSlug, hardwareId, legacyData);
return legacyData;
}
}
}
catch (JsonException) { }
return null;
}
catch
{
return null;
}
}
/// <summary>
/// Prueft, ob ein gelesener Cache tatsaechlich zu diesem Produkt gehoert.
///
/// Fuer LLS2-Dateien ist die Hardware-Bindung bereits durch die
/// Schluesselableitung gegeben - dort faellt die Entschluesselung sonst aus.
/// Entscheidend ist der Produktbezug: ohne ihn wuerde eine fremde
/// state.dat im selben Verzeichnis uebernommen und ueberschrieben.
/// </summary>
private static bool BelongsHere(LocalCacheData data, string productSlug, string hardwareId)
{
if (string.IsNullOrWhiteSpace(data.ProductSlug))
return false;
if (!string.Equals(data.ProductSlug, productSlug, StringComparison.OrdinalIgnoreCase))
return false;
// Ein Cache ohne Schluessel taugt zu nichts und ist meist ein
// Standardobjekt aus einer Datei, die gar keine unsrige war.
if (string.IsNullOrWhiteSpace(data.LicenseKey))
return false;
return true;
}
public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData)
{
try
{
cacheData.SchemaVersion = CurrentSchemaVersion;
string dir = LicenseConfig.GetStorageDirectory(productSlug);
Directory.CreateDirectory(dir);
string statePath = Path.Combine(dir, "state.dat");
string jsonStr = JsonSerializer.Serialize(cacheData);
byte[] jsonBytes = Encoding.UTF8.GetBytes(jsonStr);
byte[] key = DeriveKey(hardwareId, productSlug);
byte[] envelopeBytes = EncryptAesGcmEnvelope(jsonBytes, key);
byte[] finalFileBytes = envelopeBytes;
// Wrap with DPAPI on Windows
if (OperatingSystemHelpers.IsWindows())
{
try
{
finalFileBytes = ProtectedData.Protect(envelopeBytes, null, DataProtectionScope.CurrentUser);
}
catch
{
finalFileBytes = envelopeBytes;
}
}
File.WriteAllBytes(statePath, finalFileBytes);
SetUnixPermissions(statePath, "600");
return true;
}
catch
{
return false;
}
}
private static bool StartsWithMagic(byte[] data, byte[] magic)
{
if (data.Length < magic.Length) return false;
for (int i = 0; i < magic.Length; i++)
{
if (data[i] != magic[i]) return false;
}
return true;
}
private static byte[] DeriveKey(string hardwareId, string productSlug)
{
string ikmStr = $"{hardwareId}:{productSlug}";
byte[] ikm = Encoding.UTF8.GetBytes(ikmStr);
byte[] salt = Encoding.UTF8.GetBytes("LicenseLabradorHKDFSaltV2");
byte[] info = Encoding.UTF8.GetBytes("StateStoreEncryptionKey");
#if NET8_0_OR_GREATER
return HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, info);
#else
var hkdf = new HkdfBytesGenerator(new Org.BouncyCastle.Crypto.Digests.Sha256Digest());
hkdf.Init(new HkdfParameters(ikm, salt, info));
byte[] key = new byte[32];
hkdf.GenerateBytes(key, 0, 32);
return key;
#endif
}
private static byte[] EncryptAesGcmEnvelope(byte[] plaintext, byte[] key)
{
byte[] nonce = new byte[12];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(nonce);
}
#if NET8_0_OR_GREATER
byte[] ciphertext = new byte[plaintext.Length];
byte[] tag = new byte[16];
using (var aes = new AesGcm(key, 16))
{
aes.Encrypt(nonce, plaintext, ciphertext, tag);
}
byte[] envelope = new byte[4 + 12 + ciphertext.Length + 16];
Buffer.BlockCopy(Magic, 0, envelope, 0, 4);
Buffer.BlockCopy(nonce, 0, envelope, 4, 12);
Buffer.BlockCopy(ciphertext, 0, envelope, 16, ciphertext.Length);
Buffer.BlockCopy(tag, 0, envelope, 16 + ciphertext.Length, 16);
return envelope;
#else
var cipher = new GcmBlockCipher(new AesEngine());
var parameters = new AeadParameters(new KeyParameter(key), 128, nonce);
cipher.Init(true, parameters);
byte[] output = new byte[cipher.GetOutputSize(plaintext.Length)];
int len = cipher.ProcessBytes(plaintext, 0, plaintext.Length, output, 0);
cipher.DoFinal(output, len);
// output in BouncyCastle contains Ciphertext + 16-byte Tag
int cipherLen = output.Length - 16;
byte[] ciphertext = new byte[cipherLen];
byte[] tag = new byte[16];
Buffer.BlockCopy(output, 0, ciphertext, 0, cipherLen);
Buffer.BlockCopy(output, cipherLen, tag, 0, 16);
byte[] envelope = new byte[4 + 12 + ciphertext.Length + 16];
Buffer.BlockCopy(Magic, 0, envelope, 0, 4);
Buffer.BlockCopy(nonce, 0, envelope, 4, 12);
Buffer.BlockCopy(ciphertext, 0, envelope, 16, ciphertext.Length);
Buffer.BlockCopy(tag, 0, envelope, 16 + ciphertext.Length, 16);
return envelope;
#endif
}
private static byte[]? DecryptAesGcmEnvelope(byte[] envelope, byte[] key)
{
try
{
if (envelope.Length < 4 + 12 + 16)
return null;
byte[] nonce = new byte[12];
Buffer.BlockCopy(envelope, 4, nonce, 0, 12);
int cipherLen = envelope.Length - 4 - 12 - 16;
byte[] ciphertext = new byte[cipherLen];
Buffer.BlockCopy(envelope, 16, ciphertext, 0, cipherLen);
byte[] tag = new byte[16];
Buffer.BlockCopy(envelope, 16 + cipherLen, tag, 0, 16);
#if NET8_0_OR_GREATER
byte[] plaintext = new byte[cipherLen];
using (var aes = new AesGcm(key, 16))
{
aes.Decrypt(nonce, ciphertext, tag, plaintext);
}
return plaintext;
#else
var cipher = new GcmBlockCipher(new AesEngine());
var parameters = new AeadParameters(new KeyParameter(key), 128, nonce);
cipher.Init(false, parameters);
byte[] input = new byte[ciphertext.Length + 16];
Buffer.BlockCopy(ciphertext, 0, input, 0, ciphertext.Length);
Buffer.BlockCopy(tag, 0, input, ciphertext.Length, 16);
byte[] plaintext = new byte[cipher.GetOutputSize(input.Length)];
int len = cipher.ProcessBytes(input, 0, input.Length, plaintext, 0);
cipher.DoFinal(plaintext, len);
return plaintext;
#endif
}
catch
{
return null; // Auth/Decryption failed
}
}
private static void SetUnixPermissions(string filePath, string mode)
{
if (OperatingSystemHelpers.IsWindows())
return;
#if NET8_0_OR_GREATER
try
{
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch { }
#else
try
{
var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "chmod",
Arguments = $"{mode} \"{filePath}\"",
UseShellExecute = false,
CreateNoWindow = true
});
proc?.WaitForExit();
}
catch { }
#endif
}
}