├── .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 |
--------------------------------------------------------------------------------