diff --git a/.gitattributes b/.gitattributes
index 6ead97d..1285aca 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -17,3 +17,6 @@
/phpstan.neon.dist export-ignore
/phpunit.xml.dist export-ignore
/sonar-project.properties export-ignore
+
+# Do not count these files on github code language
+/tests/_files/** linguist-detectable=false
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1b89dea..62b00e0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,5 +1,6 @@
name: build
on:
+ workflow_dispatch:
pull_request:
branches: [ "main" ]
push:
@@ -17,11 +18,11 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
coverage: none
tools: composer-normalize
env:
@@ -34,11 +35,11 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
coverage: none
tools: cs2pr, phpcs
env:
@@ -51,30 +52,28 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
coverage: none
tools: cs2pr, php-cs-fixer
env:
fail-fast: true
- name: Code style (php-cs-fixer)
run: php-cs-fixer fix --dry-run --format=checkstyle | cs2pr
- env:
- PHP_CS_FIXER_IGNORE_ENV: 1
phpstan:
name: Code analysis (phpstan)
runs-on: "ubuntu-latest"
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
coverage: none
tools: composer:v2, phpstan
env:
@@ -83,7 +82,7 @@ jobs:
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
@@ -98,10 +97,10 @@ jobs:
runs-on: "ubuntu-latest"
strategy:
matrix:
- php-versions: ['7.3', '7.4', '8.0', '8.1', '8.2']
+ php-versions: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3']
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
@@ -114,7 +113,7 @@ jobs:
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 09687f8..47b1d86 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -1,5 +1,6 @@
name: coverage
on:
+ workflow_dispatch:
push:
branches: [ "main" ]
@@ -14,11 +15,11 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
coverage: xdebug
tools: composer:v2
env:
@@ -27,7 +28,7 @@ jobs:
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
@@ -37,7 +38,7 @@ jobs:
- name: Create code coverage
run: vendor/bin/phpunit --testdox --verbose --coverage-xml=build/coverage --coverage-clover=build/coverage/clover.xml --log-junit=build/coverage/junit.xml
- name: Store code coverage
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: code-coverage
path: build/coverage
@@ -72,20 +73,20 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Unshallow clone to provide blame information
run: git fetch --unshallow
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
coverage: none
tools: composer:v2
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
@@ -93,7 +94,7 @@ jobs:
- name: Install project dependencies
run: composer upgrade --no-interaction --no-progress --prefer-dist
- name: Obtain code coverage
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
name: code-coverage
path: build/coverage
diff --git a/.phive/phars.xml b/.phive/phars.xml
index ade4305..99c1f3c 100644
--- a/.phive/phars.xml
+++ b/.phive/phars.xml
@@ -1,8 +1,8 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index bfdf895..2b6cddb 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -22,7 +22,7 @@
'whitespace_after_comma_in_array' => true,
'no_empty_statement' => true,
'no_extra_blank_lines' => true,
- 'function_typehint_space' => true,
+ 'type_declaration_spaces' => true,
'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays']],
'no_blank_lines_after_phpdoc' => true,
'object_operator_without_whitespace' => true,
diff --git a/LICENSE b/LICENSE
index 97083c0..79ffed7 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2019 - 2023 PhpCfdi https://www.phpcfdi.com/
+Copyright (c) 2019 - 2024 PhpCfdi https://www.phpcfdi.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 3892685..890a22e 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +119,31 @@ Notas de tratamiento de archivos `DER`:
Para entender más de los formatos de llaves privadas se puede consultar la siguiente liga:
+## Acerca de los números de serie
+
+Los certificados contienen un número de serie expresado en notación hexadecimal, por ejemplo, el número
+de serie `27 2B` se refiere al certificado número `10027` expresado en decimal.
+
+Para el SAT, sin embargo, se reconoce el número de serie no como el estándar en hexadecimal.
+El SAT pide que el número de serie reflejado sea **la expresión hexadecimal convertida a ASCII**.
+Luego entonces, el certificado con número de serie `3330303031303030303030333030303233373038`
+lo identifica como `30001000000300023708`.
+
+Esta práctica del SAT no es estándar, y no es comúnmente observada. Sin embargo, así ha decidido que se
+interpreten el dato de "número de serie" referido en sus certificados emitidos, por ejemplo en el atributo
+`Comprobante@NoCertificado`.
+
+Como ejemplo contrario: En el firmado de documentos XML utilizado en el servicio web de descarga masiva,
+sí se utiliza la notación decimal (el número hexadecimal convertido a decimal), en lugar de la notación de bytes.
+
+La notación de bytes es problemática porque no todos los caracteres son imprimibles o
+cuentan una representación gráfica. La notación hexadecimal es ligeramente problemática
+porque tiene muchas variantes como el uso de mayúsculas y minúsculas o el prefijo `0x`.
+La notación decimal no tiene problema, se trata simplemente de un entero muy grande,
+tan grande que debe tratarse como una cadena de caracteres.
+
+Espero que en algún futuro el SAT reconsidere y utilice una notación decimal, para referirnos al número de serie.
+
## Leer y exportar archivos PFX
Esta librería soporta obtener el objeto `Credential` desde un archivo PFX (PKCS #12) y vicerversa.
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 27e079e..35b1538 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -11,6 +11,37 @@ versión, aunque sí su incorporación en la rama principal de trabajo. Generalm
## Listado de cambios
+### Versión 1.2.2 2024-06-06
+
+Se corrigió el problema de no crear correctamente el número de serie cuando incluía caracteres en mayúsculas.
+Anteriormente, se hacía una conversión a minúsculas, ahora se expresa en mayúsculas.
+
+Se agrega el método `SerialNumber::bytesArePrintable(): bool` para identificar que el número de serie de un certificado
+contiene solamente caracteres imprimibles en su representación como *bytes*, como en el caso de los números de serie
+utilizados por el SAT.
+
+Se refactorizan los métodos `SerialNumber::createFromBytes()` y `SerialNumber::bytes()` para usar las funciones
+de PHP `bin2hex` y `hex2bin` respectivamente.
+
+Se agrega documentación en el archivo `README.md` explicando la interpretación del número de serie como hexadecimal,
+decimal y *bytes*. Así como el uso específico del SAT.
+
+Se actualiza el año de licencia a 2024.
+
+Se garantiza la compatibilidad con PHP 8.3.
+
+Adicionalmente, se hacen los siguientes cambios internos:
+
+- Se remueven los archivos `test/_files` de la detección de lenguaje de GitHub.
+- En los flujos de trabajo de GitHub.
+ - Se permite la ejecución manual.
+ - Se agrega PHP 8.3 a la matriz de pruebas.
+ - Se ejecutan los trabajos en PHP 8.3.
+ - Se actualizan las acciones de GitHub a la versión 4.
+ - En el trabajo `php-cs-fixer` se remueve la variable de entorno `PHP_CS_FIXER_IGNORE_ENV`.
+- Se corrige `.php-cs-fixer.dist.php` sustituyendo `function_typehint_space` por `type_declaration_spaces`.
+- Se actualizan las herramientas de desarrollo.
+
### Versión 1.2.1 2023-05-24
PHPStan detectó un uso inapropiado de conversión de objeto a cadena de caracteres.
diff --git a/docs/Certificados.md b/docs/Certificados.md
index 6aa825e..83fc36b 100644
--- a/docs/Certificados.md
+++ b/docs/Certificados.md
@@ -11,6 +11,3 @@ Los certificados están vinculados con un creador, llamado emisor o entidad cert
Con un certificado se pueden realizar pocas acciones, en específico:
- extraer información de quien esté relacionado con el certificado
- obtener la llave pública (para poder verificar un mensaje)
-
-
-
diff --git a/sonar-project.properties b/sonar-project.properties
index 971f562..88a52ed 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -4,7 +4,7 @@ sonar.sourceEncoding=UTF-8
sonar.language=php
sonar.sources=src
sonar.tests=tests
-sonar.exclusions=vendor/,tools/,build/,tests/_files/
+sonar.test.exclusions=tests/_files/**/*
sonar.working.directory=build/.scannerwork
sonar.php.tests.reportPath=build/sonar-junit.xml
sonar.php.coverage.reportPaths=build/sonar-coverage.xml
diff --git a/src/Internal/BaseConverter.php b/src/Internal/BaseConverter.php
index 27dc342..089c047 100644
--- a/src/Internal/BaseConverter.php
+++ b/src/Internal/BaseConverter.php
@@ -27,7 +27,7 @@ public function __construct(BaseConverterSequence $sequence)
public static function createBase36(): self
{
- return new self(new BaseConverterSequence('0123456789abcdefghijklmnopqrstuvwxyz'));
+ return new self(new BaseConverterSequence('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'));
}
public function sequence(): BaseConverterSequence
diff --git a/src/SerialNumber.php b/src/SerialNumber.php
index 6601596..d0ad37d 100644
--- a/src/SerialNumber.php
+++ b/src/SerialNumber.php
@@ -25,7 +25,8 @@ public function __construct(string $hexadecimal)
if (0 === strcasecmp('0x', substr($hexadecimal, 0, 2))) {
$hexadecimal = substr($hexadecimal, 2);
}
- if (! boolval(preg_match('/^[0-9a-f]*$/', $hexadecimal))) {
+ $hexadecimal = strtoupper($hexadecimal);
+ if (! preg_match('/^[0-9A-F]*$/', $hexadecimal)) {
throw new UnexpectedValueException('The hexadecimal string contains invalid characters');
}
$this->hexadecimal = $hexadecimal;
@@ -44,14 +45,7 @@ public static function createFromDecimal(string $decString): self
public static function createFromBytes(string $input): self
{
- /** @noinspection PhpRedundantOptionalArgumentInspection */
- $hexadecimal = implode('', array_map(
- function (string $value): string {
- return dechex(ord($value));
- },
- str_split($input, 1)
- ));
- return new self($hexadecimal);
+ return new self(bin2hex($input));
}
public function hexadecimal(): string
@@ -61,13 +55,16 @@ public function hexadecimal(): string
public function bytes(): string
{
- return implode('', array_map(function (string $value): string {
- return chr(intval(hexdec($value)));
- }, str_split($this->hexadecimal, 2)));
+ return (string) hex2bin($this->hexadecimal);
}
public function decimal(): string
{
return BaseConverter::createBase36()->convert($this->hexadecimal(), 16, 10);
}
+
+ public function bytesArePrintable(): bool
+ {
+ return (bool) preg_match('/^[[:print:]]*$/', $this->bytes());
+ }
}
diff --git a/tests/Unit/SerialNumberTest.php b/tests/Unit/SerialNumberTest.php
index ce7d782..bd82ce3 100644
--- a/tests/Unit/SerialNumberTest.php
+++ b/tests/Unit/SerialNumberTest.php
@@ -29,6 +29,7 @@ public function testCreateFromHexadecimal(string $prefix): void
$this->assertSame(self::SERIAL_HEXADECIMAL, $serial->hexadecimal());
$this->assertSame(self::SERIAL_DECIMAL, $serial->decimal());
$this->assertSame(self::SERIAL_BYTES, $serial->bytes());
+ $this->assertTrue($serial->bytesArePrintable());
}
public function testCreateHexadecimalEmpty(): void
@@ -63,4 +64,31 @@ public function testCreateFromBytes(): void
$serial = SerialNumber::createFromBytes(self::SERIAL_BYTES);
$this->assertSame(self::SERIAL_HEXADECIMAL, $serial->hexadecimal());
}
+
+ /** @return array */
+ public function providerSerialNumbersNotIssuedFromSat(): array
+ {
+ return [
+ 'Mifiel pruebas' => ['272B', '10027', "'+", true],
+ 'SN Letsencrypt' => [
+ '045E9B96CBBA0057885950B3B59A5B2B98FB',
+ '380642499533550337925875167187989405866235',
+ (string) hex2bin('045E9B96CBBA0057885950B3B59A5B2B98FB'),
+ false,
+ ],
+ ];
+ }
+
+ /** @dataProvider providerSerialNumbersNotIssuedFromSat */
+ public function testSerialNumbersNotIssuedFromSat(
+ string $hexadecimalInput,
+ string $expectedDecimal,
+ string $expectedBytes,
+ bool $expectedBytesArePrintable
+ ): void {
+ $serial = SerialNumber::createFromHexadecimal($hexadecimalInput);
+ $this->assertSame($expectedDecimal, $serial->decimal());
+ $this->assertSame($expectedBytes, $serial->bytes());
+ $this->assertSame($expectedBytesArePrintable, $serial->bytesArePrintable());
+ }
}
diff --git a/tests/bin/certificado.php b/tests/bin/certificado.php
index 40d40e2..f5bf77e 100644
--- a/tests/bin/certificado.php
+++ b/tests/bin/certificado.php
@@ -10,30 +10,36 @@
require __DIR__ . '/../../vendor/autoload.php';
exit(call_user_func(
- function ($cmd, $cerFile): int {
+ function (string $cmd, string $cerFile): int {
+ if (in_array($cerFile, ['-h', '--help'], true)) {
+ echo 'Show certificate information', PHP_EOL;
+ echo "Syntax: $cmd certificate-file", PHP_EOL;
+ return 0;
+ }
try {
- if (in_array($cerFile, ['-h', '--help', ''], true)) {
- echo 'Show certificate information', PHP_EOL;
- echo "Syntax: $cmd certificate-file", PHP_EOL;
- if ('' === $cerFile) {
- throw new Exception('No certificate file was set');
- }
- return 0;
+ if ('' === $cerFile) {
+ throw new Exception('No certificate file was set');
}
$certificate = Certificate::openFile($cerFile);
+ $serialNumber = $certificate->serialNumber();
echo json_encode([
'file' => $cerFile,
'rfc' => $certificate->rfc(),
- 'serial' => $certificate->serialNumber()->bytes(),
+ 'serial' => [
+ 'hexadecimal' => $serialNumber->hexadecimal(),
+ 'decimal' => $serialNumber->decimal(),
+ 'bytes' => $serialNumber->bytesArePrintable() ? $serialNumber->bytes() : '',
+ ],
'valid since' => $certificate->validFromDateTime()->format('c'),
'valid until' => $certificate->validToDateTime()->format('c'),
'legalname' => $certificate->legalName(),
'satType' => $certificate->satType()->value(),
'parsed' => $certificate->parsed(),
- ], JSON_PRETTY_PRINT), PHP_EOL;
+ ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), PHP_EOL;
return 0;
} catch (Throwable $exception) {
file_put_contents('php://stderr', 'ERROR: ' . $exception->getMessage() . PHP_EOL, FILE_APPEND);
+ // file_put_contents('php://stderr', print_r($exception, true) . PHP_EOL, FILE_APPEND);
return 1;
}
},