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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
#if NETSTANDARD2_0
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
#endif
|
||||
|
||||
namespace Deploymentcenter.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Prueft die Signatur eines Releases.
|
||||
///
|
||||
/// Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket
|
||||
/// selbst. Wer den Webroot oder die FTP-Zugangsdaten kontrolliert, tauscht
|
||||
/// beide gemeinsam aus - der Hash schuetzt dann gegen Uebertragungsfehler,
|
||||
/// nicht gegen Manipulation. Ausgerechnet auf dem Pfad, der fremden Code
|
||||
/// ausfuehrt.
|
||||
///
|
||||
/// Bewusst asymmetrisch: bei einem HMAC braeuchte der pruefende Agent
|
||||
/// denselben geheimen Schluessel wie der signierende Server. Der Agent
|
||||
/// laeuft auf Kundensystemen; ein dort ausgelesener Schluessel liesse sich
|
||||
/// zum Signieren beliebiger Pakete verwenden. Der Server signiert daher
|
||||
/// mit einem privaten RSA-Schluessel, der Agent prueft mit dem
|
||||
/// oeffentlichen aus /api/updateservice/v1/pubkey.
|
||||
/// </summary>
|
||||
public static class ReleaseVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Kanonische Darstellung eines Releases - muss zeichengenau der
|
||||
/// serverseitigen Fassung in ReleaseSigner::canonical() entsprechen.
|
||||
/// Signiert wird bewusst diese Zeile und nicht das Manifest-JSON:
|
||||
/// JSON-Ausgabe ist nicht bytestabil (Schluesselreihenfolge, Escaping,
|
||||
/// Zahlenformat), eine Signatur darueber waere unzuverlaessig pruefbar.
|
||||
/// </summary>
|
||||
public static string BuildCanonical(
|
||||
string productSlug,
|
||||
string version,
|
||||
string channel,
|
||||
string platform,
|
||||
string? sha256Hash,
|
||||
string downloadUrl,
|
||||
long sizeBytes)
|
||||
{
|
||||
return string.Join("\n", new[]
|
||||
{
|
||||
"dc-release-v1",
|
||||
productSlug ?? string.Empty,
|
||||
version ?? string.Empty,
|
||||
channel ?? string.Empty,
|
||||
platform ?? string.Empty,
|
||||
(sha256Hash ?? string.Empty).ToLowerInvariant(),
|
||||
downloadUrl ?? string.Empty,
|
||||
sizeBytes.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prueft eine base64-kodierte RSA-SHA256-Signatur gegen einen
|
||||
/// oeffentlichen Schluessel im PEM-Format.
|
||||
/// </summary>
|
||||
public static bool Verify(string canonical, string? signatureBase64, string? publicKeyPem)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signatureBase64) || string.IsNullOrWhiteSpace(publicKeyPem))
|
||||
return false;
|
||||
|
||||
byte[] signature;
|
||||
try
|
||||
{
|
||||
signature = Convert.FromBase64String(signatureBase64!.Trim());
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] data = Encoding.UTF8.GetBytes(canonical);
|
||||
|
||||
try
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem!.ToCharArray());
|
||||
return rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
#else
|
||||
AsymmetricKeyParameter key = PublicKeyFactory.CreateKey(DecodePem(publicKeyPem!));
|
||||
ISigner signer = SignerUtilities.GetSigner("SHA256withRSA");
|
||||
signer.Init(false, key);
|
||||
signer.BlockUpdate(data, 0, data.Length);
|
||||
return signer.VerifySignature(signature);
|
||||
#endif
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ein nicht lesbarer Schluessel oder eine unpassende Signatur
|
||||
// sind kein Sonderfall, sondern schlicht "nicht geprueft".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fingerabdruck eines PEM-Schluessels. Damit erkennt der Agent den
|
||||
/// einmal geholten Schluessel wieder, statt ihm bei jedem Aufruf neu
|
||||
/// zu vertrauen - ein spaeter ausgetauschter Schluessel faellt so auf.
|
||||
/// </summary>
|
||||
public static string Fingerprint(string publicKeyPem)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(publicKeyPem ?? string.Empty));
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#if NETSTANDARD2_0
|
||||
/// <summary>Entfernt Kopf- und Fusszeile eines PEM und dekodiert base64.</summary>
|
||||
private static byte[] DecodePem(string pem)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (string line in pem.Split('\n'))
|
||||
{
|
||||
string trimmed = line.Trim();
|
||||
if (trimmed.Length == 0 || trimmed.StartsWith("-----", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
builder.Append(trimmed);
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(builder.ToString());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user