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
16 changes: 11 additions & 5 deletions src/Experimental/KeyEncryption/Chacha20Poly1305.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@
$k = $this->getKey($key);
$nonce = random_bytes(12);

// We set header parameters
$additionalHeader['nonce'] = Base64UrlSafe::encodeUnpadded($nonce);

$tag = null;
$result = openssl_encrypt($cek, 'chacha20-poly1305', $k, OPENSSL_RAW_DATA, $nonce, $tag);
if ($result === false || ! is_string($tag)) {
if ($result === false || ! is_string($tag) || strlen($tag) !== 16) {
throw new RuntimeException('Unable to encrypt the CEK');
}

$additionalHeader['nonce'] = Base64UrlSafe::encodeUnpadded($nonce);
$additionalHeader['tag'] = Base64UrlSafe::encodeUnpadded($tag);

return $result;
}

Expand All @@ -66,14 +66,20 @@
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string
{
$k = $this->getKey($key);
isset($header['nonce']) || throw new InvalidArgumentException('The header parameter "nonce" is missing.');

Check failure on line 69 in src/Experimental/KeyEncryption/Chacha20Poly1305.php

View workflow job for this annotation

GitHub Actions / 1️⃣ Static Analysis (PHPStan)

Ignored error "Language construct isset() should not be used." (ergebnis.noIsset) in path /__w/jwt-framework/jwt-framework/src/Experimental/KeyEncryption/Chacha20Poly1305.php is expected to occur 1 time, but occurred 2 times.
is_string($header['nonce']) || throw new InvalidArgumentException('The header parameter "nonce" is not valid.');
$nonce = Base64UrlSafe::decodeNoPadding($header['nonce']);
if (strlen($nonce) !== 12) {
throw new InvalidArgumentException('The header parameter "nonce" is not valid.');
}
isset($header['tag']) || throw new InvalidArgumentException('The header parameter "tag" is missing.');

Check failure on line 75 in src/Experimental/KeyEncryption/Chacha20Poly1305.php

View workflow job for this annotation

GitHub Actions / 1️⃣ Static Analysis (PHPStan)

Language construct isset() should not be used.
is_string($header['tag']) || throw new InvalidArgumentException('The header parameter "tag" is not valid.');
$tag = Base64UrlSafe::decodeNoPadding($header['tag']);
if (strlen($tag) !== 16) {
throw new InvalidArgumentException('The header parameter "tag" is not valid.');
}

$result = openssl_decrypt($encrypted_cek, 'chacha20-poly1305', $k, OPENSSL_RAW_DATA, $nonce);
$result = openssl_decrypt($encrypted_cek, 'chacha20-poly1305', $k, OPENSSL_RAW_DATA, $nonce, $tag);
if ($result === false) {
throw new RuntimeException('Unable to decrypt the CEK');
}
Expand Down
12 changes: 11 additions & 1 deletion src/Library/Encryption/Algorithm/KeyEncryption/PBES2AESKW.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@
use function in_array;
use function is_int;
use function is_string;
use function sprintf;

abstract readonly class PBES2AESKW implements KeyWrapping
{
public const DEFAULT_MAX_COUNT = 1_000_000;

public function __construct(
private readonly int $salt_size = 64,
private readonly int $nb_count = 4096
private readonly int $nb_count = 4096,
private readonly int $max_count = self::DEFAULT_MAX_COUNT
) {
if (! interface_exists(WrapperInterface::class)) {
throw new RuntimeException('Please install "spomky-labs/aes-key-wrap" to use AES-KW algorithms');
Expand Down Expand Up @@ -139,6 +143,12 @@ protected function checkHeaderAdditionalParameters(array $header): void
if (! is_int($header['p2c']) || $header['p2c'] <= 0) {
throw new InvalidArgumentException('The header parameter "p2c" is not valid.');
}
if ($header['p2c'] > $this->max_count) {
throw new InvalidArgumentException(sprintf(
'The header parameter "p2c" is too large. The maximum allowed value is %d.',
$this->max_count
));
}
}

abstract protected function getWrapper(): A256KW|A128KW|A192KW;
Expand Down
50 changes: 50 additions & 0 deletions src/Library/Encryption/Algorithm/KeyEncryption/RSA15.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,54 @@

namespace Jose\Component\Encryption\Algorithm\KeyEncryption;

use InvalidArgumentException;
use Jose\Component\Core\JWK;
use Jose\Component\Core\Util\RSAKey;
use Jose\Component\Encryption\Algorithm\KeyEncryption\Util\RSACrypt;
use Override;
use function is_string;

final readonly class RSA15 extends RSA
{
/**
* @var array<string, int>
*/
private const CEK_LENGTHS = [
'A128GCM' => 16,
'A192GCM' => 24,
'A256GCM' => 32,
'A128CBC-HS256' => 32,
'A192CBC-HS384' => 48,
'A256CBC-HS512' => 64,
];

#[Override]
public function name(): string
{
return 'RSA1_5';
}

/**
* @param array<string, mixed> $header
*/
#[Override]
public function decryptKey(JWK $key, string $encrypted_cek, array $header): string
{
$this->checkKey($key);
if (! $key->has('d')) {
throw new InvalidArgumentException('The key is not a private key');
}
$priv = RSAKey::createFromJWK($key);

return RSACrypt::decrypt(
$priv,
$encrypted_cek,
RSACrypt::ENCRYPTION_PKCS1,
null,
$this->getExpectedCekLength($header)
);
}

#[Override]
protected function getEncryptionMode(): int
{
Expand All @@ -26,4 +63,17 @@ protected function getHashAlgorithm(): ?string
{
return null;
}

/**
* @param array<string, mixed> $header
*/
private function getExpectedCekLength(array $header): ?int
{
$enc = $header['enc'] ?? null;
if (! is_string($enc)) {
return null;
}

return self::CEK_LENGTHS[$enc] ?? null;
}
}
82 changes: 71 additions & 11 deletions src/Library/Encryption/Algorithm/KeyEncryption/Util/RSACrypt.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ public static function encrypt(RSAKey $key, string $data, int $mode, ?string $ha
}
}

public static function decrypt(RSAKey $key, string $plaintext, int $mode, ?string $hash = null): string
{
public static function decrypt(
RSAKey $key,
string $plaintext,
int $mode,
?string $hash = null,
?int $expectedKeyLength = null
): string {
switch ($mode) {
case self::ENCRYPTION_OAEP:
if ($hash === null) {
Expand All @@ -57,7 +62,7 @@ public static function decrypt(RSAKey $key, string $plaintext, int $mode, ?strin

return self::decryptWithRSAOAEP($key, $plaintext, $hash);
case self::ENCRYPTION_PKCS1:
return self::decryptWithRSA15($key, $plaintext);
return self::decryptWithRSA15($key, $plaintext, $expectedKeyLength);
default:
throw new InvalidArgumentException('Unsupported mode.');
}
Expand Down Expand Up @@ -86,24 +91,79 @@ public static function encryptWithRSA15(RSAKey $key, string $data): string
return self::convertIntegerToOctetString($c, $key->getModulusLength());
}

public static function decryptWithRSA15(RSAKey $key, string $c): string
public static function decryptWithRSA15(RSAKey $key, string $c, ?int $expectedKeyLength = null): string
{
if (strlen($c) !== $key->getModulusLength()) {
throw new InvalidArgumentException('Unable to decrypt');
}
$c = BigInteger::createFromBinaryString($c);
$m = self::getRSADP($key, $c);
$em = self::convertIntegerToOctetString($m, $key->getModulusLength());
if (ord($em[0]) !== 0 || ord($em[1]) > 2) {
throw new InvalidArgumentException('Unable to decrypt');
if ($expectedKeyLength === null) {
if (ord($em[0]) !== 0 || ord($em[1]) > 2) {
throw new InvalidArgumentException('Unable to decrypt');
}
$ps = substr($em, 2, (int) strpos($em, chr(0), 2) - 2);
$m = substr($em, strlen($ps) + 3);
if (strlen($ps) < 8) {
throw new InvalidArgumentException('Unable to decrypt');
}

return $m;
}
$ps = substr($em, 2, (int) strpos($em, chr(0), 2) - 2);
$m = substr($em, strlen($ps) + 3);
if (strlen($ps) < 8) {
throw new InvalidArgumentException('Unable to decrypt');

return self::extractRSA15KeyOrRandom($em, $expectedKeyLength);
}

private static function extractRSA15KeyOrRandom(string $em, int $expectedKeyLength): string
{
$k = strlen($em);
$random = random_bytes($expectedKeyLength);

if ($k < $expectedKeyLength + 11) {
return $random;
}
$candidate = substr($em, $k - $expectedKeyLength);

$valid = self::ctEq(ord($em[0]), 0x00) & self::ctEq(ord($em[1]), 0x02);

$seenSeparator = 0;
$separatorIndex = 0;
$psLength = 0;
for ($i = 2; $i < $k; ++$i) {
$isZero = self::ctEq(ord($em[$i]), 0x00);
$firstZero = $isZero & (1 - $seenSeparator);
$separatorIndex |= $firstZero * $i;
$psLength += (1 - $seenSeparator) & (1 - $isZero);
$seenSeparator |= $isZero;
}

return $m;
$valid &= $seenSeparator;
$valid &= self::ctGe($psLength, 8);

$messageLength = $k - $separatorIndex - 1;
$valid &= self::ctEq($messageLength, $expectedKeyLength);

return self::ctSelect($valid, $candidate, $random);
}

private static function ctEq(int $a, int $b): int
{
$diff = $a ^ $b;

return (($diff - 1) >> 63) & 1;
}

private static function ctGe(int $a, int $b): int
{
return (($b - $a - 1) >> 63) & 1;
}

private static function ctSelect(int $condition, string $a, string $b): string
{
$mask = str_repeat(chr(($condition * 0xFF) & 0xFF), strlen($a));

return ($a & $mask) | ($b & ~$mask);
}

/**
Expand Down
11 changes: 6 additions & 5 deletions src/Library/Signature/JWSVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,17 @@ private function checkPayload(JWS $jws, ?string $detachedPayload = null): void
*/
private function getAlgorithm(Signature $signature): Algorithm
{
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
if (! isset($completeHeader['alg'])) {
throw new InvalidArgumentException('No "alg" parameter set in the header.');
$protectedHeader = $signature->getProtectedHeader();
if (! isset($protectedHeader['alg'])) {
throw new InvalidArgumentException('No "alg" parameter set in the protected header.');
}
$alg = $protectedHeader['alg'];

$algorithm = $this->signatureAlgorithmManager->get($completeHeader['alg']);
$algorithm = $this->signatureAlgorithmManager->get($alg);
if (! $algorithm instanceof SignatureAlgorithm && ! $algorithm instanceof MacAlgorithm) {
throw new InvalidArgumentException(sprintf(
'The algorithm "%s" is not supported or is not a signature or MAC algorithm.',
$completeHeader['alg']
$alg
));
}

Expand Down
102 changes: 102 additions & 0 deletions tests/Component/Encryption/RSA15ImplicitRejectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Jose\Tests\Component\Encryption;

use InvalidArgumentException;
use Jose\Component\Core\Util\RSAKey;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSA15;
use Jose\Component\Encryption\Algorithm\KeyEncryption\Util\RSACrypt;
use Jose\Component\KeyManagement\JWKFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function mb_strlen;

/**
* @internal
*/
final class RSA15ImplicitRejectionTest extends TestCase
{
#[Test]
public function validRoundTripStillWorks(): void
{
$jwk = JWKFactory::createRSAKey(2048, [
'alg' => 'RSA1_5',
'use' => 'enc',
]);
$algorithm = new RSA15();
$cek = random_bytes(16); // A128GCM CEK
$header = [
'alg' => 'RSA1_5',
'enc' => 'A128GCM',
];

$additionalHeader = [];
$encrypted = $algorithm->encryptKey($jwk, $cek, $header, $additionalHeader);
$decrypted = $algorithm->decryptKey($jwk, $encrypted, $header);

static::assertSame($cek, $decrypted);
}

#[Test]
public function malformedCiphertextDoesNotThrowAndReturnsExpectedLength(): void
{
$jwk = JWKFactory::createRSAKey(2048, [
'alg' => 'RSA1_5',
'use' => 'enc',
]);
$algorithm = new RSA15();
$key = RSAKey::createFromJWK($jwk);
$garbage = "\x00" . random_bytes($key->getModulusLength() - 1);
$header = [
'alg' => 'RSA1_5',
'enc' => 'A128GCM',
];

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

// 16 bytes for A128GCM, and (overwhelmingly) not a valid recovery.
static::assertSame(16, mb_strlen($result, '8bit'));
}

#[Test]
public function implicitRejectionRespectsEncCekLength(): void
{
$jwk = JWKFactory::createRSAKey(2048, [
'alg' => 'RSA1_5',
'use' => 'enc',
]);
$algorithm = new RSA15();
$key = RSAKey::createFromJWK($jwk);
$garbage = "\x00" . random_bytes($key->getModulusLength() - 1);

$lengths = [
'A128GCM' => 16,
'A192GCM' => 24,
'A256GCM' => 32,
'A128CBC-HS256' => 32,
'A192CBC-HS384' => 48,
'A256CBC-HS512' => 64,
];
foreach ($lengths as $enc => $expected) {
$result = $algorithm->decryptKey($jwk, $garbage, ['alg' => 'RSA1_5', 'enc' => $enc]);
static::assertSame($expected, mb_strlen($result, '8bit'), $enc);
}
}

#[Test]
public function legacyDirectDecryptStillThrowsOnGarbage(): void
{
$jwk = JWKFactory::createRSAKey(2048, [
'alg' => 'RSA1_5',
'use' => 'enc',
]);
$key = RSAKey::createFromJWK($jwk);
$garbage = "\x00" . random_bytes($key->getModulusLength() - 1);

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unable to decrypt');
RSACrypt::decrypt($key, $garbage, RSACrypt::ENCRYPTION_PKCS1);
}
}
Loading
Loading