From 060bd2959de8ab9fdd69c4aa28ed3cdc26a345d0 Mon Sep 17 00:00:00 2001 From: Milad Rahimi Date: Sat, 1 Aug 2026 15:05:45 +0200 Subject: [PATCH] test: make the RSA-PSS encode vectors kill the emBits and masking mutants The Infection run on this PR flaked: the `emBits = modBits - 1` mutant in AbstractRsaPssSigner::sign() escaped, and a local run lost the same coin flip on `0xFF >> $clearBits` in encode(). Neither escape was new to this branch. Both mutants only shift bits in the leading byte of the encoded message, so every test that signs with a random salt catches them only about half the time, and the fixed-salt vector could not catch them at all: with the old salt that byte was 0x6B, whose top and bottom bits are already clear, so the mutated encoders produced the very same signature. Pin the salt to one whose masked data block starts with 0xFF, and add a second vector under the 2047-bit key, which has two excess bits to clear where the 2041-bit key has none. Every bit of the clearing step is then observable in a deterministic signature, for both shapes of modulus. The OpenSSL CLI verifies both vectors (PSS, salt length 32). --- .../RsaPss/AbstractRsaPssSignerTest.php | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/tests/Cryptography/Algorithms/RsaPss/AbstractRsaPssSignerTest.php b/tests/Cryptography/Algorithms/RsaPss/AbstractRsaPssSignerTest.php index fa0b52e..4a17710 100644 --- a/tests/Cryptography/Algorithms/RsaPss/AbstractRsaPssSignerTest.php +++ b/tests/Cryptography/Algorithms/RsaPss/AbstractRsaPssSignerTest.php @@ -16,14 +16,27 @@ class AbstractRsaPssSignerTest extends TestCase { /** * The signature the encoder must produce for the message `Text` under the 2041-bit fixture key with the - * salt fixed to `\xAB` * 32 — that modulus leaves no excess bits to clear and prepends a zero byte, so the - * whole encode path is pinned byte for byte. The OpenSSL CLI verifies this vector (PSS with salt length 32). + * salt `fixedSaltSigner()` pins — that modulus leaves no excess bits to clear and prepends a zero byte, so + * the whole encode path is pinned byte for byte and the encoded message keeps its `0xFF` leading byte. The + * OpenSSL CLI verifies this vector (PSS with salt length 32). */ private const FIXED_SALT_SIGNATURE_2041 = - 'ARZEDL31+Fo7wUwGXgh2nqc8J6I6Tbc5gRfzYIsVeJQhXW+1/nqoP9KDVDZy7O46jX52Fze7+c1wFBAuq3c1BOkBtcx0zZOf' - . 'Jp6aZh6Vpvb5xhxcPb8+btLtGffC4Ypp2WcnGYdIuBYa0OGINAUNn5P9fMMxk3WJFYUDHzQx3I7NlaRISXmTSqrJOJUlardQ' - . 'P5Agmo/DKbXJmSmCyGm+lgeFAG+Rn2XvFGHhLc8nOrMA2uA5GFxWzAb2cMbosN95SwlqSinL0iEJG7BBFtGjUNjDxuMdd2j9' - . 'nN+sU3fzm1aIRI7tuqOtUkzoXJodg6APJn5zSbCnOIZ/8OTJ3YDfTA=='; + 'ANo31z7z+wPHh8Afo/ZX+YErxy/Fj+ngyfgRVC8tVCyOEzdR9mec6bSG05nsE6oGubxSLyQ8V9MYQ0V7qSci+Wl6VT3digh8' + . 'mJ3JkEtm+pQ7k6+VrBp9MYCC+yQmI6setHLV6DGqwIIMjyz3KrKwjxaAtYYDYvuTbBzNSSdSxSQsZinGUVLliT+E879Nh4rH' + . '9JZLBX4egxByGyvTfoMKUAX3pNnQD/SqpEkJxdxJHiZtm1O2laOpk3oI4jp8Ke9OBJWOiV99JrjuSBiPuFzd1TzEpH2hEGHO' + . 'hkT34v3iiawUYZ/vziuRMpk/11KjfqvmNN9aILyalsLgL+tVcGUdGg=='; + + /** + * The same vector under the 2047-bit fixture key, whose two excess bits the encoder must clear: the `0xFF` + * leading byte of the encoded message has to come out as `0x3F`. Together with the 2041-bit vector, which + * clears nothing, this pins the bit-clearing step for both shapes of modulus — no run of the suite depends + * on a random salt happening to expose those bits. The OpenSSL CLI verifies this vector too. + */ + private const FIXED_SALT_SIGNATURE_2047 = + 'A8kN0RON8TzrHc/0ZFhoyegSJoegm+oMmkOynOMJjY3HqFd7Nx2M4cAvAwfh1otJd6GPDKsKG5bC5GyB+FRig4TWqrliLR0P' + . 'dyPMNNp5N0V+iXMsENOGtPEZ8ofnv6HK+Sn+PV2PuUr64K4suIrnSyvpcrZ1oUkCxHqOdbCYG7UwwnVg2AGNJoVoaE/qI5Y/' + . 'oBynmdxLyFlqvlRM7th/p0iAO7qRDDJ3nedAWxgbjdpGJzXSpgy2SASk1KxUOwsTTy+BByVGPUJ39Ear/EqZ04t3ujLHboCZ' + . '/iWYCiL2HfFDd/BCVW8amphnUMus3X4XpUVRGapGAyZGFs3NqCelkw=='; protected RsaPrivateKey $rsaPrivateKey; @@ -75,14 +88,39 @@ public function test_sign_it_should_produce_a_signature_of_the_modulus_size() */ public function test_sign_with_a_fixed_salt_it_should_produce_the_expected_signature() { - $signer = new class (new RsaPrivateKey(KeyFixtures::PRIVATE_KEY_2041)) extends PS256Signer { + $signer = $this->fixedSaltSigner(KeyFixtures::PRIVATE_KEY_2041); + + $this->assertSame((string)base64_decode(self::FIXED_SALT_SIGNATURE_2041), $signer->sign('Text')); + } + + /** + * The same for a modulus that leaves excess bits to clear, so the signature also pins the bit-clearing step + * of the encoding rather than only the bits a random salt happens to expose. + * + * @throws Throwable + */ + public function test_sign_with_a_fixed_salt_and_excess_bits_it_should_produce_the_expected_signature() + { + $signer = $this->fixedSaltSigner(KeyFixtures::PRIVATE_KEY_2047); + + $this->assertSame((string)base64_decode(self::FIXED_SALT_SIGNATURE_2047), $signer->sign('Text')); + } + + /** + * A signer whose salt is pinned, which makes EMSA-PSS deterministic. The salt is chosen so the masked data + * block starts with `0xFF` before the excess bits are cleared: every bit the encoder is supposed to clear + * (or leave alone) is then visible in the signature, whatever the modulus size leaves over. + * + * @throws Throwable + */ + private function fixedSaltSigner(string $privateKey): PS256Signer + { + return new class (new RsaPrivateKey($privateKey)) extends PS256Signer { protected function salt(int $length): string { - return str_repeat("\xAB", $length); + return str_repeat("\xAB", $length - 1) . "\x72"; } }; - - $this->assertSame((string)base64_decode(self::FIXED_SALT_SIGNATURE_2041), $signer->sign('Text')); } /**