2026-05-05 10:29:24 -04:00
|
|
|
<?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.
|
|
|
|
|
*
|
2026-05-11 10:13:13 -04:00
|
|
|
* 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)
|
2026-05-05 10:29:24 -04:00
|
|
|
* 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.
|
|
|
|
|
*
|
2026-05-11 10:13:13 -04:00
|
|
|
* 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.
|
|
|
|
|
*
|
2026-05-05 10:29:24 -04:00
|
|
|
* 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 NATIVE = [
|
|
|
|
|
'rsa-v1_5-sha256',
|
2026-05-11 06:33:35 -04:00
|
|
|
'rsa-v1_5-sha384',
|
|
|
|
|
'rsa-v1_5-sha512',
|
2026-05-05 10:29:24 -04:00
|
|
|
'ecdsa-p256-sha256',
|
|
|
|
|
'ecdsa-p384-sha384',
|
|
|
|
|
'ed25519',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-11 10:13:13 -04:00
|
|
|
* $privateKey is a PEM private key. Returns raw signature bytes (R||S for
|
|
|
|
|
* ECDSA). Ed25519 is verify-only and is rejected here.
|
2026-05-05 10:29:24 -04:00
|
|
|
*
|
|
|
|
|
* @throws SignatureException
|
|
|
|
|
*/
|
|
|
|
|
public static function sign(string $signatureBase, string $privateKey, string $algorithm): string {
|
|
|
|
|
$normalized = self::normalize($algorithm);
|
|
|
|
|
|
|
|
|
|
if ($normalized === 'ed25519') {
|
2026-05-11 10:13:13 -04:00
|
|
|
throw new SignatureException('Ed25519 signing is not supported; use ECDSA P-256 or RSA');
|
2026-05-05 10:29:24 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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') {
|
2026-05-11 10:13:13 -04:00
|
|
|
if (!function_exists('sodium_crypto_sign_verify_detached')) {
|
|
|
|
|
throw new SignatureException('verifying Ed25519 signatures requires ext-sodium');
|
|
|
|
|
}
|
2026-05-05 10:29:24 -04:00
|
|
|
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) 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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Default JOSE alg for {@see \Firebase\JWT\JWK::parseKey} when the JWK has
|
|
|
|
|
* no `alg` (RFC 7517 leaves it optional). 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;
|
|
|
|
|
}
|
|
|
|
|
}
|