; s=[,…]` — je * Empfänger eine HMAC über Referenz und Empfängeradresse. Ein Zustellbericht * bringt die Original-Header zurück; nur wenn die Signatur zur gemeldeten * Adresse passt, stammt der Bericht nachweislich von einer Mail, die wir an * genau diese Adresse geschickt haben. Erst dann darf ein harter Bounce * sperren — sonst könnte jeder mit einer Mail ans Return-Path-Postfach * beliebige Adressen sperren und damit Magic Links, Resets und Rechnungen * abschneiden. * * Schlüssel: `bounces.signing_key` (BOUNCE_SIGNING_KEY), sonst aus APP_KEY * abgeleitet. Ein Schlüsselwechsel entwertet nur die Signaturen bereits * versendeter Mails — deren Rückläufer zählen dann weich. */ class BounceReference { /** Ohne fachliche Referenz der Mailable. */ public const DefaultReference = 'mail'; /** Mehr Signaturen schreibt kein Header; weitere Empfänger bleiben unsigniert. */ public const MaxRecipients = 20; private const SignatureLength = 32; /** * @param list $recipients */ public function sign(?string $reference, array $recipients): string { $reference = $this->plain($reference) ?? self::DefaultReference; $signatures = collect($recipients) ->map(fn (string $email): ?string => EmailSuppressionService::normalise($email)) ->filter() ->unique() ->take(self::MaxRecipients) ->map(fn (string $email): string => $this->signature($reference, $email)) ->implode(','); return $signatures === '' ? $reference : $reference.'; s='.$signatures; } /** * Ob der Header eine gültige Signatur für genau diesen Empfänger trägt. */ public function verify(?string $header, string $recipient): bool { $reference = $this->plain($header); $email = EmailSuppressionService::normalise($recipient); if ($reference === null || $email === null) { return false; } $expected = $this->signature($reference, $email); foreach ($this->signatures($header) as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } /** * Ob der Header überhaupt Signaturen trägt — unterscheidet „vor der * Umstellung versendet" (keine) von „gefälscht oder verändert" (falsche). */ public function isSigned(?string $header): bool { return $this->signatures($header) !== []; } /** * Die fachliche Referenz ohne Signaturteil (für Protokoll und Anzeige). */ public function plain(?string $header): ?string { if ($header === null) { return null; } $reference = trim((string) preg_replace('/;\s*s=.*$/s', '', $header)); return $reference === '' ? null : $reference; } /** * @return list */ private function signatures(?string $header): array { if ($header === null || preg_match('/;\s*s=(.+)$/s', $header, $m) !== 1) { return []; } $list = (string) preg_replace('/\s+/', '', $m[1]); return array_values(array_filter(explode(',', $list), fn (string $sig): bool => preg_match('/^[0-9a-f]{'.self::SignatureLength.'}$/', $sig) === 1)); } private function signature(string $reference, string $email): string { return substr(hash_hmac('sha256', $reference."\n".$email, $this->key()), 0, self::SignatureLength); } private function key(): string { $configured = (string) config('bounces.signing_key'); if ($configured !== '') { return $configured; } return hash_hmac('sha256', 'pk-bounce-reference', (string) config('app.key')); } }