Files
Git'Fellow 4920bd1725 refactor: add PHP 8.3 types to BC-safe class constants
PHP 8.3 allows class constants to declare a type. Adopt it where doing so
cannot break third-party apps.

Typing an inheritable constant is a hard BC break: a subclass that
redeclares it untyped fails to load with "Type of C::FOO must be
compatible with P::FOO of type string". Changes are therefore limited to
constants that cannot be redeclared by a subclass:

  - private const (not inherited)
  - final public/protected const
  - constants declared in a final class or an enum

Interface constants, trait constants, and public/protected constants in
non-final (including abstract) classes are left untyped, as is all of
lib/public (OCP) and lib/unstable (NCU).

One review-requested exception: the public OBJECT_PREFIX/OBJECT_SUFFIX
constants of the app-internal CalDAV import helpers (TextImporter,
XmlImporter) are typed as well; these classes are not public API and
have no subclasses.

Only string, int and array are used. float is avoided because it would
silently coerce an int literal and change === comparisons. No constant
name or value is modified: every changed line adds only the type token,
so runtime behaviour is unchanged.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
2026-08-11 13:52:52 +01:00

225 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use InvalidArgumentException;
use OCP\Security\Signature\Exceptions\SignatureException;
use Throwable;
/**
* RFC 9421 §3.3 sign/verify primitives.
*
* Sign supports asymmetric algorithms reachable via ext-openssl: RSA-PKCS1-v1_5
* (SHA-256/384/512) and ECDSA P-256 / P-384. JOSE aliases (RFC 7518 / RFC 8037)
* accepted per RFC 9421 §3.3.7. RSA-PSS is rejected: OPENSSL_PKCS1_PSS_PADDING
* needs PHP 8.5 and we still support 8.2-8.4.
*
* Verify additionally accepts Ed25519 when ext-sodium is loaded; without sodium
* an Ed25519 signature throws {@see SignatureException}. Sodium is used directly
* because firebase/php-jwt's `validateEdDSAKey` base64url-decodes the key
* material, which mangles the raw sodium bytes.
*
* Sign delegates to {@see JWT::sign}. Verify takes a {@see Key} parsed by
* firebase/php-jwt (which has already validated the JWK's kty/crv/alg
* consistency) and only enforces the cross-source agreement between the JWK
* `alg` and the Signature-Input `alg` parameter (RFC 9421 §3.2 step 6).
*/
final class Algorithm {
public const array NATIVE = [
'rsa-v1_5-sha256',
'rsa-v1_5-sha384',
'rsa-v1_5-sha512',
'ecdsa-p256-sha256',
'ecdsa-p384-sha384',
'ed25519',
];
/**
* $privateKey is a PEM private key. Returns raw signature bytes (R||S for
* ECDSA). Ed25519 is verify-only and is rejected here.
*
* @throws SignatureException
*/
public static function sign(string $signatureBase, string $privateKey, string $algorithm): string {
$normalized = self::normalize($algorithm);
if ($normalized === 'ed25519') {
throw new SignatureException('Ed25519 signing is not supported; use ECDSA P-256 or RSA');
}
try {
return JWT::sign($signatureBase, $privateKey, self::nativeToJose($normalized));
} catch (Throwable $e) {
throw new SignatureException('signing failed for ' . $normalized . ': ' . $e->getMessage(), 0, $e);
}
}
/**
* @param string $signature raw signature bytes (already base64-decoded)
* @param string|null $algorithm algorithm hint from Signature-Input `alg=`
* @throws SignatureException
*/
public static function verify(string $signatureBase, string $signature, Key $key, ?string $algorithm): bool {
$resolved = self::normalize($key->getAlgorithm());
if ($algorithm !== null && $algorithm !== '') {
$hintNative = self::normalize($algorithm);
if ($hintNative !== $resolved) {
throw new SignatureException(
'algorithm sources disagree: Signature-Input alg says ' . $hintNative . ', JWK alg says ' . $resolved
);
}
}
$material = $key->getKeyMaterial();
if ($resolved === 'ed25519') {
if (!function_exists('sodium_crypto_sign_verify_detached')) {
throw new SignatureException('verifying Ed25519 signatures requires ext-sodium');
}
if (strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
return false;
}
// parseKey hands OKP material as plain base64 of the 32 raw bytes.
$rawPublic = base64_decode((string)$material, true);
if ($rawPublic === false || strlen($rawPublic) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
return false;
}
return sodium_crypto_sign_verify_detached($signature, $signatureBase, $rawPublic);
}
[$opensslAlgo, $encoding] = self::opensslParametersForAlgorithm($resolved);
if ($encoding === 'ecdsa') {
$signature = self::ecdsaRawToDer($signature, self::ecdsaCoordinateSize($resolved));
if ($signature === null) {
return false;
}
}
return openssl_verify($signatureBase, $signature, $material, $opensslAlgo) === 1;
}
/**
* Map a JOSE alg (RFC 7518/8037, including fully-specified RFC 9864
* names such as `Ed25519`) to the RFC 9421 native identifier.
* Pass-through if already native.
*
* @throws SignatureException
*/
public static function normalize(string $algorithm): string {
$lower = strtolower($algorithm);
if (in_array($lower, self::NATIVE, true)) {
return $lower;
}
return match ($algorithm) {
'EdDSA' => 'ed25519',
'ES256' => 'ecdsa-p256-sha256',
'ES384' => 'ecdsa-p384-sha384',
'RS256' => 'rsa-v1_5-sha256',
'RS384' => 'rsa-v1_5-sha384',
'RS512' => 'rsa-v1_5-sha512',
default => throw new SignatureException('unsupported signature algorithm: ' . $algorithm),
};
}
/**
* JOSE alg implied by a JWK's kty/crv, used to cross-check the JWK's
* mandatory `alg` member against its key material. Null if kty/crv
* don't pin one down (e.g. RSA, where the hash isn't determined).
*
* @param array<string, mixed> $jwk
*/
public static function deriveJoseAlgFromJwk(array $jwk): ?string {
return match ($jwk['kty'] ?? '') {
'OKP' => match ($jwk['crv'] ?? '') {
'Ed25519' => 'EdDSA',
default => null,
},
'EC' => match ($jwk['crv'] ?? '') {
'P-256' => 'ES256',
'P-384' => 'ES384',
default => null,
},
default => null,
};
}
private static function nativeToJose(string $native): string {
return match ($native) {
'ecdsa-p256-sha256' => 'ES256',
'ecdsa-p384-sha384' => 'ES384',
'rsa-v1_5-sha256' => 'RS256',
'rsa-v1_5-sha384' => 'RS384',
'rsa-v1_5-sha512' => 'RS512',
default => throw new SignatureException('unsupported signature algorithm: ' . $native),
};
}
/**
* @return array{0: int, 1: string} [openssl digest, wire encoding]
*/
private static function opensslParametersForAlgorithm(string $native): array {
return match ($native) {
'rsa-v1_5-sha256' => [OPENSSL_ALGO_SHA256, 'raw'],
'rsa-v1_5-sha384' => [OPENSSL_ALGO_SHA384, 'raw'],
'rsa-v1_5-sha512' => [OPENSSL_ALGO_SHA512, 'raw'],
'ecdsa-p256-sha256' => [OPENSSL_ALGO_SHA256, 'ecdsa'],
'ecdsa-p384-sha384' => [OPENSSL_ALGO_SHA384, 'ecdsa'],
default => throw new SignatureException('unsupported signature algorithm: ' . $native),
};
}
private static function ecdsaCoordinateSize(string $native): int {
return match ($native) {
'ecdsa-p256-sha256' => 32,
'ecdsa-p384-sha384' => 48,
default => throw new InvalidArgumentException('not an ECDSA algorithm: ' . $native),
};
}
/**
* Raw R||S (RFC 9421 §3.3.4 wire form) to DER for openssl_verify.
* firebase/php-jwt has the inverse but keeps it private.
*/
public static function ecdsaRawToDer(string $raw, int $coordinateSize): ?string {
if (strlen($raw) !== $coordinateSize * 2) {
return null;
}
$r = ltrim(substr($raw, 0, $coordinateSize), "\x00");
$s = ltrim(substr($raw, $coordinateSize), "\x00");
// DER INTEGER must be positive; pad if high bit is set.
if ($r === '' || (ord($r[0]) & 0x80) !== 0) {
$r = "\x00" . $r;
}
if ($s === '' || (ord($s[0]) & 0x80) !== 0) {
$s = "\x00" . $s;
}
$rEncoded = "\x02" . self::derLength(strlen($r)) . $r;
$sEncoded = "\x02" . self::derLength(strlen($s)) . $s;
$body = $rEncoded . $sEncoded;
return "\x30" . self::derLength(strlen($body)) . $body;
}
private static function derLength(int $length): string {
if ($length < 0x80) {
return chr($length);
}
$bytes = '';
while ($length > 0) {
$bytes = chr($length & 0xff) . $bytes;
$length >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
}