├── .phive └── phars.xml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── docs ├── CHANGELOG.md ├── Certificados.md ├── EjemploCrearCredencialVerificacion.md ├── SEMVER.md ├── TODO.md └── VerificacionCertificadosSAT.md └── src ├── Certificate.php ├── Credential.php ├── Internal ├── BaseConverter.php ├── BaseConverterSequence.php ├── DataArrayTrait.php ├── Key.php ├── LocalFileOpenTrait.php ├── OpenSslKeyTypeEnum.php ├── Rfc4514.php └── SatTypeEnum.php ├── PemExtractor.php ├── Pfx ├── PfxExporter.php └── PfxReader.php ├── PrivateKey.php ├── PublicKey.php └── SerialNumber.php /.phive/phars.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Código de Conducta Convenido para Contribuyentes 2 | 3 | ## Nuestro compromiso 4 | 5 | Nosotros, como miembros, contribuyentes y administradores nos comprometemos a hacer de la participación en nuestra comunidad una experiencia libre de acoso para todo el mundo, independientemente de la edad, dimensión corporal, minusvalía visible o invisible, etnicidad, características sexuales, identidad y expresión de género, nivel de experiencia, educación, nivel socioeconómico, nacionalidad, apariencia personal, raza, religión, o identidad u orientación sexual. 6 | 7 | Nos comprometemos a actuar e interactuar de maneras que contribuyan a una comunidad abierta, acogedora, diversa, inclusiva y sana. 8 | 9 | ## Nuestros estándares 10 | 11 | Ejemplos de comportamiento que contribuyen a crear un ambiente positivo para nuestra comunidad: 12 | 13 | * Demostrar empatía y amabilidad ante otras personas 14 | * Respeto a diferentes opiniones, puntos de vista y experiencias 15 | * Dar y aceptar adecuadamente retroalimentación constructiva 16 | * Aceptar la responsabilidad y disculparse ante quienes se vean afectados por nuestros errores, aprendiendo de la experiencia 17 | * Centrarse en lo que sea mejor no solo para nosotros como individuos, sino para la comunidad en general 18 | 19 | Ejemplos de comportamiento inaceptable: 20 | 21 | * El uso de lenguaje o imágenes sexualizadas, y aproximaciones o atenciones sexuales de cualquier tipo 22 | * Comentarios despectivos (_trolling_), insultantes o derogatorios, y ataques personales o políticos 23 | * El acoso en público o privado 24 | * Publicar información privada de otras personas, tales como direcciones físicas o de correo electrónico, sin su permiso explícito 25 | * Otras conductas que puedan ser razonablemente consideradas como inapropiadas en un entorno profesional 26 | 27 | ## Aplicación de las responsabilidades 28 | 29 | Los administradores de la comunidad son responsables de aclarar y hacer cumplir nuestros estándares de comportamiento aceptable y tomarán acciones apropiadas y correctivas de forma justa en respuesta a cualquier comportamiento que consideren inapropiado, amenazante, ofensivo o dañino. 30 | 31 | Los administradores de la comunidad tendrán el derecho y la responsabilidad de eliminar, editar o rechazar comentarios, _commits_, código, ediciones de páginas de wiki, _issues_ y otras contribuciones que no se alineen con este Código de Conducta, y comunicarán las razones para sus decisiones de moderación cuando sea apropiado. 32 | 33 | ## Alcance 34 | 35 | Este código de conducta aplica tanto a espacios del proyecto como a espacios públicos donde un individuo esté en representación del proyecto o comunidad. Ejemplos de esto incluyen el uso de la cuenta oficial de correo electrónico, publicaciones a través de las redes sociales oficiales, o presentaciones con personas designadas en eventos en línea o no. 36 | 37 | ## Aplicación 38 | 39 | Instancias de comportamiento abusivo, acosador o inaceptable de otro modo podrán ser reportadas a los administradores de la comunidad responsables del cumplimiento a través de [coc@phpcfdi.com](mailto:coc@phpcfdi.com). Todas las quejas serán evaluadas e investigadas de una manera puntual y justa. 40 | 41 | Todos los administradores de la comunidad están obligados a respetar la privacidad y la seguridad de quienes reporten incidentes. 42 | 43 | ## Guías de Aplicación 44 | 45 | Los administradores de la comunidad seguirán estas Guías de Impacto en la Comunidad para determinar las consecuencias de cualquier acción que juzguen como un incumplimiento de este Código de Conducta: 46 | 47 | ### 1. Corrección 48 | 49 | **Impacto en la Comunidad**: El uso de lenguaje inapropiado u otro comportamiento considerado no profesional o no acogedor en la comunidad. 50 | 51 | **Consecuencia**: Un aviso escrito y privado por parte de los administradores de la comunidad, proporcionando claridad alrededor de la naturaleza de este incumplimiento y una explicación de por qué el comportamiento es inaceptable. Una disculpa pública podría ser solicitada. 52 | 53 | ### 2. Aviso 54 | 55 | **Impacto en la Comunidad**: Un incumplimiento causado por un único incidente o por una cadena de acciones. 56 | 57 | **Consecuencia**: Un aviso con consecuencias por comportamiento prolongado. No se interactúa con las personas involucradas, incluyendo interacción no solicitada con quienes se encuentran aplicando el Código de Conducta, por un periodo especificado de tiempo. Esto incluye evitar las interacciones en espacios de la comunidad, así como a través de canales externos como las redes sociales. Incumplir estos términos puede conducir a una expulsión temporal o permanente. 58 | 59 | ### 3. Expulsión temporal 60 | 61 | **Impacto en la Comunidad**: Una serie de incumplimientos de los estándares de la comunidad, incluyendo comportamiento inapropiado continuo. 62 | 63 | **Consecuencia**: Una expulsión temporal de cualquier forma de interacción o comunicación pública con la comunidad durante un intervalo de tiempo especificado. No se permite interactuar de manera pública o privada con las personas involucradas, incluyendo interacciones no solicitadas con quienes se encuentran aplicando el Código de Conducta, durante este periodo. Incumplir estos términos puede conducir a una expulsión permanente. 64 | 65 | ### 4. Expulsión permanente 66 | 67 | **Impacto en la Comunidad**: Demostrar un patrón sistemático de incumplimientos de los estándares de la comunidad, incluyendo conductas inapropiadas prolongadas en el tiempo, acoso de individuos, o agresiones o menosprecio a grupos de individuos. 68 | 69 | **Consecuencia**: Una expulsión permanente de cualquier tipo de interacción pública con la comunidad del proyecto. 70 | 71 | ## Atribución 72 | 73 | Este Código de Conducta es una adaptación del [Contributor Covenant](https://www.contributor-covenant.org), versión 2.0, disponible en . 74 | 75 | Las Guías de Impacto en la Comunidad están inspiradas en la [escalera de aplicación del código de conducta de Mozilla](https://github.com/mozilla/diversity). 76 | 77 | Para respuestas a las preguntas frecuentes de este código de conducta, consulta las FAQ en . Hay traducciones disponibles en . 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribuciones 2 | 3 | Las contribuciones son bienvenidas. Aceptamos *Pull Requests* en el [repositorio GitHub][project]. 4 | 5 | Este proyecto se apega al siguiente [Código de Conducta][coc]. 6 | Al participar en este proyecto y en su comunidad, deberás seguir este código. 7 | 8 | ## Miembros del equipo 9 | 10 | * [phpCfdi][] - Organización que mantiene el proyecto. 11 | * [Contribuidores][contributors]. 12 | 13 | ## Canales de comunicación 14 | 15 | Puedes encontrar ayuda y comentar asuntos relacionados con este proyecto en estos lugares: 16 | 17 | * Comunidad Discord: 18 | * GitHub Issues: 19 | 20 | ## Reportar Bugs 21 | 22 | Publica los *Bugs* en la sección [GitHub Issues][issues] del proyecto. 23 | 24 | Sigue las recomendaciones generales de [phpCfdi][] para reportar problemas 25 | . 26 | 27 | Cuando se reporte un *Bug*, por favor incluye la mayor información posible para reproducir el problema, preferentemente 28 | con ejemplos de código o cualquier otra información técnica que nos pueda ayudar a identificar el caso. 29 | 30 | **Recuerda no incluir contraseñas, información personal o confidencial.** 31 | 32 | ## Corrección de Bugs 33 | 34 | Apreciamos mucho los *Pull Request* para corregir Bugs. 35 | 36 | Si encuentras un reporte de Bug y te gustaría solucionarlo siéntete libre de hacerlo. 37 | Sigue las directrices de "Agregar nuevas funcionalidades" a continuación. 38 | 39 | ## Agregar nuevas funcionalidades 40 | 41 | Si tienes una idea para una nueva funcionalidad revisa primero que existan discusiones o *Pull Requests* 42 | en donde ya se esté trabajando en la funcionalidad. 43 | 44 | Antes de trabajar en la nueva característica, utiliza los "Canales de comunicación" mencionados 45 | anteriormente para platicar acerca de tu idea. Si dialogas tus ideas con la comunidad y los 46 | mantenedores del proyecto, podrás ahorrar mucho esfuerzo de desarrollo y prevenir que tu 47 | *Pull Request* sea rechazado. No nos gusta rechazar contribuciones, pero algunas características 48 | o la forma de desarrollarlas puede que no estén alineadas con el proyecto. 49 | 50 | Considera las siguientes directrices: 51 | 52 | * Usa una rama única que se desprenda de la rama principal. 53 | No mezcles dos diferentes funcionalidades en una misma rama o *Pull Request*. 54 | * Describe claramente y en detalle los cambios que hiciste. 55 | * **Escribe pruebas** para la funcionalidad que deseas agregar. 56 | * **Asegúrate que las pruebas pasan** antes de enviar tu contribución. 57 | Usamos integración continua donde se hace esta verificación, pero es mucho mejor si lo pruebas localmente. 58 | * Intenta enviar una historia coherente, entenderemos cómo cambia el código si los *commits* tienen significado. 59 | * La documentación es parte del proyecto. 60 | Realiza los cambios en los archivos de ayuda para que reflejen los cambios en el código. 61 | 62 | ## Proceso de construcción 63 | 64 | ```shell 65 | # Actualiza tus dependencias 66 | composer update 67 | phive update 68 | 69 | # Verificación de estilo de código 70 | composer dev:check-style 71 | 72 | # Corrección de estilo de código 73 | composer dev:fix-style 74 | 75 | # Ejecución de pruebas 76 | composer dev:test 77 | 78 | # Ejecución todo en uno: corregir estilo, verificar estilo y correr pruebas 79 | composer dev:build 80 | ``` 81 | 82 | ## Ejecutar GitHub Actions localmente 83 | 84 | Puedes utilizar la herramienta [`act`](https://github.com/nektos/act) para ejecutar las GitHub Actions localmente. 85 | Según [`actions/setup-php-action`](https://github.com/marketplace/actions/setup-php-action#local-testing-setup) 86 | puedes ejecutar el siguiente comando para revisar los flujos de trabajo localmente: 87 | 88 | ```shell 89 | act -P ubuntu-latest=shivammathur/node:latest 90 | ``` 91 | 92 | [phpCfdi]: https://github.com/phpcfdi/ 93 | [project]: https://github.com/phpcfdi/credentials 94 | [contributors]: https://github.com/phpcfdi/credentials/graphs/contributors 95 | [coc]: https://github.com/phpcfdi/credentials/blob/main/CODE_OF_CONDUCT.md 96 | [issues]: https://github.com/phpcfdi/credentials/issues 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 - 2025 PhpCfdi https://www.phpcfdi.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phpcfdi/credentials 2 | 3 | [![Source Code][badge-source]][source] 4 | [![Packagist PHP Version Support][badge-php-version]][php-version] 5 | [![Discord][badge-discord]][discord] 6 | [![Latest Version][badge-release]][release] 7 | [![Software License][badge-license]][license] 8 | [![Build Status][badge-build]][build] 9 | [![Reliability][badge-reliability]][reliability] 10 | [![Maintainability][badge-maintainability]][maintainability] 11 | [![Code Coverage][badge-coverage]][coverage] 12 | [![Violations][badge-violations]][violations] 13 | [![Total Downloads][badge-downloads]][downloads] 14 | 15 | > Library to use eFirma (fiel) and CSD (sellos) from SAT 16 | 17 | :us: The documentation of this project is in spanish as this is the natural language for intended audience. 18 | 19 | :mexico: La documentación del proyecto está en español porque ese es el lenguaje principal de los usuarios. 20 | 21 | Esta librería ha sido creada para poder trabajar con los archivos CSD y FIEL del SAT. De esta forma, 22 | se simplifica el proceso de firmar, verificar firma y obtener datos particulares del archivo de certificado 23 | así como de la llave pública. 24 | 25 | - El CSD (Certificado de Sello Digital) es utilizado para firmar Comprobantes Fiscales Digitales. 26 | 27 | - La FIEL (o eFirma) es utilizada para firmar electrónicamente documentos (generalmente usando XML-SEC) y 28 | está reconocida por el gobierno mexicano como una manera de firma legal de una persona física o moral. 29 | 30 | Con esta librería no es necesario convertir los archivos generados por el SAT a otro formato, 31 | se pueden utilizar tal y como el SAT los entrega. 32 | 33 | ## Instalación 34 | 35 | Usa [composer](https://getcomposer.org/) 36 | 37 | ```shell 38 | composer require phpcfdi/credentials 39 | ``` 40 | 41 | ## Ejemplo básico de uso 42 | 43 | ```php 44 | sign($sourceString); 55 | echo base64_encode($signature), PHP_EOL; 56 | 57 | // alias de certificado/publicKey/verify 58 | $verify = $fiel->verify($sourceString, $signature); 59 | var_dump($verify); // bool(true) 60 | 61 | // objeto certificado 62 | $certificado = $fiel->certificate(); 63 | echo $certificado->rfc(), PHP_EOL; // el RFC del certificado 64 | echo $certificado->legalName(), PHP_EOL; // el nombre del propietario del certificado 65 | echo $certificado->branchName(), PHP_EOL; // el nombre de la sucursal (en CSD, en FIEL está vacía) 66 | echo $certificado->serialNumber()->bytes(), PHP_EOL; // número de serie del certificado 67 | ``` 68 | 69 | ## Acerca de los archivos de certificado y llave privada 70 | 71 | Los archivos de certificado vienen en formato `X.509 DER` y los de llave privada en formato `PKCS#8 DER`. 72 | Ambos formatos no se pueden interpretar directamente en PHP (con `ext-openssl`), sin embargo, sí lo pueden hacer 73 | en el formato compatible [`PEM`](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail). 74 | 75 | Esta librería tiene la capacidad de hacer esta conversión internamente (sin `openssl`), pues solo consiste en codificar 76 | a `base64`, en renglones de 64 caracteres y con cabeceras específicas para certificado y llave privada. 77 | 78 | De esta forma, para usar el certificado `AAA010101AAA.cer` o la llave privada `AAA010101AAA.key` provistos por 79 | el SAT, no es necesario convertirlos con `openssl` y la librería los detectará correctamente. 80 | 81 | ### Crear un objeto de certificado `Certificate` 82 | 83 | El objeto `Certificate` no se creará si contiene datos no válidos. 84 | 85 | El SAT entrega el certificado en formato `X.509 DER`, por lo que internamente se puede convertir a `X.509 PEM`. 86 | También es frecuente usar el formato `X.509 DER base64`, por ejemplo, en el atributo `Comprobante@Certificado` 87 | o en las firmas XML, por este motivo, los formatos soportados para crear un objeto `Certificate` son 88 | `X.509 DER`, `X.509 DER base64` y `X.509 PEM`. 89 | 90 | - Para abrir usando un archivo local: `$certificate = Certificate::openFile($filename);` 91 | - Para abrir usando una cadena de caracteres: `$certificate = new Certificate($content);` 92 | - Si `$content` es un certificado en formato `X.509 PEM` con cabeceras ese se utiliza. 93 | - Si `$content` está totalmente en `base64`, se interpreta como `X.509 DER base64` y se formatea a `X.509 PEM` 94 | - En otro caso, se interpreta como formato `X.509 DER`, por lo que se formatea a `X.509 PEM`. 95 | 96 | ### Crear un objeto de llave privada `PrivateKey` 97 | 98 | El objeto `PrivateKey` no se creará si contiene datos no válidos. 99 | 100 | En SAT entrega la llave en formato `PKCS#8 DER`, por lo que internamente se puede convertir a `PKCS#8 PEM` 101 | (con la misma contraseña) y usarla desde PHP. 102 | 103 | Una vez abierta la llave también se puede cambiar o eliminar la contraseña, creando así un nuevo objeto `PrivateKey`. 104 | 105 | - Para abrir usando un archivo local: `$key = PrivateKey::openFile($filename, $passPhrase);` 106 | - Para abrir usando una cadena de caracteres: `$key = new PrivateKey($content, $passPhrase);` 107 | - Si `$content` es una llave privada en formato `PEM` (`PKCS#8` o `PKCS#5`) se utiliza. 108 | - En otro caso, se interpreta como formato `PKCS#8 DER`, por lo que se formatea a `PKCS#8 PEM`. 109 | 110 | Notas de tratamiento de archivos `DER`: 111 | 112 | - Al convertir `PKCS#8 DER` a `PKCS#8 PEM` se determina si es una llave encriptada si se estableció 113 | una contraseña, si no se estableció se tratará como una llave plana (no encriptada). 114 | - No se sabe reconocer de forma automática si se trata de un archivo `PKCS#5 DER` por lo que este 115 | tipo de llave se deben convertir *manualmente* antes de intentar abrirlos, su cabecera es `RSA PRIVATE KEY`. 116 | - A diferencia de los certificados que pueden interpretar un formato `DER base64`, la lectura de llave 117 | privada no hace esta distinción, si desea trabajar con un formato sin caracteres especiales use `PEM`. 118 | 119 | Para entender más de los formatos de llaves privadas se puede consultar la siguiente liga: 120 | 121 | 122 | ## Acerca de los números de serie 123 | 124 | Los certificados contienen un número de serie expresado en notación hexadecimal, por ejemplo, el número 125 | de serie `27 2B` se refiere al certificado número `10027` expresado en decimal. 126 | 127 | Para el SAT, sin embargo, se reconoce el número de serie no como el estándar en hexadecimal. 128 | El SAT pide que el número de serie reflejado sea **la expresión hexadecimal convertida a ASCII**. 129 | Luego entonces, el certificado con número de serie `3330303031303030303030333030303233373038` 130 | lo identifica como `30001000000300023708`. 131 | 132 | Esta práctica del SAT no es estándar, y no es comúnmente observada. Sin embargo, así ha decidido que se 133 | interpreten el dato de "número de serie" referido en sus certificados emitidos, por ejemplo en el atributo 134 | `Comprobante@NoCertificado`. 135 | 136 | Como ejemplo contrario: En el firmado de documentos XML utilizado en el servicio web de descarga masiva, 137 | sí se utiliza la notación decimal (el número hexadecimal convertido a decimal), en lugar de la notación de bytes. 138 | 139 | La notación de bytes es problemática porque no todos los caracteres son imprimibles o 140 | cuentan una representación gráfica. La notación hexadecimal es ligeramente problemática 141 | porque tiene muchas variantes como el uso de mayúsculas y minúsculas o el prefijo `0x`. 142 | La notación decimal no tiene problema, se trata simplemente de un entero muy grande, 143 | tan grande que debe tratarse como una cadena de caracteres. 144 | 145 | Espero que en algún futuro el SAT reconsidere y utilice una notación decimal, para referirnos al número de serie. 146 | 147 | ## Leer y exportar archivos PFX 148 | 149 | Esta librería soporta obtener el objeto `Credential` desde un archivo PFX (PKCS #12) y vicerversa. 150 | 151 | Para exportar el archivo PFX: 152 | 153 | ```php 154 | export('pfx-passphrase'); 168 | 169 | // guarda el archivo pfx a la ruta local dada usando la contraseña dada 170 | $pfxExporter->exportToFile('credential.pfx', 'pfx-passphrase'); 171 | ``` 172 | 173 | Para leer el archivo PFX y obtener un objeto `Credential`: 174 | 175 | ```php 176 | createCredentialFromContents('contenido-del-archivo', 'pfx-passphrase'); 184 | 185 | // crea un objeto Credential dada la ruta local de un archivo pfx 186 | $credential = $pfxReader->createCredentialFromFile('pfxFilePath', 'pfx-passphrase'); 187 | ``` 188 | 189 | ## Compatibilidad 190 | 191 | Esta librería se mantendrá compatible con al menos la versión con 192 | [soporte activo de PHP](https://www.php.net/supported-versions.php) más reciente. 193 | 194 | También utilizamos [Versionado Semántico 2.0.0](docs/SEMVER.md) por lo que puedes usar esta librería 195 | sin temor a romper tu aplicación. 196 | 197 | ## Contribuciones 198 | 199 | Las contribuciones con bienvenidas. Por favor lee [CONTRIBUTING][] para más detalles 200 | y recuerda revisar el archivo de tareas pendientes [TODO][] y el archivo [CHANGELOG][]. 201 | 202 | ## Copyright and License 203 | 204 | The `phpcfdi/credentials` library is copyright © [PhpCfdi](https://www.phpcfdi.com/) 205 | and licensed for use under the MIT License (MIT). Please see [LICENSE][] for more information. 206 | 207 | [contributing]: https://github.com/phpcfdi/credentials/blob/main/CONTRIBUTING.md 208 | [changelog]: https://github.com/phpcfdi/credentials/blob/main/docs/CHANGELOG.md 209 | [todo]: https://github.com/phpcfdi/credentials/blob/main/docs/TODO.md 210 | 211 | [source]: https://github.com/phpcfdi/credentials 212 | [php-version]: https://packagist.org/packages/phpcfdi/credentials 213 | [discord]: https://discord.gg/aFGYXvX 214 | [release]: https://github.com/phpcfdi/credentials/releases 215 | [license]: https://github.com/phpcfdi/credentials/blob/main/LICENSE 216 | [build]: https://github.com/phpcfdi/credentials/actions/workflows/build.yml?query=branch:main 217 | [reliability]:https://sonarcloud.io/component_measures?id=phpcfdi_credentials&metric=Reliability 218 | [maintainability]: https://sonarcloud.io/component_measures?id=phpcfdi_credentials&metric=Maintainability 219 | [coverage]: https://sonarcloud.io/component_measures?id=phpcfdi_credentials&metric=Coverage 220 | [violations]: https://sonarcloud.io/project/issues?id=phpcfdi_credentials&resolved=false 221 | [downloads]: https://packagist.org/packages/phpcfdi/credentials 222 | 223 | [badge-source]: https://img.shields.io/badge/source-phpcfdi/credentials-blue?logo=github 224 | [badge-discord]: https://img.shields.io/discord/459860554090283019?logo=discord 225 | [badge-php-version]: https://img.shields.io/packagist/php-v/phpcfdi/credentials?logo=php 226 | [badge-release]: https://img.shields.io/github/release/phpcfdi/credentials?logo=git 227 | [badge-license]: https://img.shields.io/github/license/phpcfdi/credentials?logo=open-source-initiative 228 | [badge-build]: https://img.shields.io/github/actions/workflow/status/phpcfdi/credentials/build.yml?branch=main&logo=github-actions 229 | [badge-reliability]: https://sonarcloud.io/api/project_badges/measure?project=phpcfdi_credentials&metric=reliability_rating 230 | [badge-maintainability]: https://sonarcloud.io/api/project_badges/measure?project=phpcfdi_credentials&metric=sqale_rating 231 | [badge-coverage]: https://img.shields.io/sonar/coverage/phpcfdi_credentials/main?logo=sonarcloud&server=https%3A%2F%2Fsonarcloud.io 232 | [badge-violations]: https://img.shields.io/sonar/violations/phpcfdi_credentials/main?format=long&logo=sonarcloud&server=https%3A%2F%2Fsonarcloud.io 233 | [badge-downloads]: https://img.shields.io/packagist/dt/phpcfdi/credentials?logo=packagist 234 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpcfdi/credentials", 3 | "description": "Library to use eFirma (fiel) and CSD (sellos) from SAT", 4 | "license": "MIT", 5 | "keywords": [ 6 | "efirma", 7 | "fiel", 8 | "csd", 9 | "sat", 10 | "cfdi", 11 | "sello", 12 | "certificado" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Carlos C Soto", 17 | "email": "eclipxe13@gmail.com" 18 | } 19 | ], 20 | "homepage": "https://github.com/phpcfdi/credentials", 21 | "support": { 22 | "issues": "https://github.com/phpcfdi/credentials/issues", 23 | "source": "https://github.com/phpcfdi/credentials" 24 | }, 25 | "require": { 26 | "php": ">=8.1", 27 | "ext-mbstring": "*", 28 | "ext-openssl": "*", 29 | "eclipxe/enum": "^0.2.7" 30 | }, 31 | "require-dev": { 32 | "ext-json": "*", 33 | "phpunit/phpunit": "^10.5.45" 34 | }, 35 | "prefer-stable": true, 36 | "autoload": { 37 | "psr-4": { 38 | "PhpCfdi\\Credentials\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "PhpCfdi\\Credentials\\Tests\\": "tests/" 44 | } 45 | }, 46 | "config": { 47 | "optimize-autoloader": true, 48 | "preferred-install": { 49 | "*": "dist" 50 | } 51 | }, 52 | "scripts": { 53 | "dev:build": [ 54 | "@dev:fix-style", 55 | "@dev:check-style", 56 | "@dev:test" 57 | ], 58 | "dev:check-style": [ 59 | "@php tools/composer-normalize normalize --dry-run", 60 | "@php tools/php-cs-fixer fix --dry-run --verbose", 61 | "@php tools/phpcs --colors -sp" 62 | ], 63 | "dev:coverage": [ 64 | "@php -dzend_extension=xdebug.so -dxdebug.mode=coverage vendor/bin/phpunit --coverage-html build/coverage/html/" 65 | ], 66 | "dev:fix-style": [ 67 | "@php tools/composer-normalize normalize", 68 | "@php tools/php-cs-fixer fix --verbose", 69 | "@php tools/phpcbf --colors -sp" 70 | ], 71 | "dev:test": [ 72 | "@php vendor/bin/phpunit --testdox --stop-on-failure", 73 | "@php tools/phpstan analyse --no-progress --verbose" 74 | ] 75 | }, 76 | "scripts-descriptions": { 77 | "dev:build": "DEV: run dev:fix-style dev:check-style and dev:tests, run before pull request", 78 | "dev:check-style": "DEV: search for code style errors using composer-normalize, php-cs-fixer and phpcs", 79 | "dev:coverage": "DEV: run phpunit with xdebug and storage coverage in build/coverage/html/", 80 | "dev:fix-style": "DEV: fix code style errors using composer-normalize, php-cs-fixer and phpcbf", 81 | "dev:test": "DEV: run phpunit and phpstan" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## Acerca de SemVer 4 | 5 | Usamos [Versionado Semántico 2.0.0](SEMVER.md) por lo que puedes usar esta librería sin temor a romper tu aplicación. 6 | 7 | ## Cambios no liberados en una versión 8 | 9 | Pueden aparecer cambios no liberados que se integran a la rama principal, pero no ameritan una nueva liberación de 10 | versión, aunque sí su incorporación en la rama principal de trabajo. Generalmente, se tratan de cambios en el desarrollo. 11 | 12 | ## Listado de cambios 13 | 14 | ### Versión 1.3.0 2025-04-12 15 | 16 | - Se mejoran las declaraciones de tipos. 17 | - Se elimina la compatiblidad con PHP 7.3, PHP 7.4 y PHP 8.0. 18 | - Se actualiza la acción de GitHub a `Update to SonarSource/sonarqube-scan-action@v5`. 19 | 20 | ### Versión 1.2.3 2025-03-30 21 | 22 | Se corrigieron los problemas asociados a la compatibilidad de PHP 8.4. 23 | 24 | - Se agregó explícitamente el operador de tipos *nullable* `?`. 25 | - Se actualizó la dependencia `eclipxe/enum` a una versión compatible con PHP 8.4. 26 | 27 | Se actualiza el año de licencia a 2025. 28 | 29 | Se hicieron cambios menores al código sugeridos por PHPStan y PSalm. 30 | 31 | Adicionalmente, se hacen los siguientes cambios internos: 32 | 33 | - Se agrega PHP 8.4 a la matriz de pruebas del flujo de trabajo `build`. 34 | - Se ejecuta la mayoría de los trabajos de los flujos de trabajo usando PHP 8.4. 35 | - Se actualizan las herramientas de desarrollo. 36 | 37 | ### Versión 1.2.2 2024-06-06 38 | 39 | Se corrigió el problema de no crear correctamente el número de serie cuando incluía caracteres en mayúsculas. 40 | Anteriormente, se hacía una conversión a minúsculas, ahora se expresa en mayúsculas. 41 | 42 | Se agrega el método `SerialNumber::bytesArePrintable(): bool` para identificar que el número de serie de un certificado 43 | contiene solamente caracteres imprimibles en su representación como *bytes*, como en el caso de los números de serie 44 | utilizados por el SAT. 45 | 46 | Se refactorizan los métodos `SerialNumber::createFromBytes()` y `SerialNumber::bytes()` para usar las funciones 47 | de PHP `bin2hex` y `hex2bin` respectivamente. 48 | 49 | Se agrega documentación en el archivo `README.md` explicando la interpretación del número de serie como hexadecimal, 50 | decimal y *bytes*. Así como el uso específico del SAT. 51 | 52 | Se actualiza el año de licencia a 2024. 53 | 54 | Se garantiza la compatibilidad con PHP 8.3. 55 | 56 | Adicionalmente, se hacen los siguientes cambios internos: 57 | 58 | - Se remueven los archivos `test/_files` de la detección de lenguaje de GitHub. 59 | - En los flujos de trabajo de GitHub. 60 | - Se permite la ejecución manual. 61 | - Se agrega PHP 8.3 a la matriz de pruebas. 62 | - Se ejecutan los trabajos en PHP 8.3. 63 | - Se actualizan las acciones de GitHub a la versión 4. 64 | - En el trabajo `php-cs-fixer` se remueve la variable de entorno `PHP_CS_FIXER_IGNORE_ENV`. 65 | - Se corrige `.php-cs-fixer.dist.php` sustituyendo `function_typehint_space` por `type_declaration_spaces`. 66 | - Se actualizan las herramientas de desarrollo. 67 | 68 | ### Versión 1.2.1 2023-05-24 69 | 70 | PHPStan detectó un uso inapropiado de conversión de objeto a cadena de caracteres. 71 | Esta conversión es innecesaria, por lo que se eliminó. 72 | 73 | Se agregó información básica de cómo verificar un certificado emitido por el SAT usando OCSP. 74 | 75 | Se actualizaron las herramientas de desarrollo. 76 | 77 | ### Versión 1.2.0 2023-02-24 78 | 79 | Se agrega la funcionalidad para exportar (`PfxExporter`) y leer (`PfxReader`) una credencial con formato PKCS#12 (PFX). 80 | Gracias `@celli33` por tu contribución. 81 | 82 | Los siguientes cambios ya estaban incluidos en la rama principal: 83 | 84 | #### Mantenimiento 2023-02-22 85 | 86 | Los siguientes cambios son de mantenimiento: 87 | 88 | - Se actualiza el año en el archivo de licencia. ¡Feliz 2023! 89 | - Se agrega una prueba para comprobar certificados *Teletex*. 90 | Ver https://github.com/nodecfdi/credentials/commit/cd8f1827e06a5917c41940e82b8d696379362d5d. 91 | - Se agrega un archivo de documentación: *Ejemplo de creación de una credencial con verificaciones previas*. 92 | - Se corrige la insignia de construcción del proyecto `[bagde-build]`. 93 | - Se sustituye la referencia `[homepage]` a `[project]` en el archivo `CONTRIBUTING.md`. 94 | - Se actualizan los archivos de configuración de estilo de código. 95 | - Se actualizan los flujos de trabajo de GitHub: 96 | - Los trabajos de PHP se ejecutan en la versión 8.2. 97 | - Se actualizan las acciones de GitHub a la versión 3. 98 | - Se agrega PHP 8.2 a la matriz de pruebas. 99 | - Se cambia la directiva `::set-output` a `$GITHUB_OUTPUT`. 100 | - Se corrige el trabajo `phpcs` eliminando las rutas fijas. 101 | - Se actualizan las versiones de las herramientas de desarrollo. 102 | 103 | ### Versión 1.1.4 2022-01-31 104 | 105 | - Se mejora la forma en como son procesados los tipos de datos del certificado. 106 | - Se actualiza el año de licencia. ¡Feliz 2022! 107 | - Se actualizan las herramientas de desarrollo, en especial PHPStan 1.4.4. 108 | - Se hacen las correcciones a los problemas detectados por PHPStan. 109 | - Se mejoran las pruebas y se incrementa la cobertura de código. 110 | - Se actualiza el flujo de *CI* llevando los pasos a trabajos y se agrega PHP 8.1. 111 | - Se actualiza el nombre del grupo de mantenedores de phpCfdi. 112 | - Se agrega la plataforma SonarQube vía . 113 | - Se elimina la integración con Scrutinizer CI. ¡Gracias Scrutinizer CI! 114 | 115 | ### Versión 1.1.3 2021-09-03 116 | 117 | - La versión menor de PHP es 7.3. 118 | - Se actualiza PHPUnit a 9.5. 119 | - Se migra de Travis-CI a GitHub Workflows. Gracias Travis-CI. 120 | - Se instalan las herramientas de desarrollo usando `phive` en lugar de `composer`. 121 | - Se cambia la rama principal a `main`. 122 | - Se actualiza el archivo de licencia al año 2021. 123 | - Se cambia la documentación a español. 124 | 125 | ### Versión 1.1.2 2020-12-20 126 | 127 | - Desde esta versión se soporta PHP 8.0. Se hicieron cambios porque en la nueva versión de PHP la librería 128 | `openssl` ya no devuelve recursos y se deprecaron las funciones de liberación de recursos. 129 | - Se agregó la capacidad de abrir un archivo con el path `c:\archivos\certificado.cer`. 130 | - Se agregó información de cómo poder verificar un certificado usando la API del Gobierno de Colima. 131 | 132 | ### Version 1.1.1 2020-01-22 133 | 134 | - Weak Break Compatibility Change: `PemExtractor::__construct($contents)` se podría construir con un parámetro de 135 | cualquier tipo de datos y al intentar usar el objeto inevitablemente iba a generar un `TypeError`. Se cambió la 136 | firma del constructor a `PemExtractor::__construct(string $contents)`, así fallaría desde construir el objeto y 137 | no al usar cualquiera de sus métodos. 138 | - Se actualiza la licencia a 2020. 139 | - Se actualiza de `phpstan/phpstan-shim: ^0.11` a `phpstan/phpstan: ^0.12`. 140 | - Se actualiza la integración continua en Travis y Scrutinizer. 141 | - Se actualizan los badges al nuevo estilo de phpCfdi. 142 | 143 | ### Version 1.1.0 2019-11-19 144 | 145 | - Se puede crear una llave privada en formato `PKCS#8 DER` encriptada o desprotegida. 146 | Con este cambio se pueden leer las llaves tal y como las envía el SAT. Gracias @eislasq. 147 | - Si la llave privada no estaba en formato `PEM` se hace una conversión de `PKCS#8 DER` a `PKCS#8 PEM`. 148 | - Se agrega el método `PrivateKey::changePassPhrase` que devuelve una llave privada con la nueva contraseña. 149 | - Se documenta la apertida de certificados y llaves privadas en diferentes formatos. 150 | - Se limpia el entorno de desarrollo y se publica en el paquete distribuible la carpeta de documentación. 151 | - Se hacen refactorizaciones menores para un mejor uso de memoria y rendimiento. 152 | 153 | ### Version 1.0.1 2019-09-18 154 | 155 | - Agregar métodos a `PrivateKey` para poder exponer la llave privada en formato PEM y la frase de paso. 156 | - Traducir documentación en `docs/` a español 157 | 158 | ### Version 1.0.0 2019-08-13 159 | 160 | - Primera versión funcional 161 | - Los proyectos `phpcfdi/xml-cancelacion` y `phpcfdi/sat-ws-descarga-masiva` tienen este proyecto como dependencia, 162 | la implementación en ambos proyectos dan algunas pistas de cómo mejorar este proyecto. 163 | -------------------------------------------------------------------------------- /docs/Certificados.md: -------------------------------------------------------------------------------- 1 | # Certificados 2 | 3 | Los certificados son los archivos con extensión `.cer` que te entregan con tu CSD o FIEL. 4 | 5 | Los certificados no son privados, los certificados son públicos. 6 | El SAT pide que se envíe su contenido en los CFDI y en otros servicios. 7 | 8 | Los certificados están vinculados con un creador, llamado emisor o entidad certificadora. 9 | 10 | 11 | Con un certificado se pueden realizar pocas acciones, en específico: 12 | - extraer información de quien esté relacionado con el certificado 13 | - obtener la llave pública (para poder verificar un mensaje) 14 | -------------------------------------------------------------------------------- /docs/EjemploCrearCredencialVerificacion.md: -------------------------------------------------------------------------------- 1 | # Ejemplo de creación de una credencial con verificaciones previas 2 | 3 | Al momento de crear una *credencial* (`Credential`), es posible que queramos verificar la creación con 4 | una lista detallada de errores. Si bien esto puede significar una doble verificación, es posible implementarlo 5 | con el siguiente código de ejemplo: 6 | 7 | ```php 8 | rfc() !== $expectedRfc) { 32 | throw new Exception(sprintf('El certificado no pertenece al RFC %s.', $expectedRfc)); 33 | } 34 | if ($certificate->validOn()) { 35 | throw new Exception('El certificado no es vigente en este momento.'); 36 | } 37 | if ($expectedType->isFiel() && ! $certificate->satType()->isFiel()) { 38 | throw new Exception('El certificado no corresponde a una eFirma/FIEL.'); 39 | } 40 | if ($expectedType->isCsd() && ! $certificate->satType()->isCsd()) { 41 | throw new Exception('El certificado no corresponde a un CSD.'); 42 | } 43 | 44 | try { 45 | $privateKey = PrivateKey::openFile($privateKeyFile, $passPhrase); 46 | } catch (Throwable $exception) { 47 | throw new Exception('El archivo de llave privada no se pudo abrir, el archivo o la contraseña son incorrectos.', 0, $exception); 48 | } 49 | if (! $privateKey->belongsTo($certificate)) { 50 | throw new Exception('La llave privada no es par del certificado.'); 51 | } 52 | 53 | return new Credential($certificate, $privateKey); 54 | } 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/SEMVER.md: -------------------------------------------------------------------------------- 1 | # SEMVER 2 | 3 | Respetamos el estándar [Versionado Semántico 2.0.0](https://semver.org/lang/es/). 4 | 5 | En resumen, [SemVer](https://semver.org/) es un sistema de versiones de tres componentes `X.Y.Z` 6 | que nombraremos ` Breaking . Feature . Fix `, donde: 7 | 8 | - `Breaking`: Rompe la compatibilidad de código con versiones anteriores. 9 | - `Feature`: Agrega una nueva característica que es compatible con lo anterior. 10 | - `Fix`: Incluye algún cambio (generalmente correcciones) que no agregan nueva funcionalidad. 11 | 12 | ## Composer 13 | 14 | El gestor de dependencias en proyectos para PHP [Composer](https://getcomposer.org/) 15 | usa las [reglas](https://getcomposer.org/doc/articles/versions.md) de versionado semántico 16 | para instalar y actualizar paquetes. 17 | 18 | Te recomendamos instalar dependencias de librerías (no frameworks) con *Caret Version Range*. 19 | Por ejemplo: `"vendor/package": "^2.5"`. 20 | 21 | Esto significa que: 22 | 23 | - no debe actualizar a versiones `3.x.x` 24 | - no debe utilizar ninguna versión menor a `2.5.0` 25 | 26 | ## Versiones 0.x.y no rompen compatibilidad 27 | 28 | Las versiones que inician con cero, por ejemplo `0.y.z`, no se ajustan a las reglas de versionado. 29 | Se considera que estas versiones son previas a la madurez del proyecto y por lo tanto 30 | introducen cambios sin previo aviso. 31 | 32 | Sin embargo, nos apegaremos a `[ 0 ] . [ Breaking ] . [ Feature || Fix ]`. Lo que significa que `0.3.0` 33 | no es compatible con `0.2.15` pero `0.3.4` sí es compatible con `0.3.0`. 34 | 35 | ## `@internal` no rompe compatibilidad 36 | 37 | Si la librería contiene elementos marcados como `@internal` significa que no deben ser utilizados por tu código. 38 | Son partes de código internos de la librería. Por lo tanto, no se consideran *breaking changes*. 39 | 40 | Cuando un elemento es `@internal`, dicho elemento: 41 | 42 | - no debe ser una entrada (parámetro) 43 | - no debe ser una salida (retorno) 44 | - no debe exponer funcionalidades en los objetos públicos (rasgos) 45 | -------------------------------------------------------------------------------- /docs/TODO.md: -------------------------------------------------------------------------------- 1 | # phpcfdi/credentials To Do List 2 | 3 | ## Tareas pendientes 4 | 5 | - [ ] Verificar si un certificado fue realmente emitido por el SAT 6 | Ver [VerificacionCertificadosSAT](VerificacionCertificadosSAT.md). 7 | 8 | - [ ] Usar excepciones específicas en lugar de genéricas. 9 | 10 | ## Tareas completadas 11 | 12 | - Migrar de Travis-CI a GitHub Workflows. 13 | 14 | - Encontrar cómo diferenciar entre un archivo CSD y un archivo FIEL. 15 | R: Se identifica por el campo OU (Organization Unit / Sucursal) del certificado, 16 | si está vacío es FIEL, si tiene contenido es CSD. 17 | -------------------------------------------------------------------------------- /docs/VerificacionCertificadosSAT.md: -------------------------------------------------------------------------------- 1 | # Verificación de certificados SAT 2 | 3 | Los certificados del SAT se pueden ser verificados contra los certificados raíz 4 | y así asegurar que el certificado fue emitido por el SAT. 5 | 6 | Al no tener un servicio público de verificación, el SAT comparte sus certificados raíz desde 7 | . 8 | 9 | Para ello necesitaremos de `openssl`. El procedimiento general consiste en: 10 | 11 | 1. Descargar los certificados raíz de producción 12 | 2. Convertir los certificados DER en PEM 13 | 3. Adaptar la carpeta para reconocerla como un directorio de Certificate Authority (CA) 14 | 4. Comparar el certificado PEM contra los certificados raíz. 15 | 16 | Lo mejor sería que el SAT tuviera un servicio público de consulta de certificados, incluso saber si un 17 | certificado ha sido revocado, el problema es que sí tienen el servicio, pero está restringido a agencias 18 | gubernamentales 19 | 20 | ## Verificación local de certificado 21 | 22 | Con el siguiente comando se hace la verificación de un certificado 23 | 24 | ```shell 25 | openssl verify -no_check_time -CApath sat_ca_prod mi_certificado.pem 26 | ``` 27 | 28 | Donde: 29 | 30 | - `-no_check_time`: No verificar que los certificados raíz sean válidos en el tiempo. 31 | - `-CAPath sat_ca_prod`: Lugar en donde están los certificados raíz ya procesados. 32 | - `mi_certificado.pem`: Certificado en formato PEM a validar 33 | 34 | ## Creación de la carpeta de certificados raíz 35 | 36 | Una vez que tengas los archivos raíz descomprimidos puedes ejecutar estos comandos para que la carpeta sea 37 | usable para el comando `openssl verify` o `openssl ocsp`. 38 | 39 | * Exportar en formato PEM los certificados que no están como tal: 40 | 41 | Para cada archivo `.cer` o `.crt` en el directorio `sat_ca_prod` ejecuta `openssl` para exportar de formato DER a PEM. 42 | 43 | * Crear enlaces simbólicos a los archivos por el número de hash 44 | 45 | El comando `openssl verify -CApath sat_ca_prod`, busca que el directorio especificado en `-CApath` tenga 46 | los archivos por número de hash, si no están así entonces no se tomarán en cuenta. 47 | 48 | ```shell 49 | openssl rehash sat_ca_prod 50 | ``` 51 | 52 | ### Script para crear toda la estructura de producción y pruebas 53 | 54 | El siguiente script básico de bash ejecuta todos los comandos que se requieren para descargar, exportar y poder 55 | utilizar como `CApath` los certificados raíz ofrecidos por el SAT: 56 | 57 | ```bash 58 | #!/bin/bash -e 59 | 60 | CA_PROD_SOURCE="http://omawww.sat.gob.mx/tramitesyservicios/Paginas/documentos/Cert_Prod.zip" 61 | CA_PROD_DEST="ca_production" 62 | CA_TEST_SOURCE="http://omawww.sat.gob.mx/tramitesyservicios/Paginas/documentos/Certificados_P.zip" 63 | CA_TEST_DEST="ca_testing" 64 | 65 | function extract_certificates() { 66 | local url="$1" 67 | local source="$(basename "$url")" 68 | local extractto="${source%.*}" 69 | local ca_folder="$2" 70 | rm -f "$source" 71 | wget "$url" -O "$source" 72 | rm -rf "$ca_folder" "$extractto" 73 | mkdir -p "$ca_folder" "$extractto" 74 | unzip "$source" -d "$extractto" 75 | 76 | find "$extractto" -type f \( -name "*.cer" -o -name "*.crt" \) | while read certificate; do 77 | rename_or_convert_certificate "$certificate" "$ca_folder" 78 | done 79 | rm -rf "$extractto" 80 | 81 | openssl rehash "$ca_folder" 82 | } 83 | 84 | function rename_or_convert_certificate { 85 | local source="$1" 86 | local sourcebasename="$(basename "$source")" 87 | local destination="$2/${sourcebasename%.*}.pem" 88 | if [ "text/plain" == "$(file "$source" -b --mime-type)" ]; then 89 | echo "Copy $source -> $destination" 90 | cp "$source" "$destination" 91 | return; 92 | fi 93 | echo "Convert $source -> $destination" 94 | openssl x509 -inform DER -outform PEM -in "$source" -out "$destination" 95 | } 96 | 97 | extract_certificates "$CA_PROD_SOURCE" "$CA_PROD_DEST" 98 | extract_certificates "$CA_TEST_SOURCE" "$CA_TEST_DEST" 99 | ``` 100 | 101 | ## Verificación a través de OCSP 102 | 103 | A pesar de que el SAT anuncia que su servicio OCSP es privado, en realidad sí se encuentra públicamente disponible. 104 | 105 | El siguiente comando sirve para verificar un certificado (FIEL o CSD) emitido por el SAT. 106 | 107 | ```shell 108 | OPENSSL_CONF=/etc/ssl/openssl_custom.cnf \ 109 | openssl ocsp -issuer ca_production/AC4_SAT.cer.pem -cert certificate.cer \ 110 | -text -CApath ca_production -url https://cfdi.sat.gob.mx/edofiel 111 | ``` 112 | 113 | Y entrega una respuesta como: 114 | 115 | ```text 116 | Response verify OK 117 | certificate.cer: revoked 118 | This Update: May 23 14:44:07 2023 GMT 119 | Next Update: May 23 14:45:07 2023 GMT 120 | Reason: unspecified 121 | Revocation Time: May 18 19:02:47 2023 GMT 122 | ``` 123 | 124 | ### `OPENSSL_CONF=/etc/ssl/openssl_custom.cnf` 125 | 126 | El sitio del SAT no tiene la seguridad adecuada y las nuevas versiones de OpenSSL 3.x no permiten hacer la consulta. 127 | 128 | En 2023-05-23 se encontró que utilizaba `TLSv1.2, Cipher is DHE-RSA-AES256-GCM-SHA384 ... Server Temp Key: DH, 1024 bits` 129 | y no es considerado seguro en el nivel 2 de OpenSSL: 130 | *RSA, DSA and DH keys shorter than 2048 bits and ECC keys shorter than 224 bits are prohibited*. 131 | 132 | Por lo que hay que degradar la configuración a `SECLEVEL=1`, generalmente agregando la siguiente información: 133 | 134 | ```ini 135 | [openssl_init] 136 | ssl_conf = ssl_sect 137 | 138 | [ssl_sect] 139 | system_default = system_default_sect 140 | 141 | [system_default_sect] 142 | CipherString = DEFAULT@SECLEVEL=1 143 | ``` 144 | 145 | ### `-issuer ca_production/AC4_SAT.cer.pem` 146 | 147 | El certificado padre del SAT, si se está usando el certificado incorrecto el comando fallará y 148 | mostrará un mensaje de error como este: 149 | 150 | ```text 151 | Responder Error: trylater (3) 152 | ``` 153 | 154 | ### `-cert certificate.cer` 155 | 156 | El certificado que se desea revisar, no es necesario convertirlo a formato PEM. 157 | 158 | ### `-url https://cfdi.sat.gob.mx/edofiel` 159 | 160 | Dirección del servicio OSCP del SAT. 161 | 162 | ### `-CApath ca_production` 163 | 164 | Dirección donde están los certificados de confianza del SAT. 165 | 166 | ## Verificación de certificados a través de la página del Gobierno de Colima 167 | 168 | El Gobierno de Colima expone una API JSON en que sirve para el propósito 169 | de verificar el estado de un certificado. 170 | 171 | El principal inconveniente del servicio es que no establece la fecha de revocación. 172 | Por lo que el estado del certificado solo es relativo al momento de la consulta. 173 | 174 | Ejemplo de consumo: 175 | 176 | ```shell 177 | curl -X POST -F 'certificado=@/path/to/certificate.cer' \ 178 | https://apisnet.col.gob.mx/wsSignGob/apiV1/Valida/Certificado 179 | ``` 180 | 181 | Ejemplo de respuesta: 182 | 183 | ```json 184 | { 185 | "RESTService": { 186 | "Message": "Certificado Aceptado ante el SAT" 187 | }, 188 | "Response": { 189 | "OCSPStatus": "Revocado" 190 | } 191 | } 192 | ``` 193 | -------------------------------------------------------------------------------- /src/Certificate.php: -------------------------------------------------------------------------------- 1 | extractCertificate(); 44 | if ('' === $pem) { // it could be a DER content, convert to PEM 45 | $pem = static::convertDerToPem($contents); 46 | } 47 | 48 | $parsed = openssl_x509_parse($pem, true); 49 | if (false === $parsed) { 50 | throw new UnexpectedValueException('Cannot parse X509 certificate from contents'); 51 | } 52 | $this->pem = $pem; 53 | $this->dataArray = $parsed; 54 | $this->rfc = strval(strstr($this->subjectData('x500UniqueIdentifier') . ' ', ' ', true)); 55 | $this->legalName = $this->subjectData('name'); 56 | } 57 | 58 | /** 59 | * Convert X.509 DER base64 or X.509 DER to X.509 PEM 60 | * 61 | * @param string $contents can be a certificate format X.509 DER or X.509 DER base64 62 | */ 63 | public static function convertDerToPem(string $contents): string 64 | { 65 | // effectively compare that all the content is base64, if it isn't then encode it 66 | if ($contents !== base64_encode(base64_decode($contents, true) ?: '')) { 67 | $contents = base64_encode($contents); 68 | } 69 | return '-----BEGIN CERTIFICATE-----' . PHP_EOL 70 | . chunk_split($contents, 64, PHP_EOL) 71 | . '-----END CERTIFICATE-----'; 72 | } 73 | 74 | /** 75 | * Create a Certificate object by opening a local file 76 | * The content file can be a certificate format X.509 PEM, X.509 DER or X.509 DER base64 77 | * 78 | * @param string $filename must be a local file (without scheme or file:// scheme) 79 | */ 80 | public static function openFile(string $filename): self 81 | { 82 | return new self(self::localFileOpen($filename)); 83 | } 84 | 85 | public function pem(): string 86 | { 87 | return $this->pem; 88 | } 89 | 90 | public function pemAsOneLine(): string 91 | { 92 | return implode('', preg_grep('/^((?!-).)*$/', explode(PHP_EOL, $this->pem())) ?: []); 93 | } 94 | 95 | /** 96 | * @return array 97 | */ 98 | public function parsed(): array 99 | { 100 | return $this->dataArray; 101 | } 102 | 103 | public function rfc(): string 104 | { 105 | return $this->rfc; 106 | } 107 | 108 | public function legalName(): string 109 | { 110 | return $this->legalName; 111 | } 112 | 113 | public function branchName(): string 114 | { 115 | return $this->subjectData('OU'); 116 | } 117 | 118 | public function name(): string 119 | { 120 | return $this->extractString('name'); 121 | } 122 | 123 | /** @return array */ 124 | public function subject(): array 125 | { 126 | return $this->extractArrayStrings('subject'); 127 | } 128 | 129 | public function subjectData(string $key): string 130 | { 131 | return strval($this->subject()[$key] ?? ''); 132 | } 133 | 134 | public function hash(): string 135 | { 136 | return $this->extractString('hash'); 137 | } 138 | 139 | /** @return array */ 140 | public function issuer(): array 141 | { 142 | return $this->extractArrayStrings('issuer'); 143 | } 144 | 145 | public function issuerData(string $string): string 146 | { 147 | return strval($this->issuer()[$string] ?? ''); 148 | } 149 | 150 | public function version(): string 151 | { 152 | return $this->extractString('version'); 153 | } 154 | 155 | public function serialNumber(): SerialNumber 156 | { 157 | if (null === $this->serialNumber) { 158 | $this->serialNumber = $this->createSerialNumber( 159 | $this->extractString('serialNumberHex'), 160 | $this->extractString('serialNumber') 161 | ); 162 | } 163 | return $this->serialNumber; 164 | } 165 | 166 | public function validFrom(): string 167 | { 168 | return $this->extractString('validFrom'); 169 | } 170 | 171 | public function validTo(): string 172 | { 173 | return $this->extractString('validTo'); 174 | } 175 | 176 | public function validFromDateTime(): DateTimeImmutable 177 | { 178 | return $this->extractDateTime('validFrom_time_t'); 179 | } 180 | 181 | public function validToDateTime(): DateTimeImmutable 182 | { 183 | return $this->extractDateTime('validTo_time_t'); 184 | } 185 | 186 | public function signatureTypeSN(): string 187 | { 188 | return $this->extractString('signatureTypeSN'); 189 | } 190 | 191 | public function signatureTypeLN(): string 192 | { 193 | return $this->extractString('signatureTypeLN'); 194 | } 195 | 196 | public function signatureTypeNID(): string 197 | { 198 | return $this->extractString('signatureTypeNID'); 199 | } 200 | 201 | /** @return array */ 202 | public function purposes(): array 203 | { 204 | return $this->extractArray('purposes'); 205 | } 206 | 207 | /** @return array */ 208 | public function extensions(): array 209 | { 210 | return $this->extractArrayStrings('extensions'); 211 | } 212 | 213 | public function publicKey(): PublicKey 214 | { 215 | if (null === $this->publicKey) { 216 | // The public key can be created from PUBLIC KEY or CERTIFICATE 217 | $this->publicKey = new PublicKey($this->pem); 218 | } 219 | return $this->publicKey; 220 | } 221 | 222 | public function satType(): SatTypeEnum 223 | { 224 | // as of 2019-08-01 is known that only CSD have OU (Organization Unit) 225 | if ('' === $this->branchName()) { 226 | return SatTypeEnum::fiel(); 227 | } 228 | return SatTypeEnum::csd(); 229 | } 230 | 231 | public function validOn(?DateTimeImmutable $datetime = null): bool 232 | { 233 | if (null === $datetime) { 234 | $datetime = new DateTimeImmutable(); 235 | } 236 | return $datetime >= $this->validFromDateTime() && $datetime <= $this->validToDateTime(); 237 | } 238 | 239 | protected function createSerialNumber(string $hexadecimal, string $decimal): SerialNumber 240 | { 241 | if ('' !== $hexadecimal) { 242 | return SerialNumber::createFromHexadecimal($hexadecimal); 243 | } 244 | if ('' !== $decimal) { 245 | // in some cases openssl report serialNumberHex on serialNumber 246 | if (0 === strcasecmp('0x', substr($decimal, 0, 2))) { 247 | return SerialNumber::createFromHexadecimal(substr($decimal, 2)); 248 | } 249 | return SerialNumber::createFromDecimal($decimal); 250 | } 251 | throw new UnexpectedValueException('Certificate does not contain a serial number'); 252 | } 253 | 254 | public function issuerAsRfc4514(): string 255 | { 256 | $issuer = $this->issuer(); 257 | return (new Internal\Rfc4514())->escapeArray($issuer); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/Credential.php: -------------------------------------------------------------------------------- 1 | belongsTo($certificate)) { 23 | throw new UnexpectedValueException('Certificate does not belong to private key'); 24 | } 25 | $this->certificate = $certificate; 26 | $this->privateKey = $privateKey; 27 | } 28 | 29 | /** 30 | * Create a Credential object based on string contents 31 | * 32 | * The certificate content can be X.509 PEM, X.509 DER or X.509 DER base64 33 | * The private key content can be PKCS#8 DER, PKCS#8 PEM or PKCS#5 PEM 34 | */ 35 | public static function create(string $certificateContents, string $privateKeyContents, string $passPhrase): self 36 | { 37 | $certificate = new Certificate($certificateContents); 38 | $privateKey = new PrivateKey($privateKeyContents, $passPhrase); 39 | return new self($certificate, $privateKey); 40 | } 41 | 42 | /** 43 | * Create a Credential object based on local files 44 | * 45 | * File paths must be local, can have no schema or file:// schema 46 | * The certificate file content can be X.509 PEM, X.509 DER or X.509 DER base64 47 | * The private key file content can be PKCS#8 DER, PKCS#8 PEM or PKCS#5 PEM 48 | */ 49 | public static function openFiles(string $certificateFile, string $privateKeyFile, string $passPhrase): self 50 | { 51 | $certificate = Certificate::openFile($certificateFile); 52 | $privateKey = PrivateKey::openFile($privateKeyFile, $passPhrase); 53 | return new self($certificate, $privateKey); 54 | } 55 | 56 | public function certificate(): Certificate 57 | { 58 | return $this->certificate; 59 | } 60 | 61 | public function privateKey(): PrivateKey 62 | { 63 | return $this->privateKey; 64 | } 65 | 66 | public function rfc(): string 67 | { 68 | return $this->certificate->rfc(); 69 | } 70 | 71 | public function legalName(): string 72 | { 73 | return $this->certificate->legalName(); 74 | } 75 | 76 | public function isFiel(): bool 77 | { 78 | return $this->certificate()->satType()->isFiel(); 79 | } 80 | 81 | public function isCsd(): bool 82 | { 83 | return $this->certificate()->satType()->isCsd(); 84 | } 85 | 86 | public function sign(string $data, int $algorithm = OPENSSL_ALGO_SHA256): string 87 | { 88 | return $this->privateKey()->sign($data, $algorithm); 89 | } 90 | 91 | public function verify(string $data, string $signature, int $algorithm = OPENSSL_ALGO_SHA256): bool 92 | { 93 | return $this->certificate()->publicKey()->verify($data, $signature, $algorithm); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Internal/BaseConverter.php: -------------------------------------------------------------------------------- 1 | sequence; 32 | } 33 | 34 | public function maximumBase(): int 35 | { 36 | return $this->sequence->length(); 37 | } 38 | 39 | public function convert(string $input, int $frombase, int $tobase): string 40 | { 41 | if ($frombase < 2 || $frombase > $this->maximumBase()) { 42 | throw new UnexpectedValueException('Invalid from base'); 43 | } 44 | if ($tobase < 2 || $tobase > $this->maximumBase()) { 45 | throw new UnexpectedValueException('Invalid to base'); 46 | } 47 | 48 | $originalSequence = $this->sequence()->value(); 49 | if ('' === $input) { 50 | $input = $originalSequence[0]; // use zero as input 51 | } 52 | $chars = substr($originalSequence, 0, $frombase); 53 | if (! preg_match("/^[$chars]+$/", $input)) { 54 | throw new UnexpectedValueException('The number to convert contains invalid characters'); 55 | } 56 | 57 | $length = strlen($input); 58 | $values = []; 59 | for ($i = 0; $i < $length; $i++) { 60 | $values[] = intval(stripos($originalSequence, $input[$i])); 61 | } 62 | 63 | $result = ''; 64 | do { 65 | $divide = 0; 66 | $newlen = 0; 67 | for ($i = 0; $i < $length; $i++) { 68 | $divide = $divide * $frombase + $values[$i]; 69 | if ($divide >= $tobase) { 70 | $values[$newlen] = intval($divide / $tobase); 71 | $divide = $divide % $tobase; 72 | $newlen = $newlen + 1; 73 | } elseif ($newlen > 0) { 74 | $values[$newlen] = 0; 75 | $newlen = $newlen + 1; 76 | } 77 | } 78 | $length = $newlen; 79 | $result = $originalSequence[$divide] . $result; 80 | } while ($newlen > 0); 81 | 82 | return $result; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Internal/BaseConverterSequence.php: -------------------------------------------------------------------------------- 1 | sequence = $sequence; 21 | $this->length = strlen($sequence); 22 | } 23 | 24 | public function __toString(): string 25 | { 26 | return $this->sequence; 27 | } 28 | 29 | public function value(): string 30 | { 31 | return $this->sequence; 32 | } 33 | 34 | public function length(): int 35 | { 36 | return $this->length; 37 | } 38 | 39 | public static function isValid(string $value): bool 40 | { 41 | try { 42 | static::checkIsValid($value); 43 | return true; 44 | } catch (UnexpectedValueException) { 45 | return false; 46 | } 47 | } 48 | 49 | public static function checkIsValid(string $sequence): void 50 | { 51 | $length = strlen($sequence); 52 | 53 | // is not empty 54 | if ($length < 2) { 55 | throw new UnexpectedValueException('Sequence does not contains enough elements'); 56 | } 57 | 58 | if ($length !== mb_strlen($sequence)) { 59 | throw new UnexpectedValueException('Cannot use multibyte strings in dictionary'); 60 | } 61 | 62 | $valuesCount = array_count_values(str_split(strtoupper($sequence))); 63 | $repeated = array_filter($valuesCount, fn (int $count): bool => 1 !== $count); 64 | if ([] !== $repeated) { 65 | throw new UnexpectedValueException( 66 | sprintf('The sequence has not unique values: "%s"', implode(', ', array_keys($repeated))) 67 | ); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Internal/DataArrayTrait.php: -------------------------------------------------------------------------------- 1 | content of openssl_x509_parse or openssl_pkey_get_details */ 13 | protected $dataArray; 14 | 15 | /** 16 | * @param string|int|float|bool $default 17 | * @return string|int|float|bool 18 | */ 19 | protected function extractScalar(string $key, $default) 20 | { 21 | $value = $this->dataArray[$key] ?? $default; 22 | if (is_scalar($value)) { 23 | return $value; 24 | } 25 | return $default; 26 | } 27 | 28 | protected function extractString(string $key): string 29 | { 30 | return strval($this->extractScalar($key, '')); 31 | } 32 | 33 | protected function extractInteger(string $key): int 34 | { 35 | $value = $this->extractScalar($key, 0); 36 | if (is_numeric($value)) { 37 | return intval($this->extractScalar($key, 0)); 38 | } 39 | return 0; 40 | } 41 | 42 | /** @return array */ 43 | protected function extractArray(string $key): array 44 | { 45 | $data = $this->dataArray[$key] ?? null; 46 | if (! is_array($data)) { 47 | return []; 48 | } 49 | return $data; 50 | } 51 | 52 | /** @return array */ 53 | protected function extractArrayStrings(string $key): array 54 | { 55 | $array = []; 56 | foreach ($this->extractArray($key) as $name => $value) { 57 | if (is_scalar($value)) { 58 | $array[(string) $name] = strval($value); 59 | } 60 | } 61 | return $array; 62 | } 63 | 64 | protected function extractDateTime(string $key): DateTimeImmutable 65 | { 66 | return new DateTimeImmutable(sprintf('@%d', $this->extractInteger($key))); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Internal/Key.php: -------------------------------------------------------------------------------- 1 | $dataArray */ 15 | public function __construct(array $dataArray) 16 | { 17 | $this->dataArray = $dataArray; 18 | } 19 | 20 | /** @return array */ 21 | public function parsed(): array 22 | { 23 | return $this->dataArray; 24 | } 25 | 26 | public function publicKeyContents(): string 27 | { 28 | return $this->extractString('key'); 29 | } 30 | 31 | public function numberOfBits(): int 32 | { 33 | return $this->extractInteger('bits'); 34 | } 35 | 36 | public function type(): OpenSslKeyTypeEnum 37 | { 38 | if (null === $this->type) { 39 | $this->type = new OpenSslKeyTypeEnum($this->extractInteger('type')); 40 | } 41 | return $this->type; 42 | } 43 | 44 | /** @return array */ 45 | public function typeData(): array 46 | { 47 | return $this->extractArray($this->type()->value()); 48 | } 49 | 50 | /** 51 | * @param int $type one of OPENSSL_KEYTYPE_RSA, OPENSSL_KEYTYPE_DSA, OPENSSL_KEYTYPE_DH, OPENSSL_KEYTYPE_EC 52 | */ 53 | public function isType(int $type): bool 54 | { 55 | return $this->type()->index() === $type; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Internal/LocalFileOpenTrait.php: -------------------------------------------------------------------------------- 1 | 1) { 25 | throw new UnexpectedValueException('Invalid scheme to open file'); 26 | } 27 | 28 | $path = (realpath($filename) ?: ''); 29 | if ('' === $path) { 30 | throw new RuntimeException('Unable to locate the file to open'); 31 | } 32 | 33 | /** @noinspection PhpUsageOfSilenceOperatorInspection */ 34 | $contents = @file_get_contents($path, false) ?: ''; 35 | if ('' === $contents) { 36 | throw new RuntimeException('File content is empty'); 37 | } 38 | 39 | return $contents; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Internal/OpenSslKeyTypeEnum.php: -------------------------------------------------------------------------------- 1 | 29 | * @noinspection PhpMissingParentCallCommonInspection 30 | */ 31 | protected static function overrideIndices(): array 32 | { 33 | return [ 34 | 'rsa' => OPENSSL_KEYTYPE_RSA, 35 | 'dsa' => OPENSSL_KEYTYPE_DSA, 36 | 'dh' => OPENSSL_KEYTYPE_DH, 37 | 'ec' => OPENSSL_KEYTYPE_EC, 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Internal/Rfc4514.php: -------------------------------------------------------------------------------- 1 | ']; 23 | 24 | public const INNER_REPLACEMENTS = ['\5C', '\22', '\2b', '\2c', '\3b', '\3c', '\3d', '\3e']; 25 | 26 | public function escape(string $subject): string 27 | { 28 | $prefix = ''; 29 | $sufix = ''; 30 | $firstChar = substr($subject, 0, 1); 31 | if (in_array($firstChar, self::LEAD_CHARS, true)) { 32 | $prefix = str_replace(self::LEAD_CHARS, self::LEAD_REPLACEMENTS, $firstChar); 33 | $subject = substr($subject, 1); 34 | } 35 | 36 | $lastChar = substr($subject, -1); 37 | if (in_array($lastChar, self::TRAIL_CHARS, true)) { 38 | $sufix = str_replace(self::TRAIL_CHARS, self::TRAIL_REPLACEMENTS, $lastChar); 39 | $subject = substr($subject, 0, -1); 40 | } 41 | 42 | return $prefix . str_replace(self::INNER_CHARS, self::INNER_REPLACEMENTS, $subject) . $sufix; 43 | } 44 | 45 | /** 46 | * @param array $values 47 | */ 48 | public function escapeArray(array $values): string 49 | { 50 | return implode(',', array_map( 51 | fn (string $name, string $value): string => $this->escape($name) . '=' . $this->escape($value), 52 | array_keys($values), 53 | $values 54 | )); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Internal/SatTypeEnum.php: -------------------------------------------------------------------------------- 1 | contents; 16 | } 17 | 18 | public function extractCertificate(): string 19 | { 20 | return $this->extractBase64('CERTIFICATE'); 21 | } 22 | 23 | public function extractPublicKey(): string 24 | { 25 | return $this->extractBase64('PUBLIC KEY'); 26 | } 27 | 28 | public function extractPrivateKey(): string 29 | { 30 | // see https://github.com/kjur/jsrsasign/wiki/Tutorial-for-PKCS5-and-PKCS8-PEM-private-key-formats-differences 31 | // PKCS#8 plain private key 32 | if ('' !== $extracted = $this->extractBase64('PRIVATE KEY')) { 33 | return $extracted; 34 | } 35 | // PKCS#5 plain private key 36 | if ('' !== $extracted = $this->extractBase64('RSA PRIVATE KEY')) { 37 | return $extracted; 38 | } 39 | // PKCS#5 encrypted private key 40 | if ('' !== $extracted = $this->extractRsaProtected()) { 41 | return $extracted; 42 | } 43 | // PKCS#8 encrypted private key 44 | return $this->extractBase64('ENCRYPTED PRIVATE KEY'); 45 | } 46 | 47 | protected function extractBase64(string $type): string 48 | { 49 | $matches = []; 50 | $type = preg_quote($type, '/'); 51 | $pattern = '/^' 52 | . '-----BEGIN ' . $type . '-----\r?\n' 53 | . '([A-Za-z0-9+\/=]+\r?\n)+' 54 | . '-----END ' . $type . '-----\r?\n?' 55 | . '$/m'; 56 | preg_match($pattern, $this->getContents(), $matches); 57 | return $this->normalizeLineEndings(strval($matches[0] ?? '')); 58 | } 59 | 60 | protected function extractRsaProtected(): string 61 | { 62 | $matches = []; 63 | $pattern = '/^' 64 | . '-----BEGIN RSA PRIVATE KEY-----\r?\n' 65 | . 'Proc-Type: .+\r?\n' 66 | . 'DEK-Info: .+\r?\n\r?\n' 67 | . '([A-Za-z0-9+\/=]+\r?\n)+' 68 | . '-----END RSA PRIVATE KEY-----\r?\n?' 69 | . '$/m'; 70 | preg_match($pattern, $this->getContents(), $matches); 71 | return $this->normalizeLineEndings(strval($matches[0] ?? '')); 72 | } 73 | 74 | /** 75 | * Changes EOL CRLF or LF to PHP_EOL. 76 | * This won't alter CR that are not at EOL. 77 | * This won't alter LF+CR used in old Mac style 78 | * 79 | * @internal 80 | */ 81 | protected function normalizeLineEndings(string $content): string 82 | { 83 | // move '\r\n' or '\n' to PHP_EOL 84 | // first substitution '\r\n' -> '\n' 85 | // second substitution '\n' -> PHP_EOL 86 | // remove any EOL at the EOF 87 | return rtrim(str_replace(["\r\n", "\n"], ["\n", PHP_EOL], $content), PHP_EOL); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Pfx/PfxExporter.php: -------------------------------------------------------------------------------- 1 | credential; 22 | } 23 | 24 | public function export(string $passPhrase): string 25 | { 26 | $pfxContents = ''; 27 | /** @noinspection PhpUsageOfSilenceOperatorInspection */ 28 | $success = @openssl_pkcs12_export( 29 | $this->credential->certificate()->pem(), 30 | $pfxContents, 31 | [$this->credential->privateKey()->pem(), $this->credential->privateKey()->passPhrase()], 32 | $passPhrase, 33 | ); 34 | if (! $success || ! is_string($pfxContents)) { 35 | throw $this->exceptionFromLastError(sprintf( 36 | 'Cannot export credential with certificate %s', 37 | $this->credential->certificate()->serialNumber()->bytes() 38 | )); 39 | } 40 | return $pfxContents; 41 | } 42 | 43 | public function exportToFile(string $pfxFile, string $passPhrase): void 44 | { 45 | /** @noinspection PhpUsageOfSilenceOperatorInspection */ 46 | $success = @openssl_pkcs12_export_to_file( 47 | $this->credential->certificate()->pem(), 48 | $pfxFile, 49 | [$this->credential->privateKey()->pem(), $this->credential->privateKey()->passPhrase()], 50 | $passPhrase 51 | ); 52 | if (! $success) { 53 | throw $this->exceptionFromLastError(sprintf( 54 | 'Cannot export credential with certificate %s to file %s', 55 | $this->credential->certificate()->serialNumber()->bytes(), 56 | $pfxFile 57 | )); 58 | } 59 | } 60 | 61 | private function exceptionFromLastError(string $message): RuntimeException 62 | { 63 | $previousError = error_get_last() ?? []; 64 | return new RuntimeException(sprintf('%s: %s', $message, $previousError['message'] ?? '(Unknown reason)')); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Pfx/PfxReader.php: -------------------------------------------------------------------------------- 1 | loadPkcs12($contents, $passPhrase); 21 | $certificatePem = $pfx['cert']; 22 | $privateKeyPem = $pfx['pkey']; 23 | return Credential::create($certificatePem, $privateKeyPem, ''); 24 | } 25 | 26 | public function createCredentialFromFile(string $fileName, string $passPhrase): Credential 27 | { 28 | return $this->createCredentialFromContents(self::localFileOpen($fileName), $passPhrase); 29 | } 30 | 31 | /** 32 | * @return array{cert:string, pkey:string} 33 | */ 34 | public function loadPkcs12(string $contents, string $password = ''): array 35 | { 36 | $pfx = []; 37 | if (! openssl_pkcs12_read($contents, $pfx, $password)) { 38 | throw new UnexpectedValueException('Invalid PKCS#12 contents or wrong passphrase'); 39 | } 40 | $certificate = ''; 41 | $privateKey = ''; 42 | if (is_array($pfx)) { 43 | $certificate = $pfx['cert'] ?? null; 44 | $privateKey = $pfx['pkey'] ?? null; 45 | } 46 | return [ 47 | 'cert' => is_string($certificate) ? $certificate : '', 48 | 'pkey' => is_string($privateKey) ? $privateKey : '', 49 | ]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/PrivateKey.php: -------------------------------------------------------------------------------- 1 | extractPrivateKey(); 39 | if ('' === $pem) { 40 | // it could be a DER content, convert to PEM 41 | $convertSourceIsEncrypted = ('' !== $passPhrase); 42 | $pem = static::convertDerToPem($source, $convertSourceIsEncrypted); 43 | } 44 | $this->pem = $pem; 45 | $this->passPhrase = $passPhrase; 46 | $dataArray = $this->callOnPrivateKey( 47 | fn ($privateKey): array => 48 | // no need to verify that openssl_pkey_get_details returns false since it is already open 49 | openssl_pkey_get_details($privateKey) ?: [] 50 | ); 51 | parent::__construct($dataArray); 52 | } 53 | 54 | /** 55 | * Convert PKCS#8 DER to PKCS#8 PEM 56 | * 57 | * @param string $contents can be a PKCS#8 DER 58 | */ 59 | public static function convertDerToPem(string $contents, bool $isEncrypted): string 60 | { 61 | $privateKeyName = ($isEncrypted) ? 'ENCRYPTED PRIVATE KEY' : 'PRIVATE KEY'; 62 | return "-----BEGIN $privateKeyName-----" . PHP_EOL 63 | . chunk_split(base64_encode($contents), 64, PHP_EOL) 64 | . "-----END $privateKeyName-----"; 65 | } 66 | 67 | /** 68 | * Create a PrivateKey object by opening a local file 69 | * The content file can be a PKCS#8 DER, PKCS#8 PEM or PKCS#5 PEM 70 | * 71 | * @param string $filename must be a local file (without scheme or file:// scheme) 72 | */ 73 | public static function openFile(string $filename, string $passPhrase): self 74 | { 75 | return new self(self::localFileOpen($filename), $passPhrase); 76 | } 77 | 78 | public function pem(): string 79 | { 80 | return $this->pem; 81 | } 82 | 83 | public function passPhrase(): string 84 | { 85 | return $this->passPhrase; 86 | } 87 | 88 | public function publicKey(): PublicKey 89 | { 90 | if (null === $this->publicKey) { 91 | $this->publicKey = new PublicKey($this->publicKeyContents()); 92 | } 93 | return $this->publicKey; 94 | } 95 | 96 | public function sign(string $data, int $algorithm = OPENSSL_ALGO_SHA256): string 97 | { 98 | return $this->callOnPrivateKey( 99 | function ($privateKey) use ($data, $algorithm): string { 100 | if (false === $this->openSslSign($data, $signature, $privateKey, $algorithm)) { 101 | throw new RuntimeException('Cannot sign data: ' . openssl_error_string()); 102 | } 103 | $signature = strval($signature); 104 | if ('' === $signature) { 105 | throw new RuntimeException('Cannot sign data: empty signature'); 106 | } 107 | return $signature; 108 | } 109 | ); 110 | } 111 | 112 | /** 113 | * This method id created to wrap and mock openssl_sign 114 | * 115 | * @param OpenSSLAsymmetricKey $privateKey 116 | * @internal 117 | */ 118 | protected function openSslSign(string $data, ?string &$signature, $privateKey, int $algorithm): bool 119 | { 120 | return openssl_sign($data, $signature, $privateKey, $algorithm); // @phpstan-ignore parameterByRef.type 121 | } 122 | 123 | public function belongsTo(Certificate $certificate): bool 124 | { 125 | return $this->belongsToPEMCertificate($certificate->pem()); 126 | } 127 | 128 | public function belongsToPEMCertificate(string $certificate): bool 129 | { 130 | return $this->callOnPrivateKey( 131 | fn ($privateKey): bool => openssl_x509_check_private_key($certificate, $privateKey) 132 | ); 133 | } 134 | 135 | /** 136 | * @template T 137 | * @param Closure(OpenSSLAsymmetricKey): T $function 138 | * @return T 139 | * @throws RuntimeException when cannot open the public key from certificate 140 | */ 141 | public function callOnPrivateKey(Closure $function) 142 | { 143 | /** @var false|OpenSSLAsymmetricKey $privateKey */ 144 | $privateKey = openssl_get_privatekey($this->pem(), $this->passPhrase()); 145 | if (false === $privateKey) { 146 | throw new RuntimeException('Cannot open private key: ' . openssl_error_string()); 147 | } 148 | return $function($privateKey); 149 | } 150 | 151 | /** 152 | * Export the current private key to a new private key with a different password 153 | * 154 | * @param string $newPassPhrase If empty the new private key will be unencrypted 155 | */ 156 | public function changePassPhrase(string $newPassPhrase): self 157 | { 158 | $pem = $this->callOnPrivateKey( 159 | function ($privateKey) use ($newPassPhrase): string { 160 | $exportConfig = [ 161 | 'private_key_bits' => $this->publicKey()->numberOfBits(), 162 | 'encrypt_key' => ('' !== $newPassPhrase), // if empty then set that the key is not encrypted 163 | ]; 164 | // @codeCoverageIgnoreStart 165 | if (! openssl_pkey_export($privateKey, $exported, $newPassPhrase, $exportConfig)) { 166 | throw new RuntimeException('Cannot export the private KEY to change password'); 167 | } 168 | if (! is_string($exported) || '' === $exported) { 169 | throw new RuntimeException('Exported KEY has not a valid content'); 170 | } 171 | // @codeCoverageIgnoreEnd 172 | return $exported; 173 | } 174 | ); 175 | return new self($pem, $newPassPhrase); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/PublicKey.php: -------------------------------------------------------------------------------- 1 | callOnPublicKeyWithContents( 20 | fn ($publicKey): array => 21 | // no need to verify that openssl_pkey_get_details returns false since it is already open 22 | openssl_pkey_get_details($publicKey) ?: [], 23 | $source 24 | ); 25 | parent::__construct($dataArray); 26 | } 27 | 28 | public static function openFile(string $filename): self 29 | { 30 | return new self(self::localFileOpen($filename)); 31 | } 32 | 33 | /** 34 | * Verify the signature of some data 35 | * 36 | * 37 | * 38 | * @throws RuntimeException when openssl report an error on verify 39 | */ 40 | public function verify(string $data, string $signature, int $algorithm = OPENSSL_ALGO_SHA256): bool 41 | { 42 | return $this->callOnPublicKey( 43 | function ($publicKey) use ($data, $signature, $algorithm): bool { 44 | $verify = $this->openSslVerify($data, $signature, $publicKey, $algorithm); 45 | if (-1 === $verify) { 46 | /** @codeCoverageIgnore Don't know how make openssl_verify returns -1 */ 47 | throw new RuntimeException('Verify error: ' . openssl_error_string()); 48 | } 49 | return 1 === $verify; 50 | } 51 | ); 52 | } 53 | 54 | /** 55 | * This method id created to wrap and mock openssl_verify 56 | * 57 | * @param OpenSSLAsymmetricKey $publicKey 58 | */ 59 | protected function openSslVerify(string $data, string $signature, $publicKey, int $algorithm): int 60 | { 61 | $verify = openssl_verify($data, $signature, $publicKey, $algorithm); 62 | if (false === $verify) { 63 | return -1; // @codeCoverageIgnore 64 | } 65 | return $verify; 66 | } 67 | 68 | /** 69 | * Run a closure with this public key opened 70 | * 71 | * @template T 72 | * @param Closure(OpenSSLAsymmetricKey): T $function 73 | * @return T 74 | */ 75 | public function callOnPublicKey(Closure $function) 76 | { 77 | return $this->callOnPublicKeyWithContents($function, $this->publicKeyContents()); 78 | } 79 | 80 | /** 81 | * @template T 82 | * @param Closure(OpenSSLAsymmetricKey): T $function 83 | * @return T 84 | * @throws RuntimeException when Cannot open public key 85 | */ 86 | private function callOnPublicKeyWithContents(Closure $function, string $publicKeyContents) 87 | { 88 | /** @var false|OpenSSLAsymmetricKey $pubKey */ 89 | $pubKey = openssl_get_publickey($publicKeyContents); 90 | if (false === $pubKey) { 91 | throw new RuntimeException('Cannot open public key: ' . openssl_error_string()); 92 | } 93 | return $function($pubKey); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/SerialNumber.php: -------------------------------------------------------------------------------- 1 | hexadecimal) { 23 | throw new UnexpectedValueException('The hexadecimal string is empty'); 24 | } 25 | if (0 === strcasecmp('0x', substr($this->hexadecimal, 0, 2))) { 26 | $this->hexadecimal = substr($this->hexadecimal, 2); 27 | } 28 | $this->hexadecimal = strtoupper($this->hexadecimal); 29 | if (! preg_match('/^[0-9A-F]*$/', $this->hexadecimal)) { 30 | throw new UnexpectedValueException('The hexadecimal string contains invalid characters'); 31 | } 32 | } 33 | 34 | public static function createFromHexadecimal(string $hexadecimal): self 35 | { 36 | return new self($hexadecimal); 37 | } 38 | 39 | public static function createFromDecimal(string $decString): self 40 | { 41 | $hexadecimal = BaseConverter::createBase36()->convert($decString, 10, 16); 42 | return new self($hexadecimal); 43 | } 44 | 45 | public static function createFromBytes(string $input): self 46 | { 47 | return new self(bin2hex($input)); 48 | } 49 | 50 | public function hexadecimal(): string 51 | { 52 | return $this->hexadecimal; 53 | } 54 | 55 | public function bytes(): string 56 | { 57 | return (string) hex2bin($this->hexadecimal); 58 | } 59 | 60 | public function decimal(): string 61 | { 62 | return BaseConverter::createBase36()->convert($this->hexadecimal(), 16, 10); 63 | } 64 | 65 | public function bytesArePrintable(): bool 66 | { 67 | return (bool) preg_match('/^[[:print:]]*$/', $this->bytes()); 68 | } 69 | } 70 | --------------------------------------------------------------------------------