Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,29 @@ jobs:
strategy:
fail-fast: false
matrix:
# ml-dsa: whether the ML-DSA algorithms (RFC 9964) must be available on the job's platform, which
# tests/SignatureAlgorithm/MLDSA/MLDSASignatureTest.php asserts. The phpqa images ship OpenSSL 3.5, so the
# gate is the PHP version alone here: closed below 8.4, open from 8.4 on. The OpenSSL side of the gate is
# exercised by the tests_without_ml_dsa job below.
include:
- php-version: '8.2'
lowest-deps: true
ml-dsa: 'no'
- php-version: '8.2'
ml-dsa: 'no'
- php-version: '8.3'
ml-dsa: 'no'
- php-version: '8.4'
ml-dsa: 'yes'
- php-version: '8.5'
experimental: true
ml-dsa: 'yes'
container:
image: ghcr.io/spomky-labs/phpqa:${{ matrix.php-version }}
env:
XDEBUG_MODE: coverage
PHP_VERSION: ${{ matrix.php-version }}
JOSE_ML_DSA_EXPECTED: ${{ matrix.ml-dsa }}
steps:
- uses: actions/checkout@v5
- name: Install dependencies
Expand All @@ -211,6 +221,42 @@ jobs:
--log-junit=.ci-tools/coverage/junit.xml \
--configuration .ci-tools/phpunit.xml.dist

# The other side of the ML-DSA platform gate: a PHP recent enough (8.4) on an OpenSSL too old (3.0, the one
# Ubuntu 24.04 ships and the PHP of the PPA links against). MLDSA44::isSupported() must be false there, the
# constructor must throw, the bundle must compile and the rest of the suite must be unaffected; MLDSASignatureTest
# asserts the first through JOSE_ML_DSA_EXPECTED. The runner is pinned rather than "latest" so that a newer image
# with OpenSSL 3.5 cannot silently turn this job into a copy of the one above.
tests_without_ml_dsa:
name: "🧪 Unit & Functional Tests (PHP 8.4 - OpenSSL 3.0, no ML-DSA)"
needs:
- prepare_dependencies
- phpstan
- ecs
- rector
- lint
- validate
- check_licenses
- deptrac
runs-on: ubuntu-24.04
env:
JOSE_ML_DSA_EXPECTED: 'no'
steps:
- uses: actions/checkout@v5
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: openssl, sodium, mbstring, gmp, intl
tools: composer, phpunit:11
coverage: none
- name: Show the OpenSSL the PHP binary loaded
run: |
php -r 'echo PHP_VERSION, " built against ", OPENSSL_VERSION_TEXT, PHP_EOL;'
openssl version
- name: Install dependencies
run: composer install --no-interaction --no-progress
- name: Run PHPUnit
run: phpunit --configuration .ci-tools/phpunit.xml.dist

infection:
name: "🧬 Mutation Testing"
needs: [prepare_dependencies, tests]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Jose\Component\Signature\Algorithm\Ed25519;
use Jose\Component\Signature\Algorithm\Ed448;
use Jose\Component\Signature\Algorithm\HMAC;
use Jose\Component\Signature\Algorithm\MLDSA44;
use Jose\Component\Signature\Algorithm\RSAPSS;
use Jose\Experimental\Signature\HS1;
use Jose\Unsecured\Signature\None;
Expand Down Expand Up @@ -115,6 +116,9 @@ private function getAlgorithmsFiles(): array
if (Ed25519::isSupported() || Ed448::isSupported()) {
$algorithms[Ed25519::class] = 'signature_eddsa.php';
}
if (MLDSA44::isSupported()) {
$algorithms[MLDSA44::class] = 'signature_mldsa.php';
}

return $algorithms;
}
Expand Down
33 changes: 33 additions & 0 deletions src/Bundle/Resources/config/Algorithms/signature_mldsa.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

use Jose\Component\Signature\Algorithm\MLDSA44;
use Jose\Component\Signature\Algorithm\MLDSA65;
use Jose\Component\Signature\Algorithm\MLDSA87;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

