keyBytes() !== null; } /** * @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 = [], ): ?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, '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()); if (! $this->disk()->put($path, $this->encrypter()->encrypt($zip, false))) { throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.'); } return ArchiveEntry::query()->create([ 'subject_type' => $subject->getMorphClass(), 'subject_id' => $subject->getKey(), 'press_release_id' => $pressReleaseId, 'event' => $event, 'actor_type' => $actorType, 'actor_id' => $actorId, 'reason_category' => $reason, 'summary' => $summary ?: null, 'payload_path' => $path, 'payload_hash' => $hash, 'payload_bytes' => strlen($zip), 'retain_until' => ArchiveEntry::retentionEndFor(now()), 'created_at' => now(), ]); } /** * 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); $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) : []; } /** * Export für den Anwalt: äußeres ZIP mit payload.zip, metadata.json und * einer HMAC-Signatur über den Hash (mit dem Archivschlüssel). */ 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, '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)."\nhmac-sha256=".hash_hmac('sha256', (string) $entry->payload_hash, (string) $this->keyBytes())."\n", ], 'metadata.json'); } public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void { $entry->forceFill([ 'legal_hold' => true, 'legal_hold_note' => Str::limit($note, 990, ''), 'legal_hold_set_by' => $admin->getKey(), 'legal_hold_set_at' => now(), ])->save(); } /** * Aufhebung: die Frist läuft ab dem Aufhebungsdatum neu (4.3). */ public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void { $entry->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(); } /** * Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt. */ public function purgeDue(bool $dryRun = false): int { $count = 0; ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$count, $dryRun): void { foreach ($entries as $entry) { $count++; if ($dryRun) { continue; } $this->tombstone($entry); } }); return $count; } /** * Tombstone-Stand exportieren, damit er einen Restore überlebt. */ 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(); $this->disk()->put((string) config('archive.tombstone_export_path'), (string) json_encode([ 'exported_at' => now()->toIso8601String(), 'entries' => $rows, ], JSON_PRETTY_PRINT)); return count($rows); } /** * Nach dem Einspielen einer Sicherung: alles, was im exportierten * Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile). * * @return array{re_tombstoned: int, files_removed: int} */ public function reconcileAfterRestore(): array { $result = ['re_tombstoned' => 0, 'files_removed' => 0]; $raw = $this->disk()->get((string) config('archive.tombstone_export_path')); if (! is_string($raw) || $raw === '') { return $result; } $rows = (array) (json_decode($raw, true)['entries'] ?? []); foreach ($rows as $row) { $path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null; if ($path !== null && $this->disk()->exists($path)) { $this->disk()->delete($path); $result['files_removed']++; } $entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0)); if ($entry !== null && ! $entry->isTombstoned()) { $entry->forceFill(['deleted_at' => now()])->save(); $result['re_tombstoned']++; } } return $result; } private function tombstone(ArchiveEntry $entry): void { DB::transaction(function () use ($entry): void { if ($entry->payload_path !== null && $this->disk()->exists($entry->payload_path)) { $this->disk()->delete($entry->payload_path); } // Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei // wiederfindet; der Inhalt ist weg. $entry->forceFill(['deleted_at' => now()])->save(); }); Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]); } 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(), '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.'); } $zip = (string) $this->encrypter()->decrypt($cipher, false); 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 $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 { $key = (string) config('archive.key'); 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')); } }