Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Backup\S3Client;
use OpenNIT\Backup\SelfBackupManager;
/**
* Deckt die Ablage der Sicherung in einem S3-Speicher und die
* Aufbewahrungssperre ab.
*
* Die Sperre ist der eigentliche Zweck: Wer die Installation übernimmt, soll
* die Sicherungen nicht mitlöschen können. Daraus folgt die Zusage, die hier
* festgehalten wird – **OpenNIT entfernt nichts, was gesperrt ist**, weder beim
* Aufräumen noch auf Knopfdruck, und es sagt das, statt einen Erfolg zu melden,
* den der Speicher gar nicht zulässt.
*
* Der zweite Punkt ist die Signatur. Sie ist von Hand gebaut, ohne fremdes SDK;
* ein Fehler darin fiele erst am fremden Speicher auf. Deshalb wird sie gegen
* das veröffentlichte Beispiel von AWS gerechnet.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
// ----------------------------------------------------------------
// Signatur v4
// ----------------------------------------------------------------
test('Der Signierschlüssel folgt dem Beispiel von AWS', function () {
// Aus der Dokumentation zu „Signature Version 4": Aus Secret, Datum,
// Region und Dienst entsteht über vier HMAC-Schritte der Schlüssel.
// Stimmt ein Schritt nicht, weist jeder Speicher die Anfrage ab.
$secret = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
$tag = '20150830';
$region = 'us-east-1';
$dienst = 'iam';
$schluessel = hash_hmac(
'sha256',
'aws4_request',
hash_hmac('sha256', $dienst,
hash_hmac('sha256', $region,
hash_hmac('sha256', $tag, 'AWS4' . $secret, true), true), true),
true
);
assertSame(
'c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9',
bin2hex($schluessel),
'Die Schlüsselableitung weicht vom Beispiel ab'
);
// Derselbe Aufbau steht im Code – Reihenfolge und Zutaten müssen passen.
$src = (string)file_get_contents(dirname(__DIR__) . '/src/Backup/S3Client.php');
assertTrue(
str_contains($src, "hash_hmac('sha256', 'aws4_request', hash_hmac('sha256', 's3',"),
'Die Ableitung im Code hat einen anderen Aufbau'
);
});
test('Ein Pfad wird segmentweise kodiert', function () {
// Würden auch die Trenner kodiert, zeigte die Signatur auf einen
// anderen Pfad als die Anfrage – der Speicher antwortet dann mit
// „SignatureDoesNotMatch", ohne zu sagen, warum.
$ref = new ReflectionMethod(S3Client::class, 'encodePath');
$ref->setAccessible(true);
$client = (new ReflectionClass(S3Client::class))->newInstanceWithoutConstructor();
assertSame('/eimer/opennit-2026-01-01.zip', $ref->invoke($client, '/eimer/opennit-2026-01-01.zip'));
assertSame('/eimer/mit%20leerzeichen.zip', $ref->invoke($client, '/eimer/mit leerzeichen.zip'));
assertSame('/a/b/c', $ref->invoke($client, '/a/b/c'), 'Die Trenner wurden kodiert');
});
test('Die Prüfsumme des Inhalts wird mitsigniert', function () use ($quelle) {
// Mit `UNSIGNED-PAYLOAD` schützt die Signatur den Inhalt nicht; über
// eine Verbindung ohne TLS ließe er sich unterwegs austauschen.
$src = $quelle('src/Backup/S3Client.php');
// Der Begriff darf im Kommentar stehen – nur nicht als Wert.
assertFalse(str_contains($src, "'UNSIGNED-PAYLOAD'"), 'Der Rumpf bleibt ungesichert');
assertFalse(str_contains($src, '"UNSIGNED-PAYLOAD"'), 'Der Rumpf bleibt ungesichert');
assertTrue(str_contains($src, "hash_file('sha256', \$datei)"), 'Die Datei wird nicht gehasht');
assertTrue(str_contains($src, "'x-amz-content-sha256'"), 'Die Prüfsumme wird nicht mitgesendet');
});
// ----------------------------------------------------------------
// Hochladen
// ----------------------------------------------------------------
test('Ein abgebrochener mehrteiliger Upload wird verworfen', function () use ($quelle) {
// Ein liegengebliebener Rest kostet dauerhaft Speicher, ohne je eine
// vollständige Sicherung zu ergeben.
$src = $quelle('src/Backup/S3Client.php');
$start = strpos($src, 'private function putInTeilen(');
$body = substr($src, (int)$start, 3000);
assertSame(3, substr_count($body, "['uploadId' => \$uploadId], [], '', hash('sha256', '')"), 'Nicht jeder Abbruch räumt auf');
assertTrue(str_contains($body, "\$this->request('DELETE', \$schluessel, ['uploadId' => \$uploadId]"), 'Der Abbruch fehlt');
});
test('Ein Fehler im Abschluss-Rumpf gilt als Fehlschlag', function () use ($quelle) {
// S3 antwortet auf den Abschluss mit HTTP 200, auch wenn im Rumpf ein
// Fehler steht – wer nur den Status liest, hält eine halbe Datei für
// eine ganze.
$src = $quelle('src/Backup/S3Client.php');
$start = strpos($src, 'private function putInTeilen(');
$body = substr($src, (int)$start, 3200);
assertTrue(str_contains($body, "str_contains(\$ende['body'], '<Error>')"), 'Der Rumpf des Abschlusses wird nicht geprüft');
});
test('In den Fernspeicher geht nur eine geprüfte Sicherung', function () use ($quelle) {
// Unter einer Sperre ließe sich ein beschädigtes Archiv später weder
// ersetzen noch entfernen.
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'if ($this->s3Aktiv()) {');
assertTrue($start !== false, 'Es wird nicht übertragen');
$body = substr($src, (int)$start, 1200);
$pruefen = strpos($body, 'pruefeDatei(');
$hochladen = strpos($body, 'ladeHoch(');
assertTrue($pruefen !== false && $hochladen !== false && $pruefen < $hochladen, 'Es wird vor der Prüfung übertragen');
assertTrue(str_contains($body, "if (!\$pruefung['ok'])"), 'Ein beschädigtes Archiv geht trotzdem hinaus');
});
// ----------------------------------------------------------------
// Die Sperre
// ----------------------------------------------------------------
test('Es gibt genau drei Sperren, und „keine" ist die Vorgabe', function () {
assertSame(['', 'GOVERNANCE', 'COMPLIANCE'], array_keys(SelfBackupManager::LOCK_MODES));
$ref = new ReflectionClass(SelfBackupManager::class);
$m = $ref->newInstanceWithoutConstructor();
// Ein unbekannter Wert darf nicht auf eine Sperre hinauslaufen, die
// sich hinterher nicht mehr aufheben lässt.
$einstellung = $ref->getMethod('einstellung');
assertTrue($einstellung->isPublic(), 'einstellung() ist nicht erreichbar');
});
test('Eine gesperrte Sicherung wird nicht entfernt – und das wird gesagt', function () use ($quelle) {
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'private function entferneImFernspeicher(');
assertTrue($start !== false, 'Der Umgang mit dem Fernspeicher fehlt');
$body = substr($src, (int)$start, 1400);
assertTrue(str_contains($body, "strtotime(\$gesperrtBis) > time()"), 'Die Sperre wird nicht geprüft');
assertTrue(str_contains($body, 'gesperrt und wurde dort nicht entfernt'), 'Es wird nicht gesagt, dass sie stehen bleibt');
// Und es wird gar nicht erst versucht: Ein Löschversuch gegen COMPLIANCE
// scheitert ohnehin und verschleiert nur, was gilt.
$sperrePos = strpos($body, 'gesperrt und wurde dort nicht entfernt');
$loeschPos = strpos($body, 'deleteObject(');
assertTrue($sperrePos !== false && $loeschPos !== false && $sperrePos < $loeschPos, 'Es wird trotz Sperre gelöscht');
});
test('Das Aufräumen lässt einen Eintrag stehen, dessen Objekt bleibt', function () use ($quelle) {
// Sonst verlöre die Aufbewahrung die Spur zu einem Objekt, das im
// Fernspeicher weiter Platz belegt.
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'public function raeumeAuf(string $schonen');
$body = substr($src, (int)$start, 1800);
assertTrue(str_contains($body, 'entferneImFernspeicher('), 'Das Aufräumen kümmert sich nicht um den Fernspeicher');
assertTrue(str_contains($body, "if (!\$fern['ok']) {"), 'Ein verbliebenes Objekt wird übergangen');
assertTrue(str_contains($body, 'continue;'), 'Der Eintrag verschwindet trotzdem');
});
test('Ein Eimer ohne eingeschaltete Sperre wird benannt', function () use ($quelle) {
// Er nimmt die Datei an und übergeht die Angabe stillschweigend –
// wer sich darauf verlässt, hätte eine Sperre, die keine ist.
$ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
$start = strpos($ctrl, 'public function s3Test(');
$body = substr($ctrl, (int)$start, 1200);
assertTrue(str_contains($body, 'keine Aufbewahrungssperre eingeschaltet'), 'Der Fall wird nicht benannt');
assertTrue(str_contains($body, 'nur beim'), 'Es wird nicht gesagt, dass sie sich nicht nachrüsten lässt');
// Und es lässt sich an einem übertragenen Objekt nachlesen.
$src = $quelle('src/Backup/SelfBackupManager.php');
assertTrue(str_contains($src, 'public function sperrePruefen('), 'Die Sperre lässt sich nicht nachlesen');
assertTrue(str_contains($src, 'objectRetention('), 'Es wird nicht am Objekt selbst nachgesehen');
});
// ----------------------------------------------------------------
// Zugangsdaten
// ----------------------------------------------------------------
test('Der geheime Schlüssel steht verschlüsselt und nicht im Formular', function () use ($quelle) {
$ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
assertTrue(str_contains($ctrl, 'CredentialCrypto::encrypt('), 'Das Geheimnis geht im Klartext in die Datenbank');
assertTrue(str_contains($ctrl, "\$geheim !== ''"), 'Eine leere Eingabe löscht den hinterlegten Wert');
// Und es gibt einen Weg, es wieder loszuwerden.
assertTrue(str_contains($ctrl, "\$request->has('s3_secret_remove')"), 'Ein hinterlegtes Geheimnis lässt sich nicht entfernen');
$tpl = $quelle('templates/admin/selfbackup/index.php');
assertTrue(str_contains($tpl, 'name="s3_secret"'), 'Das Feld fehlt');
assertFalse(str_contains($tpl, 'name="s3_secret" class="form-control" value='), 'Der Wert wird in die Seite geschrieben');
assertTrue(str_contains($tpl, '(hinterlegt)'), 'Es ist nicht erkennbar, ob etwas hinterlegt ist');
assertTrue(str_contains($tpl, 'name="s3_secret_remove"'), 'Das Kästchen zum Entfernen fehlt');
$mgr = $quelle('src/Backup/SelfBackupManager.php');
assertTrue(str_contains($mgr, 'CredentialCrypto::decrypt('), 'Das Geheimnis wird nicht entschlüsselt');
});
test('Das Protokoll nennt die Sperre, aber kein Geheimnis', function () use ($quelle) {
$ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
$start = strpos($ctrl, "'selfbackup_settings_saved'");
$body = substr($ctrl, (int)$start, 500);
assertTrue(str_contains($body, "'fernspeicher'"), 'Es steht nicht im Protokoll, ob der Fernspeicher an ist');
assertFalse(str_contains($body, 's3_secret'), 'Das Geheimnis landet im Protokoll');
assertFalse(str_contains($body, 's3_key'), 'Der Zugriffsschlüssel landet im Protokoll');
});
// ----------------------------------------------------------------
// Ablage
// ----------------------------------------------------------------
test('Die Sicherung führt ihr Gegenstück im Fernspeicher mit', function () use ($quelle) {
$sql = $quelle('database/migrations/224_selfbackup_s3.sql');
foreach (['s3_key', 's3_status', 's3_message', 's3_locked_until'] as $spalte) {
assertTrue(str_contains($sql, '`' . $spalte . '`'), $spalte . ' fehlt');
}
assertTrue(str_contains($sql, 'ALTER TABLE `self_backups`'), 'Die Spalten hängen an der falschen Tabelle');
});
};