assertSame('ed25519', Algorithm::normalize('ed25519')); $this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256')); $this->assertSame('rsa-v1_5-sha384', Algorithm::normalize('rsa-v1_5-sha384')); $this->assertSame('rsa-v1_5-sha512', Algorithm::normalize('rsa-v1_5-sha512')); $this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ecdsa-p256-sha256')); $this->assertSame('ecdsa-p384-sha384', Algorithm::normalize('ecdsa-p384-sha384')); } public function testNormalizeJoseAliases(): void { $this->assertSame('ed25519', Algorithm::normalize('EdDSA')); $this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ES256')); $this->assertSame('ecdsa-p384-sha384', Algorithm::normalize('ES384')); $this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('RS256')); } public function testNormalizeRejectsUnknown(): void { $this->expectException(SignatureException::class); Algorithm::normalize('totally-not-real'); } public function testNormalizeRejectsRsaPss(): void { $this->expectException(SignatureException::class); Algorithm::normalize('rsa-pss-sha512'); } public function testNormalizeRejectsJosePsAlias(): void { $this->expectException(SignatureException::class); Algorithm::normalize('PS512'); } public function testDeriveJoseAlgFromJwk(): void { $this->assertSame('EdDSA', Algorithm::deriveJoseAlgFromJwk(['kty' => 'OKP', 'crv' => 'Ed25519'])); $this->assertSame('ES256', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-256'])); $this->assertSame('ES384', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-384'])); // RSA: hash function isn't determined by key shape. $this->assertNull(Algorithm::deriveJoseAlgFromJwk(['kty' => 'RSA'])); $this->assertNull(Algorithm::deriveJoseAlgFromJwk([])); } public function testEd25519SigningIsRejected(): void { $this->expectException(SignatureException::class); $this->expectExceptionMessageMatches('/Ed25519 signing is not supported/'); Algorithm::sign('payload', str_repeat("\x00", 64), 'ed25519'); } public function testEd25519VerifyRoundTripWithSodium(): void { $this->skipUnlessSodium(); [$secret, $key] = $this->ed25519KeyPair(); $base = 'arbitrary signature base'; $sig = sodium_crypto_sign_detached($base, $secret); $this->assertSame(64, strlen($sig)); $this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519')); // JOSE alias accepted. $this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA')); // alg-omitted path resolves through Key alg. $this->assertTrue(Algorithm::verify($base, $sig, $key, null)); // tamper detection $this->assertFalse(Algorithm::verify($base . 'x', $sig, $key, 'ed25519')); } public function testRsaPkcs1RoundTrip(): void { [$priv, $key] = $this->rsaKeyPair(); $sig = Algorithm::sign('payload', $priv, 'rsa-v1_5-sha256'); $this->assertSame(256, strlen($sig)); $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'rsa-v1_5-sha256')); $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'RS256')); } public function testEcdsaP256RoundTrip(): void { [$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256'); $sig = Algorithm::sign('payload', $priv, 'ecdsa-p256-sha256'); $this->assertSame(64, strlen($sig)); $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p256-sha256')); $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ES256')); } public function testEcdsaP384RoundTrip(): void { [$priv, $key] = $this->ecKeyPair('secp384r1', 'P-384', 'ES384'); $sig = Algorithm::sign('payload', $priv, 'ecdsa-p384-sha384'); $this->assertSame(96, strlen($sig)); $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p384-sha384')); } public function testKeyTypeMismatchFailsClosed(): void { [, $rsaKey] = $this->rsaKeyPair(); $this->expectException(SignatureException::class); Algorithm::verify('payload', random_bytes(64), $rsaKey, 'ed25519'); } public function testAlgHintConflictsWithJwkAlgRejected(): void { $this->skipUnlessSodium(); // Ed25519 JWK, request claims ES256: RFC 9421 §3.2 step 6 disagreement. [, $key] = $this->ed25519KeyPair(); $this->expectException(SignatureException::class); Algorithm::verify('payload', random_bytes(64), $key, 'ES256'); } public function testParseKeyRejectsContradictoryAlg(): void { $this->markTestSkipped( 'firebase/php-jwt JWK::parseKey does not validate kty/crv/alg coherence; ' . 'the alg mismatch is caught at verify() time instead — see testVerifyEd25519KeyAgainstES256Alg.' ); } public function testEcdsaRawToDerProducesValidSignature(): void { [$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256'); $rawSig = Algorithm::sign('msg', $priv, 'ecdsa-p256-sha256'); $der = Algorithm::ecdsaRawToDer($rawSig, 32); $this->assertNotNull($der); $this->assertTrue(Algorithm::verify('msg', $rawSig, $key, 'ecdsa-p256-sha256')); } public function testEcdsaRawToDerWrongLength(): void { $this->assertNull(Algorithm::ecdsaRawToDer('short', 32)); } private function skipUnlessSodium(): void { if (!extension_loaded('sodium')) { $this->markTestSkipped('ext-sodium is not loaded'); } } /** * @return array{0: string, 1: Key} */ private function ed25519KeyPair(): array { $keypair = sodium_crypto_sign_keypair(); $publicKey = sodium_crypto_sign_publickey($keypair); $secretKey = sodium_crypto_sign_secretkey($keypair); $key = JWK::parseKey([ 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'k', 'alg' => 'EdDSA', 'x' => self::b64url($publicKey), ], 'EdDSA'); return [$secretKey, $key]; } /** * @return array{0: string, 1: Key} */ private function rsaKeyPair(): array { $pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); $priv = ''; openssl_pkey_export($pkey, $priv); $details = openssl_pkey_get_details($pkey); $key = JWK::parseKey([ 'kty' => 'RSA', 'kid' => 'k', 'alg' => 'RS256', 'n' => self::b64url($details['rsa']['n']), 'e' => self::b64url($details['rsa']['e']), ], 'RS256'); return [$priv, $key]; } /** * @return array{0: string, 1: Key} */ private function ecKeyPair(string $opensslCurve, string $jwkCurve, string $joseAlg): array { $pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => $opensslCurve]); $priv = ''; openssl_pkey_export($pkey, $priv); $details = openssl_pkey_get_details($pkey); $key = JWK::parseKey([ 'kty' => 'EC', 'crv' => $jwkCurve, 'kid' => 'k', 'alg' => $joseAlg, 'x' => self::b64url($details['ec']['x']), 'y' => self::b64url($details['ec']['y']), ], $joseAlg); return [$priv, $key]; } private static function b64url(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } }