fix(releases): Lizenzschluessel nicht mehr im Klartext, Selbsttest, Zielorte

Vier Befunde aus einer externen Durchsicht der 2.4-Integration.

1. Die .htpasswd war eine Klartext-Kundenliste
   Das htpasswd-Format hasht nur die Passwortspalte. Benutzername UND Passwort
   waren der Lizenzschluessel - der Schluessel stand also im Klartext direkt
   neben seinem eigenen bcrypt-Hash, und der Hash war Dekoration. Geschuetzt
   hat das Ganze nur die FilesMatch-Regel in derselben Datei.
   Der Benutzername wird jetzt abgeleitet: lic_<sha256(schluessel), 16 Hex>.
   Die Datei enthaelt damit nur noch eine Einwegableitung und einen Hash ueber
   einen hochentropen Schluessel.
   Server und SDK muessen dabei zeichengenau uebereinstimmen; ein Test prueft
   die C#-Ableitung gegen die PHP-Formel.

2. Ein Formatwechsel blieb unbemerkt liegen
   Beim Umbau auf 1. faellt auf: reconcile() sah keinen Anlass zur
   Neuerzeugung, die Dateien behielten das alte Format, waehrend die Clients
   bereits das neue schickten. Die erzeugten Dateien tragen deshalb jetzt eine
   Formatkennung; weicht sie ab, wird neu erzeugt.

3. Doku beschrieb Nginx, der Schutz ist Apache-only
   .htaccess wird von Nginx ignoriert - dort waeren die Verzeichnisse offen und
   die .htpasswd oeffentlich abrufbar. Die Statusanzeige pruefte nur, ob die
   Dateien existieren, und haette in dem Fall "GESCHUETZT" gemeldet.
   Neu: ein echter Selbsttest ruft die eigene Paket-Adresse OHNE Zugangsdaten
   ab und erwartet 401. Er laeuft beim manuellen Erzeugen und nach jeder
   automatischen Neuerzeugung; das Ergebnis steht in der Oberflaeche, ein
   Fehlschlag im Log. Er findet nebenbei auch abgeschaltetes AllowOverride und
   Tippfehler in der erzeugten Datei. Doku korrigiert, Nginx-Vorlage ergaenzt.

4. Erstinstallation schrieb an einen Ort, an dem Linux-Anwendungen nicht lesen
   setup.json-Ziele waren immer installationsrelativ. Eine Anwendung, die sich
   unter Linux richtig verhaelt, liest aus $XDG_CONFIG_HOME - /opt/<app> ist
   fuer den Dienstbenutzer meist nicht schreibbar. Der Installer legte die
   Datei also dorthin, wo nie jemand nachsieht.
   Ziele haben jetzt ein "location": install (Vorgabe), config, data, home,
   plus ${VAR}- und %VAR%-Ersetzung in "file". Unbekannte Variablen bleiben
   stehen statt leer zu werden - ein Platzhalter faellt auf, ein falscher Pfad
   nicht. Der Installer gibt den aufgeloesten Pfad aus, weil bei config das
   Konto entscheidet, unter dem er laeuft.

Ausserdem
- Doku zeigte "status": "ok" fuer update/delete; Http::ok() erzeugt
  "status": "success".
- UPGRADE §16.1 deckte Neuprodukte nicht ab: Fuer ein Produkt ohne Release
  existiert /releases/<slug>/ nicht und wird uebersprungen. Das Verzeichnis
  entsteht erst mit dem ersten Upload, der naechste Tick schuetzt es. Der erste
  ausgelieferte Build muss die Zugangsdaten also schon mitbringen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 11:47:26 +02:00
