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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"symfony/config": "^7.0|^8.0",
"symfony/console": "^7.0|^8.0",
"symfony/dependency-injection": "^7.0|^8.0",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/event-dispatcher": "^7.0|^8.0",
"symfony/http-client-contracts": "^3.4",
"symfony/http-kernel": "^7.0|^8.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public function encryptKey(JWK $key, string $cek, array $completeHeader, array &
* @param JWK $key The key used to wrap the CEK
* @param string $encrypted_cek The CEK to decrypt
* @param array<string, mixed> $header The complete header of the JWT
*
* BC NOTE: since 4.2, the JWEDecrypter calls this method with an additional argument
* "int $encryptionKeyLength": the size (in bits) of the key expected by the content encryption algorithm,
* as returned by ContentEncryptionAlgorithm::getCEKSize(). As it is not declared yet, implementations that
* need it can read it with func_num_args()/func_get_arg(3). It will be declared and required in 5.0.
*/
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public function wrapKey(JWK $key, string $cek, array $completeHeader, array &$ad
* @param JWK $key The key used to wrap the CEK
* @param string $encrypted_cek The CEK to decrypt
* @param array<string, mixed> $completeHeader The complete header of the JWT
*
* BC NOTE: since 4.2, the JWEDecrypter calls this method with an additional argument
* "int $encryptionKeyLength": the size (in bits) of the key expected by the content encryption algorithm,
* as returned by ContentEncryptionAlgorithm::getCEKSize(). As it is not declared yet, implementations that
* need it can read it with func_num_args()/func_get_arg(3). It will be declared and required in 5.0.
*/
public function unwrapKey(JWK $key, string $encrypted_cek, array $completeHeader): string;
}
31 changes: 29 additions & 2 deletions src/Library/Encryption/Algorithm/KeyEncryption/RSA15.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@
use Jose\Component\Core\Util\RSAKey;
use Jose\Component\Encryption\Algorithm\KeyEncryption\Util\RSACrypt;
use Override;
use function func_get_arg;
use function func_num_args;
use function is_int;
use function is_string;
use function trigger_deprecation;

final readonly class RSA15 extends RSA
{
/**
* BC NOTE: deprecated since 4.2 and will be removed in 5.0. The expected CEK size is now provided by the
* caller as the fourth argument of the "decryptKey" method.
*
* @var array<string, int>
*/
private const CEK_LENGTHS = [
Expand All @@ -32,11 +39,16 @@ public function name(): string
}

/**
* The size (in bits) of the key expected by the content encryption algorithm may be passed as a fourth
* argument. That argument is not declared yet for BC reasons; it will be in 5.0 (see the KeyEncryption
* interface).
*
* @param array<string, mixed> $header
*/
#[Override]
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string
{
$encryptionKeyLength = func_num_args() > 3 ? func_get_arg(3) : null;
$this->checkKey($key);
if (! $key->has('d')) {
throw new InvalidArgumentException('The key is not a private key');
Expand All @@ -48,7 +60,7 @@ public function decryptKey(JWK $key, string $encrypted_cek, array $header): stri
$encrypted_cek,
RSACrypt::ENCRYPTION_PKCS1,
null,
$this->getExpectedCekLength($header)
$this->getExpectedCekLength($header, $encryptionKeyLength)
);
}

Expand All @@ -65,10 +77,25 @@ protected function getHashAlgorithm(): ?string
}