/*
* Loaded only when the platform can run ML-DSA (PHP 8.4 and an OpenSSL runtime providing it): the algorithm manager
* factory instantiates every tagged algorithm when the container is built, and an unsupported one would throw there.
*/
return function (ContainerConfigurator $container): void {
$container = $container->services()
->defaults()
->private()
->autoconfigure()
->autowire();

$container->set(MLDSA44::class)
->tag('jose.algorithm', [
'alias' => 'ML-DSA-44',
]);
$container->set(MLDSA65::class)
->tag('jose.algorithm', [
'alias' => 'ML-DSA-65',
]);
$container->set(MLDSA87::class)
->tag('jose.algorithm', [
'alias' => 'ML-DSA-87',
]);
};
2 changes: 2 additions & 0 deletions src/Bundle/Resources/config/analyzers.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Jose\Component\KeyManagement\Analyzer\KeysetAnalyzerManager;
use Jose\Component\KeyManagement\Analyzer\MixedKeyTypes;
use Jose\Component\KeyManagement\Analyzer\MixedPublicAndPrivateKeys;
use Jose\Component\KeyManagement\Analyzer\MLDSAKeyAnalyzer;
use Jose\Component\KeyManagement\Analyzer\NoneAnalyzer;
use Jose\Component\KeyManagement\Analyzer\OctAnalyzer;
use Jose\Component\KeyManagement\Analyzer\OKPKeyAnalyzer;
Expand All @@ -41,6 +42,7 @@
$container->set(NoneAnalyzer::class);
$container->set(OctAnalyzer::class);
$container->set(OKPKeyAnalyzer::class);
$container->set(MLDSAKeyAnalyzer::class);
$container->set(MixedKeyTypes::class);
$container->set(MixedPublicAndPrivateKeys::class);
$container->set(HS256KeyAnalyzer::class);
Expand Down
4 changes: 4 additions & 0 deletions src/Bundle/Resources/config/commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Jose\Component\Console\KeyFileLoaderCommand;
use Jose\Component\Console\KeysetAnalyzerCommand;
use Jose\Component\Console\MergeKeysetCommand;
use Jose\Component\Console\MldsaKeyGeneratorCommand;
use Jose\Component\Console\MldsaKeysetGeneratorCommand;
use Jose\Component\Console\NoneKeyGeneratorCommand;
use Jose\Component\Console\OctKeyGeneratorCommand;
use Jose\Component\Console\OctKeysetGeneratorCommand;
Expand Down Expand Up @@ -47,6 +49,8 @@
$container->set(NoneKeyGeneratorCommand::class);
$container->set(OctKeyGeneratorCommand::class);
$container->set(OctKeysetGeneratorCommand::class);
$container->set(MldsaKeyGeneratorCommand::class);
$container->set(MldsaKeysetGeneratorCommand::class);
$container->set(OkpKeyGeneratorCommand::class);
$container->set(OkpKeysetGeneratorCommand::class);
$container->set(P12CertificateLoaderCommand::class);
Expand Down
2 changes: 1 addition & 1 deletion src/Library/Console/GetThumbprintCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ protected function configure(): void
'uri',
null,
InputOption::VALUE_NONE,
'Output the JWK Thumbprint URI (RFC 9278) instead of the bare thumbprint. The hashing algorithm may then be given by its IANA name (e.g. "sha-256").'
'Output the JWK Thumbprint URI instead of the bare thumbprint. The hashing algorithm may then be given by its IANA name (e.g. "sha-256").'
);
}

Expand Down
39 changes: 39 additions & 0 deletions src/Library/Console/MldsaKeyGeneratorCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Jose\Component\Console;

use Jose\Component\Core\Exception\InvalidArgumentException;
use Override;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function is_string;

