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; } },