co-authored by Claude Opus 5
parent ceb977187e
commit 7a3a5dad69
11 changed files with 684 additions and 31 deletions
+211 -8
View File
@@ -47,6 +47,18 @@ final class ReleaseGuard
/** Von Apache verstandene Hash-Praefixe. */
private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$'];
/**
* Leitet den Basic-Auth-Benutzernamen aus einem Lizenzschluessel ab.
*
* MUSS zeichengenau mit ReleaseCredentials.FromLicenseKey() im .NET-SDK
* uebereinstimmen - weichen die beiden voneinander ab, kommt niemand mehr
* an seine Updates.
*/
public static function licenseUsername(string $licenseKey): string
{
return 'lic_' . substr(hash('sha256', trim($licenseKey)), 0, 16);
}
/** Wurzelverzeichnis der Releases im Dateisystem. */
public static function releasesRoot(): string
{
@@ -103,10 +115,15 @@ final class ReleaseGuard
}
}
// Auch der Weg ueber die Oberflaeche vermerkt das Format - sonst
// sieht der naechste Tick eine Formataenderung, die es nicht gibt.
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
Logger::info('Release-Zugangsschutz neu erzeugt', [
'projects' => $result['projects'],
'entries' => $result['entries'],
'errors' => count($result['errors']),
'format' => self::FORMAT_VERSION,
]);
return $result;
@@ -165,9 +182,16 @@ final class ReleaseGuard
// 1. Gueltige Lizenzen dieses Produkts.
//
// Benutzername und Passwort sind beide der Lizenzschluessel: Basic Auth
// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
// Benutzernamen muessen eindeutig sein.
// Der Benutzername wird aus dem Schluessel ABGELEITET, er ist nicht der
// Schluessel selbst. Das htpasswd-Format hasht nur die Passwortspalte;
// stuende der Schluessel als Benutzername daneben, waere die Datei eine
// vollstaendige Klartext-Kundenliste und der bcrypt-Hash daneben blosse
// Dekoration. So enthaelt sie nur eine Einwegableitung und einen Hash
// ueber einen hochentropen Schluessel - selbst offengelegt ist damit
// nichts anzufangen.
//
// Die Ableitung muss zeichengenau der clientseitigen in
// ReleaseCredentials.FromLicenseKey() entsprechen.
try {
$stmt = $db->prepare("
SELECT l.license_key
@@ -185,7 +209,8 @@ final class ReleaseGuard
continue;
}
$entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
$entries[self::licenseUsername($key)] =
password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
}
} catch (\Throwable $e) {
Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [
@@ -304,6 +329,171 @@ final class ReleaseGuard
/** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */
private const LAST_RUN_KEY = 'release_guard_last_run';
/** Schluessel in dc_settings fuer das Ergebnis des letzten Selbsttests. */
private const SELFTEST_KEY = 'release_guard_selftest';
/** Schluessel in dc_settings fuer das Format der erzeugten Dateien. */
private const FORMAT_KEY = 'release_guard_format';
/**
* Format der erzeugten .htpasswd. Bei einer Aenderung muessen alle Dateien
* neu geschrieben werden, sonst passen Server und Client nicht mehr
* zusammen.
*
* 1 - Benutzername = Lizenzschluessel im Klartext
* 2 - Benutzername = lic_<sha256(schluessel), 16 Hexzeichen>
*
* Ohne diese Kennung waere der Umstieg auf 2 unbemerkt liegengeblieben:
* reconcile() sah keinen Anlass, und die Dateien behielten bis zum
* naechsten turnusmaessigen Lauf das alte Format - waehrend die Clients
* bereits das neue schicken.
*/
private const FORMAT_VERSION = 2;
/**
* Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf.
*
* Dass .htaccess und .htpasswd vorhanden sind, beweist nichts. Unter Nginx
* werden .htaccess-Dateien vollstaendig ignoriert; die Verzeichnisse
* waeren dann offen und die .htpasswd oeffentlich abrufbar, waehrend die
* Oberflaeche "geschuetzt" meldet. Dasselbe gilt bei abgeschaltetem
* AllowOverride oder einem Tippfehler in der erzeugten Datei.
*
* Nur ein Abruf der eigenen Paket-Adresse ohne Zugangsdaten beantwortet
* die Frage, und der findet alle drei Faelle auf einmal.
*
* @return array{checked:int,ok:int,failed:list<array<string,mixed>>,ran_at:string}
*/
public static function selfTest(PDO $db, ?string $slug = null): array
{
$baseUrl = rtrim((string)\Deploymentcenter\Core\Config::get('app.url', ''), '/');
$result = ['checked' => 0, 'ok' => 0, 'failed' => [], 'ran_at' => gmdate('Y-m-d H:i:s')];
if ($baseUrl === '') {
$result['failed'][] = ['slug' => '-', 'reason' => 'app.url ist nicht konfiguriert.'];
return $result;
}
$sql = '
SELECT product_slug, download_url
FROM updateservice_releases
WHERE download_url <> ""
';
$params = [];
if ($slug !== null && $slug !== '') {
$sql .= ' AND product_slug = :slug';
$params[':slug'] = $slug;
}
$sql .= ' ORDER BY product_slug ASC, created_at DESC';
try {
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll() ?: [];
} catch (\Throwable $e) {
$result['failed'][] = ['slug' => '-', 'reason' => 'Releases nicht lesbar: ' . $e->getMessage()];
return $result;
}
// Je Produkt genuegt eine Adresse.
$seen = [];
foreach ($rows as $row) {
$product = (string)$row['product_slug'];
if (isset($seen[$product])) {
continue;
}
$seen[$product] = true;
$url = (string)$row['download_url'];
$result['checked']++;
$status = self::probe($url);
if ($status === 401) {
$result['ok']++;
continue;
}
$result['failed'][] = [
'slug' => $product,
'status' => $status,
'url' => $url,
'reason' => $status === 200
? 'Das Paket ist OHNE Zugangsdaten abrufbar. Wird .htaccess ausgewertet? '
. 'Unter Nginx wirkungslos, unter Apache AllowOverride pruefen.'
: sprintf('Unerwarteter Status %s - erwartet wurde 401.', $status === null ? 'keiner' : (string)$status),
];
}
self::writeSetting($db, self::SELFTEST_KEY, json_encode($result) ?: '');
Logger::info('Release-Selbsttest', [
'checked' => $result['checked'],
'ok' => $result['ok'],
'failed' => count($result['failed']),
]);
return $result;
}
/** Ergebnis des letzten Selbsttests, oder null. */
public static function lastSelfTest(PDO $db): ?array
{
$raw = self::readSetting($db, self::SELFTEST_KEY);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
/**
* Ruft eine Adresse ohne Zugangsdaten ab und liefert den Statuscode.
* Bewusst nur der Kopf der Antwort - es geht um den Status, nicht um
* mehrere Megabyte Paket.
*/
private static function probe(string $url): ?int
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
// Bewusst kein Zugangsdaten-Header: geprueft wird ja gerade,
// ob der Abruf ohne scheitert.
]);
curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return $status > 0 ? $status : null;
}
$context = stream_context_create(['http' => [
'method' => 'HEAD',
'timeout' => 10,
'ignore_errors' => true,
]]);
@file_get_contents($url, false, $context);
foreach ($http_response_header ?? [] as $header) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $m) === 1) {
return (int)$m[1];
}
}
return null;
}
/**
* Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist.
*
@@ -325,6 +515,8 @@ final class ReleaseGuard
if ($lastRun === null) {
$reason = 'noch nie gelaufen';
} elseif ((int)(self::readSetting($db, self::FORMAT_KEY) ?? '1') !== self::FORMAT_VERSION) {
$reason = 'Dateiformat geaendert';
} elseif (self::hasExpiredSince($db, $lastRun)) {
$reason = 'Lizenz abgelaufen';
} elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) {
@@ -366,10 +558,21 @@ final class ReleaseGuard
}
private static function readLastRun(PDO $db): ?string
{
return self::readSetting($db, self::LAST_RUN_KEY);
}
private static function writeLastRun(PDO $db): void
{
self::writeSetting($db, self::LAST_RUN_KEY, gmdate('Y-m-d H:i:s'));
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
}
private static function readSetting(PDO $db, string $key): ?string
{
try {
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
$stmt->execute([':k' => self::LAST_RUN_KEY]);
$stmt->execute([':k' => $key]);
$value = $stmt->fetchColumn();
return is_string($value) && $value !== '' ? $value : null;
} catch (\Throwable $e) {
@@ -377,16 +580,16 @@ final class ReleaseGuard
}
}
private static function writeLastRun(PDO $db): void
private static function writeSetting(PDO $db, string $key, string $value): void
{
try {
$stmt = $db->prepare('
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
');
$stmt->execute([':k' => self::LAST_RUN_KEY, ':v' => gmdate('Y-m-d H:i:s')]);
$stmt->execute([':k' => $key, ':v' => $value]);
} catch (\Throwable $e) {
Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]);
Logger::warning('Einstellung nicht gespeichert', ['key' => $key, 'error' => $e->getMessage()]);
}
}