presseportale/app/Services/Archive/EvidenceArchive.php
Kevin Adametz 60650baeb8 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>
2026-10-02 13:09:01 +00:00

1085 lines
40 KiB
PHP

<?php
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;
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;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
use ZipArchive;
/**
* Beweisarchiv (Bildkennzeichnung Block F, Decision-Update Abschnitt 4).
*
* Jeder Eintrag ist ein verschlüsseltes ZIP (manifest.json plus Dateien)
* auf der Archiv-Disk, eigener Schlüssel (ARCHIVE_KEY), SHA-256 des
* Klartext-ZIPs am Eintrag. Lesen nur über open()/export() mit Zweck und
* Zugriffsprotokoll. Löschen nur durch purgeDue() nach Frist ohne
* legal_hold; die Zeile bleibt als Tombstone. Nach einem Restore aus einer
* Sicherung entfernt reconcileAfterRestore() wiederhergestellte Payloads
* anhand des täglich exportierten Tombstone-Stands und der Tombstones in
* der Datenbank.
*
* Dateioperationen werden ausgewertet (Umsetzungsaudit 25.09.2026, N10):
* 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
* noch .tmp zurück (N11). Eine Datei ohne Zeile (Commit nach dem
* Verschieben gescheitert) räumt sweepOrphans() auf.
*
* @param array<string, mixed> $manifest Fachdaten (werden als manifest.json abgelegt)
* @param array<string, string> $files Dateiname → Bytes
* @param array<string, mixed> $summary Kleine, nicht personenbezogene Kennzahlen für die Liste
*/
public function record(
ArchiveEvent $event,
Model $subject,
?int $pressReleaseId,
ArchiveReasonCategory $reason,
string $actorType,
?int $actorId,
array $manifest,
array $files = [],
array $summary = [],
?int $legalRequestId = null,
): ?ArchiveEntry {
if (! $this->isConfigured()) {
Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [
'event' => $event->value,
'subject' => $subject->getMorphClass().'#'.$subject->getKey(),
]);
return null;
}
$manifest = [
'event' => $event->value,
'subject_type' => $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'press_release_id' => $pressReleaseId,
'legal_request_id' => $legalRequestId,
'reason_category' => $reason->value,
'actor_type' => $actorType,
'actor_id' => $actorId,
'recorded_at' => now()->toIso8601String(),
] + $manifest;
$zip = $this->buildZip($manifest, $files);
$hash = hash('sha256', $zip);
$path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString());
$temp = $path.'.tmp';
if (! $this->disk()->put($temp, $this->encrypter()->encrypt($zip, false))) {
throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.');
}
try {
return DB::transaction(function () use ($subject, $pressReleaseId, $legalRequestId, $event, $actorType, $actorId, $reason, $summary, $path, $temp, $hash, $zip): ArchiveEntry {
$entry = ArchiveEntry::query()->create([
'subject_type' => $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'press_release_id' => $pressReleaseId,
'legal_request_id' => $legalRequestId,
'event' => $event,
'actor_type' => $actorType,
'actor_id' => $actorId,
'reason_category' => $reason,
'summary' => $summary ?: null,
'payload_path' => $path,
'payload_hash' => $hash,
'payload_bytes' => strlen($zip),
'key_id' => $this->keyId(),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
'created_at' => now(),
]);
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
throw new RuntimeException('Beweisarchiv: Payload konnte nicht abgelegt werden.');
}
return $entry;
});
} catch (Throwable $exception) {
$this->disk()->delete($temp);
throw $exception;
}
}
/**
* Entschlüsseltes ZIP eines Eintrags — protokolliert mit Zweck.
*/
public function open(ArchiveEntry $entry, User $admin, string $purpose): string
{
$this->logAccess($entry, $admin, 'open', $purpose);
return $this->plaintext($entry);
}
/**
* manifest.json eines Eintrags als Array — protokolliert mit Zweck.
*
* @return array<string, mixed>
*/
public function manifest(ArchiveEntry $entry, User $admin, string $purpose): array
{
$this->logAccess($entry, $admin, 'manifest', $purpose);
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. 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
{
$this->logAccess($entry, $admin, 'export', $purpose);
$payload = $this->plaintext($entry);
return $this->buildZip([
'entry_id' => $entry->getKey(),
'event' => $entry->event->value,
'subject' => $entry->subject_type.'#'.$entry->subject_id,
'press_release_id' => $entry->press_release_id,
'legal_request_id' => $entry->legal_request_id,
'reason_category' => $entry->reason_category->value,
'created_at' => $entry->created_at?->toIso8601String(),
'retain_until' => $entry->retain_until->toDateString(),
'legal_hold' => $entry->legal_hold,
'payload_sha256' => $entry->payload_hash,
'exported_at' => now()->toIso8601String(),
'exported_by' => $admin->getKey(),
], [
'payload.zip' => $payload,
'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
* Purge fertig ist — dann ist der Eintrag Tombstone und der Hold wird
* abgelehnt (N02).
*/
public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
DB::transaction(function () use ($entry, $admin, $note): void {
$locked = $this->lockedEntry($entry);
if ($locked->isTombstoned()) {
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone), ein Legal Hold ist nicht mehr möglich.');
}
$locked->forceFill([
'legal_hold' => true,
'legal_hold_note' => Str::limit($note, 990, ''),
'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();
}
/**
* Aufhebung: die Frist läuft ab dem Aufhebungsdatum neu (4.3).
*/
public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
DB::transaction(function () use ($entry, $admin, $note): void {
$locked = $this->lockedEntry($entry);
if ($locked->isTombstoned()) {
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone).');
}
$locked->forceFill([
'legal_hold' => false,
'legal_hold_note' => Str::limit($note, 990, ''),
'legal_hold_set_by' => $admin->getKey(),
'legal_hold_set_at' => now(),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
])->save();
$this->logAccess($locked, $admin, 'hold_released', $note);
});
$entry->refresh();
}
/**
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
* Jeder Eintrag wird unter Zeilensperre erneut geprüft; Fehlschläge
* werden gezählt und beim nächsten Lauf wiederholt.
*
* @return array{due: int, purged: int, failed: int}
*/
public function purgeDue(bool $dryRun = false): array
{
$result = ['due' => 0, 'purged' => 0, 'failed' => 0];
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$result, $dryRun): void {
foreach ($entries as $entry) {
$result['due']++;
if ($dryRun) {
continue;
}
try {
if ($this->tombstone($entry)) {
$result['purged']++;
}
} catch (Throwable $exception) {
$result['failed']++;
Log::error('Beweisarchiv: Löschung nach Fristablauf fehlgeschlagen, wird wiederholt.', [
'entry_id' => $entry->getKey(),
'error' => $exception->getMessage(),
]);
}
}
});
return $result;
}
/**
* Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird
* 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
{
$path = (string) config('archive.tombstone_export_path');
$temp = $path.'.tmp';
$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 ($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)) {
$this->disk()->delete($temp);
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.');
}
return $count;
}
/**
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
* 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, conflicts: int, export_signature: string, sweep_paused: bool}
*/
public function reconcileAfterRestore(bool $acceptUnsigned = false): array
{
$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) {
$path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null;
if ($path !== null && ! isset($handled[$path]) && $this->disk()->exists($path)) {
$handled[$path] = true;
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
}
// 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) {
$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')
->chunkById(500, function ($entries) use (&$result, &$handled): void {
foreach ($entries as $entry) {
$path = (string) $entry->payload_path;
if (isset($handled[$path]) || ! $this->disk()->exists($path)) {
continue;
}
$handled[$path] = true;
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
}
});
if ($result['failed'] > 0) {
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)
* 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{paused: bool, scanned: int, orphans: int, quarantined: int, removed: int, expired: int}
*/
public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array
{
$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 ($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) {
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;
}
if (isset($tombstoned[$target])) {
$result['skipped_tombstoned']++;
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()]);
}
}
}
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
* fällig ist (Hold gesetzt, Frist verlängert, schon Tombstone).
*/
private function tombstone(ArchiveEntry $entry): bool
{
$done = DB::transaction(function () use ($entry): bool {
$locked = $this->lockedEntry($entry);
if ($locked->isTombstoned() || $locked->legal_hold || $locked->retain_until->gte(today())) {
return false;
}
if ($locked->payload_path !== null) {
FileDeletion::deleteOrThrow($this->disk(), $locked->payload_path, 'Beweisarchiv');
}
$this->purgeApplicationCopies($locked);
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
// wiederfindet; der Inhalt ist weg.
$locked->forceFill(['deleted_at' => now()])->save();
return true;
});
if ($done) {
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
}
return $done;
}
/**
* Mit dem Archiv-Tombstone eines entfernten Bildes verschwinden auch die
* Kopien in der Anwendung: Bestätigungen, Korrekturen und die weich
* gelöschte Bildzeile (5.1 a). Das Archiv hielt die vollständige Kopie.
*/
private function purgeApplicationCopies(ArchiveEntry $entry): void
{
if (! in_array($entry->event, [ArchiveEvent::ImageRemoved, ArchiveEvent::ImageReplaced], true)
|| $entry->subject_type !== (new PressReleaseImage)->getMorphClass()) {
return;
}
$image = PressReleaseImage::withTrashed()->find($entry->subject_id);
if ($image !== null && ! $image->trashed()) {
return;
}
ImageRightsConfirmation::query()
->where('subject_type', $entry->subject_type)
->where('subject_id', $entry->subject_id)
->get()
->each(fn (ImageRightsConfirmation $confirmation) => $confirmation->purgeAfterArchive());
ImageDeclarationCorrection::query()
->where('press_release_image_id', $entry->subject_id)
->get()
->each(fn (ImageDeclarationCorrection $correction) => $correction->purgeAfterArchive());
$image?->forceDelete();
}
private function lockedEntry(ArchiveEntry $entry): ArchiveEntry
{
return ArchiveEntry::query()->whereKey($entry->getKey())->lockForUpdate()->firstOrFail();
}
private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void
{
$purpose = trim($purpose);
if ($purpose === '') {
throw new RuntimeException('Beweisarchiv: Zugriff ohne Zweck ist nicht vorgesehen.');
}
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(),
]);
}
private function plaintext(ArchiveEntry $entry): string
{
if ($entry->isTombstoned() || $entry->payload_path === null) {
throw new RuntimeException('Beweisarchiv: Eintrag ist nach Fristablauf gelöscht (Tombstone).');
}
$cipher = $this->disk()->get($entry->payload_path);
if (! is_string($cipher) || $cipher === '') {
throw new RuntimeException('Beweisarchiv: Payload fehlt auf der Archiv-Disk.');
}
$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.');
}
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
*/
private function buildZip(array $manifest, array $files, string $manifestName = 'manifest.json'): string
{
$temp = $this->tempFile('');
$zip = new ZipArchive;
try {
if ($zip->open($temp, ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Beweisarchiv: ZIP konnte nicht erzeugt werden.');
}
$zip->addFromString($manifestName, (string) json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
foreach ($files as $name => $bytes) {
$zip->addFromString($name, $bytes);
}
$zip->close();
return (string) file_get_contents($temp);
} finally {
@unlink($temp);
}
}
private function tempFile(string $contents): string
{
$temp = tempnam(sys_get_temp_dir(), 'pk-archive-');
if ($temp === false) {
throw new RuntimeException('Beweisarchiv: keine temporäre Datei verfügbar.');
}
file_put_contents($temp, $contents);
return $temp;
}
private function encrypter(): Encrypter
{
$key = $this->keyBytes();
if ($key === null) {
throw new RuntimeException('Beweisarchiv: ARCHIVE_KEY fehlt.');
}
return new Encrypter($key, (string) config('archive.cipher', 'AES-256-CBC'));
}
private function keyBytes(): ?string
{
return $this->decodeKey((string) config('archive.key'));
}
private function decodeKey(string $key): ?string
{
if ($key === '') {
return null;
}
if (Str::startsWith($key, 'base64:')) {
$key = base64_decode(substr($key, 7), true) ?: '';
}
return strlen($key) === 32 ? $key : null;
}
private function disk(): Filesystem
{
return Storage::disk((string) config('archive.disk', 'archive'));
}
}