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