archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)
- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue), Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel FAILURE; archive_pending_at nur noch Sammelmarker - O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre - O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder offener Auftrag, nie ein still unvollständiger Nachweis - A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore, archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut - O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab - A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold- Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export - 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock), N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für auslieferbare Bilder), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c82f279fef
commit
60650baeb8
29 changed files with 2918 additions and 412 deletions
|
|
@ -4,6 +4,7 @@ namespace App\Services\Archive;
|
|||
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Models\AppSetting;
|
||||
use App\Models\ArchiveAccessLog;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
|
|
@ -11,9 +12,11 @@ use App\Models\ImageRightsConfirmation;
|
|||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Support\FileDeletion;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -38,14 +41,50 @@ use ZipArchive;
|
|||
* ein Tombstone entsteht erst nach bestätigter Löschung, Purge und
|
||||
* Restore-Abgleich melden Fehlschläge, der Export wird atomar ersetzt.
|
||||
* Hold und Purge koordinieren sich über dieselbe Zeilensperre (N02).
|
||||
*
|
||||
* Umsetzungsaudit 29.09.2026:
|
||||
* - Schlüsselrotation (A-15): jeder Eintrag trägt die Schlüssel-ID; frühere
|
||||
* Schlüssel stehen in ARCHIVE_PREVIOUS_KEYS. Signaturen (Anwaltsexport,
|
||||
* Tombstone-Export) nutzen per HKDF abgeleitete eigene Schlüssel.
|
||||
* - Der Orphan-Sweep löscht nicht mehr, er verschiebt in die Quarantäne
|
||||
* (A-03); er lässt sich aussetzen, der Restore-Abgleich setzt ihn aus,
|
||||
* `reregister()` stellt Zeilen aus dem Manifest wieder her.
|
||||
* - Hold setzen/aufheben steht im Zugriffsprotokoll (A-07).
|
||||
*/
|
||||
class EvidenceArchive
|
||||
{
|
||||
/** AppSetting-Schalter: Orphan-Sweep ausgesetzt (Restore, A-03). */
|
||||
public const SWEEP_PAUSED_SETTING = 'archive.sweep_paused';
|
||||
|
||||
private const EXPORT_SIGNATURE_CONTEXT = 'pk-archive:export-signature:v1';
|
||||
|
||||
private const TOMBSTONE_EXPORT_CONTEXT = 'pk-archive:tombstone-export:v1';
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->keyBytes() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennung des aktuellen Schlüssels (16 Hex-Zeichen), null ohne Schlüssel.
|
||||
*/
|
||||
public function keyId(): ?string
|
||||
{
|
||||
$key = $this->keyBytes();
|
||||
|
||||
return $key !== null ? $this->idForKey($key) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennungen aller bekannten Schlüssel (aktuell zuerst).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function knownKeyIds(): array
|
||||
{
|
||||
return array_keys($this->keyring());
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload zuerst als .tmp, dann die Zeile in einer Transaktion, dann
|
||||
* die Datei an ihren Platz — schlägt etwas fehl, bleibt weder Zeile
|
||||
|
|
@ -113,6 +152,7 @@ class EvidenceArchive
|
|||
'payload_path' => $path,
|
||||
'payload_hash' => $hash,
|
||||
'payload_bytes' => strlen($zip),
|
||||
'key_id' => $this->keyId(),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
|
@ -149,27 +189,14 @@ class EvidenceArchive
|
|||
{
|
||||
$this->logAccess($entry, $admin, 'manifest', $purpose);
|
||||
|
||||
$temp = $this->tempFile($this->plaintext($entry));
|
||||
|
||||
try {
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->open($temp) !== true) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload ist kein lesbares ZIP.');
|
||||
}
|
||||
|
||||
$json = $zip->getFromName('manifest.json');
|
||||
$zip->close();
|
||||
} finally {
|
||||
@unlink($temp);
|
||||
}
|
||||
|
||||
return is_string($json) ? (array) json_decode($json, true) : [];
|
||||
return $this->manifestFromZip($this->plaintext($entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export für den Anwalt: äußeres ZIP mit payload.zip, metadata.json und
|
||||
* einer HMAC-Signatur über den Hash (mit dem Archivschlüssel).
|
||||
* einer HMAC-Signatur über den Hash. Der Signaturschlüssel ist per HKDF
|
||||
* vom Archivschlüssel abgeleitet (A-15), nicht der Verschlüsselungs-
|
||||
* schlüssel selbst; verifyExportSignature() prüft neue und alte Exporte.
|
||||
*/
|
||||
public function export(ArchiveEntry $entry, User $admin, string $purpose): string
|
||||
{
|
||||
|
|
@ -192,10 +219,47 @@ class EvidenceArchive
|
|||
'exported_by' => $admin->getKey(),
|
||||
], [
|
||||
'payload.zip' => $payload,
|
||||
'signature.txt' => 'sha256='.hash('sha256', $payload)."\nhmac-sha256=".hash_hmac('sha256', (string) $entry->payload_hash, (string) $this->keyBytes())."\n",
|
||||
'signature.txt' => 'sha256='.hash('sha256', $payload)
|
||||
."\nkey-id=".$this->keyId()
|
||||
."\nhmac-sha256-hkdf=".hash_hmac('sha256', (string) $entry->payload_hash, $this->derivedKey((string) $this->keyBytes(), self::EXPORT_SIGNATURE_CONTEXT))."\n",
|
||||
], 'metadata.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Signatur eines Anwaltsexports prüfen. Neu: `hmac-sha256-hkdf=` mit
|
||||
* abgeleitetem Schlüssel (Schlüssel-ID in `key-id=`); Exporte vor dem
|
||||
* 29.09.2026 tragen `hmac-sha256=` mit dem Archivschlüssel selbst —
|
||||
* beides wird gegen alle bekannten Schlüssel geprüft.
|
||||
*/
|
||||
public function verifyExportSignature(string $payloadHash, string $signature): bool
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
foreach (preg_split('/\R/', trim($signature)) ?: [] as $line) {
|
||||
[$name, $value] = array_pad(explode('=', $line, 2), 2, '');
|
||||
$fields[trim($name)] = trim($value);
|
||||
}
|
||||
|
||||
$keys = $this->keyring();
|
||||
|
||||
if (isset($fields['key-id']) && isset($keys[$fields['key-id']])) {
|
||||
$keys = [$fields['key-id'] => $keys[$fields['key-id']]];
|
||||
}
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (isset($fields['hmac-sha256-hkdf'])
|
||||
&& hash_equals(hash_hmac('sha256', $payloadHash, $this->derivedKey($key, self::EXPORT_SIGNATURE_CONTEXT)), $fields['hmac-sha256-hkdf'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($fields['hmac-sha256']) && hash_equals(hash_hmac('sha256', $payloadHash, $key), $fields['hmac-sha256'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold setzen unter Zeilensperre: ein gleichzeitig laufender Purge sieht
|
||||
* den Hold bei seiner erneuten Prüfung, oder der Hold wartet, bis der
|
||||
|
|
@ -217,6 +281,10 @@ class EvidenceArchive
|
|||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
])->save();
|
||||
|
||||
// Historie (A-07): die Felder am Eintrag zeigen nur den letzten
|
||||
// Stand, das Protokoll jeden Wechsel mit Vermerk.
|
||||
$this->logAccess($locked, $admin, 'hold_set', $note);
|
||||
});
|
||||
|
||||
$entry->refresh();
|
||||
|
|
@ -241,6 +309,8 @@ class EvidenceArchive
|
|||
'legal_hold_set_at' => now(),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
])->save();
|
||||
|
||||
$this->logAccess($locked, $admin, 'hold_released', $note);
|
||||
});
|
||||
|
||||
$entry->refresh();
|
||||
|
|
@ -284,29 +354,61 @@ class EvidenceArchive
|
|||
|
||||
/**
|
||||
* Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird
|
||||
* atomar ersetzt; ein Fehlschlag wirft, statt eine Anzahl zu melden.
|
||||
* gestreamt geschrieben (OPT-6) und atomar ersetzt; ein Fehlschlag
|
||||
* wirft, statt eine Anzahl zu melden. Das Feld `hmac` (HKDF-Schlüssel,
|
||||
* A-15) schützt den Abgleich davor, auf eine untergeschobene Liste hin
|
||||
* echte Payloads zu löschen.
|
||||
*/
|
||||
public function exportTombstones(): int
|
||||
{
|
||||
$rows = ArchiveEntry::query()->whereNotNull('deleted_at')->orderBy('id')
|
||||
->get(['id', 'payload_path', 'deleted_at'])
|
||||
->map(fn (ArchiveEntry $entry): array => [
|
||||
'id' => $entry->getKey(),
|
||||
'payload_path' => $entry->payload_path,
|
||||
'deleted_at' => $entry->deleted_at?->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$path = (string) config('archive.tombstone_export_path');
|
||||
$temp = $path.'.tmp';
|
||||
$json = (string) json_encode([
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'entries' => $rows,
|
||||
], JSON_PRETTY_PRINT);
|
||||
$exportedAt = now()->toIso8601String();
|
||||
$key = $this->keyBytes();
|
||||
$hmac = $key !== null ? hash_init('sha256', HASH_HMAC, $this->derivedKey($key, self::TOMBSTONE_EXPORT_CONTEXT)) : null;
|
||||
$stream = fopen('php://temp', 'w+b');
|
||||
|
||||
if (! $this->disk()->put($temp, $json)) {
|
||||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.');
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht vorbereitet werden.');
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
try {
|
||||
fwrite($stream, '{"exported_at":'.json_encode($exportedAt).',"key_id":'.json_encode($this->keyId()).',"entries":[');
|
||||
|
||||
if ($hmac !== null) {
|
||||
hash_update($hmac, $exportedAt."\n");
|
||||
}
|
||||
|
||||
ArchiveEntry::query()->whereNotNull('deleted_at')->select(['id', 'payload_path', 'deleted_at'])
|
||||
->lazyById(1000)
|
||||
->each(function (ArchiveEntry $entry) use ($stream, $hmac, &$count): void {
|
||||
$line = $this->tombstoneLine([
|
||||
'id' => $entry->getKey(),
|
||||
'payload_path' => $entry->payload_path,
|
||||
'deleted_at' => $entry->deleted_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
fwrite($stream, ($count > 0 ? ',' : '')."\n".$line);
|
||||
|
||||
if ($hmac !== null) {
|
||||
hash_update($hmac, $line."\n");
|
||||
}
|
||||
|
||||
$count++;
|
||||
});
|
||||
|
||||
fwrite($stream, "\n],\"hmac\":".json_encode($hmac !== null ? hash_final($hmac) : null)."}\n");
|
||||
rewind($stream);
|
||||
|
||||
if (! $this->disk()->put($temp, $stream)) {
|
||||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.');
|
||||
}
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
|
||||
|
|
@ -315,23 +417,34 @@ class EvidenceArchive
|
|||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.');
|
||||
}
|
||||
|
||||
return count($rows);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
|
||||
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
|
||||
* Zusätzlich jede Tombstone-Zeile der Datenbank, deren Payload wieder
|
||||
* auf der Disk liegt — deckt den Fall „Disk alt, Datenbank aktuell"
|
||||
* ab (5.1 c).
|
||||
* Tombstone-Stand gelöscht war, erneut entfernen (Datei, Zeile und die
|
||||
* Kopien in der Anwendung, A-06). Zusätzlich jede Tombstone-Zeile der
|
||||
* Datenbank, deren Payload wieder auf der Disk liegt — deckt den Fall
|
||||
* „Disk alt, Datenbank aktuell" ab (5.1 c). Ein Export mit ungültiger
|
||||
* Signatur wird verworfen (A-15). Der Orphan-Sweep wird ausgesetzt,
|
||||
* bis die Wiederregistrierung gelaufen ist (A-03).
|
||||
*
|
||||
* @return array{re_tombstoned: int, files_removed: int, failed: int}
|
||||
* @return array{re_tombstoned: int, files_removed: int, failed: int, conflicts: int, export_signature: string, sweep_paused: bool}
|
||||
*/
|
||||
public function reconcileAfterRestore(): array
|
||||
public function reconcileAfterRestore(bool $acceptUnsigned = false): array
|
||||
{
|
||||
$result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 0];
|
||||
$raw = $this->disk()->get((string) config('archive.tombstone_export_path'));
|
||||
$rows = is_string($raw) && $raw !== '' ? (array) (json_decode($raw, true)['entries'] ?? []) : [];
|
||||
$result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 0, 'conflicts' => 0, 'export_signature' => 'missing', 'sweep_paused' => false];
|
||||
|
||||
$this->pauseSweep();
|
||||
$result['sweep_paused'] = true;
|
||||
|
||||
[$rows, $result['export_signature']] = $this->readTombstoneExport($acceptUnsigned);
|
||||
|
||||
if (in_array($result['export_signature'], ['invalid', 'unsigned_refused'], true)) {
|
||||
$result['failed']++;
|
||||
Log::error('Beweisarchiv: Tombstone-Export nicht vertrauenswürdig ('.$result['export_signature'].') — verworfen, nur Datenbank-Tombstones abgeglichen.');
|
||||
}
|
||||
|
||||
$handled = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
|
|
@ -342,15 +455,37 @@ class EvidenceArchive
|
|||
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
|
||||
}
|
||||
|
||||
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
|
||||
// Identität ist der Payload-Pfad (UUID), nicht die ID: Nach einem
|
||||
// Restore oder einer Wiederregistrierung kann dieselbe ID einen
|
||||
// anderen Eintrag tragen (Umsetzungsaudit 02.10.2026, O04).
|
||||
$entry = $path !== null ? ArchiveEntry::query()->where('payload_path', $path)->first() : null;
|
||||
|
||||
if ($entry !== null && ! $entry->isTombstoned()) {
|
||||
$entry->forceFill(['deleted_at' => now()])->save();
|
||||
if ($entry === null) {
|
||||
$sameId = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
|
||||
|
||||
if ($sameId !== null && $sameId->payload_path !== $path) {
|
||||
$result['conflicts']++;
|
||||
Log::warning('Beweisarchiv: Tombstone-Export und Datenbank widersprechen sich — Eintrag nicht angefasst.', [
|
||||
'export_id' => (int) ($row['id'] ?? 0),
|
||||
'export_path' => $path,
|
||||
'db_path' => $sameId->payload_path,
|
||||
]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $entry->isTombstoned()) {
|
||||
DB::transaction(function () use ($entry): void {
|
||||
$locked = $this->lockedEntry($entry);
|
||||
$this->purgeApplicationCopies($locked);
|
||||
$locked->forceFill(['deleted_at' => now()])->save();
|
||||
});
|
||||
$result['re_tombstoned']++;
|
||||
}
|
||||
}
|
||||
|
||||
ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')->orderBy('id')
|
||||
ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')
|
||||
->chunkById(500, function ($entries) use (&$result, &$handled): void {
|
||||
foreach ($entries as $entry) {
|
||||
$path = (string) $entry->payload_path;
|
||||
|
|
@ -365,36 +500,143 @@ class EvidenceArchive
|
|||
});
|
||||
|
||||
if ($result['failed'] > 0) {
|
||||
Log::error('Beweisarchiv: Restore-Abgleich konnte Payloads nicht entfernen.', ['failed' => $result['failed']]);
|
||||
Log::error('Beweisarchiv: Restore-Abgleich unvollständig.', ['failed' => $result['failed'], 'export_signature' => $result['export_signature']]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback,
|
||||
* liegengebliebene .tmp) nach Karenzzeit entfernen (N11).
|
||||
* Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback)
|
||||
* nach Karenzzeit in die Quarantäne verschieben, statt sie zu löschen
|
||||
* (A-03): nach einem DB-Restore fehlen echten Einträgen die Zeilen.
|
||||
* Liegengebliebene `.tmp` sind nie ein vollständiger Eintrag und werden
|
||||
* gelöscht. Quarantäne-Tage älter als `archive.quarantine_days` fallen.
|
||||
* Ausgesetzt (Config oder Schalter) passiert nichts. Verzeichnisweise
|
||||
* statt einer Gesamtliste (OPT-6).
|
||||
*
|
||||
* @return array{scanned: int, orphans: int, removed: int}
|
||||
* @return array{paused: bool, scanned: int, orphans: int, quarantined: int, removed: int, expired: int}
|
||||
*/
|
||||
public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array
|
||||
{
|
||||
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
|
||||
$result = ['paused' => false, 'scanned' => 0, 'orphans' => 0, 'quarantined' => 0, 'removed' => 0, 'expired' => 0];
|
||||
|
||||
if ($this->isSweepPaused()) {
|
||||
$result['paused'] = true;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$threshold = now()->subHours($olderThanHours)->getTimestamp();
|
||||
$quarantineDay = 'quarantine/'.now()->toDateString();
|
||||
|
||||
foreach (array_chunk($this->disk()->allFiles('entries'), 500) as $chunk) {
|
||||
$result['scanned'] += count($chunk);
|
||||
$known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all();
|
||||
foreach ($this->payloadDirectories('entries') as $directory) {
|
||||
foreach (array_chunk($this->disk()->files($directory), 500) as $chunk) {
|
||||
$result['scanned'] += count($chunk);
|
||||
$known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all();
|
||||
|
||||
foreach (array_diff($chunk, $known) as $file) {
|
||||
if ($this->disk()->lastModified($file) > $threshold) {
|
||||
foreach (array_diff($chunk, $known) as $file) {
|
||||
if ($this->disk()->lastModified($file) > $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['orphans']++;
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_ends_with($file, '.tmp')) {
|
||||
if (FileDeletion::delete($this->disk(), $file)) {
|
||||
$result['removed']++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->disk()->move($file, $quarantineDay.'/'.$file)) {
|
||||
$result['quarantined']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['expired'] = $this->expireQuarantine($dryRun);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeilen für Payloads ohne Zeile aus deren Manifest wiederherstellen
|
||||
* (nach einem DB-Restore, A-03): aus `entries/` und aus der Quarantäne
|
||||
* (dann zurück an den ursprünglichen Pfad). Payloads, die laut
|
||||
* Tombstone-Export gelöscht sind, bleiben außen vor. Legal Hold und
|
||||
* Kennzahlen kennt das Manifest nicht — sie müssen geprüft werden.
|
||||
*
|
||||
* Ohne vertrauenswürdigen Export lässt sich nicht unterscheiden, ob eine
|
||||
* Payload ohne Zeile ein verlorener Eintrag oder ein absichtlich
|
||||
* gelöschter ist. Deshalb bricht der Lauf ab bei ungültiger Signatur,
|
||||
* bei unsigniertem Export ohne `$acceptUnsigned` und bei fehlendem Export
|
||||
* ohne `$withoutExport` (Umsetzungsaudit 02.10.2026, O05).
|
||||
*
|
||||
* @return array{candidates: int, registered: int, skipped_tombstoned: int, failed: int, export_signature: string, aborted: bool}
|
||||
*/
|
||||
public function reregister(bool $dryRun = false, bool $acceptUnsigned = false, bool $withoutExport = false): array
|
||||
{
|
||||
$result = ['candidates' => 0, 'registered' => 0, 'skipped_tombstoned' => 0, 'failed' => 0, 'export_signature' => 'missing', 'aborted' => false];
|
||||
[$rows, $result['export_signature']] = $this->readTombstoneExport($acceptUnsigned);
|
||||
|
||||
if (in_array($result['export_signature'], ['invalid', 'unsigned_refused'], true)
|
||||
|| ($result['export_signature'] === 'missing' && ! $withoutExport)) {
|
||||
$result['aborted'] = true;
|
||||
Log::error('Beweisarchiv: Wiederregistrierung abgebrochen — Tombstone-Export '.$result['export_signature'].'.');
|
||||
|
||||
return $result;
|
||||
}
|
||||
$tombstoned = array_fill_keys(array_filter(array_map(fn (array $row): ?string => is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null, $rows)), true);
|
||||
|
||||
$sources = [];
|
||||
|
||||
foreach ($this->payloadDirectories('entries') as $directory) {
|
||||
foreach ($this->disk()->files($directory) as $file) {
|
||||
$sources[] = [$file, $file];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->disk()->directories('quarantine') as $day) {
|
||||
foreach ($this->payloadDirectories($day.'/entries') as $directory) {
|
||||
foreach ($this->disk()->files($directory) as $file) {
|
||||
$sources[] = [$file, Str::after($file, $day.'/')];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_chunk($sources, 500) as $chunk) {
|
||||
$known = ArchiveEntry::query()->whereIn('payload_path', array_column($chunk, 1))->pluck('payload_path')->flip()->all();
|
||||
|
||||
foreach ($chunk as [$source, $target]) {
|
||||
if (! str_ends_with($target, '.bin') || isset($known[$target])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['orphans']++;
|
||||
if (isset($tombstoned[$target])) {
|
||||
$result['skipped_tombstoned']++;
|
||||
|
||||
if (! $dryRun && FileDeletion::delete($this->disk(), $file)) {
|
||||
$result['removed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['candidates']++;
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->reregisterPayload($source, $target);
|
||||
$result['registered']++;
|
||||
} catch (Throwable $exception) {
|
||||
$result['failed']++;
|
||||
Log::error('Beweisarchiv: Wiederregistrierung fehlgeschlagen.', ['path' => $target, 'error' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -402,6 +644,21 @@ class EvidenceArchive
|
|||
return $result;
|
||||
}
|
||||
|
||||
public function isSweepPaused(): bool
|
||||
{
|
||||
return (bool) config('archive.sweep_paused') || (bool) AppSetting::getValueFresh(self::SWEEP_PAUSED_SETTING, false);
|
||||
}
|
||||
|
||||
public function pauseSweep(): void
|
||||
{
|
||||
AppSetting::setValue(self::SWEEP_PAUSED_SETTING, true);
|
||||
}
|
||||
|
||||
public function resumeSweep(): void
|
||||
{
|
||||
AppSetting::setValue(self::SWEEP_PAUSED_SETTING, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unter Zeilensperre erneut prüfen, dann Datei bestätigt löschen, dann
|
||||
* Tombstone. Liefert false, wenn der Eintrag inzwischen nicht mehr
|
||||
|
|
@ -484,6 +741,8 @@ class EvidenceArchive
|
|||
ArchiveAccessLog::query()->create([
|
||||
'entry_id' => $entry->getKey(),
|
||||
'admin_id' => $admin->getKey(),
|
||||
'admin_name' => Str::limit((string) $admin->name, 250, ''),
|
||||
'admin_email' => Str::limit((string) $admin->email, 250, ''),
|
||||
'action' => $action,
|
||||
'purpose' => Str::limit($purpose, 490, ''),
|
||||
'accessed_at' => now(),
|
||||
|
|
@ -502,7 +761,17 @@ class EvidenceArchive
|
|||
throw new RuntimeException('Beweisarchiv: Payload fehlt auf der Archiv-Disk.');
|
||||
}
|
||||
|
||||
$zip = (string) $this->encrypter()->decrypt($cipher, false);
|
||||
$keys = $this->keyring();
|
||||
|
||||
if ($entry->key_id !== null) {
|
||||
if (! isset($keys[$entry->key_id])) {
|
||||
throw new RuntimeException('Beweisarchiv: Schlüssel '.$entry->key_id.' dieses Eintrags ist nicht konfiguriert (ARCHIVE_KEY/ARCHIVE_PREVIOUS_KEYS).');
|
||||
}
|
||||
|
||||
$keys = [$entry->key_id => $keys[$entry->key_id]];
|
||||
}
|
||||
|
||||
[$zip] = $this->decryptWithAny($cipher, $keys);
|
||||
|
||||
if (! hash_equals((string) $entry->payload_hash, hash('sha256', $zip))) {
|
||||
throw new RuntimeException('Beweisarchiv: Hash des Payloads stimmt nicht mit dem Eintrag überein.');
|
||||
|
|
@ -511,6 +780,234 @@ class EvidenceArchive
|
|||
return $zip;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $keys Schlüssel-ID → Schlüssel
|
||||
* @return array{0: string, 1: string} Klartext und Schlüssel-ID
|
||||
*/
|
||||
private function decryptWithAny(string $cipher, array $keys): array
|
||||
{
|
||||
$cipherName = (string) config('archive.cipher', 'AES-256-CBC');
|
||||
|
||||
foreach ($keys as $id => $key) {
|
||||
try {
|
||||
return [(string) (new Encrypter($key, $cipherName))->decrypt($cipher, false), $id];
|
||||
} catch (DecryptException) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Beweisarchiv: Payload lässt sich mit keinem bekannten Schlüssel entschlüsseln.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function manifestFromZip(string $bytes): array
|
||||
{
|
||||
$temp = $this->tempFile($bytes);
|
||||
|
||||
try {
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->open($temp) !== true) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload ist kein lesbares ZIP.');
|
||||
}
|
||||
|
||||
$json = $zip->getFromName('manifest.json');
|
||||
$zip->close();
|
||||
} finally {
|
||||
@unlink($temp);
|
||||
}
|
||||
|
||||
return is_string($json) ? (array) json_decode($json, true) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeile aus dem Manifest eines verwaisten Payloads anlegen, Datei ggf.
|
||||
* aus der Quarantäne zurück an ihren Pfad (wie record(): Zeile und
|
||||
* Verschiebung in einer Transaktion).
|
||||
*/
|
||||
private function reregisterPayload(string $source, string $target): void
|
||||
{
|
||||
$cipher = $this->disk()->get($source);
|
||||
|
||||
if (! is_string($cipher) || $cipher === '') {
|
||||
throw new RuntimeException('Beweisarchiv: Payload ist leer.');
|
||||
}
|
||||
|
||||
[$zip, $keyId] = $this->decryptWithAny($cipher, $this->keyring());
|
||||
$manifest = $this->manifestFromZip($zip);
|
||||
$event = ArchiveEvent::tryFrom((string) ($manifest['event'] ?? ''));
|
||||
$reason = ArchiveReasonCategory::tryFrom((string) ($manifest['reason_category'] ?? ''));
|
||||
|
||||
if ($event === null || $reason === null || ! isset($manifest['subject_type'], $manifest['subject_id'])) {
|
||||
throw new RuntimeException('Beweisarchiv: Manifest unvollständig.');
|
||||
}
|
||||
|
||||
$recordedAt = isset($manifest['recorded_at']) ? Carbon::parse((string) $manifest['recorded_at']) : Carbon::createFromTimestamp($this->disk()->lastModified($source));
|
||||
|
||||
DB::transaction(function () use ($manifest, $event, $reason, $zip, $keyId, $recordedAt, $source, $target): void {
|
||||
ArchiveEntry::query()->create([
|
||||
'subject_type' => (string) $manifest['subject_type'],
|
||||
'subject_id' => (int) $manifest['subject_id'],
|
||||
'press_release_id' => isset($manifest['press_release_id']) ? (int) $manifest['press_release_id'] : null,
|
||||
'legal_request_id' => isset($manifest['legal_request_id']) ? (int) $manifest['legal_request_id'] : null,
|
||||
'event' => $event,
|
||||
'actor_type' => (string) ($manifest['actor_type'] ?? 'system'),
|
||||
'actor_id' => isset($manifest['actor_id']) ? (int) $manifest['actor_id'] : null,
|
||||
'reason_category' => $reason,
|
||||
'summary' => ['reregistered_at' => now()->toIso8601String()],
|
||||
'payload_path' => $target,
|
||||
'payload_hash' => hash('sha256', $zip),
|
||||
'payload_bytes' => strlen($zip),
|
||||
'key_id' => $keyId,
|
||||
'retain_until' => ArchiveEntry::retentionEndFor($recordedAt),
|
||||
'created_at' => $recordedAt,
|
||||
]);
|
||||
|
||||
if ($source !== $target && (! $this->disk()->move($source, $target) || ! $this->disk()->exists($target))) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload konnte nicht aus der Quarantäne zurückgeholt werden.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstone-Export lesen und die Signatur prüfen.
|
||||
*
|
||||
* @return array{0: list<array<string, mixed>>, 1: string} Zeilen und Status (valid, unsigned, invalid, missing)
|
||||
*/
|
||||
private function readTombstoneExport(bool $acceptUnsigned = false): array
|
||||
{
|
||||
$path = (string) config('archive.tombstone_export_path');
|
||||
$raw = $this->disk()->exists($path) ? $this->disk()->get($path) : null;
|
||||
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
return [[], 'missing'];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return [[], 'invalid'];
|
||||
}
|
||||
|
||||
$rows = array_values(array_filter((array) ($data['entries'] ?? []), 'is_array'));
|
||||
|
||||
if (! isset($data['hmac']) || ! is_string($data['hmac'])) {
|
||||
// Exporte vor dem 29.09.2026 (oder ohne Schlüssel) sind
|
||||
// unsigniert. Das Dateiformat beweist das aber nicht — ein
|
||||
// entferntes `hmac` sähe genauso aus. Deshalb nur auf
|
||||
// ausdrückliche Anweisung verwenden (Umsetzungsaudit 02.10.2026, O05).
|
||||
return $acceptUnsigned ? [$rows, 'unsigned'] : [[], 'unsigned_refused'];
|
||||
}
|
||||
|
||||
$keys = $this->keyring();
|
||||
|
||||
if (isset($data['key_id']) && is_string($data['key_id']) && isset($keys[$data['key_id']])) {
|
||||
$keys = [$data['key_id'] => $keys[$data['key_id']]];
|
||||
}
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$hmac = hash_init('sha256', HASH_HMAC, $this->derivedKey($key, self::TOMBSTONE_EXPORT_CONTEXT));
|
||||
hash_update($hmac, (string) ($data['exported_at'] ?? '')."\n");
|
||||
|
||||
foreach ($rows as $row) {
|
||||
hash_update($hmac, $this->tombstoneLine($row)."\n");
|
||||
}
|
||||
|
||||
if (hash_equals(hash_final($hmac), $data['hmac'])) {
|
||||
return [$rows, 'valid'];
|
||||
}
|
||||
}
|
||||
|
||||
return [[], 'invalid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function tombstoneLine(array $row): string
|
||||
{
|
||||
return (string) json_encode([
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'payload_path' => $row['payload_path'] ?? null,
|
||||
'deleted_at' => $row['deleted_at'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unterverzeichnisse (Jahre) plus das Verzeichnis selbst.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadDirectories(string $root): array
|
||||
{
|
||||
return array_values(array_merge([$root], $this->disk()->directories($root)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quarantäne-Tage nach Ablauf der Frist entfernen.
|
||||
*/
|
||||
private function expireQuarantine(bool $dryRun): int
|
||||
{
|
||||
$cutoff = today()->subDays(max(1, (int) config('archive.quarantine_days', 30)));
|
||||
$expired = 0;
|
||||
|
||||
foreach ($this->disk()->directories('quarantine') as $day) {
|
||||
$date = rescue(fn () => Carbon::createFromFormat('Y-m-d', basename($day))?->startOfDay(), null, false);
|
||||
|
||||
if (! $date instanceof Carbon || $date->gte($cutoff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = count($this->disk()->allFiles($day));
|
||||
|
||||
if ($dryRun || $this->disk()->deleteDirectory($day)) {
|
||||
$expired += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $expired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Schlüssel zuerst, dann ARCHIVE_PREVIOUS_KEYS.
|
||||
*
|
||||
* @return array<string, string> Schlüssel-ID → Schlüssel
|
||||
*/
|
||||
private function keyring(): array
|
||||
{
|
||||
$keys = [];
|
||||
$current = $this->keyBytes();
|
||||
|
||||
if ($current !== null) {
|
||||
$keys[$this->idForKey($current)] = $current;
|
||||
}
|
||||
|
||||
$previous = config('archive.previous_keys', []);
|
||||
$previous = is_string($previous) ? explode(',', $previous) : (array) $previous;
|
||||
|
||||
foreach ($previous as $candidate) {
|
||||
$bytes = $this->decodeKey(trim((string) $candidate));
|
||||
|
||||
if ($bytes !== null) {
|
||||
$keys[$this->idForKey($bytes)] ??= $bytes;
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
private function idForKey(string $key): string
|
||||
{
|
||||
return substr(hash('sha256', 'pk-archive-key-id|'.$key), 0, 16);
|
||||
}
|
||||
|
||||
private function derivedKey(string $key, string $context): string
|
||||
{
|
||||
return hash_hkdf('sha256', $key, 32, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $manifest
|
||||
* @param array<string, string> $files
|
||||
|
|
@ -565,8 +1062,11 @@ class EvidenceArchive
|
|||
|
||||
private function keyBytes(): ?string
|
||||
{
|
||||
$key = (string) config('archive.key');
|
||||
return $this->decodeKey((string) config('archive.key'));
|
||||
}
|
||||
|
||||
private function decodeKey(string $key): ?string
|
||||
{
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue