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 */ 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 $manifest Fachdaten (werden als manifest.json abgelegt) * @param array $files Dateiname → Bytes * @param array $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 */ 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 $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 */ 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>, 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 $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 */ 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 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 $manifest * @param array $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')); } }