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:
Kevin Adametz 2026-10-02 13:09:01 +00:00
parent c82f279fef
commit 60650baeb8
29 changed files with 2918 additions and 412 deletions

View file

@ -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;
}