= 8.1 con ext-sodium (incluida en PHP desde 7.2; compruébalo con * `php -m | grep sodium`) y allow_url_fopen para el cliente HTTP. * * Es un port de packages/crypto/src/envelope.js (sobre v1 "sc1") y de sdk-node/src/webhook.js * (firmas HMAC). Si cambias algo, ejecuta `php run-vectors.php`: tiene que dar 82/82. * Especificación completa: docs/13-INTEGRACION-OTROS-LENGUAJES.md * * require __DIR__ . '/securechat.php'; * use SecureChat\{Keys, Webhook, Envelope}; * * $keys = Keys::loadCompanyKeys('/ruta/segura/keys'); // private.key / public.key * $event = Webhook::verify($rawBody, $_SERVER['HTTP_X_SECURECHAT_SIGNATURE'] ?? '', $secret); * ['text' => $text] = Envelope::decryptEvent($event, [$keys]); */ declare(strict_types=1); namespace SecureChat; if (!extension_loaded('sodium')) { throw new \RuntimeException('SecureChat necesita ext-sodium (php -m | grep sodium)'); } /** Error criptográfico. `$errorCode` es uno de los códigos de envelope.js (INVALID_INPUT, DECRYPT_FAILED…). */ final class CryptoError extends \RuntimeException { public function __construct(public readonly string $errorCode, string $message) { parent::__construct("{$errorCode}: {$message}"); } } /** Firma HMAC inválida: SIGNATURE_MISSING, SIGNATURE_MALFORMED, SIGNATURE_EXPIRED, SIGNATURE_INVALID, SECRET_MISSING. */ final class SignatureError extends \RuntimeException { public function __construct(public readonly string $errorCode, string $message) { parent::__construct("{$errorCode}: {$message}"); } } /** Respuesta de error de la API /v1: `{ "error": { "code", "message", "details" } }`. */ final class ApiError extends \RuntimeException { public function __construct(public readonly int $status, public readonly string $errorCode, string $message, public readonly mixed $details = null) { parent::__construct("{$status} {$errorCode}: {$message}"); } } // ─── Codecs y validación ───────────────────────────────────────────────────── final class Codec { /** base64url sin relleno (RFC 4648 §5). */ public static function b64uEncode(string $bytes): string { return sodium_bin2base64($bytes, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); } /** Estricto: alfabeto base64url, sin '=', sin espacios ni saltos, sin bits sobrantes. */ public static function b64uDecode(mixed $str): string { if (!is_string($str)) { throw new CryptoError('INVALID_INPUT', 'b64uDecode espera string'); } $len = strlen($str); if ($len % 4 === 1) { throw new CryptoError('INVALID_INPUT', 'longitud base64url inválida'); } if (preg_match('/^[A-Za-z0-9_-]*$/D', $str) !== 1) { throw new CryptoError('INVALID_INPUT', 'carácter base64url inválido'); } $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; $rest = $len % 4; if ($rest !== 0) { $last = strpos($alphabet, $str[$len - 1]); if (($rest === 2 && ($last & 0x0f) !== 0) || ($rest === 3 && ($last & 0x03) !== 0)) { throw new CryptoError('INVALID_INPUT', 'bits sobrantes en base64url'); } } if ($len === 0) { return ''; } try { return sodium_base642bin($str, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); } catch (\SodiumException) { throw new CryptoError('INVALID_INPUT', 'base64url inválido'); } } /** UTF-8 estricto (PCRE rechaza overlongs, surrogates y > U+10FFFF). */ public static function isUtf8(string $s): bool { return preg_match('//u', $s) === 1; } /** Longitud en unidades UTF-16, como `String.prototype.length` en JS. */ public static function utf16Length(string $s): int { return preg_match_all('/./su', $s) + preg_match_all('/[\x{10000}-\x{10FFFF}]/u', $s); } public static function assertBytes(mixed $v, string $what, ?int $length = null): void { if (!is_string($v)) { throw new CryptoError('INVALID_INPUT', "{$what}: se esperaban bytes"); } if ($length !== null && strlen($v) !== $length) { throw new CryptoError('INVALID_INPUT', "{$what}: se esperaban {$length} bytes, hay " . strlen($v)); } } public static function assertId(mixed $v, string $what): void { if (!is_string($v) || preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $v) !== 1) { throw new CryptoError('INVALID_INPUT', "{$what} debe cumplir [A-Za-z0-9_-]{1,128}"); } } public static function assertKeyVersion(mixed $v): void { if (!is_int($v) || $v < 1 || $v > 0x7fffffff) { throw new CryptoError('INVALID_INPUT', 'keyVersion debe ser un entero entre 1 y 2^31-1'); } } public static function u32be(int $n): string { return pack('N', $n); } } // ─── Llaves de la empresa ─────────────────────────────────────────────────── final class Keys { /** * Lee private.key / public.key (X25519, base64url de 32 bytes + "\n") del formato que * escribe `securechat keygen`. Comprueba que la pública corresponde a la privada. * Para una llave rotada: loadCompanyKeys($dir, 'private.v1.key', 'public.v1.key'). * * @return array{publicKey: string, privateKey: string} bytes crudos */ public static function loadCompanyKeys(string $dir, string $privFile = 'private.key', string $pubFile = 'public.key'): array { $priv = Codec::b64uDecode(trim(self::read("{$dir}/{$privFile}"))); $pub = Codec::b64uDecode(trim(self::read("{$dir}/{$pubFile}"))); Codec::assertBytes($priv, 'private.key', 32); Codec::assertBytes($pub, 'public.key', 32); if (!hash_equals(sodium_crypto_box_publickey_from_secretkey($priv), $pub)) { throw new CryptoError('KEY_MISMATCH', 'public.key no corresponde a private.key'); } return ['publicKey' => $pub, 'privateKey' => $priv]; } /** * Lee signing.key (Ed25519, 64 bytes = semilla ‖ pública) y signing.pub (32 bytes). * * @return array{publicKey: string, privateKey: string} */ public static function loadSigningKeys(string $dir): array { $priv = Codec::b64uDecode(trim(self::read("{$dir}/signing.key"))); $pub = Codec::b64uDecode(trim(self::read("{$dir}/signing.pub"))); Codec::assertBytes($priv, 'signing.key', 64); Codec::assertBytes($pub, 'signing.pub', 32); if (!hash_equals(sodium_crypto_sign_publickey_from_secretkey($priv), $pub)) { throw new CryptoError('KEY_MISMATCH', 'signing.pub no corresponde a signing.key'); } return ['publicKey' => $pub, 'privateKey' => $priv]; } /** * Equivalente a `securechat keygen --out $dir`: mismos nombres, formato y permisos (0600 * para las privadas). No sobrescribe nada. * * @return array{publicKey: string, signingPublicKey: string, fingerprint: string} en base64url / huella para mostrar */ public static function generateKeyFiles(string $dir): array { foreach (['private.key', 'public.key', 'signing.key', 'signing.pub'] as $f) { if (file_exists("{$dir}/{$f}")) { throw new \RuntimeException("ya existe {$dir}/{$f}: no lo sobrescribo"); } } if (!is_dir($dir) && !mkdir($dir, 0700, true)) { throw new \RuntimeException("no se pudo crear {$dir}"); } $box = sodium_crypto_box_keypair(); $sig = sodium_crypto_sign_keypair(); $pub = sodium_crypto_box_publickey($box); $sigPub = sodium_crypto_sign_publickey($sig); self::writeSecret("{$dir}/private.key", Codec::b64uEncode(sodium_crypto_box_secretkey($box))); self::writeSecret("{$dir}/signing.key", Codec::b64uEncode(sodium_crypto_sign_secretkey($sig))); file_put_contents("{$dir}/public.key", Codec::b64uEncode($pub) . "\n"); file_put_contents("{$dir}/signing.pub", Codec::b64uEncode($sigPub) . "\n"); return [ 'publicKey' => Codec::b64uEncode($pub), 'signingPublicKey' => Codec::b64uEncode($sigPub), 'fingerprint' => Envelope::formatFingerprint(Envelope::keyFingerprint($pub)), ]; } private static function read(string $file): string { $s = @file_get_contents($file); if ($s === false) { throw new \RuntimeException("no se pudo leer {$file}"); } return $s; } private static function writeSecret(string $file, string $value): void { $old = umask(0077); try { file_put_contents($file, $value . "\n"); chmod($file, 0600); } finally { umask($old); } } } // ─── Sobre v1 (port de packages/crypto/src/envelope.js) ───────────────────── final class Envelope { public const VERSION = 1; public const SCHEME = 'sc1'; public const PAD_BLOCK = 256; public const MAX_PLAINTEXT_BYTES = 65536; public const ATTACHMENT_CHUNK = 65536; public const ATTACHMENT_MAX_BYTES = 26214400; private const DOMAIN = 'SecureChat|v1'; private const WRAP_MAGIC = 'SCK1'; // Relleno ISO/IEC 7816-4 public static function paddedLength(int $n): int { return intdiv($n + 1 + self::PAD_BLOCK - 1, self::PAD_BLOCK) * self::PAD_BLOCK; } public static function pad(string $bytes): string { return str_pad($bytes . "\x80", self::paddedLength(strlen($bytes)), "\x00"); } public static function unpad(string $bytes): string { $len = strlen($bytes); if ($len === 0 || $len % self::PAD_BLOCK !== 0) { throw new CryptoError('BAD_PADDING', 'longitud no múltiplo del bloque'); } $i = $len - 1; while ($i >= 0 && $bytes[$i] === "\x00") { $i--; } if ($i < 0 || $bytes[$i] !== "\x80") { throw new CryptoError('BAD_PADDING', 'marcador 0x80 ausente'); } if ($len - $i > self::PAD_BLOCK) { throw new CryptoError('BAD_PADDING', 'relleno más largo que un bloque'); } return substr($bytes, 0, $i); } /** SecureChat|v1|msg|||| */ public static function messageAD(mixed $conversationId, mixed $senderType, mixed $clientMessageId, mixed $keyVersion): string { Codec::assertId($conversationId, 'conversationId'); if ($senderType !== 'user' && $senderType !== 'company') { throw new CryptoError('INVALID_INPUT', 'senderType inválido'); } Codec::assertId($clientMessageId, 'clientMessageId'); Codec::assertKeyVersion($keyVersion); return self::DOMAIN . "|msg|{$conversationId}|{$senderType}|{$clientMessageId}|{$keyVersion}"; } /** crypto_box_seed_keypair: solo para los vectores (en producción, Keys::generateKeyFiles). */ public static function identityKeyPairFromSeed(string $seed): array { Codec::assertBytes($seed, 'seed', 32); $kp = sodium_crypto_box_seed_keypair($seed); return ['publicKey' => sodium_crypto_box_publickey($kp), 'privateKey' => sodium_crypto_box_secretkey($kp)]; } // Clave de conversación /** Sella K para un destinatario: crypto_box_seal("SCK1" ‖ u32be(keyVersion) ‖ K ‖ conversationId). */ public static function wrapConversationKey(string $key, string $conversationId, int $keyVersion, string $recipientPublicKey): array { Codec::assertBytes($key, 'key', 32); Codec::assertId($conversationId, 'conversationId'); Codec::assertKeyVersion($keyVersion); Codec::assertBytes($recipientPublicKey, 'recipientPublicKey', 32); $payload = self::WRAP_MAGIC . Codec::u32be($keyVersion) . $key . $conversationId; return ['keyVersion' => $keyVersion, 'sealed' => Codec::b64uEncode(sodium_crypto_box_seal($payload, $recipientPublicKey))]; } /** * Abre una envoltura `{ keyVersion, sealed }` y devuelve K (32 bytes). * DECRYPT_FAILED: no es para esta llave. KEY_MISMATCH: es para esta llave pero de otro contexto. */ public static function openConversationKey(mixed $wrapped, mixed $conversationId, string $publicKey, string $privateKey): string { Codec::assertId($conversationId, 'conversationId'); Codec::assertBytes($publicKey, 'publicKey', 32); Codec::assertBytes($privateKey, 'privateKey', 32); if (!is_array($wrapped) || !is_string($wrapped['sealed'] ?? null)) { throw new CryptoError('INVALID_INPUT', 'wrapped.sealed ausente'); } Codec::assertKeyVersion($wrapped['keyVersion'] ?? null); $sealed = Codec::b64uDecode($wrapped['sealed']); try { $kp = sodium_crypto_box_keypair_from_secretkey_and_publickey($privateKey, $publicKey); $payload = sodium_crypto_box_seal_open($sealed, $kp); } catch (\SodiumException) { $payload = false; } if ($payload === false) { throw new CryptoError('DECRYPT_FAILED', 'no se pudo abrir la clave de conversación'); } if (strlen($payload) < 4 + 4 + 32 + 1 || substr($payload, 0, 4) !== self::WRAP_MAGIC) { throw new CryptoError('KEY_MISMATCH', 'envoltura con formato inválido'); } $kv = unpack('N', substr($payload, 4, 4))[1]; if ($kv !== $wrapped['keyVersion']) { throw new CryptoError('KEY_MISMATCH', 'keyVersion de la envoltura no coincide'); } $cid = substr($payload, 40); if (!Codec::isUtf8($cid)) { throw new CryptoError('INVALID_INPUT', 'UTF-8 inválido'); } if ($cid !== $conversationId) { throw new CryptoError('KEY_MISMATCH', 'la clave pertenece a otra conversación'); } return substr($payload, 8, 32); } /** * Prueba una lista de pares [actual, anterior, …] (tras rotar la llave en el portal, las * conversaciones viejas siguen selladas para la anterior). Solo sigue con la siguiente * llave si el error es DECRYPT_FAILED. * * @param list $companyKeys */ public static function openWithAnyKey(array $wrapped, string $conversationId, array $companyKeys): string { if (!$companyKeys) { throw new CryptoError('INVALID_INPUT', 'companyKeys vacío'); } $last = null; foreach ($companyKeys as $k) { try { return self::openConversationKey($wrapped, $conversationId, $k['publicKey'], $k['privateKey']); } catch (CryptoError $e) { if ($e->errorCode !== 'DECRYPT_FAILED') { throw $e; } $last = $e; } } throw $last; } // Mensajes /** Cifra texto. `$nonce` solo para vectores; en producción se omite (aleatorio). */ public static function encryptMessage(string $key, string $conversationId, string $senderType, string $clientMessageId, int $keyVersion, string $text, ?string $nonce = null): array { Codec::assertBytes($key, 'key', 32); if (!Codec::isUtf8($text)) { throw new CryptoError('INVALID_INPUT', 'el texto no es UTF-8 válido'); } if (strlen($text) > self::MAX_PLAINTEXT_BYTES) { throw new CryptoError('INVALID_INPUT', 'mensaje demasiado largo'); } $ad = self::messageAD($conversationId, $senderType, $clientMessageId, $keyVersion); $n = $nonce ?? random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); Codec::assertBytes($n, 'nonce', 24); $ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(self::pad($text), $ad, $n, $key); return [ 'v' => self::VERSION, 'scheme' => self::SCHEME, 'keyVersion' => $keyVersion, 'clientMessageId' => $clientMessageId, 'nonce' => Codec::b64uEncode($n), 'ciphertext' => Codec::b64uEncode($ct), ]; } /** * Descifra. `$conversationId` y `$senderType` los pone el RECEPTOR (lo que espera), nunca * se toman del evento: así el servidor no puede reetiquetarlos. */ public static function decryptMessage(string $key, mixed $conversationId, string $senderType, mixed $envelope): string { Codec::assertBytes($key, 'key', 32); if (!is_array($envelope)) { throw new CryptoError('INVALID_INPUT', 'sobre ausente'); } if (($envelope['v'] ?? null) !== self::VERSION || ($envelope['scheme'] ?? null) !== self::SCHEME) { throw new CryptoError('UNSUPPORTED_VERSION', 'versión o esquema de sobre no soportado'); } $ad = self::messageAD($conversationId, $senderType, $envelope['clientMessageId'] ?? null, $envelope['keyVersion'] ?? null); $nonce = Codec::b64uDecode($envelope['nonce'] ?? null); Codec::assertBytes($nonce, 'nonce', 24); $ct = Codec::b64uDecode($envelope['ciphertext'] ?? null); if (strlen($ct) < 16 + self::PAD_BLOCK || (strlen($ct) - 16) % self::PAD_BLOCK !== 0) { throw new CryptoError('DECRYPT_FAILED', 'longitud de ciphertext inválida'); } $padded = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ct, $ad, $nonce, $key); if ($padded === false) { throw new CryptoError('DECRYPT_FAILED', 'autenticación fallida'); } $plain = self::unpad($padded); if (!Codec::isUtf8($plain)) { throw new CryptoError('INVALID_INPUT', 'UTF-8 inválido'); } return $plain; } /** Id para el AD de una respuesta: único por conversación, [A-Za-z0-9_-]. */ public static function newClientMessageId(): string { return 'srv_' . Codec::b64uEncode(random_bytes(16)); } /** Respuesta de la empresa → objeto `encryption` para POST /v1/messages. */ public static function encryptReply(string $conversationKey, string $conversationId, int $keyVersion, string $text, ?string $clientMessageId = null): array { return self::encryptMessage($conversationKey, $conversationId, 'company', $clientMessageId ?? self::newClientMessageId(), $keyVersion, $text); } /** * Webhook `message.created`: abre `data.conversationKey` y descifra `data.encryption` * con senderType fijado en `user`. * * @return array{text: string, conversationKey: string, keyVersion: int} */ public static function decryptEvent(array $event, array $companyKeys): array { $data = $event['data'] ?? null; if (!is_array($data) || !is_array($data['encryption'] ?? null) || !is_array($data['conversationKey'] ?? null)) { throw new CryptoError('INVALID_INPUT', 'el evento no trae data.encryption y data.conversationKey'); } $cid = $event['conversationId'] ?? null; Codec::assertId($cid, 'conversationId'); $k = self::openWithAnyKey($data['conversationKey'], $cid, $companyKeys); $text = self::decryptMessage($k, $cid, 'user', $data['encryption']); return ['text' => $text, 'conversationKey' => $k, 'keyVersion' => $data['conversationKey']['keyVersion']]; } // Huella e invitación /** BLAKE2b-256("SecureChat|v1|fp|" ‖ publicKey). */ public static function keyFingerprint(string $publicKey): string { Codec::assertBytes($publicKey, 'publicKey', 32); return sodium_crypto_generichash(self::DOMAIN . '|fp|' . $publicKey, '', 32); } /** 16 grupos de 4 hex separados por espacio (lo que muestra `securechat keygen`). */ public static function formatFingerprint(string $fp): string { return implode(' ', str_split(bin2hex($fp), 4)); } /** Secreto del fragmento del link: 32 bytes aleatorios. Guárdalo con el conversationId. */ public static function createInviteSecret(): string { return Codec::b64uEncode(random_bytes(32)); } /** `#s=&fp=`. */ public static function buildInviteUrl(string $inviteUrl, string $inviteSecretB64u, string $companyPublicKey): string { return $inviteUrl . '#s=' . $inviteSecretB64u . '&fp=' . Codec::b64uEncode(self::keyFingerprint($companyPublicKey)); } /** BLAKE2b-256 con clave inviteSecret sobre SecureChat|v1|invite-binding||||. */ public static function inviteBindingTag(string $inviteSecret, string $conversationId, string $userPublicKey, array $wrapForCompany): string { Codec::assertBytes($inviteSecret, 'inviteSecret', 32); Codec::assertId($conversationId, 'conversationId'); Codec::assertBytes($userPublicKey, 'userPublicKey', 32); if (!is_string($wrapForCompany['sealed'] ?? null)) { throw new CryptoError('INVALID_INPUT', 'wrapForCompany ausente'); } $kv = $wrapForCompany['keyVersion'] ?? ''; $msg = self::DOMAIN . "|invite-binding|{$conversationId}|" . Codec::b64uEncode($userPublicKey) . "|{$kv}|{$wrapForCompany['sealed']}"; return Codec::b64uEncode(sodium_crypto_generichash($msg, $inviteSecret, 32)); } /** Lanza BINDING_INVALID si la llave del usuario no vino de quien tenía el link. */ public static function verifyInviteBinding(mixed $tag, string $inviteSecret, string $conversationId, string $userPublicKey, array $wrapForCompany): bool { $expected = Codec::b64uDecode(self::inviteBindingTag($inviteSecret, $conversationId, $userPublicKey, $wrapForCompany)); if (!is_string($tag) || !hash_equals($expected, Codec::b64uDecode($tag))) { throw new CryptoError('BINDING_INVALID', 'la llave del usuario no está ligada al secreto de la invitación'); } return true; } // Firmas Ed25519 public static function companyKeyStatement(string $companyId, int $keyVersion, string $publicKey): string { Codec::assertId($companyId, 'companyId'); Codec::assertKeyVersion($keyVersion); Codec::assertBytes($publicKey, 'publicKey', 32); return self::DOMAIN . "|company-key|{$companyId}|{$keyVersion}|" . Codec::b64uEncode($publicKey); } /** Firma de la PLATAFORMA sobre tu llave (la verifica la app; útil para auditar). */ public static function verifyCompanyKey(string $companyId, int $keyVersion, string $publicKey, string $signatureB64u, string $platformPublicKey): bool { Codec::assertBytes($platformPublicKey, 'platformPublicKey', 32); $sig = Codec::b64uDecode($signatureB64u); Codec::assertBytes($sig, 'signature', 64); if (!sodium_crypto_sign_verify_detached($sig, self::companyKeyStatement($companyId, $keyVersion, $publicKey), $platformPublicKey)) { throw new CryptoError('SIGNATURE_INVALID', 'la llave de la empresa no está firmada por la plataforma'); } return true; } public static function companyRotationStatement(string $companyId, int $fromKeyVersion, string $fromPublicKey, int $toKeyVersion, string $toPublicKey): string { Codec::assertId($companyId, 'companyId'); Codec::assertKeyVersion($fromKeyVersion); Codec::assertKeyVersion($toKeyVersion); if ($toKeyVersion <= $fromKeyVersion) { throw new CryptoError('INVALID_INPUT', 'toKeyVersion debe ser mayor que fromKeyVersion'); } Codec::assertBytes($fromPublicKey, 'fromPublicKey', 32); Codec::assertBytes($toPublicKey, 'toPublicKey', 32); return self::DOMAIN . "|company-key-rotation|{$companyId}|{$fromKeyVersion}|" . Codec::b64uEncode($fromPublicKey) . "|{$toKeyVersion}|" . Codec::b64uEncode($toPublicKey); } /** `rotationSignature` que se pega en el portal. `$signingPrivateKey`: 64 bytes de signing.key. */ public static function signCompanyRotation(string $signingPrivateKey, string $companyId, int $fromKeyVersion, string $fromPublicKey, int $toKeyVersion, string $toPublicKey): string { Codec::assertBytes($signingPrivateKey, 'signingPrivateKey', 64); $stmt = self::companyRotationStatement($companyId, $fromKeyVersion, $fromPublicKey, $toKeyVersion, $toPublicKey); return Codec::b64uEncode(sodium_crypto_sign_detached($stmt, $signingPrivateKey)); } public static function verifyCompanyRotation(string $signatureB64u, string $signingPublicKey, string $companyId, int $fromKeyVersion, string $fromPublicKey, int $toKeyVersion, string $toPublicKey): bool { Codec::assertBytes($signingPublicKey, 'signingPublicKey', 32); $sig = Codec::b64uDecode($signatureB64u); Codec::assertBytes($sig, 'signature', 64); $stmt = self::companyRotationStatement($companyId, $fromKeyVersion, $fromPublicKey, $toKeyVersion, $toPublicKey); if (!sodium_crypto_sign_verify_detached($sig, $stmt, $signingPublicKey)) { throw new CryptoError('SIGNATURE_INVALID', 'la rotación no está firmada por la llave de firma de la empresa'); } return true; } // Lista de agentes de la bandeja web, firmada con tu signing.key public const AGENT_ROSTER_MAX_AGENTS = 1000; /** * SecureChat|v1|agent-roster||||:,… * `$agents`: [['agentKeyId' => 'agk_…', 'publicKey' => 32 bytes], …] en cualquier orden. */ public static function agentRosterStatement(mixed $companyId, mixed $rosterVersion, mixed $issuedAt, mixed $agents): string { Codec::assertId($companyId, 'companyId'); if (!is_int($rosterVersion) || $rosterVersion < 1 || $rosterVersion > 0x7fffffff) { throw new CryptoError('INVALID_INPUT', 'rosterVersion debe ser un entero entre 1 y 2^31-1'); } if (!is_string($issuedAt) || preg_match('/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$/D', $issuedAt, $m) !== 1) { throw new CryptoError('INVALID_INPUT', 'issuedAt debe ser YYYY-MM-DDTHH:MM:SSZ'); } [, $y, $mo, $d, $h, $mi, $s] = array_map('intval', $m); $leap = ($y % 4 === 0 && $y % 100 !== 0) || $y % 400 === 0; $days = [31, $leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if ($mo < 1 || $mo > 12 || $d < 1 || $d > $days[$mo - 1] || $h > 23 || $mi > 59 || $s > 59) { throw new CryptoError('INVALID_INPUT', 'issuedAt no es una fecha válida'); } if (!is_array($agents) || !array_is_list($agents)) { throw new CryptoError('INVALID_INPUT', 'agents debe ser una lista'); } if (count($agents) > self::AGENT_ROSTER_MAX_AGENTS) { throw new CryptoError('INVALID_INPUT', 'como mucho ' . self::AGENT_ROSTER_MAX_AGENTS . ' agentes'); } $entries = []; foreach ($agents as $a) { $id = is_array($a) ? ($a['agentKeyId'] ?? null) : null; if (!is_string($id) || preg_match('/^agk_[A-Za-z0-9_-]{8,64}$/D', $id) !== 1) { throw new CryptoError('INVALID_INPUT', 'agentKeyId debe cumplir agk_[A-Za-z0-9_-]{8,64}'); } Codec::assertBytes($a['publicKey'] ?? null, 'agent.publicKey', 32); $entries[] = [$id, Codec::b64uEncode($a['publicKey'])]; } usort($entries, fn ($x, $y) => strcmp($x[0], $y[0])); // orden de bytes $seen = []; foreach ($entries as $i => [$id, $pk]) { if ($i > 0 && $id === $entries[$i - 1][0]) { throw new CryptoError('INVALID_INPUT', 'agentKeyId repetido'); } if (isset($seen[$pk])) { throw new CryptoError('INVALID_INPUT', 'llave de agente repetida'); } $seen[$pk] = true; } $list = implode(',', array_map(fn ($e) => "{$e[0]}:{$e[1]}", $entries)); return self::DOMAIN . "|agent-roster|{$companyId}|{$rosterVersion}|{$issuedAt}|{$list}"; } /** Firma la lista con la secreta Ed25519 de 64 bytes (signing.key). */ public static function signAgentRoster(string $signingPrivateKey, mixed $companyId, mixed $rosterVersion, mixed $issuedAt, mixed $agents): string { Codec::assertBytes($signingPrivateKey, 'signingPrivateKey', 64); $stmt = self::agentRosterStatement($companyId, $rosterVersion, $issuedAt, $agents); return Codec::b64uEncode(sodium_crypto_sign_detached($stmt, $signingPrivateKey)); } public static function verifyAgentRoster(string $signatureB64u, string $signingPublicKey, mixed $companyId, mixed $rosterVersion, mixed $issuedAt, mixed $agents): bool { Codec::assertBytes($signingPublicKey, 'signingPublicKey', 32); $stmt = self::agentRosterStatement($companyId, $rosterVersion, $issuedAt, $agents); $sig = Codec::b64uDecode($signatureB64u); Codec::assertBytes($sig, 'signature', 64); if (!sodium_crypto_sign_verify_detached($sig, $stmt, $signingPublicKey)) { throw new CryptoError('SIGNATURE_INVALID', 'la lista de agentes no está firmada por la llave de firma de la empresa'); } return true; } // Adjuntos (ADR-033) public static function attachmentCiphertextLength(int $plainSize): int { return $plainSize + max(1, intdiv($plainSize + self::ATTACHMENT_CHUNK - 1, self::ATTACHMENT_CHUNK)) * 16; } /** BLAKE2b-256 sin clave, base64url. */ public static function attachmentDigest(string $bytes): string { return Codec::b64uEncode(sodium_crypto_generichash($bytes, '', 32)); } private static function chunkNonce(string $base, int $i): string { return substr($base, 0, 16) . pack('J', $i); // u64 big-endian } private static function chunkAD(string $attachmentId, int $i, bool $last): string { return self::DOMAIN . "|att|{$attachmentId}|{$i}|" . ($last ? '1' : '0'); } /** * Cifra un fichero en trozos de 64 KiB. `$key`/`$nonceBase` solo para vectores. * * @return array{ciphertext: string, meta: array} */ public static function encryptAttachment(string $bytes, string $attachmentId, string $name, string $mime, ?string $caption = null, ?string $key = null, ?string $nonceBase = null): array { self::assertAttachmentId($attachmentId); if (strlen($bytes) > self::ATTACHMENT_MAX_BYTES) { throw new CryptoError('INVALID_INPUT', 'adjunto demasiado grande'); } $k = $key ?? random_bytes(32); $nb = $nonceBase ?? random_bytes(24); Codec::assertBytes($k, 'key', 32); Codec::assertBytes($nb, 'nonceBase', 24); $size = strlen($bytes); $chunks = max(1, intdiv($size + self::ATTACHMENT_CHUNK - 1, self::ATTACHMENT_CHUNK)); $out = ''; for ($i = 0; $i < $chunks; $i++) { $part = substr($bytes, $i * self::ATTACHMENT_CHUNK, self::ATTACHMENT_CHUNK); $out .= sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($part, self::chunkAD($attachmentId, $i, $i === $chunks - 1), self::chunkNonce($nb, $i), $k); } $meta = [ 'v' => 1, 'kind' => 'attachment', 'attachmentId' => $attachmentId, 'name' => $name, 'mime' => $mime, 'size' => $size, 'key' => Codec::b64uEncode($k), 'nonce' => Codec::b64uEncode($nb), 'digest' => self::attachmentDigest($bytes), 'chunkSize' => self::ATTACHMENT_CHUNK, ]; if ($caption !== null) { $meta['caption'] = $caption; } return ['ciphertext' => $out, 'meta' => self::validateAttachmentMeta($meta)]; } /** Descifra y comprueba tamaño y huella. Lanza DECRYPT_FAILED ante cualquier manipulación. */ public static function decryptAttachment(string $ciphertext, mixed $meta): string { $m = self::validateAttachmentMeta($meta); $key = Codec::b64uDecode($m['key']); $nb = Codec::b64uDecode($m['nonce']); if (strlen($ciphertext) !== self::attachmentCiphertextLength($m['size'])) { throw new CryptoError('DECRYPT_FAILED', 'longitud del adjunto inesperada'); } $chunks = max(1, intdiv($m['size'] + self::ATTACHMENT_CHUNK - 1, self::ATTACHMENT_CHUNK)); $out = ''; $c = 0; for ($i = 0; $i < $chunks; $i++) { $plainLen = min(self::ATTACHMENT_CHUNK, $m['size'] - $i * self::ATTACHMENT_CHUNK); $part = substr($ciphertext, $c, $plainLen + 16); $c += $plainLen + 16; $pt = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($part, self::chunkAD($m['attachmentId'], $i, $i === $chunks - 1), self::chunkNonce($nb, $i), $key); if ($pt === false) { throw new CryptoError('DECRYPT_FAILED', "trozo {$i} del adjunto no autentica"); } $out .= $pt; } if (!hash_equals($m['digest'], self::attachmentDigest($out))) { throw new CryptoError('DECRYPT_FAILED', 'la huella del adjunto no coincide'); } return $out; } private static function assertAttachmentId(mixed $id): void { if (!is_string($id) || preg_match('/^att_[A-Za-z0-9_-]{8,64}$/D', $id) !== 1) { throw new CryptoError('INVALID_INPUT', 'attachmentId inválido'); } } /** Valida y normaliza la meta (mismas reglas y mismo orden de campos que envelope.js). */ public static function validateAttachmentMeta(mixed $meta): array { if (!is_array($meta)) { throw new CryptoError('INVALID_INPUT', 'meta de adjunto ausente'); } if (($meta['v'] ?? null) !== 1 || ($meta['kind'] ?? null) !== 'attachment') { throw new CryptoError('UNSUPPORTED_VERSION', 'meta de adjunto de versión desconocida'); } self::assertAttachmentId($meta['attachmentId'] ?? null); $name = $meta['name'] ?? null; if (!is_string($name) || !Codec::isUtf8($name) || self::isBlank($name) || Codec::utf16Length($name) > 255 || preg_match('#[\x00-\x1f/\\\\]#', $name) === 1) { throw new CryptoError('INVALID_INPUT', 'nombre de adjunto inválido'); } $mime = $meta['mime'] ?? null; if (!is_string($mime) || preg_match('~^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}/[a-z0-9][a-z0-9!#$&^_.+-]{0,127}$~D', $mime) !== 1) { throw new CryptoError('INVALID_INPUT', 'tipo de adjunto inválido'); } $size = $meta['size'] ?? null; if (!is_int($size) || $size < 0 || $size > self::ATTACHMENT_MAX_BYTES) { throw new CryptoError('INVALID_INPUT', 'tamaño de adjunto inválido'); } if (($meta['chunkSize'] ?? null) !== self::ATTACHMENT_CHUNK) { throw new CryptoError('UNSUPPORTED_VERSION', 'tamaño de trozo no soportado'); } Codec::assertBytes(Codec::b64uDecode($meta['key'] ?? null), 'key', 32); Codec::assertBytes(Codec::b64uDecode($meta['nonce'] ?? null), 'nonce', 24); Codec::assertBytes(Codec::b64uDecode($meta['digest'] ?? null), 'digest', 32); $out = [ 'v' => 1, 'kind' => 'attachment', 'attachmentId' => $meta['attachmentId'], 'name' => $name, 'mime' => $mime, 'size' => $size, 'key' => $meta['key'], 'nonce' => $meta['nonce'], 'digest' => $meta['digest'], 'chunkSize' => self::ATTACHMENT_CHUNK, ]; if (array_key_exists('caption', $meta) && $meta['caption'] !== null) { if (!is_string($meta['caption']) || !Codec::isUtf8($meta['caption']) || Codec::utf16Length($meta['caption']) > 4000) { throw new CryptoError('INVALID_INPUT', 'pie de adjunto inválido'); } $out['caption'] = $meta['caption']; } if (array_key_exists('width', $meta) || array_key_exists('height', $meta)) { $w = $meta['width'] ?? null; $h = $meta['height'] ?? null; if (!is_int($w) || !is_int($h) || $w < 1 || $h < 1 || $w > 20000 || $h > 20000) { throw new CryptoError('INVALID_INPUT', 'dimensiones de adjunto inválidas'); } $out['width'] = $w; $out['height'] = $h; } return $out; } /** Texto que se cifra con encryptReply en un mensaje `contentType: attachment`. */ public static function attachmentMessageText(array $meta): string { return json_encode(self::validateAttachmentMeta($meta), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS | JSON_THROW_ON_ERROR); } public static function parseAttachmentMessage(string $text): array { try { $obj = json_decode($text, true, 16, JSON_THROW_ON_ERROR); } catch (\JsonException) { throw new CryptoError('INVALID_INPUT', 'el mensaje de adjunto no es JSON'); } return self::validateAttachmentMeta($obj); } /** `String.prototype.trim()` de JS deja la cadena vacía. */ private static function isBlank(string $s): bool { return preg_match('/^[\x{09}-\x{0D}\x{20}\x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]*$/u', $s) === 1; } } // ─── Firmas HMAC (port de sdk-node/src/webhook.js) ────────────────────────── final class Webhook { public const TOLERANCE_SECONDS = 300; /** @return array{t: int, v1: string} */ public static function parseSignatureHeader(mixed $header): array { if (!is_string($header) || $header === '') { throw new SignatureError('SIGNATURE_MISSING', 'falta la cabecera X-SecureChat-Signature'); } $parts = []; foreach (explode(',', $header) as $p) { $i = strpos($p, '='); if ($i !== false && $i > 0) { $parts[trim(substr($p, 0, $i))] = trim(substr($p, $i + 1)); } } $t = $parts['t'] ?? ''; $v1 = $parts['v1'] ?? ''; if (strlen($header) > 512 || preg_match('/^[0-9]{1,15}$/D', $t) !== 1 || preg_match('/^[0-9a-f]{64}$/D', $v1) !== 1) { throw new SignatureError('SIGNATURE_MALFORMED', 'cabecera de firma con formato inválido'); } return ['t' => (int) $t, 'v1' => $v1]; } /** * Verifica un webhook entrante. `$rawBody` son los bytes CRUDOS del cuerpo * (file_get_contents('php://input')), nunca un json_encode del array. * Devuelve el evento decodificado (array asociativo). */ public static function verify(string $rawBody, mixed $signatureHeader, string $secret, int $toleranceSeconds = self::TOLERANCE_SECONDS, ?int $now = null): array { if ($secret === '') { throw new SignatureError('SECRET_MISSING', 'falta el webhookSecret'); } ['t' => $t, 'v1' => $v1] = self::parseSignatureHeader($signatureHeader); if (abs(($now ?? time()) - $t) > $toleranceSeconds) { throw new SignatureError('SIGNATURE_EXPIRED', 'timestamp fuera de la ventana permitida (posible replay)'); } if (!hash_equals(hash_hmac('sha256', "{$t}.{$rawBody}", $secret), $v1)) { throw new SignatureError('SIGNATURE_INVALID', 'la firma no coincide'); } $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); if (!is_array($event)) { throw new SignatureError('SIGNATURE_INVALID', 'el cuerpo firmado no es un objeto JSON'); } return $event; } /** Cabecera X-SecureChat-Signature para una petición a /v1. */ public static function signRequest(string $method, string $pathAndQuery, string $rawBody, string $secret, ?int $timestamp = null): string { $t = $timestamp ?? time(); return "t={$t},v1=" . hash_hmac('sha256', "{$t}." . strtoupper($method) . ".{$pathAndQuery}.{$rawBody}", $secret); } /** Firma de un webhook saliente (la usa SecureChat; aquí solo para pruebas). */ public static function signWebhook(string $rawBody, string $secret, ?int $timestamp = null): string { $t = $timestamp ?? time(); return "t={$t},v1=" . hash_hmac('sha256', "{$t}.{$rawBody}", $secret); } } // ─── Cliente mínimo de la API /v1 ──────────────────────────────────────────── final class ApiClient { public function __construct(private string $baseUrl, private string $apiKey, private string $webhookSecret, private int $timeoutSeconds = 15) { $this->baseUrl = rtrim($baseUrl, '/'); } /** * Firma y envía. `$path` empieza por /v1. `$query` se codifica con http_build_query * (RFC 3986) y se firma EXACTAMENTE la ruta+query que se envía. */ public function request(string $method, string $path, ?array $body = null, ?array $query = null, ?string $idempotencyKey = null): mixed { $method = strtoupper($method); $qs = $query ? http_build_query(array_filter($query, fn ($v) => $v !== null), '', '&', PHP_QUERY_RFC3986) : ''; $pathAndQuery = $qs === '' ? $path : "{$path}?{$qs}"; $rawBody = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); $headers = [ "Authorization: Bearer {$this->apiKey}", 'X-SecureChat-Signature: ' . Webhook::signRequest($method, $pathAndQuery, $rawBody, $this->webhookSecret), ]; if ($rawBody !== '') { $headers[] = 'Content-Type: application/json'; } if ($method !== 'GET') { $headers[] = 'Idempotency-Key: ' . ($idempotencyKey ?? bin2hex(random_bytes(16))); } $ctx = stream_context_create(['http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => $rawBody, 'ignore_errors' => true, 'timeout' => $this->timeoutSeconds, ]]); $text = @file_get_contents($this->baseUrl . $pathAndQuery, false, $ctx); if ($text === false) { throw new ApiError(0, 'NETWORK_ERROR', 'no se pudo conectar con la API'); } $status = 0; foreach ($http_response_header ?? [] as $h) { if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) { $status = (int) $m[1]; } } $json = $text === '' ? null : json_decode($text, true); if ($status < 200 || $status >= 300) { $err = is_array($json) ? ($json['error'] ?? []) : []; throw new ApiError($status, $err['code'] ?? 'HTTP_ERROR', $err['message'] ?? "HTTP {$status}", $err['details'] ?? null); } return $json; } public function getConversation(string $conversationId): array { return $this->request('GET', '/v1/conversations/' . rawurlencode($conversationId))['conversation']; } public function sendMessage(string $conversationId, array $encryption, ?string $idempotencyKey = null): array { return $this->request('POST', '/v1/messages', ['conversationId' => $conversationId, 'contentType' => 'text', 'encryption' => $encryption], null, $idempotencyKey); } public function rewrap(string $conversationId, array $wrapForUser): array { return $this->request('POST', '/v1/conversations/' . rawurlencode($conversationId) . '/rewrap', ['wrapForUser' => $wrapForUser]); } }