Verificar una entrega
`OpenEmail::verifyWebhookSignature`: en tiempo constante, con una ventana de repetición, y devuelve el evento ya analizado.
En un handler de solicitudes
La URL de un webhook es pública. Cualquier cosa en internet puede hacerle POST de un JSON con la forma correcta, así que un gestor que lee el type del evento sin comprobar la firma es una API de escritura abierta.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; $payload = (string) file_get_contents('php://input'); try { $event = OpenEmail::verifyWebhookSignature( $payload, $_SERVER, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), toleranceSeconds: 300, );} catch (WebhookSignatureException) { http_response_code(400); return;} error_log($event['type'] . ' ' . $event['id']);http_response_code(204);Pasa el cuerpo EN BRUTO, como cadena. Decodificarlo y volver a codificarlo cambia el orden de las claves y los espacios, y la firma no coincidirá, por eso la carga útil tiene el tipo string: un array, como el resultado de json_decode() o la entrada ya analizada por un framework, da un TypeError en lugar de comprobarse. Las cabeceras pueden ser un array con el nombre de la cabecera en cualquier combinación de mayúsculas y minúsculas, como lo devuelve getallheaders(), $_SERVER, donde la cabecera llega como HTTP_X_OPENEMAIL_SIGNATURE, cualquier iterable como un HeaderBag de Symfony o Laravel, o una solicitud PSR-7. Un valor que es un array se lee de su primer elemento.
Dos cosas que esto resuelve y que una comprobación casera no suele resolver: compara el MAC en tiempo constante con hash_equals(), de modo que el prefijo correcto no puede recuperarse midiendo tiempos, y rechaza una entrega con más de toleranceSeconds: de antigüedad en cualquiera de las dos direcciones, cinco minutos salvo que digas otra cosa, así que una solicitud capturada no se puede repetir para siempre. toleranceSeconds: 0 desactiva la comprobación de repetición. Ambos fallos son silenciosos. Un gestor con cualquiera de los dos pasa todas las pruebas que se te ocurriría escribir.
Lanza OpenEmail\Exception\WebhookSignatureException ante cualquier fallo: una cabecera X-OpenEmail-Signature ausente, una que no tenga la forma t=<seconds>,v1=<hex>, una marca de tiempo fuera de la ventana, una firma que no coincide o un cuerpo firmado que no es un objeto JSON. Si tiene éxito, devuelve el cuerpo decodificado en un array con claves en camelCase, con id, type, createdAt y data, así que no hay un segundo json_decode() en el que equivocarse.
Un secreto vacío lanza en cambio OpenEmail\Exception\InvalidArgumentException, porque es un error de tu configuración y no una entrega incorrecta, y el (string) delante de getenv() convierte una variable sin definir exactamente en eso. Captura solo WebhookSignatureException y responde 400. Así, un secreto ausente termina el script con una excepción no capturada, a la que PHP en producción responde con un 500, y la entrega se vuelve a intentar una vez que lo corriges, en lugar de rechazar cada evento como falsificado.
La comprobación se basa en hash_hmac() y hash_equals(), que tiene cualquier compilación de PHP, así que no necesita ninguna extensión. El ejemplo usa return en lugar de llamar a exit, así que las mismas líneas también funcionan dentro de una función o de un controlador frontal.
En una app PSR-7
Con una solicitud PSR-7, como la que te entregan Slim y Mezzio, pasa la propia solicitud como cabeceras y su cuerpo como cadena.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Psr\Http\Message\ResponseFactoryInterface;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface; function receiveOpenEmail(ServerRequestInterface $request, ResponseFactoryInterface $responses): ResponseInterface{ try { $event = OpenEmail::verifyWebhookSignature( (string) $request->getBody(), $request, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), ); } catch (WebhookSignatureException) { return $responses->createResponse(400); } error_log($event['type'] . ' ' . $event['id']); return $responses->createResponse(204);}Responde rápido: una entrega que no recibe respuesta en 5 segundos cuenta como fallida y se vuelve a enviar más tarde, así que delega la tarea en una cola y responde con un 2xx. Los controladores de Laravel y Symfony, con el paso a la cola, están en la página de frameworks.
OpenEmail\Constants\WebhookSignatureHeaders nombra las tres cabeceras que lleva una entrega: X-OpenEmail-Signature, X-OpenEmail-Event con el tipo del evento, y X-OpenEmail-Delivery con su id, el mismo id que en el cuerpo. Ese id no cambia en ningún reintento ni reenvío del evento, así que es el que hay que guardar para omitir los eventos que ya procesaste.
Mientras rota el secreto
webhooks->rotateSecret no tiene ventana de solapamiento: las entregas se firman con el secreto nuevo desde el momento en que devuelve. Despliega primero un receptor que acepte cualquiera de los dos secretos, rota, guarda el secreto nuevo donde lo lee el receptor y luego retira el antiguo.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; function verifyDelivery(string $payload, mixed $headers): array{ $next = getenv('OPENEMAIL_WEBHOOK_SECRET_NEXT'); try { return OpenEmail::verifyWebhookSignature($payload, $headers, (string) getenv('OPENEMAIL_WEBHOOK_SECRET')); } catch (WebhookSignatureException $error) { if (!is_string($next) || $next === '') { throw $error; } return OpenEmail::verifyWebhookSignature($payload, $headers, $next); }}webhooks->test envía un evento sintético firmado, así que llámalo después de la rotación para demostrar que el secreto nuevo se verifica antes de retirar el antiguo.