" cifrado * - conversation.started → verifica la vinculación con el secreto del link y, en una * reinvitación verificada, resella la clave para el dispositivo nuevo (rewrap) * * En producción: responde 2xx rápido y procesa en una cola (SecureChat corta a los 10 s), * guarda los eventId procesados y los inviteSecret en tu base de datos, y sirve esto por HTTPS. */ declare(strict_types=1); require __DIR__ . '/securechat.php'; use SecureChat\{ApiClient, CryptoError, Envelope, Keys, SignatureError, Webhook}; function respond(int $status, array $body): never { http_response_code($status); header('Content-Type: application/json'); echo json_encode($body); exit; } if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { respond(405, ['error' => 'POST only']); } $secret = getenv('SECURECHAT_WEBHOOK_SECRET') ?: ''; $keysDir = getenv('SECURECHAT_KEYS_DIR') ?: __DIR__ . '/keys'; $stateDir = getenv('SECURECHAT_STATE_DIR') ?: sys_get_temp_dir() . '/securechat-php-demo'; @mkdir($stateDir, 0700, true); // 1. Firma. El cuerpo crudo, nunca json_encode(json_decode(...)). $rawBody = file_get_contents('php://input'); try { $event = Webhook::verify($rawBody, $_SERVER['HTTP_X_SECURECHAT_SIGNATURE'] ?? '', $secret); } catch (SignatureError $e) { respond(401, ['error' => $e->errorCode]); } // 2. Idempotencia por eventId (la entrega es "al menos una vez"). $eventId = (string) ($event['eventId'] ?? ''); if (preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $eventId) !== 1) { respond(400, ['error' => 'eventId inválido']); } $seenFile = "{$stateDir}/seen-{$eventId}"; if (!@fopen($seenFile, 'x')) { respond(200, ['ok' => true, 'duplicate' => true]); } // Llave actual primero y, si rotaste, las anteriores (private.v1.key / public.v1.key…). $companyKeys = [Keys::loadCompanyKeys($keysDir)]; foreach (glob("{$keysDir}/private.v*.key") ?: [] as $old) { $v = basename($old, '.key'); // private.v1 $companyKeys[] = Keys::loadCompanyKeys($keysDir, "{$v}.key", 'public.' . substr($v, 8) . '.key'); } $api = new ApiClient(getenv('SECURECHAT_API_URL') ?: '', getenv('SECURECHAT_API_KEY') ?: '', $secret); $cid = $event['conversationId'] ?? ''; $data = $event['data'] ?? []; try { switch ($event['eventType'] ?? '') { case 'message.created': if (($data['contentType'] ?? 'text') !== 'text') { break; // adjuntos: ver docs/13 §5 } ['text' => $text, 'conversationKey' => $k, 'keyVersion' => $kv] = Envelope::decryptEvent($event, $companyKeys); error_log("[{$cid}] cliente: {$text}"); $api->sendMessage($cid, Envelope::encryptReply($k, $cid, $kv, "Recibido: {$text}"), "reply-{$eventId}"); break; case 'conversation.started': // El inviteSecret lo guardaste al crear la invitación (aquí: un JSON de demo). $secrets = json_decode(@file_get_contents("{$stateDir}/invite-secrets.json") ?: '{}', true) ?: []; $verified = false; if (isset($secrets[$cid]) && isset($data['inviteBinding'])) { Envelope::verifyInviteBinding( $data['inviteBinding'], \SecureChat\Codec::b64uDecode($secrets[$cid]), $cid, \SecureChat\Codec::b64uDecode($data['userPublicKey']), $data['conversationKey'], ); // lanza BINDING_INVALID si alguien sustituyó la llave del usuario $verified = true; } if (!empty($data['isReinvite'])) { if (!$verified) { error_log("[{$cid}] reinvitación sin vinculación: no reselló (confírmalo con el cliente)"); break; } $k = Envelope::openWithAnyKey($data['conversationKey'], $cid, $companyKeys); $wrap = Envelope::wrapConversationKey($k, $cid, $data['conversationKey']['keyVersion'], \SecureChat\Codec::b64uDecode($data['userPublicKey'])); $api->rewrap($cid, $wrap); } error_log("[{$cid}] conversación iniciada · verificada=" . ($verified ? 'sí' : 'no')); break; default: break; // tolera eventos nuevos } } catch (CryptoError $e) { // Mensaje manipulado o llave equivocada: nunca lo trates como texto. error_log("[{$cid}] {$e->errorCode}: {$e->getMessage()}"); respond(200, ['ok' => false, 'error' => $e->errorCode]); // 2xx: reintentar no lo arregla } catch (\Throwable $e) { @unlink($seenFile); // error transitorio (red, API): deja que SecureChat reintente error_log("[{$cid}] error: {$e->getMessage()}"); respond(500, ['error' => 'INTERNAL']); } respond(200, ['ok' => true]);