mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
This commit switches the default signature algorithm to ecdsa-p256-sha256 instead of Ed25519. This allows us to make sodium optional again, and we only pull it in to use it for verifying incomming signatures. If sodium is not installed, we throw on Ed25519 signatures instead. At least it is easy for most people to make their Nextcloud install fully RFC compliant by installing sodium. I also renamed all the Ed25519 function names to be more precis, using Jwks for the JSON Web Keys, and RFC9421 for the http-signature code, where it is needed to distinguish from draft-cavage signatures. Signed-off-by: Micke Nordin <kano@sunet.se>
223 lines
7.1 KiB
PHP
223 lines
7.1 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 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) 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;
|
|
}
|
|
}
|