* * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ /** * \file cfdixml/class/cfdi/Certificado.php * \ingroup cfdixml * \brief Clase para el manejo de certificados digitales del SAT */ class Certificado { /** * Processes a certificate file and extracts relevant information. * * This function reads a certificate file, extracts the certificate number * and the base64 encoded certificate content. * * @param string $certificadoPath The file path of the certificate to process. * * @return array An associative array containing: * - 'noCertificado': The certificate number as a string. * - 'certificado': The base64 encoded certificate content. * * @throws \Exception If the certificate file cannot be read or processed. */ public function procesarCertificado($certificadoPath, $keyPath = null, $password = null) { try { if (!file_exists($certificadoPath)) { throw new Exception("No se encuentra el archivo del certificado en: $certificadoPath"); } // Convertir CER a PEM $certificadoPEM = $this->convertCerToPEM($certificadoPath); // Si se proporciona key, verificarla también if ($keyPath) { $keyPEM = $this->convertKeyToPEM($keyPath, $password); // Verificar que la llave corresponde al certificado if (!$this->verificarParCertificado($certificadoPEM, $keyPEM, $password)) { throw new Exception("La llave privada no corresponde al certificado"); } } // Leer el certificado en formato PEM $cert = openssl_x509_read($certificadoPEM); if (!$cert) { throw new Exception("Error leyendo certificado: " . openssl_error_string()); } $certData = openssl_x509_parse($cert); if (!$certData) { throw new Exception("Error parseando certificado: " . openssl_error_string()); } $noCertificado = $this->procesarNumeroCertificado($certData['serialNumberHex']); $certificado = $this->limpiarCertificado($certificadoPEM); openssl_x509_free($cert); return [ 'noCertificado' => $noCertificado, 'certificado' => $certificado, 'certificadoPEM' => $certificadoPEM, 'keyPEM' => $keyPEM ?? null ]; } catch (Exception $e) { throw new Exception("Error procesando certificado: " . $e->getMessage()); } } /** * Processes the certificate number by converting it from hexadecimal to decimal format. * * This function takes a hexadecimal serial number and converts it to its decimal * representation as a string of characters. * * @param string $serialHex The hexadecimal serial number to be processed. * * @return string The processed certificate number as a string of characters * representing the decimal conversion of the input. */ private function procesarNumeroCertificado($serialHex) { // Remover posible prefijo '0x' $serialHex = str_replace('0x', '', $serialHex); // Convertir de hex a caracteres $noCertificado = ''; for ($i = 0; $i < strlen($serialHex); $i += 2) { $hex = substr($serialHex, $i, 2); // Convertir solo si no es "00" if ($hex !== "00") { $noCertificado .= chr(hexdec($hex)); } } dol_syslog("NoCertificado original: " . $serialHex . " procesado: " . $noCertificado, LOG_DEBUG); return $noCertificado; } /** * Cleans up a certificate string by removing header, footer, and whitespace. * * This function removes the "BEGIN CERTIFICATE" and "END CERTIFICATE" tags, * as well as any whitespace characters from the given certificate string. * * @param string $certificado The raw certificate string to be cleaned. * * @return string The cleaned certificate string without tags and whitespace. */ private function limpiarCertificado($certificado) { try { // Eliminar headers y footers de certificado $certificado = preg_replace('/-----BEGIN CERTIFICATE-----/', '', $certificado); $certificado = preg_replace('/-----END CERTIFICATE-----/', '', $certificado); // Eliminar cualquier espacio en blanco o salto de línea $certificado = preg_replace('/\s+/', '', $certificado); if (empty($certificado)) { throw new Exception("Certificado vacío después de limpieza"); } return $certificado; } catch (Exception $e) { throw new Exception("Error limpiando certificado: " . $e->getMessage()); } } /** * Generates a digital seal using a private key. * * This function creates a digital signature (seal) for the provided original string * using the specified private key file and password. * * @param string $cadenaOriginal The original string to be sealed. * @param string $llavePrivadaPath The file path to the private key. * @param string $password The password to unlock the private key. * * @return string The base64 encoded digital seal. * * @throws \Exception If there's an error reading the private key or generating the seal. */ public function generarSello($cadenaOriginal, $llavePrivadaPath, $password) { try { dol_syslog("Iniciando proceso de generación de sello", LOG_DEBUG); // Procesar llave privada $keyPem = $this->procesarLlavePrivada($llavePrivadaPath, $password); dol_syslog("Llave privada procesada exitosamente", LOG_DEBUG); // Verificar la cadena original if (empty($cadenaOriginal)) { throw new Exception("La cadena original está vacía"); } dol_syslog("Longitud de cadena original: " . strlen($cadenaOriginal), LOG_DEBUG); // Obtener la llave privada $privateKey = openssl_pkey_get_private($keyPem, $password); if (!$privateKey) { throw new Exception("Error al leer la llave privada: " . openssl_error_string()); } dol_syslog("Llave privada cargada exitosamente", LOG_DEBUG); // Generar firma $signature = ''; if (!openssl_sign($cadenaOriginal, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { throw new Exception("Error al generar el sello: " . openssl_error_string()); } dol_syslog("Firma generada exitosamente", LOG_DEBUG); // Liberar recursos openssl_free_key($privateKey); // Convertir a base64 $selloBase64 = base64_encode($signature); dol_syslog("Longitud del sello en base64: " . strlen($selloBase64), LOG_DEBUG); return $selloBase64; } catch (Exception $e) { dol_syslog("Error en generarSello: " . $e->getMessage(), LOG_ERR); throw new Exception("Error generando sello: " . $e->getMessage()); } } public function verificarCertificado($certificadoPath) { if (!file_exists($certificadoPath)) { return [ 'valido' => false, 'mensaje' => 'Archivo no encontrado' ]; } try { // Convertir a PEM y leer certificado $datos = file_get_contents($certificadoPath); if (strpos($datos, '-----BEGIN CERTIFICATE-----') === false) { $base64 = base64_encode($datos); $datos = "-----BEGIN CERTIFICATE-----\n" . chunk_split($base64, 64, "\n") . "-----END CERTIFICATE-----\n"; } $cert = openssl_x509_read($datos); if (!$cert) { return [ 'valido' => false, 'mensaje' => 'Certificado inválido: ' . openssl_error_string() ]; } $certData = openssl_x509_parse($cert); openssl_x509_free($cert); if (!$certData) { return [ 'valido' => false, 'mensaje' => 'No se pudo obtener información del certificado' ]; } // RFC está en x500UniqueIdentifier del subject $rfc = $certData['subject']['x500UniqueIdentifier'] ?? ''; // CP está en postalCode del issuer $cp = $certData['issuer']['postalCode'] ?? ''; // Verificar fecha de validez $ahora = time(); if ($ahora < $certData['validFrom_time_t'] || $ahora > $certData['validTo_time_t']) { return [ 'valido' => false, 'mensaje' => 'Certificado fuera de vigencia', 'validez' => [ 'desde' => date('Y-m-d H:i:s', $certData['validFrom_time_t']), 'hasta' => date('Y-m-d H:i:s', $certData['validTo_time_t']) ] ]; } return [ 'valido' => true, 'mensaje' => 'Certificado válido', 'datos' => $certData, 'pem' => $datos, 'rfc' => $rfc, 'nombre' => $certData['subject']['CN'], 'lugarExpedicion' => $cp, 'numeroSerie' => $certData['serialNumberHex'], 'validez' => [ 'desde' => date('Y-m-d H:i:s', $certData['validFrom_time_t']), 'hasta' => date('Y-m-d H:i:s', $certData['validTo_time_t']) ] ]; } catch (Exception $e) { return [ 'valido' => false, 'mensaje' => 'Error verificando certificado: ' . $e->getMessage() ]; } } /** * Convierte certificado .cer a formato PEM */ private function convertCerToPEM($cerFile) { $data = file_get_contents($cerFile); if ($data === false) { throw new Exception("No se pudo leer el archivo del certificado"); } if (strpos($data, '-----BEGIN CERTIFICATE-----') === false) { $base64 = base64_encode($data); $pem = "-----BEGIN CERTIFICATE-----\n" . chunk_split($base64, 64, "\n") . "-----END CERTIFICATE-----\n"; return $pem; } return $data; } public function procesarLlavePrivada($keyPath, $password) { try { if (!file_exists($keyPath)) { throw new Exception("No se encuentra el archivo de la llave privada"); } // Leer contenido de la llave $keyData = file_get_contents($keyPath); // Convertir a PEM si no lo está ya if ( strpos($keyData, '-----BEGIN PRIVATE KEY-----') === false && strpos($keyData, '-----BEGIN ENCRYPTED PRIVATE KEY-----') === false ) { $keyPem = $this->convertKeyToPEM($keyData, $password); } else { $keyPem = $keyData; } // Verificar que la llave se puede leer $privateKey = openssl_pkey_get_private($keyPem, $password); if (!$privateKey) { throw new Exception("Error leyendo llave privada: " . openssl_error_string()); } // Liberar recursos openssl_free_key($privateKey); return $keyPem; } catch (Exception $e) { throw new Exception("Error procesando llave privada: " . $e->getMessage()); } } /** * Convierte llave privada .key a formato PEM */ private function convertKeyToPEM($keyData, $password) { // Convertir a base64 si no lo está if (!preg_match('/^[a-zA-Z0-9\/+=]+$/', $keyData)) { $keyData = base64_encode($keyData); } // Intentar primero como llave encriptada $encryptedPem = "-----BEGIN ENCRYPTED PRIVATE KEY-----\n" . chunk_split($keyData, 64, "\n") . "-----END ENCRYPTED PRIVATE KEY-----\n"; // Verificar si la llave encriptada funciona $key = @openssl_pkey_get_private($encryptedPem, $password); if ($key) { openssl_free_key($key); return $encryptedPem; } // Si no funciona como encriptada, intentar como llave privada normal $privatePem = "-----BEGIN PRIVATE KEY-----\n" . chunk_split($keyData, 64, "\n") . "-----END PRIVATE KEY-----\n"; $key = @openssl_pkey_get_private($privatePem, $password); if ($key) { openssl_free_key($key); return $privatePem; } throw new Exception("No se pudo convertir la llave privada a formato PEM"); } /** * Verifica que la llave privada corresponde al certificado */ private function verificarParCertificado($certificadoPEM, $keyPEM, $password) { // Obtener la llave pública del certificado $cert = openssl_x509_read($certificadoPEM); $pubKey = openssl_get_publickey($cert); // Obtener la llave privada $privKey = openssl_pkey_get_private($keyPEM, $password); if (!$pubKey || !$privKey) { return false; } // Obtener detalles de ambas llaves $pubKeyDetails = openssl_pkey_get_details($pubKey); $privKeyDetails = openssl_pkey_get_details($privKey); // Liberar recursos openssl_free_key($pubKey); openssl_free_key($privKey); openssl_x509_free($cert); // Comparar las llaves return $pubKeyDetails['key'] === $privKeyDetails['key']; } // Método para debug de la llave privada public function debugLlavePrivada($keyPath, $password) { try { $keyPem = $this->procesarLlavePrivada($keyPath, $password); $key = openssl_pkey_get_private($keyPem, $password); if (!$key) { return [ 'status' => 'error', 'message' => 'No se pudo leer la llave privada', 'openssl_error' => openssl_error_string() ]; } $details = openssl_pkey_get_details($key); openssl_free_key($key); return [ 'status' => 'success', 'bits' => $details['bits'] ?? 'N/A', 'type' => $details['type'] ?? 'N/A', 'is_encrypted' => (strpos($keyPem, 'ENCRYPTED') !== false), 'pem_format' => (strpos($keyPem, '-----BEGIN') !== false) ]; } catch (Exception $e) { return [ 'status' => 'error', 'message' => $e->getMessage() ]; } } }