Skip to content

Commit 7a0e08e

Browse files
authored
Merge pull request #665 from web-token/deprecation/rsa15-cek-size
Deprecate the hardcoded RSA1_5 CEK size table
2 parents d117b5a + 02187d0 commit 7a0e08e

8 files changed

Lines changed: 304 additions & 5 deletions

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"symfony/config": "^7.0|^8.0",
5656
"symfony/console": "^7.0|^8.0",
5757
"symfony/dependency-injection": "^7.0|^8.0",
58+
"symfony/deprecation-contracts": "^2.5|^3.0",
5859
"symfony/event-dispatcher": "^7.0|^8.0",
5960
"symfony/http-client-contracts": "^3.4",
6061
"symfony/http-kernel": "^7.0|^8.0"

src/Library/Encryption/Algorithm/KeyEncryption/KeyEncryption.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ public function encryptKey(JWK $key, string $cek, array $completeHeader, array &
2525
* @param JWK $key The key used to wrap the CEK
2626
* @param string $encrypted_cek The CEK to decrypt
2727
* @param array<string, mixed> $header The complete header of the JWT
28+
*
29+
* BC NOTE: since 4.2, the JWEDecrypter calls this method with an additional argument
30+
* "int $encryptionKeyLength": the size (in bits) of the key expected by the content encryption algorithm,
31+
* as returned by ContentEncryptionAlgorithm::getCEKSize(). As it is not declared yet, implementations that
32+
* need it can read it with func_num_args()/func_get_arg(3). It will be declared and required in 5.0.
2833
*/
2934
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string;
3035
}

src/Library/Encryption/Algorithm/KeyEncryption/KeyWrapping.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ public function wrapKey(JWK $key, string $cek, array $completeHeader, array &$ad
2525
* @param JWK $key The key used to wrap the CEK
2626
* @param string $encrypted_cek The CEK to decrypt
2727
* @param array<string, mixed> $completeHeader The complete header of the JWT
28+
*
29+
* BC NOTE: since 4.2, the JWEDecrypter calls this method with an additional argument
30+
* "int $encryptionKeyLength": the size (in bits) of the key expected by the content encryption algorithm,
31+
* as returned by ContentEncryptionAlgorithm::getCEKSize(). As it is not declared yet, implementations that
32+
* need it can read it with func_num_args()/func_get_arg(3). It will be declared and required in 5.0.
2833
*/
2934
public function unwrapKey(JWK $key, string $encrypted_cek, array $completeHeader): string;
3035
}

src/Library/Encryption/Algorithm/KeyEncryption/RSA15.php

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@
99
use Jose\Component\Core\Util\RSAKey;
1010
use Jose\Component\Encryption\Algorithm\KeyEncryption\Util\RSACrypt;
1111
use Override;
12+
use function func_get_arg;
13+
use function func_num_args;
14+
use function is_int;
1215
use function is_string;
16+
use function trigger_deprecation;
1317

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

3441
/**
42+
* The size (in bits) of the key expected by the content encryption algorithm may be passed as a fourth
43+
* argument. That argument is not declared yet for BC reasons; it will be in 5.0 (see the KeyEncryption
44+
* interface).
45+
*
3546
* @param array<string, mixed> $header
3647
*/
3748
#[Override]
3849
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string
3950
{
51+
$encryptionKeyLength = func_num_args() > 3 ? func_get_arg(3) : null;
4052
$this->checkKey($key);
4153
if (! $key->has('d')) {
4254
throw new InvalidArgumentException('The key is not a private key');
@@ -48,7 +60,7 @@ public function decryptKey(JWK $key, string $encrypted_cek, array $header): stri
4860
$encrypted_cek,
4961
RSACrypt::ENCRYPTION_PKCS1,
5062
null,
51-
$this->getExpectedCekLength($header)
63+
$this->getExpectedCekLength($header, $encryptionKeyLength)
5264
);
5365
}
5466

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

