nextcloud/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php
Micke Nordin 0eb927e617 feat(http-sig): RFC 9421 protocol primitives
Add the RFC 9421 (HTTP Message Signatures) sign/verify path alongside
the existing draft-cavage implementation:

- Algorithm: sodium for Ed25519, JWT::sign for RSA / ECDSA, ecdsaRawToDer
  for the ECDSA wire format. JWK parsing via JWK::parseKey.
- SignatureBase: RFC 9421 §2.5 base construction for the derived
  components OCM uses plus plain HTTP fields.
- ContentDigest: RFC 9530 helpers used as a covered component.
- Rfc9421IncomingSignedRequest / Rfc9421OutgoingSignedRequest:
  request models. Parsing of Signature-Input / Signature delegates
  to gapple\\StructuredFields\\Parser.
- IJwkResolvingSignatoryManager: capability bit signatory managers
  advertise to participate in RFC 9421 verification.
- OcmProfile: OCM-mandated dictionary label.
- SignatureManager: dispatch to RFC 9421 inbound when Signature-Input
  is present, outbound when rfc9421.format is set.

Plus tests for each primitive and a full round-trip across the model.

Signed-off-by: Micke Nordin <kano@sunet.se>
2026-05-27 11:03:55 +02:00

197 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Security\Signature\Rfc9421;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
use OC\Security\Signature\Rfc9421\Algorithm;
use OCP\Security\Signature\Exceptions\SignatureException;
use Test\TestCase;
class AlgorithmTest extends TestCase {
public function testNormalizeNativeIsPassThrough(): void {
$this->assertSame('ed25519', Algorithm::normalize('ed25519'));
$this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256'));
$this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ecdsa-p256-sha256'));
}
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 testEd25519RoundTrip(): void {
[$priv, $key] = $this->ed25519KeyPair();
$base = 'arbitrary signature base';
$sig = Algorithm::sign($base, $priv, 'ed25519');
$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 {
// 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 {
// kty=OKP/crv=Ed25519 with alg=ES256 is contradictory; firebase's
// parseKey rejects it before we ever build a Key.
$keypair = sodium_crypto_sign_keypair();
$this->expectException(\Throwable::class);
JWK::parseKey([
'kty' => 'OKP',
'crv' => 'Ed25519',
'kid' => 'k',
'alg' => 'ES256',
'x' => self::b64url(sodium_crypto_sign_publickey($keypair)),
], null);
}
public function testAlgHintAgreesViaJoseAlias(): void {
[$priv, $key] = $this->ed25519KeyPair();
$base = 'agreement check';
$sig = Algorithm::sign($base, $priv, 'ed25519');
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519'));
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA'));
}
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));
}
/**
* @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), '+/', '-_'), '=');
}
}