/**
* Returns the expected CEK length in bytes.
*
* @param array<string, mixed> $header
* @param mixed $encryptionKeyLength Size (in bits) of the key expected by the content encryption
* algorithm, or null when the caller did not provide it
*/
private function getExpectedCekLength(array $header): ?int
private function getExpectedCekLength(array $header, mixed $encryptionKeyLength): ?int
{
if (is_int($encryptionKeyLength)) {
return intdiv($encryptionKeyLength, 8);
}

trigger_deprecation(
'web-token/jwt-framework',
'4.2.0',
'Calling "%s::decryptKey()" without the size of the key expected by the content encryption algorithm as fourth argument is deprecated. That size is currently deduced from a hardcoded table that will be removed in 5.0.0: pass the value returned by "getCEKSize()" of the content encryption algorithm in use instead.',
self::class
);

$enc = $header['enc'] ?? null;
if (! is_string($enc)) {
return null;
Expand Down
11 changes: 9 additions & 2 deletions src/Library/Encryption/JWEDecrypter.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,25 @@ private function decryptCEK(
$completeHeader
);
}
// The size of the key expected by the content encryption algorithm is passed as an additional
// argument. It is not part of the interfaces yet (it will be in 5.0.0): implementations that do not
// expect it simply ignore it, the others read it with func_num_args()/func_get_arg(3).
if ($key_encryption_algorithm instanceof KeyEncryption) {
// @phpstan-ignore arguments.count (the fourth argument will be part of the interface in 5.0.0)
return $key_encryption_algorithm->decryptKey(
$recipientKey,
$recipient->getEncryptedKey() ?? '',
$completeHeader
$completeHeader,
$content_encryption_algorithm->getCEKSize()
);
}
if ($key_encryption_algorithm instanceof KeyWrapping) {
// @phpstan-ignore arguments.count (the fourth argument will be part of the interface in 5.0.0)
return $key_encryption_algorithm->unwrapKey(
$recipientKey,
$recipient->getEncryptedKey() ?? '',
$completeHeader
$completeHeader,
$content_encryption_algorithm->getCEKSize()
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Library/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"php": ">=8.2",
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19",
"psr/clock": "^1.0",
"spomky-labs/pki-framework": "^1.2.1"
"spomky-labs/pki-framework": "^1.2.1",
"symfony/deprecation-contracts": "^2.5|^3.0"
},
"conflict": {
"spomky-labs/jose": "*"
Expand Down
63 changes: 63 additions & 0 deletions tests/Component/Encryption/LegacyKeyEncryptionAlgorithm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Jose\Tests\Component\Encryption;

use Jose\Component\Core\JWK;
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyEncryption;
use Override;
use function func_num_args;

/**
* A key encryption algorithm that declares the method signature as defined by the KeyEncryption interface in
* 4.x: it does not expect the size of the CEK the JWEDecrypter passes as an additional argument.
*
* It performs no encryption at all: it is only used to check that such an implementation still works.
*/
final class LegacyKeyEncryptionAlgorithm implements KeyEncryption
{
/**
* Number of arguments received by the last call to the decryptKey method.
*/
public int $receivedArgumentCount = 0;

#[Override]
public function name(): string
{
return 'legacy-key-encryption';
}

#[Override]
public function allowedKeyTypes(): array
{
return ['oct'];
}

#[Override]
public function getKeyManagementMode(): string
{
return self::MODE_ENCRYPT;
}

/**
* @param array<string, mixed> $completeHeader
* @param array<string, mixed> $additionalHeader
*/
#[Override]
public function encryptKey(JWK $key, string $cek, array $completeHeader, array &$additionalHeader): string
{
return $cek;
}

/**
* @param array<string, mixed> $header
*/
#[Override]
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string
{
$this->receivedArgumentCount = func_num_args();

return $encrypted_cek;
}
}
190 changes: 190 additions & 0 deletions tests/Component/Encryption/RSA15ExpectedCekSizeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

declare(strict_types=1);

namespace Jose\Tests\Component\Encryption;

use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\JWK;
use Jose\Component\Core\Util\RSAKey;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A128GCM;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSA15;
use Jose\Component\Encryption\JWEBuilder;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\CompactSerializer;
use Jose\Component\KeyManagement\JWKFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function mb_strlen;
use const E_USER_DEPRECATED;

/**
* The expected CEK size is passed by the JWEDecrypter as an additional argument of the decryptKey method.
* Until that argument is part of the KeyEncryption interface (5.0), RSA1_5 falls back to a hardcoded table
* and triggers a deprecation.
*
* @internal
*/
final class RSA15ExpectedCekSizeTest extends TestCase
{
#[Test]
public function aDeprecationIsTriggeredWhenTheExpectedCekSizeIsNotProvided(): void
{
$jwk = $this->createKey();
$algorithm = new RSA15();
$cek = random_bytes(16); // A128GCM CEK
$header = [
'alg' => 'RSA1_5',
'enc' => 'A128GCM',
];
$additionalHeader = [];
$encrypted = $algorithm->encryptKey($jwk, $cek, $header, $additionalHeader);

$decrypted = null;
$deprecations = $this->collectDeprecations(static function () use (
$algorithm,
$jwk,
$encrypted,
$header,
&$decrypted
): void {
$decrypted = $algorithm->decryptKey($jwk, $encrypted, $header);
});

static::assertSame($cek, $decrypted);
static::assertCount(1, $deprecations);
static::assertStringContainsString(
'Calling "Jose\Component\Encryption\Algorithm\KeyEncryption\RSA15::decryptKey()" without the size of the key expected by the content encryption algorithm as fourth argument is deprecated.',
$deprecations[0]
);
}

#[Test]
public function noDeprecationIsTriggeredWhenTheExpectedCekSizeIsProvided(): void
{
$jwk = $this->createKey();
$algorithm = new RSA15();
$cek = random_bytes(16); // A128GCM CEK
$header = [
'alg' => 'RSA1_5',
'enc' => 'A128GCM',
];
$additionalHeader = [];
$encrypted = $algorithm->encryptKey($jwk, $cek, $header, $additionalHeader);

$decrypted = null;
$deprecations = $this->collectDeprecations(static function () use (
$algorithm,
$jwk,
$encrypted,
$header,
&$decrypted
): void {
$decrypted = $algorithm->decryptKey($jwk, $encrypted, $header, 128);
});

static::assertSame($cek, $decrypted);
static::assertSame([], $deprecations);
}

#[Test]
public function theProvidedCekSizeIsUsedForTheImplicitRejection(): void
{
$jwk = $this->createKey();
$algorithm = new RSA15();
$key = RSAKey::createFromJWK($jwk);
$garbage = "\x00" . random_bytes($key->getModulusLength() - 1);
// The content encryption algorithm is unknown to the hardcoded table.
$header = [
'alg' => 'RSA1_5',
'enc' => 'FOO-256',
];

$result = $algorithm->decryptKey($jwk, $garbage, $header, 256);

static::assertSame(32, mb_strlen($result, '8bit'));
}

#[Test]
public function theJweDecrypterProvidesTheExpectedCekSize(): void
{
$jwk = $this->createKey();
$algorithmManager = new AlgorithmManager([new RSA15(), new A128GCM()]);
$token = $this->createToken($algorithmManager, $jwk, 'RSA1_5');

$jwe = (new CompactSerializer())->unserialize($token);
$decrypter = new JWEDecrypter($algorithmManager);

$deprecations = $this->collectDeprecations(static function () use ($decrypter, $jwe, $jwk): void {
$jweToDecrypt = $jwe;
static::assertTrue($decrypter->decryptUsingKey($jweToDecrypt, $jwk, 0));
static::assertSame('Live long and prosper.', $jweToDecrypt->getPayload());
});

static::assertSame([], $deprecations);
}

#[Test]
public function algorithmsThatDoNotExpectTheCekSizeStillWork(): void
{
$jwk = JWKFactory::createOctKey(256, [
'use' => 'enc',
]);
$algorithm = new LegacyKeyEncryptionAlgorithm();
$algorithmManager = new AlgorithmManager([$algorithm, new A128GCM()]);
$token = $this->createToken($algorithmManager, $jwk, $algorithm->name());

$jwe = (new CompactSerializer())->unserialize($token);
$decrypter = new JWEDecrypter($algorithmManager);

static::assertTrue($decrypter->decryptUsingKey($jwe, $jwk, 0));
static::assertSame('Live long and prosper.', $jwe->getPayload());
static::assertSame(4, $algorithm->receivedArgumentCount);
}

private function createKey(): JWK
{
return JWKFactory::createRSAKey(2048, [
'alg' => 'RSA1_5',
'use' => 'enc',
]);
}

private function createToken(AlgorithmManager $algorithmManager, JWK $jwk, string $algorithm): string
{
$jwe = (new JWEBuilder($algorithmManager))
->create()
->withPayload('Live long and prosper.')
->withSharedProtectedHeader([
'alg' => $algorithm,
'enc' => 'A128GCM',
])
->addRecipient($jwk)
->build();

return (new CompactSerializer())->serialize($jwe, 0);
}

/**
* @param callable(): void $callback
*
* @return list<string>
*/
private function collectDeprecations(callable $callback): array
{
$deprecations = [];
set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool {
$deprecations[] = $errstr;

return true;
}, E_USER_DEPRECATED);

try {
$callback();
} finally {
restore_error_handler();
}

return $deprecations;
}
}