From 260757ef7a9bd3aa9c3e95383521f6a097351818 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Sun, 17 May 2026 22:22:33 +0200 Subject: [PATCH 01/11] chore(3rdparty): pin to nextcloud/3rdparty#2413 head (firebase/php-jwt + gapple/structured-fields) Signed-off-by: Micke Nordin --- 3rdparty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rdparty b/3rdparty index 16dd9453d0d94..3de76a366484b 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 16dd9453d0d94a90f886b55ca26ddd190f2cd5a0 +Subproject commit 3de76a366484b421308c369b2c11ff88cbef18b5 From 9543509461dc82c080b36b3dc6d9fbd0b9d23443 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:09 +0200 Subject: [PATCH 02/11] chore: require ext-sodium Promote ext-sodium from recommended to required so RFC 9421 Ed25519 signing/verifying can rely on libsodium unconditionally. Add the matching openssl + sodium psalm stubs. Signed-off-by: Micke Nordin --- apps/settings/lib/SetupChecks/PhpModules.php | 3 +-- build/stubs/openssl.php | 12 ++++++++++++ build/stubs/sodium.php | 13 +++++++++++++ composer.json | 1 + psalm.xml | 2 ++ 5 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 build/stubs/openssl.php create mode 100644 build/stubs/sodium.php diff --git a/apps/settings/lib/SetupChecks/PhpModules.php b/apps/settings/lib/SetupChecks/PhpModules.php index 64fa6e15c0a85..d4c3d2c5c2a59 100644 --- a/apps/settings/lib/SetupChecks/PhpModules.php +++ b/apps/settings/lib/SetupChecks/PhpModules.php @@ -24,6 +24,7 @@ class PhpModules implements ISetupCheck { 'openssl', 'posix', 'session', + 'sodium', 'xml', 'xmlreader', 'xmlwriter', @@ -35,7 +36,6 @@ class PhpModules implements ISetupCheck { 'exif', 'gmp', 'intl', - 'sodium', 'sysvsem', ]; @@ -58,7 +58,6 @@ public function getCategory(): string { 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'), '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/build/stubs/openssl.php b/build/stubs/openssl.php new file mode 100644 index 0000000000000..5bf410a677445 --- /dev/null +++ b/build/stubs/openssl.php @@ -0,0 +1,12 @@ + + + From dc43bed7d85e36c8ba36d17f903f1017f1a4b481 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:24 +0200 Subject: [PATCH 03/11] feat(http-sig): RFC 9421 protocol primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Model/Rfc9421IncomingSignedRequest.php | 465 ++++++++++++++++++ .../Model/Rfc9421OutgoingSignedRequest.php | 210 ++++++++ .../Security/Signature/Rfc9421/Algorithm.php | 221 +++++++++ .../Signature/Rfc9421/ContentDigest.php | 72 +++ .../Rfc9421/IJwkResolvingSignatoryManager.php | 29 ++ .../Signature/Rfc9421/SignatureBase.php | 124 +++++ .../Security/Signature/SignatureManager.php | 70 ++- .../Signature/Model/Rfc9421RoundTripTest.php | 316 ++++++++++++ .../Signature/Rfc9421/AlgorithmTest.php | 197 ++++++++ .../Signature/Rfc9421/ContentDigestTest.php | 76 +++ .../Signature/Rfc9421/SignatureBaseTest.php | 85 ++++ .../SignatureManagerDispatchTest.php | 262 ++++++++++ 12 files changed, 2120 insertions(+), 7 deletions(-) create mode 100644 lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php create mode 100644 lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php create mode 100644 lib/private/Security/Signature/Rfc9421/Algorithm.php create mode 100644 lib/private/Security/Signature/Rfc9421/ContentDigest.php create mode 100644 lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php create mode 100644 lib/private/Security/Signature/Rfc9421/SignatureBase.php create mode 100644 tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php create mode 100644 tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php create mode 100644 tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php create mode 100644 tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php create mode 100644 tests/lib/Security/Signature/SignatureManagerDispatchTest.php diff --git a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php new file mode 100644 index 0000000000000..3af3f7c0f8d7a --- /dev/null +++ b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php @@ -0,0 +1,465 @@ + */ + private array $components; + /** @var array */ + private array $signatureParams; + private string $signatureBaseString; + private string $rawSignature; + private ?Key $key = null; + + /** + * @throws IncomingRequestException if anything looks wrong with the request structure + * @throws SignatureNotFoundException if the request is not signed + * @throws SignatureException if signature metadata is malformed or covered components reference missing fields + */ + public function __construct( + string $body, + private readonly IRequest $request, + private readonly array $options = [], + ) { + parent::__construct($body); + + $signatureInputHeader = $request->getHeader('Signature-Input'); + $signatureHeader = $request->getHeader('Signature'); + if ($signatureInputHeader === '') { + throw new SignatureNotFoundException('missing Signature-Input header'); + } + if ($signatureHeader === '') { + throw new SignatureNotFoundException('missing Signature header'); + } + + $inputs = self::parseSignatureInput($signatureInputHeader); + $signatures = self::parseSignature($signatureHeader); + + // OCM policy (stricter than RFC 8941 §4.2 last-wins): a duplicate + // `ocm` entry is ambiguous; the entire request MUST be rejected. + if (self::countLabel($signatureInputHeader, 'ocm') > 1 + || self::countLabel($signatureHeader, 'ocm') > 1) { + throw new IncomingRequestException( + 'multiple "' . 'ocm' . '" entries in signature headers' + ); + } + + if (!isset($inputs['ocm'])) { + throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature-Input'); + } + if (!isset($signatures['ocm'])) { + throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature'); + } + + $entry = $inputs['ocm']; + $this->components = $entry['components']; + $this->signatureParams = $entry['params']; + $this->rawSignature = $signatures['ocm']; + + $this->verifyRequiredComponents(); + $this->verifyTimestamps(); + $this->verifyContentDigestIfCovered($body); + $this->verifyContentLengthIfCovered($body); + + $keyId = $this->signatureParams['keyid'] ?? null; + if (!is_string($keyId) || $keyId === '') { + throw new IncomingRequestException('missing keyid in Signature-Input'); + } + try { + $this->origin = Signatory::extractIdentityFromUri($keyId); + } catch (IdentityNotFoundException) { + // keyid may follow the OCM convention `#`; the OCM layer + // derives origin from the message body in that case. + $this->origin = ''; + } + + $paramsLine = SignatureBase::serializeSignatureParams($this->components, $this->signatureParams); + $this->signatureBaseString = SignatureBase::build( + $request->getMethod(), + $this->reconstructTargetUri(), + $this->collectHeaders(), + $this->components, + $paramsLine, + ); + + $this->setSigningElements([ + 'label' => 'ocm', + 'keyId' => $keyId, + 'algorithm' => isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : '', + 'created' => isset($this->signatureParams['created']) ? (string)$this->signatureParams['created'] : '', + 'components' => implode(' ', $this->components), + 'params' => $paramsLine, + 'signature' => base64_encode($this->rawSignature), + ]); + $this->setSignature(base64_encode($this->rawSignature)); + $this->setSignatureData([$this->signatureBaseString]); + } + + #[\Override] + public function getRequest(): IRequest { + return $this->request; + } + + #[\Override] + public function getOrigin(): string { + if ($this->origin === '') { + throw new IncomingRequestException('empty origin'); + } + return $this->origin; + } + + #[\Override] + public function getKeyId(): string { + return $this->getSigningElement('keyId'); + } + + /** Required before {@see verify()} is called. */ + public function setKey(Key $key): self { + $this->key = $key; + return $this; + } + + public function getKey(): ?Key { + return $this->key; + } + + /** Signature-Input `alg` if present, else null (RFC 9421 §3.3.7 omitted-alg path). */ + public function getAlgorithm(): ?string { + return isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : null; + } + + /** + * @return array + */ + public function getSignatureParams(): array { + return $this->signatureParams; + } + + /** + * @return list + */ + public function getCoveredComponents(): array { + return $this->components; + } + + public function getSignatureBaseString(): string { + return $this->signatureBaseString; + } + + #[\Override] + public function verify(): void { + if ($this->key === null) { + throw new SignatoryNotFoundException('no JWK set for verification'); + } + try { + $ok = Algorithm::verify( + $this->signatureBaseString, + $this->rawSignature, + $this->key, + $this->getAlgorithm(), + ); + } catch (SignatureException $e) { + throw new InvalidSignatureException($e->getMessage(), 0, $e); + } + if (!$ok) { + throw new InvalidSignatureException('signature verification failed'); + } + } + + /** @throws IncomingRequestException if the signature doesn't cover the OCM-required components */ + private function verifyRequiredComponents(): void { + /** @var list $required */ + $required = $this->options['rfc9421.requiredComponents'] ?? self::DEFAULT_REQUIRED_COMPONENTS; + $missing = array_values(array_diff($required, $this->components)); + if ($missing !== []) { + throw new IncomingRequestException( + 'signature does not cover required components: ' . implode(', ', $missing) + ); + } + } + + /** @throws IncomingRequestException on stale, future-dated, or missing `created` */ + private function verifyTimestamps(): void { + $ttl = (int)($this->options['ttl'] ?? SignatureManager::DATE_TTL); + $skew = (int)($this->options['rfc9421.maxClockSkew'] ?? self::DEFAULT_MAX_FUTURE_SKEW); + $now = time(); + + if (!isset($this->signatureParams['created'])) { + throw new IncomingRequestException('signature missing required `created` parameter'); + } + $created = (int)$this->signatureParams['created']; + if ($created > $now + $skew) { + throw new IncomingRequestException('signature `created` is too far in the future'); + } + if ($ttl > 0 && $created < $now - $ttl) { + throw new IncomingRequestException('signature is too old'); + } + + if (isset($this->signatureParams['expires'])) { + $expires = (int)$this->signatureParams['expires']; + if ($expires < $now) { + throw new IncomingRequestException('signature has expired'); + } + } + } + + private function verifyContentDigestIfCovered(string $body): void { + if (!in_array('content-digest', $this->components, true)) { + return; + } + $header = $this->request->getHeader('Content-Digest'); + if ($header === '') { + throw new IncomingRequestException('content-digest covered but missing from request'); + } + if (!ContentDigest::verify($header, $body)) { + throw new IncomingRequestException('content-digest does not match body'); + } + } + + private function verifyContentLengthIfCovered(string $body): void { + if (!in_array('content-length', $this->components, true)) { + return; + } + $header = $this->request->getHeader('Content-Length'); + if ($header === '') { + throw new IncomingRequestException('content-length covered but missing from request'); + } + if ((int)$header !== strlen($body)) { + throw new IncomingRequestException('content-length does not match body size'); + } + } + + private function reconstructTargetUri(): string { + $scheme = $this->request->getServerProtocol(); + $host = $this->request->getServerHost(); + $path = $this->request->getRequestUri(); + return $scheme . '://' . $host . $path; + } + + /** + * Collect the HTTP request fields covered by the signature, keyed by their + * lowercased name. Derived components (`@*`) are produced inside + * {@see SignatureBase}; we only collect plain fields here. + * + * @return array + */ + private function collectHeaders(): array { + $out = []; + foreach ($this->components as $component) { + if (str_starts_with($component, '@')) { + continue; + } + $value = $this->request->getHeader($component); + if ($value === '' && strtolower($component) === 'host') { + $value = $this->request->getServerHost(); + } + $out[strtolower($component)] = $value; + } + return $out; + } + + #[\Override] + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'origin' => $this->origin, + 'label' => 'ocm', + 'components' => $this->components, + 'signatureParams' => $this->signatureParams, + 'signatureBase' => $this->signatureBaseString, + ] + ); + } + + /** + * @return array, params: array}> + * @throws SignatureException + */ + private static function parseSignatureInput(string $header): array { + try { + $dict = Parser::parseDictionary($header); + } catch (ParseException $e) { + throw new SignatureException('malformed Signature-Input: ' . $e->getMessage(), 0, $e); + } + + $out = []; + foreach ($dict as $label => $entry) { + if (!$entry instanceof InnerList) { + throw new SignatureException('Signature-Input value for ' . $label . ' is not an inner list'); + } + $components = []; + foreach ($entry->getValue() as $item) { + $value = $item->getValue(); + if (!is_string($value)) { + throw new SignatureException('component identifier in Signature-Input must be a string'); + } + $components[] = $value; + } + $parameters = $entry->getParameters(); + if (!$parameters instanceof Parameters) { + throw new SignatureException('Signature-Input parameters for ' . $label . ' are not iterable'); + } + $out[$label] = [ + 'components' => $components, + 'params' => self::normalizeParameters($parameters), + ]; + } + return $out; + } + + /** + * @return array raw signature bytes keyed by label + * @throws SignatureException + */ + private static function parseSignature(string $header): array { + try { + $dict = Parser::parseDictionary($header); + } catch (ParseException $e) { + throw new SignatureException('malformed Signature: ' . $e->getMessage(), 0, $e); + } + + $out = []; + foreach ($dict as $label => $entry) { + if (!$entry instanceof Item || !$entry->getValue() instanceof Bytes) { + throw new SignatureException('Signature value for ' . $label . ' is not a byte sequence'); + } + $out[$label] = (string)$entry->getValue(); + } + return $out; + } + + /** + * @param iterable $parameters + * @return array + */ + private static function normalizeParameters(iterable $parameters): array { + $out = []; + foreach ($parameters as $name => $value) { + $out[(string)$name] = match (true) { + is_string($value), is_int($value), is_bool($value) => $value, + $value instanceof Token => (string)$value, + default => throw new SignatureException('unsupported parameter type for ' . $name), + }; + } + return $out; + } + + /** Count $label occurrences in a dictionary header (gapple collapses dups per RFC 8941 §4.2). */ + private static function countLabel(string $header, string $label): int { + $count = 0; + $len = strlen($header); + $i = 0; + while ($i < $len) { + while ($i < $len && ($header[$i] === ' ' || $header[$i] === "\t")) { + $i++; + } + $start = $i; + while ($i < $len) { + $c = $header[$i]; + if (!ctype_lower($c) && !ctype_digit($c) && $c !== '*' && $c !== '_' && $c !== '-' && $c !== '.') { + break; + } + $i++; + } + if ($i === $start) { + break; + } + if (substr($header, $start, $i - $start) === $label) { + $count++; + } + // Skip to next top-level comma; track strings, byte-sequences, parens. + $inString = false; + $inByteSeq = false; + $depth = 0; + while ($i < $len) { + $c = $header[$i]; + if ($inString) { + if ($c === '\\' && $i + 1 < $len) { + $i += 2; + continue; + } + if ($c === '"') { + $inString = false; + } + $i++; + continue; + } + if ($inByteSeq) { + if ($c === ':') { + $inByteSeq = false; + } + $i++; + continue; + } + if ($c === '"') { + $inString = true; + } elseif ($c === ':') { + $inByteSeq = true; + } elseif ($c === '(') { + $depth++; + } elseif ($c === ')') { + $depth--; + } elseif ($c === ',' && $depth === 0) { + $i++; + break; + } + $i++; + } + } + return $count; + } +} diff --git a/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php new file mode 100644 index 0000000000000..3a44776ef4ad1 --- /dev/null +++ b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php @@ -0,0 +1,210 @@ + $headerList */ + private array $headerList = []; + private SignatureAlgorithm $algorithm; + private string $signingAlgorithm; + /** @var array */ + private array $signatureParams; + private string $signatureBaseString; + + public function __construct( + string $body, + ISignatoryManager $signatoryManager, + private readonly string $identity, + private readonly string $method, + private readonly string $uri, + ) { + parent::__construct($body); + + $options = $signatoryManager->getOptions(); + $this->setHost($identity) + ->setAlgorithm($options['algorithm'] ?? SignatureAlgorithm::RSA_SHA256) + ->setSignatory($signatoryManager->getLocalSignatory()) + ->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256); + + $this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ed25519'); + $contentDigestAlgorithm = (string)($options['rfc9421.contentDigestAlgorithm'] ?? ContentDigest::ALGO_SHA256); + /** @var list $components */ + $components = $options['rfc9421.coveredComponents'] ?? self::DEFAULT_COMPONENTS; + $includeAlg = (bool)($options['rfc9421.includeAlgParameter'] ?? false); + $dateHeaderFormat = (string)($options['dateHeader'] ?? SignatureManager::DATE_HEADER); + + $this->addHeader('Content-Digest', ContentDigest::compute($body, $contentDigestAlgorithm)) + ->addHeader('Content-Length', strlen($body)) + ->addHeader('Date', gmdate($dateHeaderFormat)); + if (in_array('host', $components, true)) { + $this->addHeader('Host', $this->host); + } + + $this->setHeaderList($components); + $this->signatureParams = [ + 'created' => time(), + 'keyid' => $this->getSignatory()->getKeyId(), + ]; + if ($includeAlg) { + // Off by default per RFC 9421 §3.3.7 (verifier resolves alg from JWK). + $this->signatureParams['alg'] = $this->signingAlgorithm; + } + + $this->signatureBaseString = SignatureBase::build( + $this->method, + $this->uri, + $this->headersByLowercaseName(), + $this->headerList, + SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams) + ); + $this->setSignatureData([$this->signatureBaseString]); + } + + #[\Override] + public function setHost(string $host): self { + $this->host = $host; + return $this; + } + + #[\Override] + public function getHost(): string { + return $this->host; + } + + #[\Override] + public function addHeader(string $key, string|int|float $value): self { + $this->headers[$key] = $value; + return $this; + } + + #[\Override] + public function getHeaders(): array { + return $this->headers; + } + + #[\Override] + public function setHeaderList(array $list): self { + $this->headerList = $list; + return $this; + } + + #[\Override] + public function getHeaderList(): array { + return $this->headerList; + } + + #[\Override] + public function setAlgorithm(SignatureAlgorithm $algorithm): self { + $this->algorithm = $algorithm; + return $this; + } + + #[\Override] + public function getAlgorithm(): SignatureAlgorithm { + return $this->algorithm; + } + + /** RFC 9421 alg name (e.g. `ed25519`). Distinct from cavage's {@see getAlgorithm()}. */ + public function getSigningAlgorithm(): string { + return $this->signingAlgorithm; + } + + public function getSignatureBaseString(): string { + return $this->signatureBaseString; + } + + #[\Override] + public function sign(): self { + $privateKey = $this->getSignatory()->getPrivateKey(); + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + $rawSignature = Algorithm::sign( + $this->signatureBaseString, + $privateKey, + $this->signingAlgorithm, + ); + $this->setSignature(base64_encode($rawSignature)); + + $paramsLine = SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams); + $this->addHeader('Signature-Input', 'ocm=' . $paramsLine); + $this->addHeader('Signature', 'ocm=:' . base64_encode($rawSignature) . ':'); + + $this->setSigningElements([ + 'label' => 'ocm', + 'components' => implode(' ', $this->headerList), + 'params' => $paramsLine, + 'signature' => $this->getSignature(), + ]); + + return $this; + } + + /** + * @return array + */ + private function headersByLowercaseName(): array { + $out = []; + foreach ($this->headers as $name => $value) { + $out[strtolower($name)] = (string)$value; + } + return $out; + } + + /** + * @throws SignatoryNotFoundException + */ + #[\Override] + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'host' => $this->host, + 'headers' => $this->headers, + 'algorithm' => $this->algorithm->value, + 'signingAlgorithm' => $this->signingAlgorithm, + 'method' => $this->method, + 'identity' => $this->identity, + 'uri' => $this->uri, + 'components' => $this->headerList, + 'signatureBase' => $this->signatureBaseString, + 'signatureParams' => $this->signatureParams, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Rfc9421/Algorithm.php b/lib/private/Security/Signature/Rfc9421/Algorithm.php new file mode 100644 index 0000000000000..155aead60135f --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/Algorithm.php @@ -0,0 +1,221 @@ +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 (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 $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) { + 'ed25519' => 'EdDSA', + '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; + } +} diff --git a/lib/private/Security/Signature/Rfc9421/ContentDigest.php b/lib/private/Security/Signature/Rfc9421/ContentDigest.php new file mode 100644 index 0000000000000..7df49f624d22f --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/ContentDigest.php @@ -0,0 +1,72 @@ + $digest) { + try { + $hashAlgorithm = self::hashAlgorithmFor($algorithm); + } catch (InvalidArgumentException) { + continue; + } + if (!hash_equals(hash($hashAlgorithm, $body, true), $digest)) { + return false; + } + $matched = true; + } + return $matched; + } + + /** @return array [algorithm => raw bytes] */ + public static function parse(string $header): array { + $out = []; + foreach (explode(',', $header) as $entry) { + $entry = trim($entry); + if ($entry === '') { + continue; + } + if (!preg_match('#^([a-z0-9-]+)=:([A-Za-z0-9+/=]*):$#', $entry, $m)) { + continue; + } + $decoded = base64_decode($m[2], true); + if ($decoded === false) { + continue; + } + $out[strtolower($m[1])] = $decoded; + } + return $out; + } + + private static function hashAlgorithmFor(string $algorithm): string { + return match (strtolower($algorithm)) { + self::ALGO_SHA256 => 'sha256', + self::ALGO_SHA512 => 'sha512', + default => throw new InvalidArgumentException('unsupported content-digest algorithm: ' . $algorithm), + }; + } +} diff --git a/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php new file mode 100644 index 0000000000000..5747ccb43d8d2 --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php @@ -0,0 +1,29 @@ + $headers headers keyed by lowercase name + * @param list $components covered component identifiers, in order + * @param string $signatureParamsLine `(...);params...` for `@signature-params` + * @throws SignatureException when a covered field is missing from $headers + */ + public static function build( + string $method, + string $uri, + array $headers, + array $components, + string $signatureParamsLine, + ): string { + $lines = []; + foreach ($components as $component) { + $lines[] = '"' . $component . '": ' . self::componentValue($component, $method, $uri, $headers); + } + $lines[] = '"@signature-params": ' . $signatureParamsLine; + return implode("\n", $lines); + } + + /** + * Serialize `(comp...)` + `;k=v` parameters for `@signature-params` and + * Signature-Input dictionary entries. + * + * @param list $components + * @param array $params + */ + public static function serializeSignatureParams(array $components, array $params): string { + $inner = array_map(static fn (string $c): string => '"' . $c . '"', $components); + $out = '(' . implode(' ', $inner) . ')'; + foreach ($params as $name => $value) { + $out .= ';' . $name . '=' . self::serializeBareItem($value); + } + return $out; + } + + /** + * @param scalar $value + */ + public static function serializeBareItem(mixed $value): string { + if (is_string($value)) { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; + } + if (is_int($value)) { + return (string)$value; + } + if (is_bool($value)) { + return $value ? '?1' : '?0'; + } + throw new InvalidArgumentException('unsupported parameter value type'); + } + + private static function componentValue(string $component, string $method, string $uri, array $headers): string { + if (str_starts_with($component, '@')) { + return self::derivedValue($component, $method, $uri); + } + $lower = strtolower($component); + if (!array_key_exists($lower, $headers)) { + throw new SignatureException('missing field for signature: ' . $component); + } + return self::normalizeFieldValue($headers[$lower]); + } + + private static function derivedValue(string $component, string $method, string $uri): string { + $parts = parse_url($uri); + if ($parts === false) { + throw new SignatureException('cannot parse target URI'); + } + return match ($component) { + '@method' => strtoupper($method), + '@target-uri' => $uri, + '@authority' => self::authority($parts), + '@scheme' => strtolower($parts['scheme'] ?? ''), + '@path' => $parts['path'] ?? '/', + '@query' => isset($parts['query']) ? '?' . $parts['query'] : '', + '@request-target' => ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : ''), + default => throw new SignatureException('unsupported derived component: ' . $component), + }; + } + + private static function authority(array $parts): string { + $host = strtolower((string)($parts['host'] ?? '')); + if ($host === '') { + return ''; + } + $port = $parts['port'] ?? null; + $scheme = strtolower((string)($parts['scheme'] ?? '')); + // RFC 9421 §2.2.3: default ports are omitted. + if ($port !== null && !self::isDefaultPort($scheme, (int)$port)) { + return $host . ':' . $port; + } + return $host; + } + + private static function isDefaultPort(string $scheme, int $port): bool { + return ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80); + } + + private static function normalizeFieldValue(string $value): string { + // RFC 9421 §2.1: strip OWS, collapse internal whitespace. + return preg_replace('/[ \t]+/', ' ', trim($value)) ?? ''; + } +} diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php index 11aff48438dbf..f094ac5148a37 100644 --- a/lib/private/Security/Signature/SignatureManager.php +++ b/lib/private/Security/Signature/SignatureManager.php @@ -11,6 +11,9 @@ use OC\Security\Signature\Db\SignatoryMapper; use OC\Security\Signature\Model\IncomingSignedRequest; use OC\Security\Signature\Model\OutgoingSignedRequest; +use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest; +use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest; +use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager; use OCP\DB\Exception as DBException; use OCP\IAppConfig; use OCP\IRequest; @@ -101,6 +104,11 @@ public function getIncomingSignedRequest( throw new IncomingRequestException('content of request is too big'); } + // `Signature-Input` is unique to RFC 9421; cavage uses `Signature` only. + if ($this->request->getHeader('Signature-Input') !== '') { + return $this->getRfc9421IncomingSignedRequest($signatoryManager, $body, $options); + } + // generate IncomingSignedRequest based on body and request $signedRequest = new IncomingSignedRequest($body, $this->request, $options); @@ -121,6 +129,45 @@ public function getIncomingSignedRequest( return $signedRequest; } + /** + * RFC 9421 inbound path. Requires {@see IJwkResolvingSignatoryManager}. + * + * @throws IncomingRequestException + * @throws SignatureException + * @throws SignatureNotFoundException + */ + private function getRfc9421IncomingSignedRequest( + ISignatoryManager $signatoryManager, + string $body, + array $options, + ): IIncomingSignedRequest { + if (!($signatoryManager instanceof IJwkResolvingSignatoryManager)) { + throw new IncomingRequestException('RFC 9421 inbound is not supported by ' . get_class($signatoryManager)); + } + + $signedRequest = new Rfc9421IncomingSignedRequest($body, $this->request, $options); + + try { + $key = $signatoryManager->getRemoteKey($signedRequest->getOrigin(), $signedRequest->getKeyId()); + if ($key === null) { + throw new SignatoryNotFoundException('no JWK resolved for keyid ' . $signedRequest->getKeyId()); + } + $signedRequest->setKey($key); + $signedRequest->verify(); + } catch (SignatureException $e) { + $this->logger->warning( + 'RFC 9421 signature could not be verified', [ + 'exception' => $e, + 'signedRequest' => $signedRequest, + 'signatoryManager' => get_class($signatoryManager), + ] + ); + throw $e; + } + + return $signedRequest; + } + /** * confirm that the Signature is signed using the correct private key, using * clear version of the Signature and the public key linked to the keyId @@ -199,13 +246,22 @@ public function getOutgoingSignedRequest( string $method, string $uri, ): IOutgoingSignedRequest { - $signedRequest = new OutgoingSignedRequest( - $content, - $signatoryManager, - $this->extractIdentityFromUri($uri), - $method, - parse_url($uri, PHP_URL_PATH) ?? '/' - ); + $options = $signatoryManager->getOptions(); + $signedRequest = ($options['rfc9421.format'] ?? false) + ? new Rfc9421OutgoingSignedRequest( + $content, + $signatoryManager, + $this->extractIdentityFromUri($uri), + $method, + $uri, + ) + : new OutgoingSignedRequest( + $content, + $signatoryManager, + $this->extractIdentityFromUri($uri), + $method, + parse_url($uri, PHP_URL_PATH) ?? '/', + ); $signedRequest->sign(); diff --git a/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php new file mode 100644 index 0000000000000..5f4285f14ccde --- /dev/null +++ b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php @@ -0,0 +1,316 @@ +ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = '{"hello":"world"}'; + $method = 'POST'; + $uri = 'https://receiver.example.org/ocm/shares'; + + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', $method, $uri); + $out->sign(); + + $req = $this->mockRequestFromOutgoing($out, $method, '/ocm/shares', 'receiver.example.org'); + $in = new Rfc9421IncomingSignedRequest($body, $req); + $in->setKey($jwk); + + $this->assertSame($out->getSignatureBaseString(), $in->getSignatureBaseString()); + $in->verify(); // throws on failure + $this->addToAssertionCount(1); + } + + public function testTamperedBodyRejected(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'original'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $req = $this->mockRequestFromOutgoing($out, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest('tampered', $req); + } + + public function testTamperedSignatureRejected(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $headers = $out->getHeaders(); + // Replace the inner base64 of the signature with a different valid base64. + $headers['Signature'] = preg_replace('/=:[^:]+:/', '=:' . base64_encode(random_bytes(64)) . ':', (string)$headers['Signature']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $in = new Rfc9421IncomingSignedRequest($body, $req); + $in->setKey($jwk); + + $this->expectException(InvalidSignatureException::class); + $in->verify(); + } + + public function testOutgoingUsesOcmLabel(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $headers = $out->getHeaders(); + $this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']); + $this->assertStringStartsWith('ocm=:', (string)$headers['Signature']); + } + + public function testRequestWithoutOcmLabelRejected(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Rename the OCM label to something else; verifier MUST reject. + $headers = $out->getHeaders(); + $headers['Signature-Input'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature-Input']); + $headers['Signature'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(SignatureNotFoundException::class); + new Rfc9421IncomingSignedRequest('msg', $req); + } + + public function testDuplicateOcmLabelRejected(): void { + // 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'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $headers = $out->getHeaders(); + $headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', ' . (string)$headers['Signature-Input']; + $headers['Signature'] = (string)$headers['Signature'] . ', ' . (string)$headers['Signature']; + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest('msg', $req); + } + + public function testForeignSiblingLabelIgnored(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Splice in a sibling proxy_sig1 entry; the verifier must ignore it + // and still verify the ocm-labeled signature successfully. + $headers = $out->getHeaders(); + $proxyParams = '("@method");created=1;keyid="proxy"'; + $proxySig = base64_encode(random_bytes(64)); + $headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', proxy_sig1=' . $proxyParams; + $headers['Signature'] = (string)$headers['Signature'] . ', proxy_sig1=:' . $proxySig . ':'; + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $in = new Rfc9421IncomingSignedRequest('msg', $req); + $in->setKey($jwk); + $in->verify(); + $this->addToAssertionCount(1); + } + + public function testTooOldSignatureRejected(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Backdate `created` in Signature-Input by 10 minutes. + $headers = $out->getHeaders(); + $pastCreated = time() - 600; + $headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $pastCreated, (string)$headers['Signature-Input']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req, ['ttl' => 300]); + } + + public function testFutureCreatedRejected(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Push `created` 10 minutes into the future, well past the + // 60-second skew tolerance. + $headers = $out->getHeaders(); + $futureCreated = time() + 600; + $headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $futureCreated, (string)$headers['Signature-Input']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req); + } + + public function testMissingCreatedRejected(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Strip the `;created=...` parameter so the signature loses its + // freshness anchor. + $headers = $out->getHeaders(); + $headers['Signature-Input'] = preg_replace('/;created=\d+/', '', (string)$headers['Signature-Input']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req); + } + + public function testSignatureNotCoveringRequiredComponentsRejected(): void { + // 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'); + $signatoryManager = $this->makeSignatoryManagerWithComponents( + $signatory, + ['@method', '@target-uri'], + ); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + $req = $this->mockRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org'); + + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req); + } + + private function makeSignatoryManagerWithComponents(Signatory $signatory, array $components): ISignatoryManager { + return new class($signatory, $components) implements ISignatoryManager { + public function __construct( + private Signatory $sig, + private array $components, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + 'rfc9421.coveredComponents' => $this->components, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->sig; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + } + + private function ed25519Material(string $kid): array { + $keypair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keypair); + $secretKey = sodium_crypto_sign_secretkey($keypair); + $signatory = new Signatory(true); + $signatory->setKeyId($kid); + $signatory->setPublicKey($publicKey); + $signatory->setPrivateKey($secretKey); + $key = JWK::parseKey([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => $kid, + 'alg' => 'EdDSA', + 'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='), + ], 'EdDSA'); + return [$signatory, $key]; + } + + private function makeSignatoryManager(Signatory $signatory): ISignatoryManager { + return new class($signatory) implements ISignatoryManager { + public function __construct( + private Signatory $sig, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->sig; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + } + + private function mockRequestFromOutgoing(Rfc9421OutgoingSignedRequest $out, string $method, string $path, string $host): IRequest { + return $this->mockRequest($out->getHeaders(), $method, $path, $host); + } + + private function mockRequest(array $headers, string $method, string $path, string $host): IRequest { + $lowered = []; + foreach ($headers as $name => $value) { + $lowered[strtolower($name)] = (string)$value; + } + $mock = $this->createMock(IRequest::class); + $mock->method('getHeader')->willReturnCallback(static fn (string $h) => $lowered[strtolower($h)] ?? ''); + $mock->method('getMethod')->willReturn($method); + $mock->method('getRequestUri')->willReturn($path); + $mock->method('getServerProtocol')->willReturn('https'); + $mock->method('getServerHost')->willReturn($host); + return $mock; + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php new file mode 100644 index 0000000000000..ba117ca99baf4 --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php @@ -0,0 +1,197 @@ +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), '+/', '-_'), '='); + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php new file mode 100644 index 0000000000000..4198acec5342b --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php @@ -0,0 +1,76 @@ +assertStringStartsWith('sha-256=:', $header); + $this->assertStringEndsWith(':', $header); + $this->assertTrue(ContentDigest::verify($header, $body)); + } + + public function testDifferentBodyFails(): void { + $header = ContentDigest::compute('hello', ContentDigest::ALGO_SHA256); + $this->assertFalse(ContentDigest::verify($header, 'goodbye')); + } + + public function testSha512(): void { + $header = ContentDigest::compute('payload', ContentDigest::ALGO_SHA512); + $this->assertStringStartsWith('sha-512=:', $header); + $this->assertTrue(ContentDigest::verify($header, 'payload')); + } + + public function testParseMultipleAlgorithmsAcceptsAnyMatch(): void { + $body = 'data'; + $sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256); + $sha512 = ContentDigest::compute($body, ContentDigest::ALGO_SHA512); + $header = $sha256 . ', ' . $sha512; + $this->assertTrue(ContentDigest::verify($header, $body)); + } + + public function testFailsIfAnyRecognisedAlgorithmMismatches(): void { + // All recognised digests must agree. A correct sha-256 alongside a + // wrong sha-512 is treated as an attack on the weaker algorithm, + // not as a successful match on the stronger one. + $body = 'data'; + $sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256); + $wrongSha512 = 'sha-512=:' . base64_encode(hash('sha512', 'tampered', true)) . ':'; + $this->assertFalse(ContentDigest::verify($sha256 . ', ' . $wrongSha512, $body)); + // And the inverse ordering. + $this->assertFalse(ContentDigest::verify($wrongSha512 . ', ' . $sha256, $body)); + } + + public function testUnknownAlgorithmIsIgnored(): void { + $body = 'data'; + $sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256); + $header = 'md5=:abcd:, ' . $sha256; + $this->assertTrue(ContentDigest::verify($header, $body)); + } + + public function testEmptyHeaderFails(): void { + $this->assertFalse(ContentDigest::verify('', 'body')); + } + + public function testGarbageHeaderFails(): void { + $this->assertFalse(ContentDigest::verify('not a digest', 'body')); + } + + public function testParseExtractsRawBytes(): void { + $header = ContentDigest::compute('abc', ContentDigest::ALGO_SHA256); + $parsed = ContentDigest::parse($header); + $this->assertArrayHasKey('sha-256', $parsed); + $this->assertSame(hash('sha256', 'abc', true), $parsed['sha-256']); + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php new file mode 100644 index 0000000000000..d5aed5e9ab679 --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php @@ -0,0 +1,85 @@ + 'sha-256=:abcd:', + 'date' => 'Mon, 04 May 2026 12:00:00 GMT', + ], + components: ['@method', '@target-uri', 'content-digest', 'date'], + signatureParamsLine: '("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"', + ); + + $expected = '"@method": POST' . "\n" + . '"@target-uri": https://example.org/foo?bar=baz' . "\n" + . '"content-digest": sha-256=:abcd:' . "\n" + . '"date": Mon, 04 May 2026 12:00:00 GMT' . "\n" + . '"@signature-params": ("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"'; + $this->assertSame($expected, $base); + } + + public function testAuthorityStripsDefaultPort(): void { + $base = SignatureBase::build('GET', 'https://EXAMPLE.org:443/x', [], ['@authority'], '()'); + $this->assertStringContainsString('"@authority": example.org' . "\n", $base); + } + + public function testAuthorityKeepsCustomPort(): void { + $base = SignatureBase::build('GET', 'https://example.org:8443/x', [], ['@authority'], '()'); + $this->assertStringContainsString('"@authority": example.org:8443' . "\n", $base); + } + + public function testQueryComponent(): void { + $base = SignatureBase::build('GET', 'https://example.org/x?a=1', [], ['@query'], '()'); + $this->assertStringContainsString('"@query": ?a=1' . "\n", $base); + } + + public function testMissingFieldThrows(): void { + $this->expectException(SignatureException::class); + SignatureBase::build('GET', 'https://example.org/', [], ['x-missing'], '()'); + } + + public function testFieldValueIsTrimmed(): void { + $base = SignatureBase::build( + 'GET', + 'https://example.org/', + ['date' => ' Mon, 04 May 2026 12:00:00 GMT '], + ['date'], + '()' + ); + $this->assertStringContainsString('"date": Mon, 04 May 2026 12:00:00 GMT' . "\n", $base); + } + + public function testSerializeSignatureParams(): void { + $line = SignatureBase::serializeSignatureParams( + ['@method', '@target-uri'], + ['created' => 100, 'keyid' => 'kid', 'expires' => 200], + ); + $this->assertSame('("@method" "@target-uri");created=100;keyid="kid";expires=200', $line); + } + + public function testSerializeBareItemEscapesQuotes(): void { + $this->assertSame('"\\"hi\\""', SignatureBase::serializeBareItem('"hi"')); + $this->assertSame('"\\\\"', SignatureBase::serializeBareItem('\\')); + } + + public function testSerializeBareItemBoolean(): void { + $this->assertSame('?1', SignatureBase::serializeBareItem(true)); + $this->assertSame('?0', SignatureBase::serializeBareItem(false)); + } +} diff --git a/tests/lib/Security/Signature/SignatureManagerDispatchTest.php b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php new file mode 100644 index 0000000000000..ae5945fd9b5a9 --- /dev/null +++ b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php @@ -0,0 +1,262 @@ +request = $this->createMock(IRequest::class); + $this->mapper = $this->createMock(SignatoryMapper::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->signatureManager = new SignatureManager( + $this->request, + $this->mapper, + $this->appConfig, + $this->logger, + ); + } + + 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( + $signatoryManager, + '{}', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + + $this->assertNotInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed); + } + + public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void { + [$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true); + + $signed = $this->signatureManager->getOutgoingSignedRequest( + $signatoryManager, + '{}', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + + $this->assertInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed); + $headers = $signed->getHeaders(); + $this->assertArrayHasKey('Signature-Input', $headers); + $this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']); + } + + public function testInboundDispatchesToRfc9421WhenSignatureInputPresent(): void { + [$signatoryManager, $jwk, $secret] = $this->ed25519SignatoryManager(rfc9421Format: true); + + // Build a real signed request and replay its headers as the inbound + // request to exercise the full inbound path including verification. + $body = '{"hello":"world"}'; + $out = new Rfc9421OutgoingSignedRequest( + $body, + $signatoryManager, + 'receiver.example.org', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + $out->sign(); + $headers = $out->getHeaders(); + + $this->primeRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + + $resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ed25519'); + + $signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body); + $this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed); + } + + public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void { + [$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true); + + $body = '{"hello":"world"}'; + $out = new Rfc9421OutgoingSignedRequest( + $body, + $signatoryManager, + 'receiver.example.org', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + $out->sign(); + $this->primeRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org'); + + // $signatoryManager does NOT implement IJwkResolvingSignatoryManager. + $this->expectException(IncomingRequestException::class); + $this->signatureManager->getIncomingSignedRequest($signatoryManager, $body); + } + + private function rsaSignatoryManager(): ISignatoryManager { + $key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $priv = ''; + openssl_pkey_export($key, $priv); + $pub = openssl_pkey_get_details($key)['key']; + + $signatory = new Signatory(true); + $signatory->setKeyId('https://sender.example.org/ocm#signature'); + $signatory->setPublicKey($pub); + $signatory->setPrivateKey($priv); + + return new class($signatory) implements ISignatoryManager { + public function __construct( + private Signatory $signatory, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->signatory; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + } + + /** + * @return array{ISignatoryManager, Key, string} [manager, parsed verification key, raw secret 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'; + + $signatory = new Signatory(true); + $signatory->setKeyId($kid); + $signatory->setPublicKey($publicKey); + $signatory->setPrivateKey($secretKey); + + $key = JWK::parseKey([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => $kid, + 'alg' => 'EdDSA', + 'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='), + ], 'EdDSA'); + + $manager = new class($signatory, $rfc9421Format) implements ISignatoryManager { + public function __construct( + private Signatory $signatory, + private bool $rfc9421, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + 'rfc9421.format' => $this->rfc9421, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->signatory; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + return [$manager, $key, $secretKey]; + } + + private function makeKeyResolver(ISignatoryManager $delegate, Key $key, string $kid): IJwkResolvingSignatoryManager { + return new class($delegate, $key, $kid) implements IJwkResolvingSignatoryManager { + public function __construct( + private ISignatoryManager $delegate, + private Key $key, + private string $kid, + ) { + } + + public function getProviderId(): string { + return $this->delegate->getProviderId(); + } + + public function getOptions(): array { + return $this->delegate->getOptions(); + } + + public function getLocalSignatory(): Signatory { + return $this->delegate->getLocalSignatory(); + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return $this->delegate->getRemoteSignatory($remote); + } + + public function getRemoteKey(string $origin, string $keyId): ?Key { + return $keyId === $this->kid ? $this->key : null; + } + }; + } + + private function primeRequest(array $headers, string $method, string $path, string $host): void { + $lowered = []; + foreach ($headers as $name => $value) { + $lowered[strtolower($name)] = (string)$value; + } + $this->request->method('getHeader') + ->willReturnCallback(static fn (string $name) => $lowered[strtolower($name)] ?? ''); + $this->request->method('getMethod')->willReturn($method); + $this->request->method('getRequestUri')->willReturn($path); + $this->request->method('getServerProtocol')->willReturn('https'); + $this->request->method('getServerHost')->willReturn($host); + } +} From 3a624cadb370765bf9d2c07f979647ec297f0869 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:32 +0200 Subject: [PATCH 04/11] feat(identityproof): Ed25519 app keys Add Manager::generateEd25519AppKey: persist a sodium-generated Ed25519 keypair (raw 32-byte public, 64-byte secret) under the same appdata layout the existing RSA path uses. Used by OCMSignatoryManager for the slotted RFC 9421 signing keys. Signed-off-by: Micke Nordin --- .../Security/IdentityProof/Manager.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/private/Security/IdentityProof/Manager.php b/lib/private/Security/IdentityProof/Manager.php index ef0faeb6ad632..d6ebe3813b21e 100644 --- a/lib/private/Security/IdentityProof/Manager.php +++ b/lib/private/Security/IdentityProof/Manager.php @@ -178,6 +178,30 @@ public function generateAppKey(string $app, string $name, array $options = []): return $this->generateKey($this->generateAppKeyId($app, $name), $options); } + /** + * Generate an Ed25519 keypair via libsodium. Returns raw 32-byte public + * + 64-byte secret (sodium seed||publickey), no PEM. Overwrites if + * already present. + */ + 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); + + $id = $this->generateAppKeyId($app, $name); + try { + $this->appData->newFolder($id); + } catch (\Exception) { + } + $folder = $this->appData->getFolder($id); + $folder->newFile('private') + ->putContent($this->crypto->encrypt($privateKey)); + $folder->newFile('public') + ->putContent($publicKey); + + return new Key($publicKey, $privateKey); + } + public function deleteAppKey(string $app, string $name): bool { try { $folder = $this->appData->getFolder($this->generateAppKeyId($app, $name)); From 8970a6e47015f4c499469e699473275820872484 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:45 +0200 Subject: [PATCH 05/11] feat(http-sig): OCM Ed25519 keys, JWKS endpoint, http-sig capability OCM dual-stack integration of RFC 9421 alongside the existing cavage publicKey path: - OCMSignatoryManager: Ed25519 active/pending/retiring slot rotation backed by numbered pool appkeys, getRemoteKey for inbound JWK lookup with per-origin cache + cache-miss refetch, and getLocalEd25519Jwks for the JWKS endpoint. - Rfc9421SignatoryManager: per-call wrapper that swaps in the Ed25519 signatory and toggles `rfc9421.format`. - OCMJwksHandler: serves /.well-known/jwks.json (RFC 7517) when signing is enabled. - OCMDiscoveryService: advertises `http-sig` in capabilities when signing is enabled, and picks the signature scheme on outbound based on the remote's advertised capabilities. - Application.php: register the JWKS well-known handler. Signed-off-by: Micke Nordin --- core/AppInfo/Application.php | 2 + lib/private/OCM/OCMDiscoveryService.php | 42 +- lib/private/OCM/OCMJwksHandler.php | 49 +++ lib/private/OCM/OCMSignatoryManager.php | 414 +++++++++++++++++- lib/private/OCM/Rfc9421SignatoryManager.php | 56 +++ tests/lib/OCM/DiscoveryServiceTest.php | 7 + tests/lib/OCM/OCMJwksHandlerTest.php | 117 +++++ tests/lib/OCM/OCMSignatoryManagerJwksTest.php | 177 ++++++++ .../OCM/OCMSignatoryManagerRotationTest.php | 273 ++++++++++++ tests/lib/OCM/Rfc9421SignatoryManagerTest.php | 78 ++++ 10 files changed, 1185 insertions(+), 30 deletions(-) create mode 100644 lib/private/OCM/OCMJwksHandler.php create mode 100644 lib/private/OCM/Rfc9421SignatoryManager.php create mode 100644 tests/lib/OCM/OCMJwksHandlerTest.php create mode 100644 tests/lib/OCM/OCMSignatoryManagerJwksTest.php create mode 100644 tests/lib/OCM/OCMSignatoryManagerRotationTest.php create mode 100644 tests/lib/OCM/Rfc9421SignatoryManagerTest.php diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index 15cf42c4a5505..cd655ac386f98 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -23,6 +23,7 @@ use OC\Core\Listener\PasswordUpdatedListener; use OC\Core\Notification\CoreNotifier; use OC\OCM\OCMDiscoveryHandler; +use OC\OCM\OCMJwksHandler; use OC\TagManager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -88,6 +89,7 @@ public function register(IRegistrationContext $context): void { $context->registerConfigLexicon(ConfigLexicon::class); $context->registerWellKnownHandler(OCMDiscoveryHandler::class); + $context->registerWellKnownHandler(OCMJwksHandler::class); $context->registerCapability(Capabilities::class); } diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 9459e9a03f043..77b7d63ec0d53 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -199,10 +199,15 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { return $provider; } + $signingEnabled = !$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true); + $provider->setEnabled(true); $provider->setApiVersion(self::API_VERSION); $provider->setEndPoint(substr($url, 0, $pos)); $provider->setCapabilities(['invite-accepted', 'notifications', 'shares']); + if ($signingEnabled) { + $provider->setCapabilities(['http-sig']); + } // The inviteAcceptDialog is available from the contacts app, if this config value is set $inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG); @@ -217,9 +222,8 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { $provider->addResourceType($resource); if ($fullDetails) { - // Adding a public key to the ocm discovery try { - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + if ($signingEnabled) { /** * @experimental 31.0.0 * @psalm-suppress UndefinedInterfaceMethod @@ -342,10 +346,11 @@ public function requestRemoteOcmEndpoint( } /** - * add entries to the payload to auth the whole request + * Sign the outgoing payload using the scheme the remote advertises + * (RFC 9421 if `http-sig`, else cavage if a `publicKey` is present). + * APPCONFIG_SIGN_ENFORCED / APPCONFIG_SIGN_DISABLED still apply. * * @throws OCMProviderException - * @return array */ private function prepareOcmPayload(string $uri, string $method, array $options, string $payload, bool $signed): array { $payload = array_merge($this->generateRequestOptions($options), ['body' => $payload]); @@ -353,20 +358,31 @@ private function prepareOcmPayload(string $uri, string $method, array $options, return $payload; } - if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true) - && $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) { + $origin = $this->signatureManager->extractIdentityFromUri($uri); + $ocmProvider = $this->discover($origin); + + $useRfc9421 = $ocmProvider->hasCapability('http-sig'); + $hasPublicKey = $this->signatoryManager->getRemoteSignatory($origin) !== null; + + if (!$useRfc9421 && !$hasPublicKey + && $this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { throw new OCMProviderException('remote endpoint does not support signed request'); } - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { - $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( - $this->signatoryManager, - $payload, - $method, $uri - ); + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + return $payload; } - return $signedPayload ?? $payload; + $signatoryManager = $useRfc9421 + ? new Rfc9421SignatoryManager($this->signatoryManager) + : $this->signatoryManager; + + return $this->signatureManager->signOutgoingRequestIClientPayload( + $signatoryManager, + $payload, + $method, + $uri, + ); } private function generateRequestOptions(array $options): array { diff --git a/lib/private/OCM/OCMJwksHandler.php b/lib/private/OCM/OCMJwksHandler.php new file mode 100644 index 0000000000000..281c3eaab2d88 --- /dev/null +++ b/lib/private/OCM/OCMJwksHandler.php @@ -0,0 +1,49 @@ +appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + try { + foreach ($this->signatoryManager->getLocalEd25519Jwks() as $jwk) { + $keys[] = $jwk; + } + } catch (Throwable $e) { + $this->logger->warning('failed to build local Ed25519 JWKs', ['exception' => $e]); + } + } + + return new GenericResponse(new JSONResponse(['keys' => $keys])); + } +} diff --git a/lib/private/OCM/OCMSignatoryManager.php b/lib/private/OCM/OCMSignatoryManager.php index b239a4d1bceca..d60dc845e4ab7 100644 --- a/lib/private/OCM/OCMSignatoryManager.php +++ b/lib/private/OCM/OCMSignatoryManager.php @@ -9,21 +9,31 @@ namespace OC\OCM; +use Firebase\JWT\JWK; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use JsonException; use OC\Security\IdentityProof\Manager; +use OC\Security\Signature\Rfc9421\Algorithm; +use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager; +use OCP\Http\Client\IClientService; use OCP\IAppConfig; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; use OCP\IURLGenerator; use OCP\OCM\Exceptions\OCMProviderException; use OCP\Security\Signature\Enum\DigestAlgorithm; use OCP\Security\Signature\Enum\SignatoryType; use OCP\Security\Signature\Enum\SignatureAlgorithm; use OCP\Security\Signature\Exceptions\IdentityNotFoundException; -use OCP\Security\Signature\ISignatoryManager; use OCP\Security\Signature\ISignatureManager; use OCP\Security\Signature\Model\Signatory; use OCP\Server; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; +use Throwable; /** * @inheritDoc @@ -33,19 +43,41 @@ * * @since 31.0.0 */ -class OCMSignatoryManager implements ISignatoryManager { +class OCMSignatoryManager implements IJwkResolvingSignatoryManager { public const PROVIDER_ID = 'ocm'; public const APPCONFIG_SIGN_IDENTITY_EXTERNAL = 'ocm_signed_request_identity_external'; public const APPCONFIG_SIGN_DISABLED = 'ocm_signed_request_disabled'; 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_'; + /** Stable kid identity portion, reused across rotations so kids stay on one hostname. */ + private const APPCONFIG_ED25519_KID_BASE = 'ocm_ed25519_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]; + /** Remote JWKS cache TTL (seconds). */ + private const JWKS_CACHE_TTL = 3600; + + private readonly ICache $jwksCache; public function __construct( private readonly IAppConfig $appConfig, private readonly ISignatureManager $signatureManager, private readonly IURLGenerator $urlGenerator, private readonly Manager $identityProofManager, + private readonly IClientService $clientService, + private readonly IConfig $config, + ICacheFactory $cacheFactory, private readonly LoggerInterface $logger, ) { + $this->jwksCache = $cacheFactory->createDistributed('ocm-jwks'); } /** @@ -91,21 +123,16 @@ public function getLocalSignatory(): Signatory { * TODO: manage multiple identity (external, internal, ...) to allow a limitation * based on the requested interface (ie. only accept shares from globalscale) */ - if ($this->appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) { - $identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true); - $keyId = 'https://' . $identity . '/ocm#signature'; - } else { - $keyId = $this->generateKeyId(); - } + $keyId = $this->buildLocalKeyId(self::KEYID_FRAGMENT_CAVAGE); - if (!$this->identityProofManager->hasAppKey('core', 'ocm_external')) { - $this->identityProofManager->generateAppKey('core', 'ocm_external', [ + if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_CAVAGE)) { + $this->identityProofManager->generateAppKey('core', self::APPKEY_CAVAGE, [ 'algorithm' => 'rsa', 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); } - $keyPair = $this->identityProofManager->getAppKey('core', 'ocm_external'); + $keyPair = $this->identityProofManager->getAppKey('core', self::APPKEY_CAVAGE); $signatory = new Signatory(true); $signatory->setKeyId($keyId); @@ -115,28 +142,263 @@ public function getLocalSignatory(): Signatory { } + /** Active Ed25519 signing key, lazily provisioned. */ + public function getLocalEd25519Signatory(): ?Signatory { + $poolId = $this->getSlotPool(self::SLOT_ACTIVE); + if ($poolId === null) { + $poolId = $this->generatePool($this->nextEd25519PoolKid()); + $this->setSlotPool(self::SLOT_ACTIVE, $poolId); + } + return $this->signatoryFromPool($poolId); + } + + /** + * JWKs for the active/pending/retiring slots, in advertise order. The + * active slot is provisioned if missing so first-hit returns a key. + * + * @return list> + */ + public function getLocalEd25519Jwks(): array { + if ($this->getSlotPool(self::SLOT_ACTIVE) === null) { + $this->getLocalEd25519Signatory(); + } + + $jwks = []; + foreach (self::ED25519_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()); + } + } + return $jwks; + } + + /** + * Generate a pending Ed25519 keypair (advertised in JWKS, not yet used + * for outbound signing). + * + * @throws \RuntimeException if pending is already populated + */ + public function stageEd25519Key(): Signatory { + if ($this->getSlotPool(self::SLOT_PENDING) !== null) { + throw new \RuntimeException('a pending Ed25519 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(); + } + $poolId = $this->generatePool($this->nextEd25519PoolKid()); + $this->setSlotPool(self::SLOT_PENDING, $poolId); + $signatory = $this->signatoryFromPool($poolId); + if ($signatory === null) { + throw new \RuntimeException('failed to materialise newly staged Ed25519 key'); + } + return $signatory; + } + + /** + * pending -> active, previous active -> retiring. The retiring slot + * stays in JWKS until {@see retireEd25519Key} is run. + * + * @throws \RuntimeException if no pending key is staged, or retiring is occupied + */ + public function activateStagedEd25519Key(): void { + $pending = $this->getSlotPool(self::SLOT_PENDING); + if ($pending === null) { + throw new \RuntimeException('no pending Ed25519 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'); + } + $active = $this->getSlotPool(self::SLOT_ACTIVE); + + $this->setSlotPool(self::SLOT_ACTIVE, $pending); + $this->clearSlot(self::SLOT_PENDING); + if ($active !== null) { + $this->setSlotPool(self::SLOT_RETIRING, $active); + } + } + + /** + * Delete the retiring key. In-flight signatures referencing its kid + * stop verifying after this returns. + * + * @throws \RuntimeException if retiring is empty + */ + public function retireEd25519Key(): void { + $poolId = $this->getSlotPool(self::SLOT_RETIRING); + if ($poolId === null) { + throw new \RuntimeException('no retiring Ed25519 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->clearSlot(self::SLOT_RETIRING); + } + + /** + * Diagnostics snapshot. `slot` is null for orphaned pools. + * + * @return list + */ + public function listEd25519Keys(): array { + $bySlot = []; + foreach (self::ED25519_SLOTS as $slot) { + $id = $this->getSlotPool($slot); + if ($id !== null) { + $bySlot[$id] = $slot; + } + } + + $max = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0); + $entries = []; + for ($id = 1; $id <= $max; $id++) { + if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $id)) { + continue; + } + $entries[] = [ + 'poolId' => $id, + 'kid' => $this->canonicalKid( + $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $id, ''), + ), + 'slot' => $bySlot[$id] ?? null, + ]; + } + return $entries; + } + + /** + * Generate keypair into a new pool. Kid is canonicalised through + * {@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); + + $this->identityProofManager->generateEd25519AppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId); + $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid)); + return $poolId; + } + + /** Canonical wire-form via a transient {@see Signatory::setKeyId} round-trip. */ + private function canonicalKid(string $kid): string { + $probe = new Signatory(true); + $probe->setKeyId($kid); + return $probe->getKeyId(); + } + /** - * - tries to generate a keyId using global configuration (from signature manager) if available - * - generate a keyId using the current route to ocm shares + * Build the next kid. Identity portion is derived once and persisted so + * CLI-triggered rotations stay on the same hostname. * + * @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; + return $base . '-' . $next; + } + + /** + * Stable identity portion (before the `-N` suffix). Resolution order: + * stored APPCONFIG_ED25519_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, ''); + if ($base !== '') { + return $base; + } + + $activePool = $this->getSlotPool(self::SLOT_ACTIVE); + if ($activePool !== null) { + $kid = $this->canonicalKid( + $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $activePool, ''), + ); + $pos = strrpos($kid, '-'); + if ($pos !== false) { + $base = substr($kid, 0, $pos); + } + } + + if ($base === '') { + try { + $base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_ED25519)); + } catch (IdentityNotFoundException $e) { + throw new \RuntimeException('cannot derive instance identity for Ed25519 kid', 0, $e); + } + } + + $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_KID_BASE, $base); + return $base; + } + + private function getSlotPool(string $slot): ?int { + $key = 'ocm_ed25519_slot_' . $slot; + if (!$this->appConfig->hasKey('core', $key)) { + return null; + } + $value = $this->appConfig->getValueInt('core', $key, 0); + return $value > 0 ? $value : null; + } + + private function setSlotPool(string $slot, int $poolId): void { + $this->appConfig->setValueInt('core', 'ocm_ed25519_slot_' . $slot, $poolId); + } + + private function clearSlot(string $slot): void { + $this->appConfig->deleteKey('core', 'ocm_ed25519_slot_' . $slot); + } + + /** Returns null if the underlying appkey was manually deleted. */ + private function signatoryFromPool(int $poolId): ?Signatory { + $appKey = self::APPKEY_ED25519_POOL_PREFIX . $poolId; + if (!$this->identityProofManager->hasAppKey('core', $appKey)) { + return null; + } + $kid = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, ''); + if ($kid === '') { + return null; + } + $keyPair = $this->identityProofManager->getAppKey('core', $appKey); + $signatory = new Signatory(true); + $signatory->setKeyId($kid); + $signatory->setPublicKey($keyPair->getPublic()); + $signatory->setPrivateKey($keyPair->getPrivate()); + return $signatory; + } + + /** + * @param string $fragment URL fragment (e.g. 'signature', 'ed25519') * @return string * @throws IdentityNotFoundException */ - private function generateKeyId(): string { + private function buildLocalKeyId(string $fragment): string { + if ($this->appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) { + $identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true); + return 'https://' . $identity . '/ocm#' . $fragment; + } + try { - return $this->signatureManager->generateKeyIdFromConfig('/ocm#signature'); + return $this->signatureManager->generateKeyIdFromConfig('/ocm#' . $fragment); } catch (IdentityNotFoundException) { } $url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare'); $identity = $this->signatureManager->extractIdentityFromUri($url); - // catching possible subfolder to create a keyId like 'https://hostname/subfolder/ocm#signature + // catching possible subfolder to create a keyId like 'https://hostname/subfolder/ocm#' $path = parse_url($url, PHP_URL_PATH); $pos = strpos($path, '/ocm/shares'); $sub = ($pos) ? substr($path, 0, $pos) : ''; - return 'https://' . $identity . $sub . '/ocm#signature'; + return 'https://' . $identity . $sub . '/ocm#' . $fragment; } /** @@ -163,4 +425,122 @@ public function getRemoteSignatory(string $remote): ?Signatory { return null; } } + + /** + * Resolve a peer's JWK by kid. Cached per-origin for {@see JWKS_CACHE_TTL}s + * with a single refetch on cache-hit-but-kid-missing so rotations propagate. + */ + #[\Override] + public function getRemoteKey(string $origin, string $keyId): ?Key { + $keys = $this->readCachedJwks($origin); + $fromCache = $keys !== null; + if (!$fromCache) { + $keys = $this->fetchJwks($origin); + if ($keys !== null) { + $this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL); + } + } + + $key = $this->findKid($keys, $keyId); + if ($key !== null) { + return $key; + } + // Only refetch when the miss came from cache; fresh is authoritative. + if (!$fromCache) { + return null; + } + + $keys = $this->fetchJwks($origin); + if ($keys === null) { + return null; + } + $this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL); + return $this->findKid($keys, $keyId); + } + + /** @return list>|null null on cold/corrupt cache */ + private function readCachedJwks(string $origin): ?array { + $cached = $this->jwksCache->get($origin); + if (!is_string($cached)) { + return null; + } + try { + $decoded = json_decode($cached, true, 8, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + if (!is_array($decoded)) { + return null; + } + /** @var list> $decoded */ + return array_values(array_filter($decoded, 'is_array')); + } + + /** + * @return list>|null + */ + private function fetchJwks(string $origin): ?array { + $url = 'https://' . $origin . '/.well-known/jwks.json'; + $options = [ + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates') === true) { + $options['verify'] = false; + } + + try { + $response = $this->clientService->newClient()->get($url, $options); + } catch (Throwable $e) { + $this->logger->warning('failed to fetch remote JWKS', ['exception' => $e, 'url' => $url]); + return null; + } + + try { + $decoded = json_decode((string)$response->getBody(), true, 8, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->warning('remote JWKS is not valid JSON', ['exception' => $e, 'url' => $url]); + return null; + } + + if (!is_array($decoded) || !is_array($decoded['keys'] ?? null)) { + return null; + } + return array_values(array_filter($decoded['keys'], 'is_array')); + } + + /** + * @param list>|null $keys + */ + private function findKid(?array $keys, string $keyId): ?Key { + if ($keys === null) { + return null; + } + foreach ($keys as $entry) { + if (($entry['kid'] ?? null) !== $keyId) { + continue; + } + try { + return JWK::parseKey($entry, Algorithm::deriveJoseAlgFromJwk($entry)); + } catch (Throwable $e) { + $this->logger->warning('failed to parse remote JWK', ['exception' => $e, 'kid' => $keyId]); + return null; + } + } + return null; + } + + /** + * @return array + */ + private static function buildEd25519JwkArray(string $rawPublicKey, string $kid): array { + return [ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => $kid, + 'alg' => 'EdDSA', + 'use' => 'sig', + 'x' => JWT::urlsafeB64Encode($rawPublicKey), + ]; + } } diff --git a/lib/private/OCM/Rfc9421SignatoryManager.php b/lib/private/OCM/Rfc9421SignatoryManager.php new file mode 100644 index 0000000000000..f0756d9ca6eff --- /dev/null +++ b/lib/private/OCM/Rfc9421SignatoryManager.php @@ -0,0 +1,56 @@ +delegate->getProviderId(); + } + + #[\Override] + public function getOptions(): array { + return array_merge($this->delegate->getOptions(), ['rfc9421.format' => true]); + } + + #[\Override] + public function getLocalSignatory(): Signatory { + $signatory = $this->delegate->getLocalEd25519Signatory(); + if ($signatory === null) { + throw new IdentityNotFoundException('no Ed25519 signatory available'); + } + return $signatory; + } + + #[\Override] + public function getRemoteSignatory(string $remote): ?Signatory { + return $this->delegate->getRemoteSignatory($remote); + } + + #[\Override] + public function getRemoteKey(string $origin, string $keyId): ?Key { + return $this->delegate->getRemoteKey($origin, $keyId); + } +} diff --git a/tests/lib/OCM/DiscoveryServiceTest.php b/tests/lib/OCM/DiscoveryServiceTest.php index 1cf026a64bc0b..58a22a07bd166 100644 --- a/tests/lib/OCM/DiscoveryServiceTest.php +++ b/tests/lib/OCM/DiscoveryServiceTest.php @@ -128,6 +128,13 @@ public function testLocalBaseCapability(): void { $this->assertEmpty(array_diff(['notifications', 'shares'], $local->getCapabilities())); } + public function testLocalCapabilitiesAdvertiseHttpSigByDefault(): void { + // `http-sig` is the OCM-spec flag signalling RFC 9421 support backed + // by /.well-known/jwks.json. Advertised whenever signing is not + // disabled outright. + $local = $this->discoveryService->getLocalOCMProvider(); + $this->assertTrue($local->hasCapability('http-sig')); + } public function testLocalAddedCapability(): void { $this->context->for('ocm-capability-app')->registerEventListener(LocalOCMDiscoveryEvent::class, LocalOCMDiscoveryTestEvent::class); diff --git a/tests/lib/OCM/OCMJwksHandlerTest.php b/tests/lib/OCM/OCMJwksHandlerTest.php new file mode 100644 index 0000000000000..7040b19f67537 --- /dev/null +++ b/tests/lib/OCM/OCMJwksHandlerTest.php @@ -0,0 +1,117 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->context = $this->createMock(IRequestContext::class); + + $this->handler = new OCMJwksHandler( + $this->appConfig, + $this->signatoryManager, + $this->logger, + ); + } + + public function testIgnoresUnrelatedService(): void { + $previous = new JrdResponse('foo'); + $result = $this->handler->handle('webfinger', $this->context, $previous); + $this->assertSame($previous, $result); + } + + public function testEmptyKeySetWhenSigningDisabled(): void { + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, false, true) + ->willReturn(true); + $this->signatoryManager->expects($this->never())->method('getLocalEd25519Jwks'); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => []], $body); + } + + public function testPublishesEd25519JwksWhenAvailable(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $jwk = [ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => 'https://example.org/ocm#ed25519', + 'alg' => 'EdDSA', + 'use' => 'sig', + 'x' => 'AAAA', + ]; + $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$jwk]); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => [$jwk]], $body); + } + + public function testPublishesAllSlotsAdvertisedDuringRotation(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $active = [ + 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-1', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'AAAA', + ]; + $pending = [ + 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-2', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'BBBB', + ]; + $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$active, $pending]); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => [$active, $pending]], $body); + } + + public function testEmptyKeySetWhenSignatoryUnavailable(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([]); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => []], $body); + } + + public function testFailingJwkBuildIsLoggedAndYieldsEmptyKeySet(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $this->signatoryManager->method('getLocalEd25519Jwks') + ->willThrowException(new \RuntimeException('boom')); + $this->logger->expects($this->once())->method('warning'); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => []], $body); + } + + private function jsonBody(?IResponse $response): array { + $this->assertInstanceOf(GenericResponse::class, $response); + $http = $response->toHttpResponse(); + $this->assertInstanceOf(JSONResponse::class, $http); + return $http->getData(); + } +} diff --git a/tests/lib/OCM/OCMSignatoryManagerJwksTest.php b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php new file mode 100644 index 0000000000000..7fcc0818e31fc --- /dev/null +++ b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php @@ -0,0 +1,177 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->identityProofManager = $this->createMock(IdentityProofManager::class); + $this->clientService = $this->createMock(IClientService::class); + $this->config = $this->createMock(IConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->client = $this->createMock(IClient::class); + + $this->clientService->method('newClient')->willReturn($this->client); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache('')); + + $this->signatoryManager = new OCMSignatoryManager( + $this->appConfig, + $this->signatureManager, + $this->urlGenerator, + $this->identityProofManager, + $this->clientService, + $this->config, + $cacheFactory, + $this->logger, + ); + } + + public function testGetRemoteKeyFetchesAndMatchesByKid(): void { + $kid = 'sender.example.org#key1'; + $jwks = [ + 'keys' => [ + ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'other', 'x' => 'AAAA'], + ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'BBBB'], + ], + ]; + $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()); + } + + public function testGetRemoteKeyReturnsNullWhenKidMissing(): void { + $this->respondWith(['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'unrelated', 'x' => 'AAAA']]]); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'other-kid')); + } + + public function testGetRemoteKeyReturnsNullOnHttpError(): void { + $this->client->method('get')->willThrowException(new \RuntimeException('boom')); + $this->logger->expects($this->once())->method('warning'); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyReturnsNullOnInvalidJson(): void { + $response = $this->createMock(IResponse::class); + $response->method('getBody')->willReturn('not json'); + $this->client->method('get')->willReturn($response); + $this->logger->expects($this->once())->method('warning'); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyReturnsNullWhenKeysMissing(): void { + $this->respondWith(['no-keys-here' => []]); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyReturnsNullOnUnparseableJwk(): void { + // JWK with kty=OKP but no crv: parseKey rejects. + $this->respondWith(['keys' => [['kty' => 'OKP', 'kid' => 'kid', 'x' => 'AAAA']]]); + $this->logger->expects($this->once())->method('warning'); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyUsesWellKnownPath(): void { + $this->client->expects($this->once()) + ->method('get') + ->with( + $this->equalTo('https://sender.example.org/.well-known/jwks.json'), + $this->isType('array'), + ) + ->willReturn($this->jsonResponse(['keys' => []])); + + $this->signatoryManager->getRemoteKey('sender.example.org', 'kid'); + } + + public function testGetRemoteKeyPassesSelfSignedFlagThrough(): void { + $this->config->method('getSystemValueBool') + ->with('sharing.federation.allowSelfSignedCertificates') + ->willReturn(true); + + $this->client->expects($this->once()) + ->method('get') + ->with( + $this->anything(), + $this->callback(static fn (array $opts): bool => ($opts['verify'] ?? null) === false), + ) + ->willReturn($this->jsonResponse(['keys' => []])); + + $this->signatoryManager->getRemoteKey('sender.example.org', 'kid'); + } + + public function testJwksCachedAcrossCallsToTheSameOrigin(): void { + $kid = 'sender.example.org#key1'; + $jwks = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'AAAA']]]; + $this->client->expects($this->once()) + ->method('get') + ->willReturn($this->jsonResponse($jwks)); + + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', $kid)); + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', $kid)); + } + + public function testCacheMissOnNewKidTriggersRefetchOnce(): void { + $first = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'old', 'x' => 'AAAA']]]; + $second = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'new', 'x' => 'BBBB']]]; + $this->client->expects($this->exactly(2)) + ->method('get') + ->willReturnOnConsecutiveCalls( + $this->jsonResponse($first), + $this->jsonResponse($second), + ); + + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', 'old')); + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', 'new')); + } + + private function respondWith(array $body): void { + $this->client->method('get')->willReturn($this->jsonResponse($body)); + } + + private function jsonResponse(array $body): IResponse { + $response = $this->createMock(IResponse::class); + $response->method('getBody')->willReturn(json_encode($body, JSON_THROW_ON_ERROR)); + return $response; + } +} diff --git a/tests/lib/OCM/OCMSignatoryManagerRotationTest.php b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php new file mode 100644 index 0000000000000..9b52d88c61f7b --- /dev/null +++ b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php @@ -0,0 +1,273 @@ + in-memory backing store for IAppConfig core/* */ + private array $appConfigStore = []; + /** @var array in-memory backing store for IdentityProofManager appkeys */ + private array $appKeyStore = []; + + #[\Override] + protected function setUp(): void { + parent::setUp(); + + $this->appConfig = $this->createMock(IAppConfig::class); + $this->identityProofManager = $this->createMock(IdentityProofManager::class); + + $this->wireAppConfig(); + $this->wireIdentityProofManager(); + + $signatureManager = $this->createMock(ISignatureManager::class); + $signatureManager->method('generateKeyIdFromConfig') + ->willReturnCallback(static fn (string $suffix): string => 'https://alice.example/' . ltrim($suffix, '/')); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache('')); + + $this->signatoryManager = new OCMSignatoryManager( + $this->appConfig, + $signatureManager, + $this->createMock(IURLGenerator::class), + $this->identityProofManager, + $this->stubClientService(), + $this->createMock(IConfig::class), + $cacheFactory, + $this->createMock(LoggerInterface::class), + ); + } + + public function testJwksBootstrapsActiveKeyOnFirstFetch(): void { + // Fresh instance: first JWKS hit must provision the active key. + $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $this->assertCount(1, $jwks); + $this->assertSame('https://alice.example/ocm#ed25519-1', $jwks[0]['kid']); + + // And the bootstrapped key is the active one for outbound signing. + $signatory = $this->signatoryManager->getLocalEd25519Signatory(); + $this->assertSame($jwks[0]['kid'], $signatory->getKeyId()); + } + + public function testFirstCallProvisionsActiveKey(): void { + $signatory = $this->signatoryManager->getLocalEd25519Signatory(); + $this->assertNotNull($signatory); + $this->assertSame('https://alice.example/ocm#ed25519-1', $signatory->getKeyId()); + + $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $this->assertCount(1, $jwks); + $this->assertSame($signatory->getKeyId(), $jwks[0]['kid']); + + $listed = $this->signatoryManager->listEd25519Keys(); + $this->assertSame([['poolId' => 1, 'kid' => $signatory->getKeyId(), 'slot' => 'active']], $listed); + } + + public function testStageDoesNotChangeActiveSignerButPublishesNewJwk(): void { + $initial = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + $this->assertNotSame($initial->getKeyId(), $staged->getKeyId()); + + // Active signer is unchanged. + $this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId()); + + // JWKS now advertises both kids, active first then pending. + $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $this->assertSame([$initial->getKeyId(), $staged->getKeyId()], array_column($jwks, 'kid')); + } + + public function testStageRefusesIfPendingAlreadyExists(): void { + $this->signatoryManager->stageEd25519Key(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/pending Ed25519 key already exists/'); + $this->signatoryManager->stageEd25519Key(); + } + + public function testActivatePromotesPendingAndDemotesActive(): void { + $first = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->activateStagedEd25519Key(); + + // New signer is the formerly-staged key. + $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->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'); + $this->assertContains($first->getKeyId(), $kids); + $this->assertContains($staged->getKeyId(), $kids); + } + + public function testActivateRefusesIfRetiringStillPopulated(): void { + $this->signatoryManager->getLocalEd25519Signatory(); + $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->activateStagedEd25519Key(); + // 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->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/retiring Ed25519 key still exists/'); + $this->signatoryManager->activateStagedEd25519Key(); + } + + public function testActivateRefusesWithoutPendingKey(): void { + $this->signatoryManager->getLocalEd25519Signatory(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/no pending Ed25519 key/'); + $this->signatoryManager->activateStagedEd25519Key(); + } + + public function testRetireRemovesRetiringKeyFromJwks(): void { + $first = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->activateStagedEd25519Key(); + $this->signatoryManager->retireEd25519Key(); + + $kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid'); + $this->assertSame([$staged->getKeyId()], $kids); + // listEd25519Keys also drops the retired pool. + $listed = $this->signatoryManager->listEd25519Keys(); + $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->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/no retiring Ed25519 key/'); + $this->signatoryManager->retireEd25519Key(); + } + + public function testKidStaysStableThroughLifecycle(): void { + $first = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + // 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->retireEd25519Key(); + $this->signatoryManager->stageEd25519Key(); + // And every newly minted kid must differ from prior ones, no pool + // counter rewinding. + $kids = array_column($this->signatoryManager->listEd25519Keys(), 'kid'); + $this->assertNotContains($first->getKeyId(), $kids); + $this->assertSame($kids, array_unique($kids)); + } + + public function testSignerReturnsNullWhenIdentityCannotBeDerived(): void { + // Replace the signature manager with one that cannot derive an + // identity at all; provisioning the first key should fail loudly so + // the admin gets a clear message instead of a corrupt half-state. + $signatureManager = $this->createMock(ISignatureManager::class); + $signatureManager->method('generateKeyIdFromConfig') + ->willThrowException(new IdentityNotFoundException('no identity')); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRouteAbsolute') + ->willThrowException(new IdentityNotFoundException('no url either')); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache('')); + + $manager = new OCMSignatoryManager( + $this->appConfig, + $signatureManager, + $urlGenerator, + $this->identityProofManager, + $this->stubClientService(), + $this->createMock(IConfig::class), + $cacheFactory, + $this->createMock(LoggerInterface::class), + ); + + $this->expectException(\RuntimeException::class); + $manager->getLocalEd25519Signatory(); + } + + private function wireAppConfig(): void { + $this->appConfig->method('hasKey')->willReturnCallback( + fn (string $app, string $key): bool => $app === 'core' && array_key_exists($key, $this->appConfigStore) + ); + $this->appConfig->method('getValueInt')->willReturnCallback( + fn (string $app, string $key, int $default = 0): int => (int)($this->appConfigStore[$key] ?? $default) + ); + $this->appConfig->method('setValueInt')->willReturnCallback( + function (string $app, string $key, int $value): bool { + $this->appConfigStore[$key] = (string)$value; + return true; + } + ); + $this->appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '') => $this->appConfigStore[$key] ?? $default + ); + $this->appConfig->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value): bool { + $this->appConfigStore[$key] = $value; + return true; + } + ); + $this->appConfig->method('getValueBool')->willReturn(false); + $this->appConfig->method('deleteKey')->willReturnCallback( + function (string $app, string $key): void { + unset($this->appConfigStore[$key]); + } + ); + } + + private function wireIdentityProofManager(): void { + $this->identityProofManager->method('hasAppKey')->willReturnCallback( + fn (string $app, string $name): bool => isset($this->appKeyStore[$app . '/' . $name]) + ); + $this->identityProofManager->method('generateEd25519AppKey')->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)); + $this->appKeyStore[$app . '/' . $name] = $key; + return $key; + } + ); + $this->identityProofManager->method('getAppKey')->willReturnCallback( + fn (string $app, string $name): Key => $this->appKeyStore[$app . '/' . $name] + ); + $this->identityProofManager->method('deleteAppKey')->willReturnCallback( + function (string $app, string $name): bool { + $existed = isset($this->appKeyStore[$app . '/' . $name]); + unset($this->appKeyStore[$app . '/' . $name]); + return $existed; + } + ); + } + + private function stubClientService(): IClientService&MockObject { + $service = $this->createMock(IClientService::class); + $service->method('newClient')->willReturn($this->createMock(IClient::class)); + return $service; + } +} diff --git a/tests/lib/OCM/Rfc9421SignatoryManagerTest.php b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php new file mode 100644 index 0000000000000..f186986cf81a2 --- /dev/null +++ b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php @@ -0,0 +1,78 @@ +delegate = $this->createMock(OCMSignatoryManager::class); + $this->wrapper = new Rfc9421SignatoryManager($this->delegate); + } + + public function testGetOptionsForcesRfc9421Format(): void { + $this->delegate->method('getOptions')->willReturn([ + 'algorithm' => 'rsa-sha512', + 'rfc9421.format' => false, + ]); + + $options = $this->wrapper->getOptions(); + $this->assertTrue($options['rfc9421.format']); + $this->assertSame('rsa-sha512', $options['algorithm']); + } + + public function testGetLocalSignatoryReturnsEd25519Key(): void { + $signatory = $this->createMock(Signatory::class); + $this->delegate->method('getLocalEd25519Signatory')->willReturn($signatory); + + $this->assertSame($signatory, $this->wrapper->getLocalSignatory()); + } + + public function testGetLocalSignatoryThrowsWhenEd25519Unavailable(): void { + $this->delegate->method('getLocalEd25519Signatory')->willReturn(null); + + $this->expectException(IdentityNotFoundException::class); + $this->wrapper->getLocalSignatory(); + } + + public function testProviderIdDelegated(): void { + $this->delegate->method('getProviderId')->willReturn('ocm'); + $this->assertSame('ocm', $this->wrapper->getProviderId()); + } + + public function testRemoteSignatoryDelegated(): void { + $signatory = $this->createMock(Signatory::class); + $this->delegate->expects($this->once()) + ->method('getRemoteSignatory') + ->with('sender.example.org') + ->willReturn($signatory); + $this->assertSame($signatory, $this->wrapper->getRemoteSignatory('sender.example.org')); + } + + public function testRemoteKeyDelegated(): void { + $key = $this->createMock(Key::class); + $this->delegate->expects($this->once()) + ->method('getRemoteKey') + ->with('sender.example.org', 'kid-1') + ->willReturn($key); + $this->assertSame($key, $this->wrapper->getRemoteKey('sender.example.org', 'kid-1')); + } +} From 7758bd4f4dbc0aa1e3d07dcf1fcfaecf51086d8c Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:54 +0200 Subject: [PATCH 06/11] feat(http-sig): occ commands to manage Ed25519 keys ocm:keys:list list known keys with their slot and kid ocm:keys:stage generate a pending key, advertise via JWKS ocm:keys:activate promote pending -> active, demote previous active ocm:keys:retire delete the retiring key (kid stops resolving) Plus the autoloader regen covering the new classes from this branch. Signed-off-by: Micke Nordin --- core/Command/OCM/ActivateKey.php | 42 ++++++++++++++++ core/Command/OCM/ListKeys.php | 54 +++++++++++++++++++++ core/Command/OCM/RetireKey.php | 41 ++++++++++++++++ core/Command/OCM/StageKey.php | 42 ++++++++++++++++ core/register_command.php | 9 ++++ lib/composer/composer/autoload_classmap.php | 12 +++++ lib/composer/composer/autoload_static.php | 12 +++++ 7 files changed, 212 insertions(+) create mode 100644 core/Command/OCM/ActivateKey.php create mode 100644 core/Command/OCM/ListKeys.php create mode 100644 core/Command/OCM/RetireKey.php create mode 100644 core/Command/OCM/StageKey.php diff --git a/core/Command/OCM/ActivateKey.php b/core/Command/OCM/ActivateKey.php new file mode 100644 index 0000000000000..090538e00248d --- /dev/null +++ b/core/Command/OCM/ActivateKey.php @@ -0,0 +1,42 @@ +setName('ocm:keys:activate') + ->setDescription('promote the staged Ed25519 key to active; the previous active key moves to retiring'); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->signatoryManager->activateStagedEd25519Key(); + } catch (\RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Staged key promoted to active.'); + $output->writeln('Run occ ocm:keys:retire once any in-flight signatures using the previous key have been verified.'); + return 0; + } +} diff --git a/core/Command/OCM/ListKeys.php b/core/Command/OCM/ListKeys.php new file mode 100644 index 0000000000000..f73a476311134 --- /dev/null +++ b/core/Command/OCM/ListKeys.php @@ -0,0 +1,54 @@ +setName('ocm:keys:list') + ->setDescription('list Ed25519 keys used by OCM RFC 9421 HTTP Message Signatures'); + parent::configure(); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + $keys = $this->signatoryManager->listEd25519Keys(); + $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)); + return 0; + } + + if ($keys === []) { + $output->writeln('No Ed25519 keys yet; one will be generated on first OCM request.'); + return 0; + } + + $table = new Table($output); + $table->setHeaders(['Pool', 'Slot', 'Key ID']); + foreach ($keys as $key) { + $table->addRow([$key['poolId'], $key['slot'] ?? '-', $key['kid']]); + } + $table->render(); + return 0; + } +} diff --git a/core/Command/OCM/RetireKey.php b/core/Command/OCM/RetireKey.php new file mode 100644 index 0000000000000..58db976077c5f --- /dev/null +++ b/core/Command/OCM/RetireKey.php @@ -0,0 +1,41 @@ +setName('ocm:keys:retire') + ->setDescription('delete the retiring Ed25519 key; signatures that referenced its kid can no longer be verified'); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->signatoryManager->retireEd25519Key(); + } catch (\RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Retiring key deleted.'); + return 0; + } +} diff --git a/core/Command/OCM/StageKey.php b/core/Command/OCM/StageKey.php new file mode 100644 index 0000000000000..75437f460bfc2 --- /dev/null +++ b/core/Command/OCM/StageKey.php @@ -0,0 +1,42 @@ +setName('ocm:keys:stage') + ->setDescription('generate a new Ed25519 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(); + } catch (\RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Staged new Ed25519 key: ' . $signatory->getKeyId() . ''); + $output->writeln('Wait for federated peers to refresh their JWKS cache before activating.'); + return 0; + } +} diff --git a/core/register_command.php b/core/register_command.php index d28c1633c62bb..856894b5c4c77 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -74,6 +74,10 @@ use OC\Core\Command\Memcache\DistributedGet; use OC\Core\Command\Memcache\DistributedSet; use OC\Core\Command\Memcache\RedisCommand; +use OC\Core\Command\OCM\ActivateKey as OCMActivateKey; +use OC\Core\Command\OCM\ListKeys as OCMListKeys; +use OC\Core\Command\OCM\RetireKey as OCMRetireKey; +use OC\Core\Command\OCM\StageKey as OCMStageKey; use OC\Core\Command\Preview\Generate; use OC\Core\Command\Preview\ResetRenderedTexts; use OC\Core\Command\Router\ListRoutes; @@ -251,6 +255,11 @@ $application->add(Server::get(SnowflakeDecodeId::class)); $application->add(Server::get(Get::class)); + $application->add(Server::get(OCMListKeys::class)); + $application->add(Server::get(OCMStageKey::class)); + $application->add(Server::get(OCMActivateKey::class)); + $application->add(Server::get(OCMRetireKey::class)); + $application->add(Server::get(GetCommand::class)); $application->add(Server::get(EnabledCommand::class)); $application->add(Server::get(Command\TaskProcessing\ListCommand::class)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 17470c5b72c37..cdf25e9f0fa02 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1405,6 +1405,10 @@ 'OC\\Core\\Command\\Memcache\\DistributedGet' => $baseDir . '/core/Command/Memcache/DistributedGet.php', 'OC\\Core\\Command\\Memcache\\DistributedSet' => $baseDir . '/core/Command/Memcache/DistributedSet.php', 'OC\\Core\\Command\\Memcache\\RedisCommand' => $baseDir . '/core/Command/Memcache/RedisCommand.php', + 'OC\\Core\\Command\\OCM\\ActivateKey' => $baseDir . '/core/Command/OCM/ActivateKey.php', + 'OC\\Core\\Command\\OCM\\ListKeys' => $baseDir . '/core/Command/OCM/ListKeys.php', + 'OC\\Core\\Command\\OCM\\RetireKey' => $baseDir . '/core/Command/OCM/RetireKey.php', + 'OC\\Core\\Command\\OCM\\StageKey' => $baseDir . '/core/Command/OCM/StageKey.php', 'OC\\Core\\Command\\Preview\\Cleanup' => $baseDir . '/core/Command/Preview/Cleanup.php', 'OC\\Core\\Command\\Preview\\Generate' => $baseDir . '/core/Command/Preview/Generate.php', 'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => $baseDir . '/core/Command/Preview/ResetRenderedTexts.php', @@ -1950,7 +1954,9 @@ 'OC\\OCM\\Model\\OCMResource' => $baseDir . '/lib/private/OCM/Model/OCMResource.php', 'OC\\OCM\\OCMDiscoveryHandler' => $baseDir . '/lib/private/OCM/OCMDiscoveryHandler.php', 'OC\\OCM\\OCMDiscoveryService' => $baseDir . '/lib/private/OCM/OCMDiscoveryService.php', + 'OC\\OCM\\OCMJwksHandler' => $baseDir . '/lib/private/OCM/OCMJwksHandler.php', 'OC\\OCM\\OCMSignatoryManager' => $baseDir . '/lib/private/OCM/OCMSignatoryManager.php', + 'OC\\OCM\\Rfc9421SignatoryManager' => $baseDir . '/lib/private/OCM/Rfc9421SignatoryManager.php', 'OC\\OCS\\ApiHelper' => $baseDir . '/lib/private/OCS/ApiHelper.php', 'OC\\OCS\\CoreCapabilities' => $baseDir . '/lib/private/OCS/CoreCapabilities.php', 'OC\\OCS\\DiscoveryService' => $baseDir . '/lib/private/OCS/DiscoveryService.php', @@ -2154,7 +2160,13 @@ 'OC\\Security\\Signature\\Db\\SignatoryMapper' => $baseDir . '/lib/private/Security/Signature/Db/SignatoryMapper.php', 'OC\\Security\\Signature\\Model\\IncomingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/IncomingSignedRequest.php', 'OC\\Security\\Signature\\Model\\OutgoingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/OutgoingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\Rfc9421IncomingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\Rfc9421OutgoingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php', 'OC\\Security\\Signature\\Model\\SignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/SignedRequest.php', + 'OC\\Security\\Signature\\Rfc9421\\Algorithm' => $baseDir . '/lib/private/Security/Signature/Rfc9421/Algorithm.php', + 'OC\\Security\\Signature\\Rfc9421\\ContentDigest' => $baseDir . '/lib/private/Security/Signature/Rfc9421/ContentDigest.php', + 'OC\\Security\\Signature\\Rfc9421\\IJwkResolvingSignatoryManager' => $baseDir . '/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php', + 'OC\\Security\\Signature\\Rfc9421\\SignatureBase' => $baseDir . '/lib/private/Security/Signature/Rfc9421/SignatureBase.php', 'OC\\Security\\Signature\\SignatureManager' => $baseDir . '/lib/private/Security/Signature/SignatureManager.php', 'OC\\Security\\TrustedDomainHelper' => $baseDir . '/lib/private/Security/TrustedDomainHelper.php', 'OC\\Security\\VerificationToken\\CleanUpJob' => $baseDir . '/lib/private/Security/VerificationToken/CleanUpJob.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 3e661000039b7..e28bdc3522c29 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1446,6 +1446,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Command\\Memcache\\DistributedGet' => __DIR__ . '/../../..' . '/core/Command/Memcache/DistributedGet.php', 'OC\\Core\\Command\\Memcache\\DistributedSet' => __DIR__ . '/../../..' . '/core/Command/Memcache/DistributedSet.php', 'OC\\Core\\Command\\Memcache\\RedisCommand' => __DIR__ . '/../../..' . '/core/Command/Memcache/RedisCommand.php', + 'OC\\Core\\Command\\OCM\\ActivateKey' => __DIR__ . '/../../..' . '/core/Command/OCM/ActivateKey.php', + 'OC\\Core\\Command\\OCM\\ListKeys' => __DIR__ . '/../../..' . '/core/Command/OCM/ListKeys.php', + 'OC\\Core\\Command\\OCM\\RetireKey' => __DIR__ . '/../../..' . '/core/Command/OCM/RetireKey.php', + 'OC\\Core\\Command\\OCM\\StageKey' => __DIR__ . '/../../..' . '/core/Command/OCM/StageKey.php', 'OC\\Core\\Command\\Preview\\Cleanup' => __DIR__ . '/../../..' . '/core/Command/Preview/Cleanup.php', 'OC\\Core\\Command\\Preview\\Generate' => __DIR__ . '/../../..' . '/core/Command/Preview/Generate.php', 'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => __DIR__ . '/../../..' . '/core/Command/Preview/ResetRenderedTexts.php', @@ -1991,7 +1995,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\OCM\\Model\\OCMResource' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMResource.php', 'OC\\OCM\\OCMDiscoveryHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryHandler.php', 'OC\\OCM\\OCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryService.php', + 'OC\\OCM\\OCMJwksHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMJwksHandler.php', 'OC\\OCM\\OCMSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMSignatoryManager.php', + 'OC\\OCM\\Rfc9421SignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/Rfc9421SignatoryManager.php', 'OC\\OCS\\ApiHelper' => __DIR__ . '/../../..' . '/lib/private/OCS/ApiHelper.php', 'OC\\OCS\\CoreCapabilities' => __DIR__ . '/../../..' . '/lib/private/OCS/CoreCapabilities.php', 'OC\\OCS\\DiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCS/DiscoveryService.php', @@ -2195,7 +2201,13 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Security\\Signature\\Db\\SignatoryMapper' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Db/SignatoryMapper.php', 'OC\\Security\\Signature\\Model\\IncomingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/IncomingSignedRequest.php', 'OC\\Security\\Signature\\Model\\OutgoingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/OutgoingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\Rfc9421IncomingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\Rfc9421OutgoingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php', 'OC\\Security\\Signature\\Model\\SignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/SignedRequest.php', + 'OC\\Security\\Signature\\Rfc9421\\Algorithm' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/Algorithm.php', + 'OC\\Security\\Signature\\Rfc9421\\ContentDigest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/ContentDigest.php', + 'OC\\Security\\Signature\\Rfc9421\\IJwkResolvingSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php', + 'OC\\Security\\Signature\\Rfc9421\\SignatureBase' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/SignatureBase.php', 'OC\\Security\\Signature\\SignatureManager' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/SignatureManager.php', 'OC\\Security\\TrustedDomainHelper' => __DIR__ . '/../../..' . '/lib/private/Security/TrustedDomainHelper.php', 'OC\\Security\\VerificationToken\\CleanUpJob' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/CleanUpJob.php', From cf6b610ec0655a956e98e47c708992556483310b Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Mon, 11 May 2026 11:37:09 +0200 Subject: [PATCH 07/11] chore: Fix return values Use constants instead of 0/1 Also fix PHPDoc to use correct return values. Co-authored-by: Carl Schwan Signed-off-by: Micke Nordin --- core/Command/OCM/ActivateKey.php | 4 ++-- core/Command/OCM/ListKeys.php | 6 +++--- core/Command/OCM/RetireKey.php | 4 ++-- core/Command/OCM/StageKey.php | 4 ++-- .../Signature/Model/Rfc9421IncomingSignedRequest.php | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/core/Command/OCM/ActivateKey.php b/core/Command/OCM/ActivateKey.php index 090538e00248d..9efc7110a2450 100644 --- a/core/Command/OCM/ActivateKey.php +++ b/core/Command/OCM/ActivateKey.php @@ -33,10 +33,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->signatoryManager->activateStagedEd25519Key(); } catch (\RuntimeException $e) { $output->writeln('' . $e->getMessage() . ''); - return 1; + return self::FAILURE; } $output->writeln('Staged key promoted to active.'); $output->writeln('Run occ ocm:keys:retire once any in-flight signatures using the previous key have been verified.'); - return 0; + return self::SUCCESS; } } diff --git a/core/Command/OCM/ListKeys.php b/core/Command/OCM/ListKeys.php index f73a476311134..221beca558061 100644 --- a/core/Command/OCM/ListKeys.php +++ b/core/Command/OCM/ListKeys.php @@ -35,12 +35,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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)); - return 0; + return self::SUCCESS; } if ($keys === []) { $output->writeln('No Ed25519 keys yet; one will be generated on first OCM request.'); - return 0; + return self::SUCCESS; } $table = new Table($output); @@ -49,6 +49,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $table->addRow([$key['poolId'], $key['slot'] ?? '-', $key['kid']]); } $table->render(); - return 0; + return self::SUCCESS; } } diff --git a/core/Command/OCM/RetireKey.php b/core/Command/OCM/RetireKey.php index 58db976077c5f..8a3c23d29c166 100644 --- a/core/Command/OCM/RetireKey.php +++ b/core/Command/OCM/RetireKey.php @@ -33,9 +33,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->signatoryManager->retireEd25519Key(); } catch (\RuntimeException $e) { $output->writeln('' . $e->getMessage() . ''); - return 1; + return self::FAILURE; } $output->writeln('Retiring key deleted.'); - return 0; + return self::SUCCESS; } } diff --git a/core/Command/OCM/StageKey.php b/core/Command/OCM/StageKey.php index 75437f460bfc2..f79d09c876aee 100644 --- a/core/Command/OCM/StageKey.php +++ b/core/Command/OCM/StageKey.php @@ -33,10 +33,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $signatory = $this->signatoryManager->stageEd25519Key(); } catch (\RuntimeException $e) { $output->writeln('' . $e->getMessage() . ''); - return 1; + return self::FAILURE; } $output->writeln('Staged new Ed25519 key: ' . $signatory->getKeyId() . ''); $output->writeln('Wait for federated peers to refresh their JWKS cache before activating.'); - return 0; + return self::SUCCESS; } } diff --git a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php index 3af3f7c0f8d7a..7e93da4ebbfb9 100644 --- a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php +++ b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php @@ -294,7 +294,7 @@ private function reconstructTargetUri(): string { * lowercased name. Derived components (`@*`) are produced inside * {@see SignatureBase}; we only collect plain fields here. * - * @return array + * @return array */ private function collectHeaders(): array { $out = []; From ebf76d2aace9cf7a566c24b81e9928b18b626322 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Mon, 11 May 2026 12:33:35 +0200 Subject: [PATCH 08/11] chore: Add review feedback Throw when one of the headers are empty Enumerate all the allowed algorithms in th NATIVE constant Co-authored-by: Carl Schwan Signed-off-by: Micke Nordin --- .../Security/Signature/Model/Rfc9421IncomingSignedRequest.php | 3 +++ lib/private/Security/Signature/Rfc9421/Algorithm.php | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php index 7e93da4ebbfb9..3697c156ec82b 100644 --- a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php +++ b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php @@ -306,6 +306,9 @@ private function collectHeaders(): array { if ($value === '' && strtolower($component) === 'host') { $value = $this->request->getServerHost(); } + if ($value === '') { + throw new IncomingRequestException('covered header is missing or empty: ' . $component); + } $out[strtolower($component)] = $value; } return $out; diff --git a/lib/private/Security/Signature/Rfc9421/Algorithm.php b/lib/private/Security/Signature/Rfc9421/Algorithm.php index 155aead60135f..40bec3cf15356 100644 --- a/lib/private/Security/Signature/Rfc9421/Algorithm.php +++ b/lib/private/Security/Signature/Rfc9421/Algorithm.php @@ -31,6 +31,8 @@ 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', From 01b9a337f350871254935df860618b39956be9d5 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Mon, 11 May 2026 16:13:13 +0200 Subject: [PATCH 09/11] fix: Make sodium optional 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 --- apps/settings/lib/SetupChecks/PhpModules.php | 3 +- composer.json | 4 +- core/Command/OCM/ActivateKey.php | 4 +- core/Command/OCM/ListKeys.php | 6 +- core/Command/OCM/RetireKey.php | 4 +- core/Command/OCM/StageKey.php | 6 +- lib/private/OCM/OCMJwksHandler.php | 6 +- lib/private/OCM/OCMSignatoryManager.php | 129 ++++++++++-------- lib/private/OCM/Rfc9421SignatoryManager.php | 8 +- .../Security/IdentityProof/Manager.php | 31 ++++- .../Model/Rfc9421OutgoingSignedRequest.php | 9 +- .../Security/Signature/Rfc9421/Algorithm.php | 26 ++-- tests/lib/OCM/OCMJwksHandlerTest.php | 25 ++-- tests/lib/OCM/OCMSignatoryManagerJwksTest.php | 37 +++-- .../OCM/OCMSignatoryManagerRotationTest.php | 105 +++++++------- tests/lib/OCM/Rfc9421SignatoryManagerTest.php | 8 +- .../Signature/Model/Rfc9421RoundTripTest.php | 119 ++++++++++++++-- .../Signature/Rfc9421/AlgorithmTest.php | 32 +++-- .../SignatureManagerDispatchTest.php | 44 +++--- 19 files changed, 382 insertions(+), 224 deletions(-) diff --git a/apps/settings/lib/SetupChecks/PhpModules.php b/apps/settings/lib/SetupChecks/PhpModules.php index d4c3d2c5c2a59..4ee4d9165cc2f 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 @@ public function getCategory(): string { 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 849c052ab1f80..03881a15fc071 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 9efc7110a2450..582763880a7e4 100644 --- a/core/Command/OCM/ActivateKey.php +++ b/core/Command/OCM/ActivateKey.php @@ -24,13 +24,13 @@ public function __construct( 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 221beca558061..b4eb4715fe948 100644 --- a/core/Command/OCM/ListKeys.php +++ b/core/Command/OCM/ListKeys.php @@ -25,13 +25,13 @@ public function __construct( 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 @@ protected function execute(InputInterface $input, OutputInterface $output): int } 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 8a3c23d29c166..6c26014e5a115 100644 --- a/core/Command/OCM/RetireKey.php +++ b/core/Command/OCM/RetireKey.php @@ -24,13 +24,13 @@ public function __construct( 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 f79d09c876aee..69f2167ba432a 100644 --- a/core/Command/OCM/StageKey.php +++ b/core/Command/OCM/StageKey.php @@ -24,18 +24,18 @@ public function __construct( 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 281c3eaab2d88..0013b38b1b400 100644 --- a/lib/private/OCM/OCMJwksHandler.php +++ b/lib/private/OCM/OCMJwksHandler.php @@ -18,7 +18,7 @@ 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 @@ public function handle(string $service, IRequestContext $context, ?IResponse $pr $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 d60dc845e4ab7..1bde75e552bba 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 @@ public function getLocalSignatory(): Signatory { } - /** 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 @@ public function getLocalEd25519Signatory(): ?Signatory { * * @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 @@ public function activateStagedEd25519Key(): void { * * @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 @@ public function retireEd25519Key(): void { * * @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 @@ public function listEd25519Keys(): array { * {@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 @@ private function canonicalKid(string $kid): string { * * @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 @@ private function resolveEd25519KidBase(): string { $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 @@ private function resolveEd25519KidBase(): string { 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 @@ private function getSlotPool(string $slot): ?int { } 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 @@ private function signatoryFromPool(int $poolId): ?Signatory { } /** - * @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 @@ private function findKid(?array $keys, string $keyId): ?Key { } /** + * 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 f0756d9ca6eff..f03c01eebeb42 100644 --- a/lib/private/OCM/Rfc9421SignatoryManager.php +++ b/lib/private/OCM/Rfc9421SignatoryManager.php @@ -16,8 +16,8 @@ /** * 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 @@ public function getOptions(): array { #[\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 d6ebe3813b21e..298c1d459b313 100644 --- a/lib/private/Security/IdentityProof/Manager.php +++ b/lib/private/Security/IdentityProof/Manager.php @@ -179,14 +179,31 @@ public function generateAppKey(string $app, string $name, array $options = []): } /** - * 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 3a44776ef4ad1..d2fa2a4ae86a3 100644 --- a/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php +++ b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php @@ -23,8 +23,9 @@ /** * 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 @@ public function __construct( ->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 @@ public function getAlgorithm(): SignatureAlgorithm { 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 40bec3cf15356..4fd7569a1ff12 100644 --- a/lib/private/Security/Signature/Rfc9421/Algorithm.php +++ b/lib/private/Security/Signature/Rfc9421/Algorithm.php @@ -18,11 +18,16 @@ /** * 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 @@ public static function sign(string $signatureBase, string $privateKey, string $a $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 @@ public static function verify(string $signatureBase, string $signature, Key $key $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 @@ public static function deriveJoseAlgFromJwk(array $jwk): ?string { 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 7040b19f67537..f7270298dee09 100644 --- a/tests/lib/OCM/OCMJwksHandlerTest.php +++ b/tests/lib/OCM/OCMJwksHandlerTest.php @@ -54,23 +54,24 @@ public function testEmptyKeySetWhenSigningDisabled(): void { $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 @@ public function testPublishesEd25519JwksWhenAvailable(): void { 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 @@ public function testPublishesAllSlotsAdvertisedDuringRotation(): void { 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 @@ public function testEmptyKeySetWhenSignatoryUnavailable(): void { 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 7fcc0818e31fc..b135efb8a8f1e 100644 --- a/tests/lib/OCM/OCMSignatoryManagerJwksTest.php +++ b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php @@ -24,6 +24,10 @@ 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 @@ public function testGetRemoteKeyFetchesAndMatchesByKid(): void { $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 @@ public function testGetRemoteKeyReturnsNullWhenKeysMissing(): void { } 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 @@ public function testGetRemoteKeyPassesSelfSignedFlagThrough(): void { 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 @@ public function testJwksCachedAcrossCallsToTheSameOrigin(): void { } 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 @@ private function jsonResponse(array $body): IResponse { $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 9b52d88c61f7b..24d25f4476a49 100644 --- a/tests/lib/OCM/OCMSignatoryManagerRotationTest.php +++ b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php @@ -24,7 +24,7 @@ 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 @@ protected function setUp(): void { 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 @@ public function testSignerReturnsNullWhenIdentityCannotBeDerived(): void { ); $this->expectException(\RuntimeException::class); - $manager->getLocalEd25519Signatory(); + $manager->getLocalJwksSignatory(); } private function wireAppConfig(): void { @@ -245,10 +245,15 @@ private function wireIdentityProofManager(): void { $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 f186986cf81a2..4bdda7379672f 100644 --- a/tests/lib/OCM/Rfc9421SignatoryManagerTest.php +++ b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php @@ -39,15 +39,15 @@ public function testGetOptionsForcesRfc9421Format(): void { $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 5f4285f14ccde..e7d42460987f0 100644 --- a/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php +++ b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php @@ -23,8 +23,8 @@ 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 @@ public function testEd25519RoundTripVerifies(): void { $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 @@ public function testTamperedBodyRejected(): void { } 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 @@ public function testTamperedSignatureRejected(): void { } 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 @@ public function testOutgoingUsesOcmLabel(): void { } 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 @@ public function testDuplicateOcmLabelRejected(): void { // 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 @@ public function testDuplicateOcmLabelRejected(): void { } 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 @@ public function testForeignSiblingLabelIgnored(): void { } 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 @@ public function testTooOldSignatureRejected(): void { } 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 @@ public function testFutureCreatedRejected(): void { } 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 @@ public function testSignatureNotCoveringRequiredComponentsRejected(): void { // 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 @@ public function testSignatureNotCoveringRequiredComponentsRejected(): void { 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 @@ public function getRemoteSignatory(string $remote): ?Signatory { }; } + 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 @@ private function ed25519Material(string $kid): array { '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 ba117ca99baf4..bb89430e5f074 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 @@ public function testDeriveJoseAlgFromJwk(): void { $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 @@ public function testKeyTypeMismatchFailsClosed(): void { } 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 @@ public function testAlgHintConflictsWithJwkAlgRejected(): void { } 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 @@ public function testParseKeyRejectsContradictoryAlg(): void { ], 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 @@ public function testEcdsaRawToDerWrongLength(): void { $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 ae5945fd9b5a9..698ea59d6188e 100644 --- a/tests/lib/Security/Signature/SignatureManagerDispatchTest.php +++ b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php @@ -52,9 +52,6 @@ protected function setUp(): void { } 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 @@ public function testOutgoingDispatchesToCavageByDefault(): void { } public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void { - [$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true); + [$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true); $signed = $this->signatureManager->getOutgoingSignedRequest( $signatoryManager, @@ -84,7 +81,7 @@ public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void { } 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 @@ public function testInboundDispatchesToRfc9421WhenSignatureInputPresent(): void $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 @@ public function getRemoteSignatory(string $remote): ?Signatory { } /** - * @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 @@ public function getRemoteSignatory(string $remote): ?Signatory { return null; } }; - return [$manager, $key, $secretKey]; + return [$manager, $key]; } private function makeKeyResolver(ISignatoryManager $delegate, Key $key, string $kid): IJwkResolvingSignatoryManager { From c00760e350c2bf97b8c5b1087e581cd3478da7a0 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Sun, 17 May 2026 18:41:31 +0200 Subject: [PATCH 10/11] refactor(ocm): expose confirmRequestOrigin as a function on ocmDiscoveryService Apps implementing OCM endpoints via OCMEndpointRequestEvent (e.g. SUNET/nextcloud-ocm_request_share for request-share, nextcloud/contacts for invite-accepted) need to apply the same identity check that the built-in addShare and receiveNotification handlers apply, so it makes sense to make it publicly accessible. It also allows us to refactor RequestHandlerController::confirmSignedOrigin to use the new public method and drop the confirmNotificationIdentity helper. Signed-off-by: Micke Nordin --- .../Controller/RequestHandlerController.php | 72 +++---------------- .../tests/RequestHandlerControllerTest.php | 8 --- lib/private/OCM/OCMDiscoveryService.php | 45 ++++++++++++ lib/public/OCM/IOCMDiscoveryService.php | 15 ++++ 4 files changed, 69 insertions(+), 71 deletions(-) diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 06480df1ad5a4..d2df7da8e9266 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -12,7 +12,6 @@ use OCA\CloudFederationAPI\Db\FederatedInviteMapper; use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent; use OCA\CloudFederationAPI\ResponseDefinitions; -use OCA\FederatedFileSharing\AddressHandler; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -38,11 +37,8 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\OCM\IOCMDiscoveryService; -use OCP\Security\Signature\Exceptions\IdentityNotFoundException; use OCP\Security\Signature\Exceptions\IncomingRequestException; -use OCP\Security\Signature\Exceptions\SignatoryNotFoundException; use OCP\Security\Signature\IIncomingSignedRequest; -use OCP\Security\Signature\ISignatureManager; use OCP\Share\Exceptions\ShareNotFound; use OCP\Util; use Psr\Log\LoggerInterface; @@ -69,12 +65,10 @@ public function __construct( private Config $config, private IEventDispatcher $dispatcher, private FederatedInviteMapper $federatedInviteMapper, - private readonly AddressHandler $addressHandler, private readonly IAppConfig $appConfig, private ICloudFederationFactory $factory, private ICloudIdManager $cloudIdManager, private readonly IOCMDiscoveryService $ocmDiscoveryService, - private readonly ISignatureManager $signatureManager, private ITimeFactory $timeFactory, ) { parent::__construct($appName, $request); @@ -440,6 +434,8 @@ private function mapUid($uid) { * If request is not signed, we still verify that the hostname from the extracted value does, * actually, not support signed request * + * Delegates to {@see IOCMDiscoveryService::confirmRequestOrigin()}. + * * @param IIncomingSignedRequest|null $signedRequest * @param string $key entry from data available in data * @param string $value value itself used in case request is not signed @@ -447,21 +443,13 @@ private function mapUid($uid) { * @throws IncomingRequestException */ private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void { - if ($signedRequest === null) { - $instance = $this->getHostFromFederationId($value); - try { - $this->signatureManager->getSignatory($instance); - throw new IncomingRequestException('instance is supposed to sign its request'); - } catch (SignatoryNotFoundException) { - return; - } - } - - $body = json_decode($signedRequest->getBody(), true) ?? []; - $entry = trim($body[$key] ?? '', '@'); - if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) { - throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']'); + if ($signedRequest !== null) { + $body = json_decode($signedRequest->getBody(), true) ?? []; + $entry = trim(($body[$key] ?? ''), '@'); + } else { + $entry = trim($value, '@'); } + $this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $entry); } /** @@ -498,48 +486,6 @@ private function confirmNotificationIdentity( throw new IncomingRequestException($e->getMessage(), previous: $e); } - $this->confirmNotificationEntry($signedRequest, $identity); - } - - - /** - * @param IIncomingSignedRequest|null $signedRequest - * @param string $entry - * - * @return void - * @throws IncomingRequestException - */ - private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void { - $instance = $this->getHostFromFederationId($entry); - if ($signedRequest === null) { - try { - $this->signatureManager->getSignatory($instance); - throw new IncomingRequestException('instance is supposed to sign its request'); - } catch (SignatoryNotFoundException) { - return; - } - } elseif ($instance !== $signedRequest->getOrigin()) { - throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin()); - } - } - - /** - * @param string $entry - * @return string - * @throws IncomingRequestException - */ - private function getHostFromFederationId(string $entry): string { - if (!str_contains($entry, '@')) { - throw new IncomingRequestException('entry ' . $entry . ' does not contain @'); - } - $rightPart = substr($entry, strrpos($entry, '@') + 1); - - // in case the full scheme is sent; getting rid of it - $rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart); - try { - return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart); - } catch (IdentityNotFoundException) { - throw new IncomingRequestException('invalid host within federation id: ' . $entry); - } + $this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity); } } diff --git a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php index 04cabbd234c25..326da930d9c6f 100644 --- a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php +++ b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php @@ -13,7 +13,6 @@ use OCA\CloudFederationAPI\Controller\RequestHandlerController; use OCA\CloudFederationAPI\Db\FederatedInvite; use OCA\CloudFederationAPI\Db\FederatedInviteMapper; -use OCA\FederatedFileSharing\AddressHandler; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Utility\ITimeFactory; @@ -28,7 +27,6 @@ use OCP\IUser; use OCP\IUserManager; use OCP\OCM\IOCMDiscoveryService; -use OCP\Security\Signature\ISignatureManager; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Test\TestCase; @@ -43,13 +41,11 @@ class RequestHandlerControllerTest extends TestCase { private Config&MockObject $config; private IEventDispatcher&MockObject $eventDispatcher; private FederatedInviteMapper&MockObject $federatedInviteMapper; - private AddressHandler&MockObject $addressHandler; private IAppConfig&MockObject $appConfig; private ICloudFederationFactory&MockObject $cloudFederationFactory; private ICloudIdManager&MockObject $cloudIdManager; private IOCMDiscoveryService&MockObject $discoveryService; - private ISignatureManager&MockObject $signatureManager; private ITimeFactory&MockObject $timeFactory; private RequestHandlerController $requestHandlerController; @@ -66,12 +62,10 @@ protected function setUp(): void { $this->config = $this->createMock(Config::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->federatedInviteMapper = $this->createMock(FederatedInviteMapper::class); - $this->addressHandler = $this->createMock(AddressHandler::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class); $this->cloudIdManager = $this->createMock(ICloudIdManager::class); $this->discoveryService = $this->createMock(IOCMDiscoveryService::class); - $this->signatureManager = $this->createMock(ISignatureManager::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->requestHandlerController = new RequestHandlerController( @@ -85,12 +79,10 @@ protected function setUp(): void { $this->config, $this->eventDispatcher, $this->federatedInviteMapper, - $this->addressHandler, $this->appConfig, $this->cloudFederationFactory, $this->cloudIdManager, $this->discoveryService, - $this->signatureManager, $this->timeFactory, ); } diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 77b7d63ec0d53..7aca4ab3e48d1 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -18,6 +18,7 @@ use OCP\AppFramework\Attribute\Consumable; use OCP\AppFramework\Http; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\ICloudIdManager; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; @@ -63,6 +64,7 @@ public function __construct( private IURLGenerator $urlGenerator, private readonly ISignatureManager $signatureManager, private readonly OCMSignatoryManager $signatoryManager, + private readonly ICloudIdManager $cloudIdManager, private LoggerInterface $logger, ) { $this->cache = $cacheFactory->createDistributed('ocm-discovery'); @@ -277,6 +279,49 @@ public function getIncomingSignedRequest(): ?IIncomingSignedRequest { return null; } + /** + * @inheritDoc + * + * @since 34.0.0 + */ + #[\Override] + public function confirmRequestOrigin(?string $signedOrigin, string $ocmAddress): void { + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + return; + } + + $instance = $this->getHostFromOcmAddress($ocmAddress); + + if ($signedOrigin === null) { + try { + $this->signatureManager->getSignatory($instance); + } catch (SignatoryNotFoundException) { + return; + } + throw new IncomingRequestException('instance is supposed to sign its request'); + } + + if ($instance !== $signedOrigin) { + throw new IncomingRequestException( + 'claimed origin ' . $instance . ' does not match signed origin ' . $signedOrigin + ); + } + } + + /** + * @throws IncomingRequestException on malformed address or unresolvable host + */ + private function getHostFromOcmAddress(string $entry): string { + try { + $cloudId = $this->cloudIdManager->resolveCloudId(trim($entry, '@')); + return $this->signatureManager->extractIdentityFromUri($cloudId->getRemote()); + } catch (\InvalidArgumentException $e) { + throw new IncomingRequestException('invalid OCM address: ' . $entry, previous: $e); + } catch (IdentityNotFoundException $e) { + throw new IncomingRequestException('invalid host within OCM address: ' . $entry, previous: $e); + } + } + /** * @inheritDoc * diff --git a/lib/public/OCM/IOCMDiscoveryService.php b/lib/public/OCM/IOCMDiscoveryService.php index 3f521224442a2..674c907bb1587 100644 --- a/lib/public/OCM/IOCMDiscoveryService.php +++ b/lib/public/OCM/IOCMDiscoveryService.php @@ -65,6 +65,21 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider; */ public function getIncomingSignedRequest(): ?IIncomingSignedRequest; + /** + * Confirm that the host portion of $ocmAddress matches $signedOrigin + * under the current local signing policy. + * + * @param string|null $signedOrigin verified origin of the signed request, + * typically taken from {@see IIncomingSignedRequest::getOrigin()} or + * from {@see \OCP\OCM\Events\OCMEndpointRequestEvent::getRemote()}. + * NULL if the request was not signed. + * @param string $ocmAddress in `user@host` or `user@https://host` form + * + * @throws IncomingRequestException on mismatch or malformed address + * @since 34.0.0 + */ + public function confirmRequestOrigin(?string $signedOrigin, string $ocmAddress): void; + /** * Request a remote OCM endpoint. * From b0fa03d3cae1d830577241795df34f9dda7f816b Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Sun, 17 May 2026 19:54:47 +0200 Subject: [PATCH 11/11] fix(http-sig): make setSignature public and skip third-party-dependent test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures introduced by the test additions in this PR: 1. testEd25519VerifyAcceptedWhenSodiumLoaded calls setSignature() to inject an externally-produced Ed25519 signature (since Algorithm::sign() rejects Ed25519 by design). setSignature was declared protected, so the test couldn't call it from outside the class hierarchy. Make it public — SignedRequest lives in the OC\ private namespace, so this widens internal-only visibility, not the public API surface. 2. testParseKeyRejectsContradictoryAlg expected firebase/php-jwt's JWK::parseKey() to throw on a kty=OKP/crv=Ed25519/alg=ES256 key. The current firebase/php-jwt version does not validate that coherence at parse time, so the test now fails to see any throwable. The actual security check happens at Algorithm::verify() time and is covered by testVerifyEd25519KeyAgainstES256Alg right above it. Skip the parse-time test with a comment pointing at the verify-time coverage. Signed-off-by: Micke Nordin --- .../Security/Signature/Model/SignedRequest.php | 2 +- .../Security/Signature/Rfc9421/AlgorithmTest.php | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/private/Security/Signature/Model/SignedRequest.php b/lib/private/Security/Signature/Model/SignedRequest.php index 1b60a49cedc38..137c64dffda6a 100644 --- a/lib/private/Security/Signature/Model/SignedRequest.php +++ b/lib/private/Security/Signature/Model/SignedRequest.php @@ -157,7 +157,7 @@ public function getSignatureData(): array { * @return self * @since 31.0.0 */ - protected function setSignature(string $signature): self { + public function setSignature(string $signature): self { $this->signature = $signature; return $this; } diff --git a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php index bb89430e5f074..ce8339c12a2c2 100644 --- a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php +++ b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php @@ -115,18 +115,10 @@ public function testAlgHintConflictsWithJwkAlgRejected(): void { } 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(); - $this->expectException(\Throwable::class); - JWK::parseKey([ - 'kty' => 'OKP', - 'crv' => 'Ed25519', - 'kid' => 'k', - 'alg' => 'ES256', - 'x' => self::b64url(sodium_crypto_sign_publickey($keypair)), - ], null); + $this->markTestSkipped( + 'firebase/php-jwt JWK::parseKey does not validate kty/crv/alg coherence; ' + . 'the alg mismatch is caught at verify() time instead — see testVerifyEd25519KeyAgainstES256Alg.' + ); } public function testEcdsaRawToDerProducesValidSignature(): void {