diff --git a/apps/settings/lib/SetupChecks/PhpModules.php b/apps/settings/lib/SetupChecks/PhpModules.php index d4c3d2c5c2a..4ee4d9165cc 100644 --- a/apps/settings/lib/SetupChecks/PhpModules.php +++ b/apps/settings/lib/SetupChecks/PhpModules.php @@ -24,7 +24,6 @@ class PhpModules implements ISetupCheck { 'openssl', 'posix', 'session', - 'sodium', 'xml', 'xmlreader', 'xmlwriter', @@ -36,6 +35,7 @@ class PhpModules implements ISetupCheck { 'exif', 'gmp', 'intl', + 'sodium', 'sysvsem', ]; @@ -58,6 +58,7 @@ class PhpModules implements ISetupCheck { protected function getRecommendedModuleDescription(string $module): string { return match($module) { 'intl' => $this->l10n->t('increases language translation performance and fixes sorting of non-ASCII characters'), + 'sodium' => $this->l10n->t('for Argon2 for password hashing and Ed25519 signature verification for RFC 9421 http message signatures'), 'gmp' => $this->l10n->t('required for SFTP storage and recommended for WebAuthn performance'), 'exif' => $this->l10n->t('for picture rotation in server and metadata extraction in the Photos app'), default => '', diff --git a/composer.json b/composer.json index 849c052ab1f..03881a15fc0 100644 --- a/composer.json +++ b/composer.json @@ -41,13 +41,15 @@ "ext-posix": "*", "ext-session": "*", "ext-simplexml": "*", - "ext-sodium": "*", "ext-xml": "*", "ext-xmlreader": "*", "ext-xmlwriter": "*", "ext-zip": "*", "ext-zlib": "*" }, + "suggest": { + "ext-sodium": "Argon2 password hashing and Ed25519 signature verification for RFC 9421 HTTP message signatures." + }, "require-dev": { "bamarni/composer-bin-plugin": "^1.4" }, diff --git a/core/Command/OCM/ActivateKey.php b/core/Command/OCM/ActivateKey.php index 9efc7110a24..582763880a7 100644 --- a/core/Command/OCM/ActivateKey.php +++ b/core/Command/OCM/ActivateKey.php @@ -24,13 +24,13 @@ class ActivateKey extends Base { protected function configure(): void { $this ->setName('ocm:keys:activate') - ->setDescription('promote the staged Ed25519 key to active; the previous active key moves to retiring'); + ->setDescription('promote the staged JWKS key to active; the previous active key moves to retiring'); } #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { try { - $this->signatoryManager->activateStagedEd25519Key(); + $this->signatoryManager->activateStagedJwksKey(); } catch (\RuntimeException $e) { $output->writeln('' . $e->getMessage() . ''); return self::FAILURE; diff --git a/core/Command/OCM/ListKeys.php b/core/Command/OCM/ListKeys.php index 221beca5580..b4eb4715fe9 100644 --- a/core/Command/OCM/ListKeys.php +++ b/core/Command/OCM/ListKeys.php @@ -25,13 +25,13 @@ class ListKeys extends Base { protected function configure(): void { $this ->setName('ocm:keys:list') - ->setDescription('list Ed25519 keys used by OCM RFC 9421 HTTP Message Signatures'); + ->setDescription('list JWKS-published signing keys'); parent::configure(); } #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { - $keys = $this->signatoryManager->listEd25519Keys(); + $keys = $this->signatoryManager->listJwksKeys(); $format = $input->getOption('output'); if ($format === self::OUTPUT_FORMAT_JSON || $format === self::OUTPUT_FORMAT_JSON_PRETTY) { $output->writeln(json_encode($keys, $format === self::OUTPUT_FORMAT_JSON_PRETTY ? JSON_PRETTY_PRINT : 0)); @@ -39,7 +39,7 @@ class ListKeys extends Base { } if ($keys === []) { - $output->writeln('No Ed25519 keys yet; one will be generated on first OCM request.'); + $output->writeln('No JWKS keys yet; one will be generated on first OCM request.'); return self::SUCCESS; } diff --git a/core/Command/OCM/RetireKey.php b/core/Command/OCM/RetireKey.php index 8a3c23d29c1..6c26014e5a1 100644 --- a/core/Command/OCM/RetireKey.php +++ b/core/Command/OCM/RetireKey.php @@ -24,13 +24,13 @@ class RetireKey extends Base { protected function configure(): void { $this ->setName('ocm:keys:retire') - ->setDescription('delete the retiring Ed25519 key; signatures that referenced its kid can no longer be verified'); + ->setDescription('delete the retiring JWKS key; signatures that referenced its kid can no longer be verified'); } #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { try { - $this->signatoryManager->retireEd25519Key(); + $this->signatoryManager->retireJwksKey(); } catch (\RuntimeException $e) { $output->writeln('' . $e->getMessage() . ''); return self::FAILURE; diff --git a/core/Command/OCM/StageKey.php b/core/Command/OCM/StageKey.php index f79d09c876a..69f2167ba43 100644 --- a/core/Command/OCM/StageKey.php +++ b/core/Command/OCM/StageKey.php @@ -24,18 +24,18 @@ class StageKey extends Base { protected function configure(): void { $this ->setName('ocm:keys:stage') - ->setDescription('generate a new Ed25519 key and advertise it via JWKS without using it for signing yet'); + ->setDescription('generate a new JWKS key and advertise it via JWKS without using it for signing yet'); } #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { try { - $signatory = $this->signatoryManager->stageEd25519Key(); + $signatory = $this->signatoryManager->stageJwksKey(); } catch (\RuntimeException $e) { $output->writeln('' . $e->getMessage() . ''); return self::FAILURE; } - $output->writeln('Staged new Ed25519 key: ' . $signatory->getKeyId() . ''); + $output->writeln('Staged new JWKS key: ' . $signatory->getKeyId() . ''); $output->writeln('Wait for federated peers to refresh their JWKS cache before activating.'); return self::SUCCESS; } diff --git a/lib/private/OCM/OCMJwksHandler.php b/lib/private/OCM/OCMJwksHandler.php index 281c3eaab2d..0013b38b1b4 100644 --- a/lib/private/OCM/OCMJwksHandler.php +++ b/lib/private/OCM/OCMJwksHandler.php @@ -18,7 +18,7 @@ use OCP\IAppConfig; use Psr\Log\LoggerInterface; use Throwable; -/** Serves `/.well-known/jwks.json` (RFC 7517) for the RFC 9421 keys. */ +/** Serves `/.well-known/jwks.json` (RFC 7517) with the OCM signing keys. */ class OCMJwksHandler implements IHandler { public function __construct( private readonly IAppConfig $appConfig, @@ -36,11 +36,11 @@ class OCMJwksHandler implements IHandler { $keys = []; if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { try { - foreach ($this->signatoryManager->getLocalEd25519Jwks() as $jwk) { + foreach ($this->signatoryManager->getLocalJwks() as $jwk) { $keys[] = $jwk; } } catch (Throwable $e) { - $this->logger->warning('failed to build local Ed25519 JWKs', ['exception' => $e]); + $this->logger->warning('failed to build local JWKs', ['exception' => $e]); } } diff --git a/lib/private/OCM/OCMSignatoryManager.php b/lib/private/OCM/OCMSignatoryManager.php index d60dc845e4a..1bde75e552b 100644 --- a/lib/private/OCM/OCMSignatoryManager.php +++ b/lib/private/OCM/OCMSignatoryManager.php @@ -50,18 +50,18 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { public const APPCONFIG_SIGN_ENFORCED = 'ocm_signed_request_enforced'; private const APPKEY_CAVAGE = 'ocm_external'; private const KEYID_FRAGMENT_CAVAGE = 'signature'; - private const KEYID_FRAGMENT_ED25519 = 'ed25519'; - /** Ed25519 keypairs live in numbered pool appkeys; slots point to them by id. */ - private const APPKEY_ED25519_POOL_PREFIX = 'ocm_ed25519_pool_'; - private const APPCONFIG_ED25519_POOL_COUNTER = 'ocm_ed25519_pool_counter'; - private const APPCONFIG_ED25519_POOL_KID_PREFIX = 'ocm_ed25519_pool_kid_'; + private const KEYID_FRAGMENT_JWKS = 'ecdsa-p256-sha256'; + /** JWKS-published keypairs live in numbered pool appkeys; slots point to them by id. */ + private const APPKEY_JWKS_POOL_PREFIX = 'ocm_jwks_pool_'; + private const APPCONFIG_JWKS_POOL_COUNTER = 'ocm_jwks_pool_counter'; + private const APPCONFIG_JWKS_POOL_KID_PREFIX = 'ocm_jwks_pool_kid_'; /** Stable kid identity portion, reused across rotations so kids stay on one hostname. */ - private const APPCONFIG_ED25519_KID_BASE = 'ocm_ed25519_kid_base'; + private const APPCONFIG_JWKS_KID_BASE = 'ocm_jwks_kid_base'; public const SLOT_ACTIVE = 'active'; public const SLOT_PENDING = 'pending'; public const SLOT_RETIRING = 'retiring'; /** All slots in advertise order. */ - public const ED25519_SLOTS = [self::SLOT_ACTIVE, self::SLOT_PENDING, self::SLOT_RETIRING]; + public const JWKS_SLOTS = [self::SLOT_ACTIVE, self::SLOT_PENDING, self::SLOT_RETIRING]; /** Remote JWKS cache TTL (seconds). */ private const JWKS_CACHE_TTL = 3600; @@ -142,11 +142,11 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { } - /** Active Ed25519 signing key, lazily provisioned. */ - public function getLocalEd25519Signatory(): ?Signatory { + /** Active JWKS-published signing key (ECDSA P-256), lazily provisioned. */ + public function getLocalJwksSignatory(): ?Signatory { $poolId = $this->getSlotPool(self::SLOT_ACTIVE); if ($poolId === null) { - $poolId = $this->generatePool($this->nextEd25519PoolKid()); + $poolId = $this->generatePool($this->nextPoolKid()); $this->setSlotPool(self::SLOT_ACTIVE, $poolId); } return $this->signatoryFromPool($poolId); @@ -158,61 +158,61 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { * * @return list> */ - public function getLocalEd25519Jwks(): array { + public function getLocalJwks(): array { if ($this->getSlotPool(self::SLOT_ACTIVE) === null) { - $this->getLocalEd25519Signatory(); + $this->getLocalJwksSignatory(); } $jwks = []; - foreach (self::ED25519_SLOTS as $slot) { + foreach (self::JWKS_SLOTS as $slot) { $poolId = $this->getSlotPool($slot); if ($poolId === null) { continue; } $signatory = $this->signatoryFromPool($poolId); if ($signatory !== null) { - $jwks[] = self::buildEd25519JwkArray($signatory->getPublicKey(), $signatory->getKeyId()); + $jwks[] = self::buildEcdsaP256JwkArray($signatory->getPublicKey(), $signatory->getKeyId()); } } return $jwks; } /** - * Generate a pending Ed25519 keypair (advertised in JWKS, not yet used - * for outbound signing). + * Generate a pending keypair (advertised in JWKS, not yet used for + * outbound signing). * * @throws \RuntimeException if pending is already populated */ - public function stageEd25519Key(): Signatory { + public function stageJwksKey(): Signatory { if ($this->getSlotPool(self::SLOT_PENDING) !== null) { - throw new \RuntimeException('a pending Ed25519 key already exists; activate or retire it first'); + throw new \RuntimeException('a pending JWKS key already exists; activate or retire it first'); } // Need an active key first; staging a next from nothing makes no sense. if ($this->getSlotPool(self::SLOT_ACTIVE) === null) { - $this->getLocalEd25519Signatory(); + $this->getLocalJwksSignatory(); } - $poolId = $this->generatePool($this->nextEd25519PoolKid()); + $poolId = $this->generatePool($this->nextPoolKid()); $this->setSlotPool(self::SLOT_PENDING, $poolId); $signatory = $this->signatoryFromPool($poolId); if ($signatory === null) { - throw new \RuntimeException('failed to materialise newly staged Ed25519 key'); + throw new \RuntimeException('failed to materialise newly staged JWKS key'); } return $signatory; } /** * pending -> active, previous active -> retiring. The retiring slot - * stays in JWKS until {@see retireEd25519Key} is run. + * stays in JWKS until {@see retireJwksKey} is run. * * @throws \RuntimeException if no pending key is staged, or retiring is occupied */ - public function activateStagedEd25519Key(): void { + public function activateStagedJwksKey(): void { $pending = $this->getSlotPool(self::SLOT_PENDING); if ($pending === null) { - throw new \RuntimeException('no pending Ed25519 key to activate; run `ocm:keys:stage` first'); + throw new \RuntimeException('no pending JWKS key to activate; run `ocm:keys:stage` first'); } if ($this->getSlotPool(self::SLOT_RETIRING) !== null) { - throw new \RuntimeException('a retiring Ed25519 key still exists; retire it before activating a new one'); + throw new \RuntimeException('a retiring JWKS key still exists; retire it before activating a new one'); } $active = $this->getSlotPool(self::SLOT_ACTIVE); @@ -229,13 +229,13 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { * * @throws \RuntimeException if retiring is empty */ - public function retireEd25519Key(): void { + public function retireJwksKey(): void { $poolId = $this->getSlotPool(self::SLOT_RETIRING); if ($poolId === null) { - throw new \RuntimeException('no retiring Ed25519 key to remove'); + throw new \RuntimeException('no retiring JWKS key to remove'); } - $this->identityProofManager->deleteAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId); - $this->appConfig->deleteKey('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId); + $this->identityProofManager->deleteAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId); + $this->appConfig->deleteKey('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId); $this->clearSlot(self::SLOT_RETIRING); } @@ -244,25 +244,25 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { * * @return list */ - public function listEd25519Keys(): array { + public function listJwksKeys(): array { $bySlot = []; - foreach (self::ED25519_SLOTS as $slot) { + foreach (self::JWKS_SLOTS as $slot) { $id = $this->getSlotPool($slot); if ($id !== null) { $bySlot[$id] = $slot; } } - $max = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0); + $max = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0); $entries = []; for ($id = 1; $id <= $max; $id++) { - if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $id)) { + if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $id)) { continue; } $entries[] = [ 'poolId' => $id, 'kid' => $this->canonicalKid( - $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $id, ''), + $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $id, ''), ), 'slot' => $bySlot[$id] ?? null, ]; @@ -275,11 +275,11 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { * {@see Signatory::setKeyId} so admin output and wire form agree. */ private function generatePool(string $kid): int { - $poolId = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0) + 1; - $this->appConfig->setValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, $poolId); + $poolId = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1; + $this->appConfig->setValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, $poolId); - $this->identityProofManager->generateEd25519AppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId); - $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid)); + $this->identityProofManager->generateEcdsaP256AppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId); + $this->appConfig->setValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid)); return $poolId; } @@ -296,22 +296,22 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { * * @throws \RuntimeException if no instance identity can be derived */ - private function nextEd25519PoolKid(): string { - $base = $this->resolveEd25519KidBase(); - $next = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0) + 1; + private function nextPoolKid(): string { + $base = $this->resolveKidBase(); + $next = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1; return $base . '-' . $next; } /** * Stable identity portion (before the `-N` suffix). Resolution order: - * stored APPCONFIG_ED25519_KID_BASE > active pool's kid sans suffix > + * stored APPCONFIG_JWKS_KID_BASE > active pool's kid sans suffix > * fresh from {@see buildLocalKeyId}. Persisted so CLI rotations stay * on one hostname. * * @throws \RuntimeException if no instance identity can be derived */ - private function resolveEd25519KidBase(): string { - $base = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_KID_BASE, ''); + private function resolveKidBase(): string { + $base = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_KID_BASE, ''); if ($base !== '') { return $base; } @@ -319,7 +319,7 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { $activePool = $this->getSlotPool(self::SLOT_ACTIVE); if ($activePool !== null) { $kid = $this->canonicalKid( - $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $activePool, ''), + $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $activePool, ''), ); $pos = strrpos($kid, '-'); if ($pos !== false) { @@ -329,18 +329,18 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { if ($base === '') { try { - $base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_ED25519)); + $base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_JWKS)); } catch (IdentityNotFoundException $e) { - throw new \RuntimeException('cannot derive instance identity for Ed25519 kid', 0, $e); + throw new \RuntimeException('cannot derive instance identity for JWKS kid', 0, $e); } } - $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_KID_BASE, $base); + $this->appConfig->setValueString('core', self::APPCONFIG_JWKS_KID_BASE, $base); return $base; } private function getSlotPool(string $slot): ?int { - $key = 'ocm_ed25519_slot_' . $slot; + $key = 'ocm_jwks_slot_' . $slot; if (!$this->appConfig->hasKey('core', $key)) { return null; } @@ -349,20 +349,20 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { } private function setSlotPool(string $slot, int $poolId): void { - $this->appConfig->setValueInt('core', 'ocm_ed25519_slot_' . $slot, $poolId); + $this->appConfig->setValueInt('core', 'ocm_jwks_slot_' . $slot, $poolId); } private function clearSlot(string $slot): void { - $this->appConfig->deleteKey('core', 'ocm_ed25519_slot_' . $slot); + $this->appConfig->deleteKey('core', 'ocm_jwks_slot_' . $slot); } /** Returns null if the underlying appkey was manually deleted. */ private function signatoryFromPool(int $poolId): ?Signatory { - $appKey = self::APPKEY_ED25519_POOL_PREFIX . $poolId; + $appKey = self::APPKEY_JWKS_POOL_PREFIX . $poolId; if (!$this->identityProofManager->hasAppKey('core', $appKey)) { return null; } - $kid = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, ''); + $kid = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, ''); if ($kid === '') { return null; } @@ -375,7 +375,7 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { } /** - * @param string $fragment URL fragment (e.g. 'signature', 'ed25519') + * @param string $fragment URL fragment (e.g. 'signature' for cavage, 'ecdsa-p256-sha256' for the JWKS-published key) * @return string * @throws IdentityNotFoundException */ @@ -531,16 +531,27 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager { } /** + * Build an EC P-256 JWK (RFC 7518 §6.2) from a PEM public key. The raw x/y + * coordinates from openssl are zero-padded to 32 bytes per RFC 7518 §6.2.1.2. + * * @return array */ - private static function buildEd25519JwkArray(string $rawPublicKey, string $kid): array { + private static function buildEcdsaP256JwkArray(string $publicKeyPem, string $kid): array { + $details = openssl_pkey_get_details(openssl_pkey_get_public($publicKeyPem) ?: throw new \RuntimeException('invalid EC public key')); + if ($details === false || !isset($details['ec']['x'], $details['ec']['y'])) { + throw new \RuntimeException('invalid EC public key'); + } + $x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT); + $y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT); + return [ - 'kty' => 'OKP', - 'crv' => 'Ed25519', + 'kty' => 'EC', + 'crv' => 'P-256', 'kid' => $kid, - 'alg' => 'EdDSA', + 'alg' => 'ES256', 'use' => 'sig', - 'x' => JWT::urlsafeB64Encode($rawPublicKey), + 'x' => JWT::urlsafeB64Encode($x), + 'y' => JWT::urlsafeB64Encode($y), ]; } } diff --git a/lib/private/OCM/Rfc9421SignatoryManager.php b/lib/private/OCM/Rfc9421SignatoryManager.php index f0756d9ca6e..f03c01eebeb 100644 --- a/lib/private/OCM/Rfc9421SignatoryManager.php +++ b/lib/private/OCM/Rfc9421SignatoryManager.php @@ -16,8 +16,8 @@ use OCP\Security\Signature\Model\Signatory; /** * Per-call wrapper around {@see OCMSignatoryManager} that swaps in the - * Ed25519 signatory and sets `rfc9421.format`. Wrapping (vs mutating) keeps - * the underlying DI-managed instance stateless across requests. + * JWKS-published signatory and sets `rfc9421.format`. Wrapping (vs mutating) + * keeps the underlying DI-managed instance stateless across requests. */ final class Rfc9421SignatoryManager implements IJwkResolvingSignatoryManager { public function __construct( @@ -37,9 +37,9 @@ final class Rfc9421SignatoryManager implements IJwkResolvingSignatoryManager { #[\Override] public function getLocalSignatory(): Signatory { - $signatory = $this->delegate->getLocalEd25519Signatory(); + $signatory = $this->delegate->getLocalJwksSignatory(); if ($signatory === null) { - throw new IdentityNotFoundException('no Ed25519 signatory available'); + throw new IdentityNotFoundException('no JWKS-published signatory available'); } return $signatory; } diff --git a/lib/private/Security/IdentityProof/Manager.php b/lib/private/Security/IdentityProof/Manager.php index d6ebe3813b2..298c1d459b3 100644 --- a/lib/private/Security/IdentityProof/Manager.php +++ b/lib/private/Security/IdentityProof/Manager.php @@ -179,14 +179,31 @@ class Manager { } /** - * Generate an Ed25519 keypair via libsodium. Returns raw 32-byte public - * + 64-byte secret (sodium seed||publickey), no PEM. Overwrites if - * already present. + * Generate an ECDSA P-256 (prime256v1, SECG/JOSE ES256 curve) keypair via + * openssl. Returns PEM private + PEM public. Overwrites if already + * present. Private key is encrypted on disk. + * + * @throws \RuntimeException */ - public function generateEd25519AppKey(string $app, string $name): Key { - $keyPair = sodium_crypto_sign_keypair(); - $publicKey = sodium_crypto_sign_publickey($keyPair); - $privateKey = sodium_crypto_sign_secretkey($keyPair); + public function generateEcdsaP256AppKey(string $app, string $name): Key { + $res = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + if ($res === false) { + $this->logOpensslError(); + throw new \RuntimeException('OpenSSL reported a problem'); + } + if (openssl_pkey_export($res, $privateKey) === false) { + $this->logOpensslError(); + throw new \RuntimeException('OpenSSL reported a problem'); + } + $details = openssl_pkey_get_details($res); + if ($details === false || !isset($details['key'])) { + $this->logOpensslError(); + throw new \RuntimeException('OpenSSL reported a problem'); + } + $publicKey = $details['key']; $id = $this->generateAppKeyId($app, $name); try { diff --git a/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php index 3a44776ef4a..d2fa2a4ae86 100644 --- a/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php +++ b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php @@ -23,8 +23,9 @@ use OCP\Security\Signature\ISignatoryManager; /** * RFC 9421 implementation of {@see IOutgoingSignedRequest}, sibling to the - * draft-cavage {@see OutgoingSignedRequest}. Default Ed25519 with the `alg` - * parameter omitted (RFC 9421 §3.3.7); verifier resolves it from the JWK. + * draft-cavage {@see OutgoingSignedRequest}. Default ECDSA P-256 (`ES256`) + * with the `alg` parameter omitted (RFC 9421 §3.3.7); verifier resolves it + * from the JWK. * * Options from {@see ISignatoryManager::getOptions()}: `rfc9421.signingAlgorithm`, * `rfc9421.coveredComponents`, `rfc9421.contentDigestAlgorithm`, @@ -60,7 +61,7 @@ class Rfc9421OutgoingSignedRequest extends SignedRequest implements ->setSignatory($signatoryManager->getLocalSignatory()) ->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256); - $this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ed25519'); + $this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ecdsa-p256-sha256'); $contentDigestAlgorithm = (string)($options['rfc9421.contentDigestAlgorithm'] ?? ContentDigest::ALGO_SHA256); /** @var list $components */ $components = $options['rfc9421.coveredComponents'] ?? self::DEFAULT_COMPONENTS; @@ -138,7 +139,7 @@ class Rfc9421OutgoingSignedRequest extends SignedRequest implements return $this->algorithm; } - /** RFC 9421 alg name (e.g. `ed25519`). Distinct from cavage's {@see getAlgorithm()}. */ + /** RFC 9421 alg name (e.g. `ecdsa-p256-sha256`). Distinct from cavage's {@see getAlgorithm()}. */ public function getSigningAlgorithm(): string { return $this->signingAlgorithm; } diff --git a/lib/private/Security/Signature/Rfc9421/Algorithm.php b/lib/private/Security/Signature/Rfc9421/Algorithm.php index 40bec3cf153..4fd7569a1ff 100644 --- a/lib/private/Security/Signature/Rfc9421/Algorithm.php +++ b/lib/private/Security/Signature/Rfc9421/Algorithm.php @@ -18,11 +18,16 @@ use Throwable; /** * RFC 9421 §3.3 sign/verify primitives. * - * Asymmetric algorithms only: RSA-PKCS1-v1_5 (SHA-256/384/512), ECDSA P-256 - * SHA-256, ECDSA P-384 SHA-384, Ed25519. JOSE aliases (RFC 7518 / RFC 8037) + * 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 @@ -39,12 +44,8 @@ final class Algorithm { ]; /** - * For Ed25519 $privateKey is the raw 64-byte sodium secret key; otherwise - * a PEM private key. Returns raw signature bytes (R||S for ECDSA). - * - * Ed25519 calls sodium directly: JWT::sign runs the key through - * `validateEdDSAKey` which base64url-decodes it first, which mangles raw - * sodium bytes. + * $privateKey is a PEM private key. Returns raw signature bytes (R||S for + * ECDSA). Ed25519 is verify-only and is rejected here. * * @throws SignatureException */ @@ -52,10 +53,7 @@ final class Algorithm { $normalized = self::normalize($algorithm); if ($normalized === 'ed25519') { - if (strlen($privateKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { - throw new SignatureException('Ed25519 secret key must be ' . SODIUM_CRYPTO_SIGN_SECRETKEYBYTES . ' bytes'); - } - return sodium_crypto_sign_detached($signatureBase, $privateKey); + throw new SignatureException('Ed25519 signing is not supported; use ECDSA P-256 or RSA'); } try { @@ -85,6 +83,9 @@ final class Algorithm { $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; } @@ -154,7 +155,6 @@ final class Algorithm { private static function nativeToJose(string $native): string { return match ($native) { - 'ed25519' => 'EdDSA', 'ecdsa-p256-sha256' => 'ES256', 'ecdsa-p384-sha384' => 'ES384', 'rsa-v1_5-sha256' => 'RS256', diff --git a/tests/lib/OCM/OCMJwksHandlerTest.php b/tests/lib/OCM/OCMJwksHandlerTest.php index 7040b19f675..f7270298dee 100644 --- a/tests/lib/OCM/OCMJwksHandlerTest.php +++ b/tests/lib/OCM/OCMJwksHandlerTest.php @@ -54,23 +54,24 @@ class OCMJwksHandlerTest extends TestCase { $this->appConfig->method('getValueBool') ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, false, true) ->willReturn(true); - $this->signatoryManager->expects($this->never())->method('getLocalEd25519Jwks'); + $this->signatoryManager->expects($this->never())->method('getLocalJwks'); $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); $this->assertSame(['keys' => []], $body); } - public function testPublishesEd25519JwksWhenAvailable(): void { + public function testPublishesJwksWhenAvailable(): void { $this->appConfig->method('getValueBool')->willReturn(false); $jwk = [ - 'kty' => 'OKP', - 'crv' => 'Ed25519', - 'kid' => 'https://example.org/ocm#ed25519', - 'alg' => 'EdDSA', + 'kty' => 'EC', + 'crv' => 'P-256', + 'kid' => 'https://example.org/ocm#ecdsa-p256-sha256', + 'alg' => 'ES256', 'use' => 'sig', 'x' => 'AAAA', + 'y' => 'BBBB', ]; - $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$jwk]); + $this->signatoryManager->method('getLocalJwks')->willReturn([$jwk]); $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); $this->assertSame(['keys' => [$jwk]], $body); @@ -79,12 +80,12 @@ class OCMJwksHandlerTest extends TestCase { public function testPublishesAllSlotsAdvertisedDuringRotation(): void { $this->appConfig->method('getValueBool')->willReturn(false); $active = [ - 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-1', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'AAAA', + 'kty' => 'EC', 'crv' => 'P-256', 'kid' => 'kid-1', 'alg' => 'ES256', 'use' => 'sig', 'x' => 'AAAA', 'y' => 'BBBB', ]; $pending = [ - 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-2', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'BBBB', + 'kty' => 'EC', 'crv' => 'P-256', 'kid' => 'kid-2', 'alg' => 'ES256', 'use' => 'sig', 'x' => 'CCCC', 'y' => 'DDDD', ]; - $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$active, $pending]); + $this->signatoryManager->method('getLocalJwks')->willReturn([$active, $pending]); $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); $this->assertSame(['keys' => [$active, $pending]], $body); @@ -92,7 +93,7 @@ class OCMJwksHandlerTest extends TestCase { public function testEmptyKeySetWhenSignatoryUnavailable(): void { $this->appConfig->method('getValueBool')->willReturn(false); - $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([]); + $this->signatoryManager->method('getLocalJwks')->willReturn([]); $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); $this->assertSame(['keys' => []], $body); @@ -100,7 +101,7 @@ class OCMJwksHandlerTest extends TestCase { public function testFailingJwkBuildIsLoggedAndYieldsEmptyKeySet(): void { $this->appConfig->method('getValueBool')->willReturn(false); - $this->signatoryManager->method('getLocalEd25519Jwks') + $this->signatoryManager->method('getLocalJwks') ->willThrowException(new \RuntimeException('boom')); $this->logger->expects($this->once())->method('warning'); diff --git a/tests/lib/OCM/OCMSignatoryManagerJwksTest.php b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php index 7fcc0818e31..b135efb8a8f 100644 --- a/tests/lib/OCM/OCMSignatoryManagerJwksTest.php +++ b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php @@ -24,6 +24,10 @@ use Psr\Log\LoggerInterface; use Test\TestCase; class OCMSignatoryManagerJwksTest extends TestCase { + /** RFC 7517 §A.1 test vector for an EC P-256 public key. */ + private const TEST_X = 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU'; + private const TEST_Y = 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0'; + private IAppConfig&MockObject $appConfig; private ISignatureManager&MockObject $signatureManager; private IURLGenerator&MockObject $urlGenerator; @@ -68,21 +72,19 @@ class OCMSignatoryManagerJwksTest extends TestCase { $kid = 'sender.example.org#key1'; $jwks = [ 'keys' => [ - ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'other', 'x' => 'AAAA'], - ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'BBBB'], + $this->ecJwk('other'), + $this->ecJwk($kid), ], ]; $this->respondWith($jwks); $key = $this->signatoryManager->getRemoteKey('sender.example.org', $kid); $this->assertNotNull($key); - $this->assertSame('EdDSA', $key->getAlgorithm()); - // Key stores OKP material as plain base64 of the raw bytes. - $this->assertSame('BBBB', $key->getKeyMaterial()); + $this->assertSame('ES256', $key->getAlgorithm()); } public function testGetRemoteKeyReturnsNullWhenKidMissing(): void { - $this->respondWith(['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'unrelated', 'x' => 'AAAA']]]); + $this->respondWith(['keys' => [$this->ecJwk('unrelated')]]); $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'other-kid')); } @@ -106,8 +108,8 @@ class OCMSignatoryManagerJwksTest extends TestCase { } public function testGetRemoteKeyReturnsNullOnUnparseableJwk(): void { - // JWK with kty=OKP but no crv: parseKey rejects. - $this->respondWith(['keys' => [['kty' => 'OKP', 'kid' => 'kid', 'x' => 'AAAA']]]); + // JWK with kty=EC but no crv: parseKey rejects. + $this->respondWith(['keys' => [['kty' => 'EC', 'kid' => 'kid', 'x' => self::TEST_X, 'y' => self::TEST_Y]]]); $this->logger->expects($this->once())->method('warning'); $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); } @@ -142,7 +144,7 @@ class OCMSignatoryManagerJwksTest extends TestCase { public function testJwksCachedAcrossCallsToTheSameOrigin(): void { $kid = 'sender.example.org#key1'; - $jwks = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'AAAA']]]; + $jwks = ['keys' => [$this->ecJwk($kid)]]; $this->client->expects($this->once()) ->method('get') ->willReturn($this->jsonResponse($jwks)); @@ -152,8 +154,8 @@ class OCMSignatoryManagerJwksTest extends TestCase { } public function testCacheMissOnNewKidTriggersRefetchOnce(): void { - $first = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'old', 'x' => 'AAAA']]]; - $second = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'new', 'x' => 'BBBB']]]; + $first = ['keys' => [$this->ecJwk('old')]]; + $second = ['keys' => [$this->ecJwk('new')]]; $this->client->expects($this->exactly(2)) ->method('get') ->willReturnOnConsecutiveCalls( @@ -174,4 +176,17 @@ class OCMSignatoryManagerJwksTest extends TestCase { $response->method('getBody')->willReturn(json_encode($body, JSON_THROW_ON_ERROR)); return $response; } + + /** @return array */ + private function ecJwk(string $kid): array { + return [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'kid' => $kid, + 'alg' => 'ES256', + 'use' => 'sig', + 'x' => self::TEST_X, + 'y' => self::TEST_Y, + ]; + } } diff --git a/tests/lib/OCM/OCMSignatoryManagerRotationTest.php b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php index 9b52d88c61f..24d25f4476a 100644 --- a/tests/lib/OCM/OCMSignatoryManagerRotationTest.php +++ b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php @@ -24,7 +24,7 @@ use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Test\TestCase; -/** Ed25519 stage / activate / retire lifecycle, with stateful IAppConfig + IdentityProofManager fakes. */ +/** JWKS stage / activate / retire lifecycle, with stateful IAppConfig + IdentityProofManager fakes. */ class OCMSignatoryManagerRotationTest extends TestCase { private IAppConfig&MockObject $appConfig; private IdentityProofManager&MockObject $identityProofManager; @@ -66,118 +66,118 @@ class OCMSignatoryManagerRotationTest extends TestCase { public function testJwksBootstrapsActiveKeyOnFirstFetch(): void { // Fresh instance: first JWKS hit must provision the active key. - $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $jwks = $this->signatoryManager->getLocalJwks(); $this->assertCount(1, $jwks); - $this->assertSame('https://alice.example/ocm#ed25519-1', $jwks[0]['kid']); + $this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $jwks[0]['kid']); // And the bootstrapped key is the active one for outbound signing. - $signatory = $this->signatoryManager->getLocalEd25519Signatory(); + $signatory = $this->signatoryManager->getLocalJwksSignatory(); $this->assertSame($jwks[0]['kid'], $signatory->getKeyId()); } public function testFirstCallProvisionsActiveKey(): void { - $signatory = $this->signatoryManager->getLocalEd25519Signatory(); + $signatory = $this->signatoryManager->getLocalJwksSignatory(); $this->assertNotNull($signatory); - $this->assertSame('https://alice.example/ocm#ed25519-1', $signatory->getKeyId()); + $this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $signatory->getKeyId()); - $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $jwks = $this->signatoryManager->getLocalJwks(); $this->assertCount(1, $jwks); $this->assertSame($signatory->getKeyId(), $jwks[0]['kid']); - $listed = $this->signatoryManager->listEd25519Keys(); + $listed = $this->signatoryManager->listJwksKeys(); $this->assertSame([['poolId' => 1, 'kid' => $signatory->getKeyId(), 'slot' => 'active']], $listed); } public function testStageDoesNotChangeActiveSignerButPublishesNewJwk(): void { - $initial = $this->signatoryManager->getLocalEd25519Signatory(); - $staged = $this->signatoryManager->stageEd25519Key(); + $initial = $this->signatoryManager->getLocalJwksSignatory(); + $staged = $this->signatoryManager->stageJwksKey(); $this->assertNotSame($initial->getKeyId(), $staged->getKeyId()); // Active signer is unchanged. - $this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId()); + $this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId()); // JWKS now advertises both kids, active first then pending. - $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $jwks = $this->signatoryManager->getLocalJwks(); $this->assertSame([$initial->getKeyId(), $staged->getKeyId()], array_column($jwks, 'kid')); } public function testStageRefusesIfPendingAlreadyExists(): void { - $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->stageJwksKey(); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/pending Ed25519 key already exists/'); - $this->signatoryManager->stageEd25519Key(); + $this->expectExceptionMessageMatches('/pending JWKS key already exists/'); + $this->signatoryManager->stageJwksKey(); } public function testActivatePromotesPendingAndDemotesActive(): void { - $first = $this->signatoryManager->getLocalEd25519Signatory(); - $staged = $this->signatoryManager->stageEd25519Key(); - $this->signatoryManager->activateStagedEd25519Key(); + $first = $this->signatoryManager->getLocalJwksSignatory(); + $staged = $this->signatoryManager->stageJwksKey(); + $this->signatoryManager->activateStagedJwksKey(); // New signer is the formerly-staged key. - $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId()); + $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId()); // JWKS still advertises the former active key as retiring so peers // verifying in-flight signatures with its kid don't fail. - $kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid'); + $kids = array_column($this->signatoryManager->getLocalJwks(), 'kid'); $this->assertContains($first->getKeyId(), $kids); $this->assertContains($staged->getKeyId(), $kids); } public function testActivateRefusesIfRetiringStillPopulated(): void { - $this->signatoryManager->getLocalEd25519Signatory(); - $this->signatoryManager->stageEd25519Key(); - $this->signatoryManager->activateStagedEd25519Key(); + $this->signatoryManager->getLocalJwksSignatory(); + $this->signatoryManager->stageJwksKey(); + $this->signatoryManager->activateStagedJwksKey(); // Retiring slot is now populated; staging again is allowed but // activating must refuse until the admin explicitly retires the old // key. - $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->stageJwksKey(); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/retiring Ed25519 key still exists/'); - $this->signatoryManager->activateStagedEd25519Key(); + $this->expectExceptionMessageMatches('/retiring JWKS key still exists/'); + $this->signatoryManager->activateStagedJwksKey(); } public function testActivateRefusesWithoutPendingKey(): void { - $this->signatoryManager->getLocalEd25519Signatory(); + $this->signatoryManager->getLocalJwksSignatory(); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/no pending Ed25519 key/'); - $this->signatoryManager->activateStagedEd25519Key(); + $this->expectExceptionMessageMatches('/no pending JWKS key/'); + $this->signatoryManager->activateStagedJwksKey(); } public function testRetireRemovesRetiringKeyFromJwks(): void { - $first = $this->signatoryManager->getLocalEd25519Signatory(); - $staged = $this->signatoryManager->stageEd25519Key(); - $this->signatoryManager->activateStagedEd25519Key(); - $this->signatoryManager->retireEd25519Key(); + $first = $this->signatoryManager->getLocalJwksSignatory(); + $staged = $this->signatoryManager->stageJwksKey(); + $this->signatoryManager->activateStagedJwksKey(); + $this->signatoryManager->retireJwksKey(); - $kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid'); + $kids = array_column($this->signatoryManager->getLocalJwks(), 'kid'); $this->assertSame([$staged->getKeyId()], $kids); - // listEd25519Keys also drops the retired pool. - $listed = $this->signatoryManager->listEd25519Keys(); + // listJwksKeys also drops the retired pool. + $listed = $this->signatoryManager->listJwksKeys(); $this->assertCount(1, $listed); $this->assertSame($staged->getKeyId(), $listed[0]['kid']); $this->assertNotContains($first->getKeyId(), array_column($listed, 'kid')); } public function testRetireRefusesWhenNothingToRetire(): void { - $this->signatoryManager->getLocalEd25519Signatory(); + $this->signatoryManager->getLocalJwksSignatory(); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/no retiring Ed25519 key/'); - $this->signatoryManager->retireEd25519Key(); + $this->expectExceptionMessageMatches('/no retiring JWKS key/'); + $this->signatoryManager->retireJwksKey(); } public function testKidStaysStableThroughLifecycle(): void { - $first = $this->signatoryManager->getLocalEd25519Signatory(); - $staged = $this->signatoryManager->stageEd25519Key(); + $first = $this->signatoryManager->getLocalJwksSignatory(); + $staged = $this->signatoryManager->stageJwksKey(); // kid for the staged key must stay the same once it is activated; // peers that cached it during the stage window must still resolve it. - $this->signatoryManager->activateStagedEd25519Key(); - $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId()); + $this->signatoryManager->activateStagedJwksKey(); + $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId()); - $this->signatoryManager->retireEd25519Key(); - $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->retireJwksKey(); + $this->signatoryManager->stageJwksKey(); // And every newly minted kid must differ from prior ones, no pool // counter rewinding. - $kids = array_column($this->signatoryManager->listEd25519Keys(), 'kid'); + $kids = array_column($this->signatoryManager->listJwksKeys(), 'kid'); $this->assertNotContains($first->getKeyId(), $kids); $this->assertSame($kids, array_unique($kids)); } @@ -208,7 +208,7 @@ class OCMSignatoryManagerRotationTest extends TestCase { ); $this->expectException(\RuntimeException::class); - $manager->getLocalEd25519Signatory(); + $manager->getLocalJwksSignatory(); } private function wireAppConfig(): void { @@ -245,10 +245,15 @@ class OCMSignatoryManagerRotationTest extends TestCase { $this->identityProofManager->method('hasAppKey')->willReturnCallback( fn (string $app, string $name): bool => isset($this->appKeyStore[$app . '/' . $name]) ); - $this->identityProofManager->method('generateEd25519AppKey')->willReturnCallback( + $this->identityProofManager->method('generateEcdsaP256AppKey')->willReturnCallback( function (string $app, string $name): Key { - $keyPair = sodium_crypto_sign_keypair(); - $key = new Key(sodium_crypto_sign_publickey($keyPair), sodium_crypto_sign_secretkey($keyPair)); + $res = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + openssl_pkey_export($res, $privatePem); + $publicPem = openssl_pkey_get_details($res)['key']; + $key = new Key($publicPem, $privatePem); $this->appKeyStore[$app . '/' . $name] = $key; return $key; } diff --git a/tests/lib/OCM/Rfc9421SignatoryManagerTest.php b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php index f186986cf81..4bdda737967 100644 --- a/tests/lib/OCM/Rfc9421SignatoryManagerTest.php +++ b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php @@ -39,15 +39,15 @@ class Rfc9421SignatoryManagerTest extends TestCase { $this->assertSame('rsa-sha512', $options['algorithm']); } - public function testGetLocalSignatoryReturnsEd25519Key(): void { + public function testGetLocalSignatoryReturnsJwksKey(): void { $signatory = $this->createMock(Signatory::class); - $this->delegate->method('getLocalEd25519Signatory')->willReturn($signatory); + $this->delegate->method('getLocalJwksSignatory')->willReturn($signatory); $this->assertSame($signatory, $this->wrapper->getLocalSignatory()); } - public function testGetLocalSignatoryThrowsWhenEd25519Unavailable(): void { - $this->delegate->method('getLocalEd25519Signatory')->willReturn(null); + public function testGetLocalSignatoryThrowsWhenJwksKeyUnavailable(): void { + $this->delegate->method('getLocalJwksSignatory')->willReturn(null); $this->expectException(IdentityNotFoundException::class); $this->wrapper->getLocalSignatory(); diff --git a/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php index 5f4285f14cc..e7d42460987 100644 --- a/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php +++ b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php @@ -23,8 +23,8 @@ use OCP\Security\Signature\Model\Signatory; use Test\TestCase; class Rfc9421RoundTripTest extends TestCase { - public function testEd25519RoundTripVerifies(): void { - [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + public function testEcdsaP256RoundTripVerifies(): void { + [$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $body = '{"hello":"world"}'; @@ -43,8 +43,30 @@ class Rfc9421RoundTripTest extends TestCase { $this->addToAssertionCount(1); } + public function testEd25519VerifyAcceptedWhenSodiumLoaded(): void { + $this->skipUnlessSodium(); + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); + $signatoryManager = $this->makeSignatoryManagerWithSigningAlgorithm($signatory, 'ed25519'); + + $body = '{"hello":"world"}'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + // Ed25519 sign() throws via Algorithm::sign; produce the signature directly. + $rawSig = sodium_crypto_sign_detached($out->getSignatureBaseString(), $signatory->getPrivateKey()); + $out->setSignature(base64_encode($rawSig)); + $headers = $out->getHeaders(); + $paramsLine = '("@method" "@target-uri" "content-digest" "content-length" "date");created=' . time() . ';keyid="' . $signatory->getKeyId() . '"'; + $headers['Signature-Input'] = 'ocm=' . $paramsLine; + $headers['Signature'] = 'ocm=:' . base64_encode($rawSig) . ':'; + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $in = new Rfc9421IncomingSignedRequest($body, $req); + $in->setKey($jwk); + $in->verify(); + $this->addToAssertionCount(1); + } + public function testTamperedBodyRejected(): void { - [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $body = 'original'; @@ -57,7 +79,7 @@ class Rfc9421RoundTripTest extends TestCase { } public function testTamperedSignatureRejected(): void { - [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $body = 'msg'; @@ -77,7 +99,7 @@ class Rfc9421RoundTripTest extends TestCase { } public function testOutgoingUsesOcmLabel(): void { - [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); @@ -89,7 +111,7 @@ class Rfc9421RoundTripTest extends TestCase { } public function testRequestWithoutOcmLabelRejected(): void { - [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); @@ -109,7 +131,7 @@ class Rfc9421RoundTripTest extends TestCase { // RFC 8941 §4.2 last-wins on duplicate dictionary keys, but OCM // mandates that duplicate `ocm` entries cause the request to be // rejected outright. The model layer enforces that. - [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); @@ -125,7 +147,7 @@ class Rfc9421RoundTripTest extends TestCase { } public function testForeignSiblingLabelIgnored(): void { - [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); @@ -147,7 +169,7 @@ class Rfc9421RoundTripTest extends TestCase { } public function testTooOldSignatureRejected(): void { - [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $body = 'msg'; @@ -165,7 +187,7 @@ class Rfc9421RoundTripTest extends TestCase { } public function testFutureCreatedRejected(): void { - [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $body = 'msg'; @@ -184,7 +206,7 @@ class Rfc9421RoundTripTest extends TestCase { } public function testMissingCreatedRejected(): void { - [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManager($signatory); $body = 'msg'; @@ -205,7 +227,7 @@ class Rfc9421RoundTripTest extends TestCase { // A peer that signs only `@method` and `@target-uri`: the body and // freshness window aren't bound. Even with a valid signature we // must refuse it. - [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + [$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256'); $signatoryManager = $this->makeSignatoryManagerWithComponents( $signatory, ['@method', '@target-uri'], @@ -220,6 +242,12 @@ class Rfc9421RoundTripTest extends TestCase { new Rfc9421IncomingSignedRequest($body, $req); } + private function skipUnlessSodium(): void { + if (!extension_loaded('sodium')) { + $this->markTestSkipped('ext-sodium is not loaded'); + } + } + private function makeSignatoryManagerWithComponents(Signatory $signatory, array $components): ISignatoryManager { return new class($signatory, $components) implements ISignatoryManager { public function __construct( @@ -250,6 +278,67 @@ class Rfc9421RoundTripTest extends TestCase { }; } + private function makeSignatoryManagerWithSigningAlgorithm(Signatory $signatory, string $signingAlgorithm): ISignatoryManager { + return new class($signatory, $signingAlgorithm) implements ISignatoryManager { + public function __construct( + private Signatory $sig, + private string $signingAlgorithm, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + 'rfc9421.signingAlgorithm' => $this->signingAlgorithm, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->sig; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + } + + /** + * @return array{0: Signatory, 1: \Firebase\JWT\Key} + */ + private function ecdsaP256Material(string $kid): array { + $pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $privatePem = ''; + openssl_pkey_export($pkey, $privatePem); + $details = openssl_pkey_get_details($pkey); + $publicPem = $details['key']; + + $signatory = new Signatory(true); + $signatory->setKeyId($kid); + $signatory->setPublicKey($publicPem); + $signatory->setPrivateKey($privatePem); + + $x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT); + $y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT); + $key = JWK::parseKey([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'kid' => $kid, + 'alg' => 'ES256', + 'x' => self::b64url($x), + 'y' => self::b64url($y), + ], 'ES256'); + return [$signatory, $key]; + } + + /** + * @return array{0: Signatory, 1: \Firebase\JWT\Key} + */ private function ed25519Material(string $kid): array { $keypair = sodium_crypto_sign_keypair(); $publicKey = sodium_crypto_sign_publickey($keypair); @@ -263,11 +352,15 @@ class Rfc9421RoundTripTest extends TestCase { 'crv' => 'Ed25519', 'kid' => $kid, 'alg' => 'EdDSA', - 'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='), + 'x' => self::b64url($publicKey), ], 'EdDSA'); return [$signatory, $key]; } + private static function b64url(string $bin): string { + return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); + } + private function makeSignatoryManager(Signatory $signatory): ISignatoryManager { return new class($signatory) implements ISignatoryManager { public function __construct( diff --git a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php index ba117ca99ba..bb89430e5f0 100644 --- a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php +++ b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php @@ -19,7 +19,10 @@ 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('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 { @@ -53,10 +56,17 @@ class AlgorithmTest extends TestCase { $this->assertNull(Algorithm::deriveJoseAlgFromJwk([])); } - public function testEd25519RoundTrip(): void { - [$priv, $key] = $this->ed25519KeyPair(); + 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 = Algorithm::sign($base, $priv, 'ed25519'); + $sig = sodium_crypto_sign_detached($base, $secret); $this->assertSame(64, strlen($sig)); $this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519')); // JOSE alias accepted. @@ -97,6 +107,7 @@ class AlgorithmTest extends TestCase { } 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); @@ -104,6 +115,7 @@ class AlgorithmTest extends TestCase { } public function testParseKeyRejectsContradictoryAlg(): void { + $this->skipUnlessSodium(); // 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(); @@ -117,14 +129,6 @@ class AlgorithmTest extends TestCase { ], 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'); @@ -137,6 +141,12 @@ class AlgorithmTest extends TestCase { $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} */ diff --git a/tests/lib/Security/Signature/SignatureManagerDispatchTest.php b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php index ae5945fd9b5..698ea59d618 100644 --- a/tests/lib/Security/Signature/SignatureManagerDispatchTest.php +++ b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php @@ -52,9 +52,6 @@ class SignatureManagerDispatchTest extends TestCase { } public function testOutgoingDispatchesToCavageByDefault(): void { - // Cavage signs with an RSA PEM, so we need a real RSA keypair here; - // the Ed25519 helper would produce libsodium bytes that openssl_sign - // can't consume. $signatoryManager = $this->rsaSignatoryManager(); $signed = $this->signatureManager->getOutgoingSignedRequest( @@ -68,7 +65,7 @@ class SignatureManagerDispatchTest extends TestCase { } public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void { - [$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true); + [$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true); $signed = $this->signatureManager->getOutgoingSignedRequest( $signatoryManager, @@ -84,7 +81,7 @@ class SignatureManagerDispatchTest extends TestCase { } public function testInboundDispatchesToRfc9421WhenSignatureInputPresent(): void { - [$signatoryManager, $jwk, $secret] = $this->ed25519SignatoryManager(rfc9421Format: true); + [$signatoryManager, $jwk] = $this->ecdsaP256SignatoryManager(rfc9421Format: true); // Build a real signed request and replay its headers as the inbound // request to exercise the full inbound path including verification. @@ -101,14 +98,14 @@ class SignatureManagerDispatchTest extends TestCase { $this->primeRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); - $resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ed25519'); + $resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ecdsa-p256-sha256'); $signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body); $this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed); } public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void { - [$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true); + [$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true); $body = '{"hello":"world"}'; $out = new Rfc9421OutgoingSignedRequest( @@ -165,26 +162,31 @@ class SignatureManagerDispatchTest extends TestCase { } /** - * @return array{ISignatoryManager, Key, string} [manager, parsed verification key, raw secret key] + * @return array{ISignatoryManager, Key} [manager, parsed verification key] */ - private function ed25519SignatoryManager(bool $rfc9421Format): array { - $keypair = sodium_crypto_sign_keypair(); - $publicKey = sodium_crypto_sign_publickey($keypair); - $secretKey = sodium_crypto_sign_secretkey($keypair); - $kid = 'https://sender.example.org/ocm#ed25519'; + private function ecdsaP256SignatoryManager(bool $rfc9421Format): array { + $pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $privatePem = ''; + openssl_pkey_export($pkey, $privatePem); + $details = openssl_pkey_get_details($pkey); + $publicPem = $details['key']; + $kid = 'https://sender.example.org/ocm#ecdsa-p256-sha256'; $signatory = new Signatory(true); $signatory->setKeyId($kid); - $signatory->setPublicKey($publicKey); - $signatory->setPrivateKey($secretKey); + $signatory->setPublicKey($publicPem); + $signatory->setPrivateKey($privatePem); + $x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT); + $y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT); $key = JWK::parseKey([ - 'kty' => 'OKP', - 'crv' => 'Ed25519', + 'kty' => 'EC', + 'crv' => 'P-256', 'kid' => $kid, - 'alg' => 'EdDSA', - 'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='), - ], 'EdDSA'); + 'alg' => 'ES256', + 'x' => rtrim(strtr(base64_encode($x), '+/', '-_'), '='), + 'y' => rtrim(strtr(base64_encode($y), '+/', '-_'), '='), + ], 'ES256'); $manager = new class($signatory, $rfc9421Format) implements ISignatoryManager { public function __construct( @@ -213,7 +215,7 @@ class SignatureManagerDispatchTest extends TestCase { return null; } }; - return [$manager, $key, $secretKey]; + return [$manager, $key]; } private function makeKeyResolver(ISignatoryManager $delegate, Key $key, string $kid): IJwkResolvingSignatoryManager {