6779
/**
80+
* Returns the expected CEK length in bytes.
81+
*
6882
* @param array<string, mixed> $header
83+
* @param mixed $encryptionKeyLength Size (in bits) of the key expected by the content encryption
84+
* algorithm, or null when the caller did not provide it
6985
*/
70-
private function getExpectedCekLength(array $header): ?int
86+
private function getExpectedCekLength(array $header, mixed $encryptionKeyLength): ?int
7187
{
88+
if (is_int($encryptionKeyLength)) {
89+
return intdiv($encryptionKeyLength, 8);
90+
}
91+
92+
trigger_deprecation(
93+
'web-token/jwt-framework',
94+
'4.2.0',
95+
'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.',
96+
self::class
97+
);
98+
7299
$enc = $header['enc'] ?? null;
73100
if (! is_string($enc)) {
74101
return null;

src/Library/Encryption/JWEDecrypter.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,18 +226,25 @@ private function decryptCEK(
226226
$completeHeader
227227
);
228228
}
229+
// The size of the key expected by the content encryption algorithm is passed as an additional
230+
// argument. It is not part of the interfaces yet (it will be in 5.0.0): implementations that do not
231+
// expect it simply ignore it, the others read it with func_num_args()/func_get_arg(3).
229232
if ($key_encryption_algorithm instanceof KeyEncryption) {
233+
// @phpstan-ignore arguments.count (the fourth argument will be part of the interface in 5.0.0)
230234
return $key_encryption_algorithm->decryptKey(
231235
$recipientKey,
232236
$recipient->getEncryptedKey() ?? '',
233-
$completeHeader
237+
$completeHeader,
238+
$content_encryption_algorithm->getCEKSize()
234239
);
235240
}
236241
if ($key_encryption_algorithm instanceof KeyWrapping) {
242+
// @phpstan-ignore arguments.count (the fourth argument will be part of the interface in 5.0.0)
237243
return $key_encryption_algorithm->unwrapKey(
238244
$recipientKey,
239245
$recipient->getEncryptedKey() ?? '',
240-
$completeHeader
246+
$completeHeader,
247+
$content_encryption_algorithm->getCEKSize()
241248
);
242249
}
243250

src/Library/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"php": ">=8.2",
4242
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19",
4343
"psr/clock": "^1.0",
44-
"spomky-labs/pki-framework": "^1.2.1"
44+
"spomky-labs/pki-framework": "^1.2.1",
45+
"symfony/deprecation-contracts": "^2.5|^3.0"
4546
},
4647
"conflict": {
4748
"spomky-labs/jose": "*"
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Jose\Tests\Component\Encryption;
6+
7+
use Jose\Component\Core\JWK;
8+
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyEncryption;
9+
use Override;
10+
use function func_num_args;
11+
12+
/**
13+
* A key encryption algorithm that declares the method signature as defined by the KeyEncryption interface in
14+
* 4.x: it does not expect the size of the CEK the JWEDecrypter passes as an additional argument.
15+
*
16+
* It performs no encryption at all: it is only used to check that such an implementation still works.
17+
*/
18+
final class LegacyKeyEncryptionAlgorithm implements KeyEncryption
19+
{
20+
/**
21+
* Number of arguments received by the last call to the decryptKey method.
22+
*/
23+
public int $receivedArgumentCount = 0;
24+
25+
#[Override]
26+
public function name(): string
27+
{
28+
return 'legacy-key-encryption';
29+
}
30+
31+
#[Override]
32+
public function allowedKeyTypes(): array
33+
{
34+
return ['oct'];
35+
}
36+
37+
#[Override]
38+
public function getKeyManagementMode(): string
39+
{
40+
return self::MODE_ENCRYPT;
41+
}
42+
43+
/**
44+
* @param array<string, mixed> $completeHeader
45+
* @param array<string, mixed> $additionalHeader
46+
*/
47+
#[Override]
48+
public function encryptKey(JWK $key, string $cek, array $completeHeader, array &$additionalHeader): string
49+
{
50+
return $cek;
51+
}
52+
53+
/**
54+
* @param array<string, mixed> $header
55+
*/
56+
#[Override]
57+
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string
58+
{
59+
$this->receivedArgumentCount = func_num_args();
60+
61+
return $encrypted_cek;
62+
}
63+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Jose\Tests\Component\Encryption;
6+
7+
use Jose\Component\Core\AlgorithmManager;
8+
use Jose\Component\Core\JWK;
9+
use Jose\Component\Core\Util\RSAKey;
10+
use Jose\Component\Encryption\Algorithm\ContentEncryption\A128GCM;
11+
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSA15;
12+
use Jose\Component\Encryption\JWEBuilder;
13+
use Jose\Component\Encryption\JWEDecrypter;
14+
use Jose\Component\Encryption\Serializer\CompactSerializer;
15+
use Jose\Component\KeyManagement\JWKFactory;
16+
use PHPUnit\Framework\Attributes\Test;
17+
use PHPUnit\Framework\TestCase;
18+
use function mb_strlen;
19+
use const E_USER_DEPRECATED;
20+
21+
/**
22+
* The expected CEK size is passed by the JWEDecrypter as an additional argument of the decryptKey method.
23+
* Until that argument is part of the KeyEncryption interface (5.0), RSA1_5 falls back to a hardcoded table
24+
* and triggers a deprecation.
25+
*
26+
* @internal
27+
*/
28+
final class RSA15ExpectedCekSizeTest extends TestCase
29+
{
30+
#[Test]
31+
public function aDeprecationIsTriggeredWhenTheExpectedCekSizeIsNotProvided(): void
32+
{
33+
$jwk = $this->createKey();
34+
$algorithm = new RSA15();
35+
$cek = random_bytes(16); // A128GCM CEK
36+
$header = [
37+
'alg' => 'RSA1_5',
38+
'enc' => 'A128GCM',
39+
];
40+
$additionalHeader = [];
41+
$encrypted = $algorithm->encryptKey($jwk, $cek, $header, $additionalHeader);
42+
43+
$decrypted = null;
44+
$deprecations = $this->collectDeprecations(static function () use (
45+
$algorithm,
46+
$jwk,
47+
$encrypted,
48+
$header,
49+
&$decrypted
50+
): void {
51+
$decrypted = $algorithm->decryptKey($jwk, $encrypted, $header);
52+
});
53+
54+
static::assertSame($cek, $decrypted);
55+
static::assertCount(1, $deprecations);
56+
static::assertStringContainsString(
57+
'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.',
58+
$deprecations[0]
59+
);
60+
}
61+
62+
#[Test]
63+
public function noDeprecationIsTriggeredWhenTheExpectedCekSizeIsProvided(): void
64+
{
65+
$jwk = $this->createKey();
66+
$algorithm = new RSA15();
67+
$cek = random_bytes(16); // A128GCM CEK
68+
$header = [
69+
'alg' => 'RSA1_5',
70+
'enc' => 'A128GCM',
71+
];
72+
$additionalHeader = [];
73+
$encrypted = $algorithm->encryptKey($jwk, $cek, $header, $additionalHeader);
74+
75+
$decrypted = null;
76+
$deprecations = $this->collectDeprecations(static function () use (
77+
$algorithm,
78+
$jwk,
79+
$encrypted,
80+
$header,
81+
&$decrypted
82+
): void {
83+
$decrypted = $algorithm->decryptKey($jwk, $encrypted, $header, 128);
84+
});
85+
86+
static::assertSame($cek, $decrypted);
87+
static::assertSame([], $deprecations);
88+
}
89+
90+
#[Test]
91+
public function theProvidedCekSizeIsUsedForTheImplicitRejection(): void
92+
{
93+
$jwk = $this->createKey();
94+
$algorithm = new RSA15();
95+
$key = RSAKey::createFromJWK($jwk);
96+
$garbage = "\x00" . random_bytes($key->getModulusLength() - 1);
97+
// The content encryption algorithm is unknown to the hardcoded table.
98+
$header = [
99+
'alg' => 'RSA1_5',
100+
'enc' => 'FOO-256',
101+
];
102+
103+
$result = $algorithm->decryptKey($jwk, $garbage, $header, 256);
104+
105+
static::assertSame(32, mb_strlen($result, '8bit'));
106+
}
107+
108+
#[Test]
109+
public function theJweDecrypterProvidesTheExpectedCekSize(): void
110+
{
111+
$jwk = $this->createKey();
112+
$algorithmManager = new AlgorithmManager([new RSA15(), new A128GCM()]);
113+
$token = $this->createToken($algorithmManager, $jwk, 'RSA1_5');
114+
115+
$jwe = (new CompactSerializer())->unserialize($token);
116+
$decrypter = new JWEDecrypter($algorithmManager);
117+
118+
$deprecations = $this->collectDeprecations(static function () use ($decrypter, $jwe, $jwk): void {
119+
$jweToDecrypt = $jwe;
120+
static::assertTrue($decrypter->decryptUsingKey($jweToDecrypt, $jwk, 0));
121+
static::assertSame('Live long and prosper.', $jweToDecrypt->getPayload());
122+
});
123+
124+
static::assertSame([], $deprecations);
125+
}
126+
127+
#[Test]
128+
public function algorithmsThatDoNotExpectTheCekSizeStillWork(): void
129+
{
130+
$jwk = JWKFactory::createOctKey(256, [
131+
'use' => 'enc',
132+
]);
133+
$algorithm = new LegacyKeyEncryptionAlgorithm();
134+
$algorithmManager = new AlgorithmManager([$algorithm, new A128GCM()]);
135+
$token = $this->createToken($algorithmManager, $jwk, $algorithm->name());
136+
137+
$jwe = (new CompactSerializer())->unserialize($token);
138+
$decrypter = new JWEDecrypter($algorithmManager);
139+
140+
static::assertTrue($decrypter->decryptUsingKey($jwe, $jwk, 0));
141+
static::assertSame('Live long and prosper.', $jwe->getPayload());
142+
static::assertSame(4, $algorithm->receivedArgumentCount);
143+
}
144+
145+
private function createKey(): JWK
146+
{
147+
return JWKFactory::createRSAKey(2048, [
148+
'alg' => 'RSA1_5',
149+
'use' => 'enc',
150+
]);
151+
}
152+
153+
private function createToken(AlgorithmManager $algorithmManager, JWK $jwk, string $algorithm): string
154+
{
155+
$jwe = (new JWEBuilder($algorithmManager))
156+
->create()
157+
->withPayload('Live long and prosper.')
158+
->withSharedProtectedHeader([
159+
'alg' => $algorithm,
160+
'enc' => 'A128GCM',
161+
])
162+
->addRecipient($jwk)
163+
->build();
164+
165+
return (new CompactSerializer())->serialize($jwe, 0);
166+
}
167+
168+
/**
169+
* @param callable(): void $callback
170+
*
171+
* @return list<string>
172+
*/
173+
private function collectDeprecations(callable $callback): array
174+
{
175+
$deprecations = [];
176+
set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool {
177+
$deprecations[] = $errstr;
178+
179+
return true;
180+
}, E_USER_DEPRECATED);
181+
182+
try {
183+
$callback();
184+
} finally {
185+
restore_error_handler();
186+
}
187+
188+
return $deprecations;
189+
}
190+
}

0 commit comments

Comments
 (0)