#[AsCommand(name: 'key:generate:mldsa', description: 'Generate an ML-DSA key (JWK format, AKP key type)')]
final class MldsaKeyGeneratorCommand extends GeneratorCommand
{
#[Override]
protected function configure(): void
{
parent::configure();
$this->addArgument('algorithm', InputArgument::REQUIRED, 'Parameter set of the key: ML-DSA-44, ML-DSA-65 or ML-DSA-87. Needs PHP 8.4 and OpenSSL 3.5.');
}

#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$algorithm = $input->getArgument('algorithm');
if (! is_string($algorithm)) {
throw new InvalidArgumentException('Invalid algorithm');
}
$args = $this->getOptions($input);

$jwk = $this->jwkFactory->mldsa($algorithm, $args);
$this->prepareJsonOutput($input, $output, $jwk);

return self::SUCCESS;
}
}
53 changes: 53 additions & 0 deletions src/Library/Console/MldsaKeysetGeneratorCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Jose\Component\Console;

use Jose\Component\Core\Exception\InvalidArgumentException;
use Jose\Component\Core\JWKSet;
use Override;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function is_numeric;
use function is_string;

#[AsCommand(
name: 'keyset:generate:mldsa',
description: 'Generate a key set with ML-DSA keys (JWKSet format)'
)]
final class MldsaKeysetGeneratorCommand extends GeneratorCommand
{
#[Override]
protected function configure(): void
{
parent::configure();
$this->addArgument('quantity', InputArgument::REQUIRED, 'Quantity of keys in the key set.')
->addArgument('algorithm', InputArgument::REQUIRED, 'Parameter set of the keys: ML-DSA-44, ML-DSA-65 or ML-DSA-87. Needs PHP 8.4 and OpenSSL 3.5.');
}

#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$quantity = $input->getArgument('quantity');
$algorithm = $input->getArgument('algorithm');
if (! is_numeric($quantity) || (int) $quantity < 1) {
throw new InvalidArgumentException('Invalid quantity');
}
$quantity = (int) $quantity;
if (! is_string($algorithm)) {
throw new InvalidArgumentException('Invalid algorithm');
}

$keyset = new JWKSet([]);
for ($i = 0; $i < $quantity; ++$i) {
$args = $this->getOptions($input);
$keyset = $keyset->with($this->jwkFactory->mldsa($algorithm, $args));
}
$this->prepareJsonOutput($input, $output, $keyset);

return self::SUCCESS;
}
}
20 changes: 17 additions & 3 deletions src/Library/Core/JWK.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,28 @@ public function all(): array
/**
* Returns the thumbprint of the key.
*
* The required members are those of RFC 7638 section 3.2 for the "oct", "RSA", "EC" and "OKP" key types, and
* "alg", "kty" and "pub" for the "AKP" key type (RFC 9964 section 6): an AKP key without "alg" has no
* thumbprint, as the type says nothing about what "pub" holds.
*
* @see https://tools.ietf.org/html/rfc7638
* @see https://www.rfc-editor.org/rfc/rfc9964.html#section-6
*/
public function thumbprint(string $hash_algorithm): string
{
if (! in_array($hash_algorithm, hash_algos(), true)) {
throw new UnsupportedAlgorithmException(sprintf('The hash algorithm "%s" is not supported.', $hash_algorithm));
}
$values = array_intersect_key($this->values, array_flip(['kty', 'n', 'e', 'crv', 'x', 'y', 'k']));
if ($this->find('kty') === 'AKP') {
if (! $this->has('alg')) {
throw new InvalidKeyException(
'Unable to compute the thumbprint of an AKP key without "alg".'
);
}
$values = array_intersect_key($this->values, array_flip(['alg', 'kty', 'pub']));
} else {
$values = array_intersect_key($this->values, array_flip(['kty', 'n', 'e', 'crv', 'x', 'y', 'k']));
}
ksort($values);
$input = json_encode($values, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($input === false) {
Expand Down Expand Up @@ -206,11 +220,11 @@ public function thumbprintUri(string $hashAlgorithm = JwkThumbprintUri::DEFAULT_
* - shared keys
* - unknown keys.
*
* Known keys are "oct", "RSA", "EC" and "OKP".
* Known keys are "oct", "RSA", "EC", "OKP" and "AKP".
*/
public function toPublic(): self
{
$values = array_diff_key($this->values, array_flip(['p', 'd', 'q', 'dp', 'dq', 'qi']));
$values = array_diff_key($this->values, array_flip(['p', 'd', 'q', 'dp', 'dq', 'qi', 'priv']));

return new self($values);
}
Expand Down
Loading
Loading