From 9a42d44e043f501a7a2a26f2235cc4d11702b469 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:02 +0200 Subject: [PATCH 1/6] chore(3rdparty): bump for firebase/php-jwt and gapple/structured-fields Companion to nextcloud/3rdparty#2413. firebase/php-jwt handles JWK parsing and JWS algorithm dispatch; gapple/structured-fields handles RFC 8941 Structured Field parsing for Signature-Input / Signature. Signed-off-by: Micke Nordin --- 3rdparty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rdparty b/3rdparty index 5d09a7f56e2d0..e73f85f09109f 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 5d09a7f56e2d01b5f4083e65db77c4f7aa775252 +Subproject commit e73f85f09109f77c033b643c68f5acbed6ce1244 From b7629175c336aa0626b0c898a9207880d3b6e73d Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:09 +0200 Subject: [PATCH 2/6] chore: require ext-sodium Promote ext-sodium from recommended to required so RFC 9421 Ed25519 signing/verifying can rely on libsodium unconditionally. Add the matching openssl + sodium psalm stubs. Signed-off-by: Micke Nordin --- apps/settings/lib/SetupChecks/PhpModules.php | 3 +-- build/stubs/openssl.php | 12 ++++++++++++ build/stubs/sodium.php | 13 +++++++++++++ composer.json | 1 + psalm.xml | 2 ++ 5 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 build/stubs/openssl.php create mode 100644 build/stubs/sodium.php diff --git a/apps/settings/lib/SetupChecks/PhpModules.php b/apps/settings/lib/SetupChecks/PhpModules.php index 64fa6e15c0a85..d4c3d2c5c2a59 100644 --- a/apps/settings/lib/SetupChecks/PhpModules.php +++ b/apps/settings/lib/SetupChecks/PhpModules.php @@ -24,6 +24,7 @@ class PhpModules implements ISetupCheck { 'openssl', 'posix', 'session', + 'sodium', 'xml', 'xmlreader', 'xmlwriter', @@ -35,7 +36,6 @@ class PhpModules implements ISetupCheck { 'exif', 'gmp', 'intl', - 'sodium', 'sysvsem', ]; @@ -58,7 +58,6 @@ public function getCategory(): string { protected function getRecommendedModuleDescription(string $module): string { return match($module) { 'intl' => $this->l10n->t('increases language translation performance and fixes sorting of non-ASCII characters'), - 'sodium' => $this->l10n->t('for Argon2 for password hashing'), 'gmp' => $this->l10n->t('required for SFTP storage and recommended for WebAuthn performance'), 'exif' => $this->l10n->t('for picture rotation in server and metadata extraction in the Photos app'), default => '', diff --git a/build/stubs/openssl.php b/build/stubs/openssl.php new file mode 100644 index 0000000000000..5bf410a677445 --- /dev/null +++ b/build/stubs/openssl.php @@ -0,0 +1,12 @@ + + + From 6fb26b74a448468e5568ef1a1f2ab57a2a5b60fe Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:24 +0200 Subject: [PATCH 3/6] feat(http-sig): RFC 9421 protocol primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the RFC 9421 (HTTP Message Signatures) sign/verify path alongside the existing draft-cavage implementation: - Algorithm: sodium for Ed25519, JWT::sign for RSA / ECDSA, ecdsaRawToDer for the ECDSA wire format. JWK parsing via JWK::parseKey. - SignatureBase: RFC 9421 §2.5 base construction for the derived components OCM uses plus plain HTTP fields. - ContentDigest: RFC 9530 helpers used as a covered component. - Rfc9421IncomingSignedRequest / Rfc9421OutgoingSignedRequest: request models. Parsing of Signature-Input / Signature delegates to gapple\\StructuredFields\\Parser. - IJwkResolvingSignatoryManager: capability bit signatory managers advertise to participate in RFC 9421 verification. - OcmProfile: OCM-mandated dictionary label. - SignatureManager: dispatch to RFC 9421 inbound when Signature-Input is present, outbound when rfc9421.format is set. Plus tests for each primitive and a full round-trip across the model. Signed-off-by: Micke Nordin --- .../Model/Rfc9421IncomingSignedRequest.php | 465 ++++++++++++++++++ .../Model/Rfc9421OutgoingSignedRequest.php | 210 ++++++++ .../Security/Signature/Rfc9421/Algorithm.php | 221 +++++++++ .../Signature/Rfc9421/ContentDigest.php | 72 +++ .../Rfc9421/IJwkResolvingSignatoryManager.php | 29 ++ .../Signature/Rfc9421/SignatureBase.php | 124 +++++ .../Security/Signature/SignatureManager.php | 70 ++- .../Signature/Model/Rfc9421RoundTripTest.php | 316 ++++++++++++ .../Signature/Rfc9421/AlgorithmTest.php | 197 ++++++++ .../Signature/Rfc9421/ContentDigestTest.php | 76 +++ .../Signature/Rfc9421/SignatureBaseTest.php | 85 ++++ .../SignatureManagerDispatchTest.php | 262 ++++++++++ 12 files changed, 2120 insertions(+), 7 deletions(-) create mode 100644 lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php create mode 100644 lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php create mode 100644 lib/private/Security/Signature/Rfc9421/Algorithm.php create mode 100644 lib/private/Security/Signature/Rfc9421/ContentDigest.php create mode 100644 lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php create mode 100644 lib/private/Security/Signature/Rfc9421/SignatureBase.php create mode 100644 tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php create mode 100644 tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php create mode 100644 tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php create mode 100644 tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php create mode 100644 tests/lib/Security/Signature/SignatureManagerDispatchTest.php diff --git a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php new file mode 100644 index 0000000000000..3af3f7c0f8d7a --- /dev/null +++ b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php @@ -0,0 +1,465 @@ + */ + private array $components; + /** @var array */ + private array $signatureParams; + private string $signatureBaseString; + private string $rawSignature; + private ?Key $key = null; + + /** + * @throws IncomingRequestException if anything looks wrong with the request structure + * @throws SignatureNotFoundException if the request is not signed + * @throws SignatureException if signature metadata is malformed or covered components reference missing fields + */ + public function __construct( + string $body, + private readonly IRequest $request, + private readonly array $options = [], + ) { + parent::__construct($body); + + $signatureInputHeader = $request->getHeader('Signature-Input'); + $signatureHeader = $request->getHeader('Signature'); + if ($signatureInputHeader === '') { + throw new SignatureNotFoundException('missing Signature-Input header'); + } + if ($signatureHeader === '') { + throw new SignatureNotFoundException('missing Signature header'); + } + + $inputs = self::parseSignatureInput($signatureInputHeader); + $signatures = self::parseSignature($signatureHeader); + + // OCM policy (stricter than RFC 8941 §4.2 last-wins): a duplicate + // `ocm` entry is ambiguous; the entire request MUST be rejected. + if (self::countLabel($signatureInputHeader, 'ocm') > 1 + || self::countLabel($signatureHeader, 'ocm') > 1) { + throw new IncomingRequestException( + 'multiple "' . 'ocm' . '" entries in signature headers' + ); + } + + if (!isset($inputs['ocm'])) { + throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature-Input'); + } + if (!isset($signatures['ocm'])) { + throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature'); + } + + $entry = $inputs['ocm']; + $this->components = $entry['components']; + $this->signatureParams = $entry['params']; + $this->rawSignature = $signatures['ocm']; + + $this->verifyRequiredComponents(); + $this->verifyTimestamps(); + $this->verifyContentDigestIfCovered($body); + $this->verifyContentLengthIfCovered($body); + + $keyId = $this->signatureParams['keyid'] ?? null; + if (!is_string($keyId) || $keyId === '') { + throw new IncomingRequestException('missing keyid in Signature-Input'); + } + try { + $this->origin = Signatory::extractIdentityFromUri($keyId); + } catch (IdentityNotFoundException) { + // keyid may follow the OCM convention `#`; the OCM layer + // derives origin from the message body in that case. + $this->origin = ''; + } + + $paramsLine = SignatureBase::serializeSignatureParams($this->components, $this->signatureParams); + $this->signatureBaseString = SignatureBase::build( + $request->getMethod(), + $this->reconstructTargetUri(), + $this->collectHeaders(), + $this->components, + $paramsLine, + ); + + $this->setSigningElements([ + 'label' => 'ocm', + 'keyId' => $keyId, + 'algorithm' => isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : '', + 'created' => isset($this->signatureParams['created']) ? (string)$this->signatureParams['created'] : '', + 'components' => implode(' ', $this->components), + 'params' => $paramsLine, + 'signature' => base64_encode($this->rawSignature), + ]); + $this->setSignature(base64_encode($this->rawSignature)); + $this->setSignatureData([$this->signatureBaseString]); + } + + #[\Override] + public function getRequest(): IRequest { + return $this->request; + } + + #[\Override] + public function getOrigin(): string { + if ($this->origin === '') { + throw new IncomingRequestException('empty origin'); + } + return $this->origin; + } + + #[\Override] + public function getKeyId(): string { + return $this->getSigningElement('keyId'); + } + + /** Required before {@see verify()} is called. */ + public function setKey(Key $key): self { + $this->key = $key; + return $this; + } + + public function getKey(): ?Key { + return $this->key; + } + + /** Signature-Input `alg` if present, else null (RFC 9421 §3.3.7 omitted-alg path). */ + public function getAlgorithm(): ?string { + return isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : null; + } + + /** + * @return array + */ + public function getSignatureParams(): array { + return $this->signatureParams; + } + + /** + * @return list + */ + public function getCoveredComponents(): array { + return $this->components; + } + + public function getSignatureBaseString(): string { + return $this->signatureBaseString; + } + + #[\Override] + public function verify(): void { + if ($this->key === null) { + throw new SignatoryNotFoundException('no JWK set for verification'); + } + try { + $ok = Algorithm::verify( + $this->signatureBaseString, + $this->rawSignature, + $this->key, + $this->getAlgorithm(), + ); + } catch (SignatureException $e) { + throw new InvalidSignatureException($e->getMessage(), 0, $e); + } + if (!$ok) { + throw new InvalidSignatureException('signature verification failed'); + } + } + + /** @throws IncomingRequestException if the signature doesn't cover the OCM-required components */ + private function verifyRequiredComponents(): void { + /** @var list $required */ + $required = $this->options['rfc9421.requiredComponents'] ?? self::DEFAULT_REQUIRED_COMPONENTS; + $missing = array_values(array_diff($required, $this->components)); + if ($missing !== []) { + throw new IncomingRequestException( + 'signature does not cover required components: ' . implode(', ', $missing) + ); + } + } + + /** @throws IncomingRequestException on stale, future-dated, or missing `created` */ + private function verifyTimestamps(): void { + $ttl = (int)($this->options['ttl'] ?? SignatureManager::DATE_TTL); + $skew = (int)($this->options['rfc9421.maxClockSkew'] ?? self::DEFAULT_MAX_FUTURE_SKEW); + $now = time(); + + if (!isset($this->signatureParams['created'])) { + throw new IncomingRequestException('signature missing required `created` parameter'); + } + $created = (int)$this->signatureParams['created']; + if ($created > $now + $skew) { + throw new IncomingRequestException('signature `created` is too far in the future'); + } + if ($ttl > 0 && $created < $now - $ttl) { + throw new IncomingRequestException('signature is too old'); + } + + if (isset($this->signatureParams['expires'])) { + $expires = (int)$this->signatureParams['expires']; + if ($expires < $now) { + throw new IncomingRequestException('signature has expired'); + } + } + } + + private function verifyContentDigestIfCovered(string $body): void { + if (!in_array('content-digest', $this->components, true)) { + return; + } + $header = $this->request->getHeader('Content-Digest'); + if ($header === '') { + throw new IncomingRequestException('content-digest covered but missing from request'); + } + if (!ContentDigest::verify($header, $body)) { + throw new IncomingRequestException('content-digest does not match body'); + } + } + + private function verifyContentLengthIfCovered(string $body): void { + if (!in_array('content-length', $this->components, true)) { + return; + } + $header = $this->request->getHeader('Content-Length'); + if ($header === '') { + throw new IncomingRequestException('content-length covered but missing from request'); + } + if ((int)$header !== strlen($body)) { + throw new IncomingRequestException('content-length does not match body size'); + } + } + + private function reconstructTargetUri(): string { + $scheme = $this->request->getServerProtocol(); + $host = $this->request->getServerHost(); + $path = $this->request->getRequestUri(); + return $scheme . '://' . $host . $path; + } + + /** + * Collect the HTTP request fields covered by the signature, keyed by their + * lowercased name. Derived components (`@*`) are produced inside + * {@see SignatureBase}; we only collect plain fields here. + * + * @return array + */ + private function collectHeaders(): array { + $out = []; + foreach ($this->components as $component) { + if (str_starts_with($component, '@')) { + continue; + } + $value = $this->request->getHeader($component); + if ($value === '' && strtolower($component) === 'host') { + $value = $this->request->getServerHost(); + } + $out[strtolower($component)] = $value; + } + return $out; + } + + #[\Override] + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'origin' => $this->origin, + 'label' => 'ocm', + 'components' => $this->components, + 'signatureParams' => $this->signatureParams, + 'signatureBase' => $this->signatureBaseString, + ] + ); + } + + /** + * @return array, params: array}> + * @throws SignatureException + */ + private static function parseSignatureInput(string $header): array { + try { + $dict = Parser::parseDictionary($header); + } catch (ParseException $e) { + throw new SignatureException('malformed Signature-Input: ' . $e->getMessage(), 0, $e); + } + + $out = []; + foreach ($dict as $label => $entry) { + if (!$entry instanceof InnerList) { + throw new SignatureException('Signature-Input value for ' . $label . ' is not an inner list'); + } + $components = []; + foreach ($entry->getValue() as $item) { + $value = $item->getValue(); + if (!is_string($value)) { + throw new SignatureException('component identifier in Signature-Input must be a string'); + } + $components[] = $value; + } + $parameters = $entry->getParameters(); + if (!$parameters instanceof Parameters) { + throw new SignatureException('Signature-Input parameters for ' . $label . ' are not iterable'); + } + $out[$label] = [ + 'components' => $components, + 'params' => self::normalizeParameters($parameters), + ]; + } + return $out; + } + + /** + * @return array raw signature bytes keyed by label + * @throws SignatureException + */ + private static function parseSignature(string $header): array { + try { + $dict = Parser::parseDictionary($header); + } catch (ParseException $e) { + throw new SignatureException('malformed Signature: ' . $e->getMessage(), 0, $e); + } + + $out = []; + foreach ($dict as $label => $entry) { + if (!$entry instanceof Item || !$entry->getValue() instanceof Bytes) { + throw new SignatureException('Signature value for ' . $label . ' is not a byte sequence'); + } + $out[$label] = (string)$entry->getValue(); + } + return $out; + } + + /** + * @param iterable $parameters + * @return array + */ + private static function normalizeParameters(iterable $parameters): array { + $out = []; + foreach ($parameters as $name => $value) { + $out[(string)$name] = match (true) { + is_string($value), is_int($value), is_bool($value) => $value, + $value instanceof Token => (string)$value, + default => throw new SignatureException('unsupported parameter type for ' . $name), + }; + } + return $out; + } + + /** Count $label occurrences in a dictionary header (gapple collapses dups per RFC 8941 §4.2). */ + private static function countLabel(string $header, string $label): int { + $count = 0; + $len = strlen($header); + $i = 0; + while ($i < $len) { + while ($i < $len && ($header[$i] === ' ' || $header[$i] === "\t")) { + $i++; + } + $start = $i; + while ($i < $len) { + $c = $header[$i]; + if (!ctype_lower($c) && !ctype_digit($c) && $c !== '*' && $c !== '_' && $c !== '-' && $c !== '.') { + break; + } + $i++; + } + if ($i === $start) { + break; + } + if (substr($header, $start, $i - $start) === $label) { + $count++; + } + // Skip to next top-level comma; track strings, byte-sequences, parens. + $inString = false; + $inByteSeq = false; + $depth = 0; + while ($i < $len) { + $c = $header[$i]; + if ($inString) { + if ($c === '\\' && $i + 1 < $len) { + $i += 2; + continue; + } + if ($c === '"') { + $inString = false; + } + $i++; + continue; + } + if ($inByteSeq) { + if ($c === ':') { + $inByteSeq = false; + } + $i++; + continue; + } + if ($c === '"') { + $inString = true; + } elseif ($c === ':') { + $inByteSeq = true; + } elseif ($c === '(') { + $depth++; + } elseif ($c === ')') { + $depth--; + } elseif ($c === ',' && $depth === 0) { + $i++; + break; + } + $i++; + } + } + return $count; + } +} diff --git a/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php new file mode 100644 index 0000000000000..3a44776ef4ad1 --- /dev/null +++ b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php @@ -0,0 +1,210 @@ + $headerList */ + private array $headerList = []; + private SignatureAlgorithm $algorithm; + private string $signingAlgorithm; + /** @var array */ + private array $signatureParams; + private string $signatureBaseString; + + public function __construct( + string $body, + ISignatoryManager $signatoryManager, + private readonly string $identity, + private readonly string $method, + private readonly string $uri, + ) { + parent::__construct($body); + + $options = $signatoryManager->getOptions(); + $this->setHost($identity) + ->setAlgorithm($options['algorithm'] ?? SignatureAlgorithm::RSA_SHA256) + ->setSignatory($signatoryManager->getLocalSignatory()) + ->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256); + + $this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ed25519'); + $contentDigestAlgorithm = (string)($options['rfc9421.contentDigestAlgorithm'] ?? ContentDigest::ALGO_SHA256); + /** @var list $components */ + $components = $options['rfc9421.coveredComponents'] ?? self::DEFAULT_COMPONENTS; + $includeAlg = (bool)($options['rfc9421.includeAlgParameter'] ?? false); + $dateHeaderFormat = (string)($options['dateHeader'] ?? SignatureManager::DATE_HEADER); + + $this->addHeader('Content-Digest', ContentDigest::compute($body, $contentDigestAlgorithm)) + ->addHeader('Content-Length', strlen($body)) + ->addHeader('Date', gmdate($dateHeaderFormat)); + if (in_array('host', $components, true)) { + $this->addHeader('Host', $this->host); + } + + $this->setHeaderList($components); + $this->signatureParams = [ + 'created' => time(), + 'keyid' => $this->getSignatory()->getKeyId(), + ]; + if ($includeAlg) { + // Off by default per RFC 9421 §3.3.7 (verifier resolves alg from JWK). + $this->signatureParams['alg'] = $this->signingAlgorithm; + } + + $this->signatureBaseString = SignatureBase::build( + $this->method, + $this->uri, + $this->headersByLowercaseName(), + $this->headerList, + SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams) + ); + $this->setSignatureData([$this->signatureBaseString]); + } + + #[\Override] + public function setHost(string $host): self { + $this->host = $host; + return $this; + } + + #[\Override] + public function getHost(): string { + return $this->host; + } + + #[\Override] + public function addHeader(string $key, string|int|float $value): self { + $this->headers[$key] = $value; + return $this; + } + + #[\Override] + public function getHeaders(): array { + return $this->headers; + } + + #[\Override] + public function setHeaderList(array $list): self { + $this->headerList = $list; + return $this; + } + + #[\Override] + public function getHeaderList(): array { + return $this->headerList; + } + + #[\Override] + public function setAlgorithm(SignatureAlgorithm $algorithm): self { + $this->algorithm = $algorithm; + return $this; + } + + #[\Override] + public function getAlgorithm(): SignatureAlgorithm { + return $this->algorithm; + } + + /** RFC 9421 alg name (e.g. `ed25519`). Distinct from cavage's {@see getAlgorithm()}. */ + public function getSigningAlgorithm(): string { + return $this->signingAlgorithm; + } + + public function getSignatureBaseString(): string { + return $this->signatureBaseString; + } + + #[\Override] + public function sign(): self { + $privateKey = $this->getSignatory()->getPrivateKey(); + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + $rawSignature = Algorithm::sign( + $this->signatureBaseString, + $privateKey, + $this->signingAlgorithm, + ); + $this->setSignature(base64_encode($rawSignature)); + + $paramsLine = SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams); + $this->addHeader('Signature-Input', 'ocm=' . $paramsLine); + $this->addHeader('Signature', 'ocm=:' . base64_encode($rawSignature) . ':'); + + $this->setSigningElements([ + 'label' => 'ocm', + 'components' => implode(' ', $this->headerList), + 'params' => $paramsLine, + 'signature' => $this->getSignature(), + ]); + + return $this; + } + + /** + * @return array + */ + private function headersByLowercaseName(): array { + $out = []; + foreach ($this->headers as $name => $value) { + $out[strtolower($name)] = (string)$value; + } + return $out; + } + + /** + * @throws SignatoryNotFoundException + */ + #[\Override] + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'host' => $this->host, + 'headers' => $this->headers, + 'algorithm' => $this->algorithm->value, + 'signingAlgorithm' => $this->signingAlgorithm, + 'method' => $this->method, + 'identity' => $this->identity, + 'uri' => $this->uri, + 'components' => $this->headerList, + 'signatureBase' => $this->signatureBaseString, + 'signatureParams' => $this->signatureParams, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Rfc9421/Algorithm.php b/lib/private/Security/Signature/Rfc9421/Algorithm.php new file mode 100644 index 0000000000000..155aead60135f --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/Algorithm.php @@ -0,0 +1,221 @@ +getMessage(), 0, $e); + } + } + + /** + * @param string $signature raw signature bytes (already base64-decoded) + * @param string|null $algorithm algorithm hint from Signature-Input `alg=` + * @throws SignatureException + */ + public static function verify(string $signatureBase, string $signature, Key $key, ?string $algorithm): bool { + $resolved = self::normalize($key->getAlgorithm()); + + if ($algorithm !== null && $algorithm !== '') { + $hintNative = self::normalize($algorithm); + if ($hintNative !== $resolved) { + throw new SignatureException( + 'algorithm sources disagree: Signature-Input alg says ' . $hintNative . ', JWK alg says ' . $resolved + ); + } + } + + $material = $key->getKeyMaterial(); + + if ($resolved === 'ed25519') { + if (strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) { + return false; + } + // parseKey hands OKP material as plain base64 of the 32 raw bytes. + $rawPublic = base64_decode((string)$material, true); + if ($rawPublic === false || strlen($rawPublic) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + return false; + } + return sodium_crypto_sign_verify_detached($signature, $signatureBase, $rawPublic); + } + + [$opensslAlgo, $encoding] = self::opensslParametersForAlgorithm($resolved); + + if ($encoding === 'ecdsa') { + $signature = self::ecdsaRawToDer($signature, self::ecdsaCoordinateSize($resolved)); + if ($signature === null) { + return false; + } + } + + return openssl_verify($signatureBase, $signature, $material, $opensslAlgo) === 1; + } + + /** + * Map a JOSE alg (RFC 7518/8037) to the RFC 9421 native identifier. + * Pass-through if already native. + * + * @throws SignatureException + */ + public static function normalize(string $algorithm): string { + $lower = strtolower($algorithm); + if (in_array($lower, self::NATIVE, true)) { + return $lower; + } + return match ($algorithm) { + 'EdDSA' => 'ed25519', + 'ES256' => 'ecdsa-p256-sha256', + 'ES384' => 'ecdsa-p384-sha384', + 'RS256' => 'rsa-v1_5-sha256', + 'RS384' => 'rsa-v1_5-sha384', + 'RS512' => 'rsa-v1_5-sha512', + default => throw new SignatureException('unsupported signature algorithm: ' . $algorithm), + }; + } + + /** + * Default JOSE alg for {@see \Firebase\JWT\JWK::parseKey} when the JWK has + * no `alg` (RFC 7517 leaves it optional). Null if kty/crv don't pin one + * down (e.g. RSA, where the hash isn't determined). + * + * @param array $jwk + */ + public static function deriveJoseAlgFromJwk(array $jwk): ?string { + return match ($jwk['kty'] ?? '') { + 'OKP' => match ($jwk['crv'] ?? '') { + 'Ed25519' => 'EdDSA', + default => null, + }, + 'EC' => match ($jwk['crv'] ?? '') { + 'P-256' => 'ES256', + 'P-384' => 'ES384', + default => null, + }, + default => null, + }; + } + + private static function nativeToJose(string $native): string { + return match ($native) { + 'ed25519' => 'EdDSA', + 'ecdsa-p256-sha256' => 'ES256', + 'ecdsa-p384-sha384' => 'ES384', + 'rsa-v1_5-sha256' => 'RS256', + 'rsa-v1_5-sha384' => 'RS384', + 'rsa-v1_5-sha512' => 'RS512', + default => throw new SignatureException('unsupported signature algorithm: ' . $native), + }; + } + + /** + * @return array{0: int, 1: string} [openssl digest, wire encoding] + */ + private static function opensslParametersForAlgorithm(string $native): array { + return match ($native) { + 'rsa-v1_5-sha256' => [OPENSSL_ALGO_SHA256, 'raw'], + 'rsa-v1_5-sha384' => [OPENSSL_ALGO_SHA384, 'raw'], + 'rsa-v1_5-sha512' => [OPENSSL_ALGO_SHA512, 'raw'], + 'ecdsa-p256-sha256' => [OPENSSL_ALGO_SHA256, 'ecdsa'], + 'ecdsa-p384-sha384' => [OPENSSL_ALGO_SHA384, 'ecdsa'], + default => throw new SignatureException('unsupported signature algorithm: ' . $native), + }; + } + + private static function ecdsaCoordinateSize(string $native): int { + return match ($native) { + 'ecdsa-p256-sha256' => 32, + 'ecdsa-p384-sha384' => 48, + default => throw new InvalidArgumentException('not an ECDSA algorithm: ' . $native), + }; + } + + /** + * Raw R||S (RFC 9421 §3.3.4 wire form) to DER for openssl_verify. + * firebase/php-jwt has the inverse but keeps it private. + */ + public static function ecdsaRawToDer(string $raw, int $coordinateSize): ?string { + if (strlen($raw) !== $coordinateSize * 2) { + return null; + } + $r = ltrim(substr($raw, 0, $coordinateSize), "\x00"); + $s = ltrim(substr($raw, $coordinateSize), "\x00"); + // DER INTEGER must be positive; pad if high bit is set. + if ($r === '' || (ord($r[0]) & 0x80) !== 0) { + $r = "\x00" . $r; + } + if ($s === '' || (ord($s[0]) & 0x80) !== 0) { + $s = "\x00" . $s; + } + $rEncoded = "\x02" . self::derLength(strlen($r)) . $r; + $sEncoded = "\x02" . self::derLength(strlen($s)) . $s; + $body = $rEncoded . $sEncoded; + return "\x30" . self::derLength(strlen($body)) . $body; + } + + private static function derLength(int $length): string { + if ($length < 0x80) { + return chr($length); + } + $bytes = ''; + while ($length > 0) { + $bytes = chr($length & 0xff) . $bytes; + $length >>= 8; + } + return chr(0x80 | strlen($bytes)) . $bytes; + } +} diff --git a/lib/private/Security/Signature/Rfc9421/ContentDigest.php b/lib/private/Security/Signature/Rfc9421/ContentDigest.php new file mode 100644 index 0000000000000..7df49f624d22f --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/ContentDigest.php @@ -0,0 +1,72 @@ + $digest) { + try { + $hashAlgorithm = self::hashAlgorithmFor($algorithm); + } catch (InvalidArgumentException) { + continue; + } + if (!hash_equals(hash($hashAlgorithm, $body, true), $digest)) { + return false; + } + $matched = true; + } + return $matched; + } + + /** @return array [algorithm => raw bytes] */ + public static function parse(string $header): array { + $out = []; + foreach (explode(',', $header) as $entry) { + $entry = trim($entry); + if ($entry === '') { + continue; + } + if (!preg_match('#^([a-z0-9-]+)=:([A-Za-z0-9+/=]*):$#', $entry, $m)) { + continue; + } + $decoded = base64_decode($m[2], true); + if ($decoded === false) { + continue; + } + $out[strtolower($m[1])] = $decoded; + } + return $out; + } + + private static function hashAlgorithmFor(string $algorithm): string { + return match (strtolower($algorithm)) { + self::ALGO_SHA256 => 'sha256', + self::ALGO_SHA512 => 'sha512', + default => throw new InvalidArgumentException('unsupported content-digest algorithm: ' . $algorithm), + }; + } +} diff --git a/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php new file mode 100644 index 0000000000000..5747ccb43d8d2 --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php @@ -0,0 +1,29 @@ + $headers headers keyed by lowercase name + * @param list $components covered component identifiers, in order + * @param string $signatureParamsLine `(...);params...` for `@signature-params` + * @throws SignatureException when a covered field is missing from $headers + */ + public static function build( + string $method, + string $uri, + array $headers, + array $components, + string $signatureParamsLine, + ): string { + $lines = []; + foreach ($components as $component) { + $lines[] = '"' . $component . '": ' . self::componentValue($component, $method, $uri, $headers); + } + $lines[] = '"@signature-params": ' . $signatureParamsLine; + return implode("\n", $lines); + } + + /** + * Serialize `(comp...)` + `;k=v` parameters for `@signature-params` and + * Signature-Input dictionary entries. + * + * @param list $components + * @param array $params + */ + public static function serializeSignatureParams(array $components, array $params): string { + $inner = array_map(static fn (string $c): string => '"' . $c . '"', $components); + $out = '(' . implode(' ', $inner) . ')'; + foreach ($params as $name => $value) { + $out .= ';' . $name . '=' . self::serializeBareItem($value); + } + return $out; + } + + /** + * @param scalar $value + */ + public static function serializeBareItem(mixed $value): string { + if (is_string($value)) { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; + } + if (is_int($value)) { + return (string)$value; + } + if (is_bool($value)) { + return $value ? '?1' : '?0'; + } + throw new InvalidArgumentException('unsupported parameter value type'); + } + + private static function componentValue(string $component, string $method, string $uri, array $headers): string { + if (str_starts_with($component, '@')) { + return self::derivedValue($component, $method, $uri); + } + $lower = strtolower($component); + if (!array_key_exists($lower, $headers)) { + throw new SignatureException('missing field for signature: ' . $component); + } + return self::normalizeFieldValue($headers[$lower]); + } + + private static function derivedValue(string $component, string $method, string $uri): string { + $parts = parse_url($uri); + if ($parts === false) { + throw new SignatureException('cannot parse target URI'); + } + return match ($component) { + '@method' => strtoupper($method), + '@target-uri' => $uri, + '@authority' => self::authority($parts), + '@scheme' => strtolower($parts['scheme'] ?? ''), + '@path' => $parts['path'] ?? '/', + '@query' => isset($parts['query']) ? '?' . $parts['query'] : '', + '@request-target' => ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : ''), + default => throw new SignatureException('unsupported derived component: ' . $component), + }; + } + + private static function authority(array $parts): string { + $host = strtolower((string)($parts['host'] ?? '')); + if ($host === '') { + return ''; + } + $port = $parts['port'] ?? null; + $scheme = strtolower((string)($parts['scheme'] ?? '')); + // RFC 9421 §2.2.3: default ports are omitted. + if ($port !== null && !self::isDefaultPort($scheme, (int)$port)) { + return $host . ':' . $port; + } + return $host; + } + + private static function isDefaultPort(string $scheme, int $port): bool { + return ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80); + } + + private static function normalizeFieldValue(string $value): string { + // RFC 9421 §2.1: strip OWS, collapse internal whitespace. + return preg_replace('/[ \t]+/', ' ', trim($value)) ?? ''; + } +} diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php index 11aff48438dbf..f094ac5148a37 100644 --- a/lib/private/Security/Signature/SignatureManager.php +++ b/lib/private/Security/Signature/SignatureManager.php @@ -11,6 +11,9 @@ use OC\Security\Signature\Db\SignatoryMapper; use OC\Security\Signature\Model\IncomingSignedRequest; use OC\Security\Signature\Model\OutgoingSignedRequest; +use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest; +use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest; +use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager; use OCP\DB\Exception as DBException; use OCP\IAppConfig; use OCP\IRequest; @@ -101,6 +104,11 @@ public function getIncomingSignedRequest( throw new IncomingRequestException('content of request is too big'); } + // `Signature-Input` is unique to RFC 9421; cavage uses `Signature` only. + if ($this->request->getHeader('Signature-Input') !== '') { + return $this->getRfc9421IncomingSignedRequest($signatoryManager, $body, $options); + } + // generate IncomingSignedRequest based on body and request $signedRequest = new IncomingSignedRequest($body, $this->request, $options); @@ -121,6 +129,45 @@ public function getIncomingSignedRequest( return $signedRequest; } + /** + * RFC 9421 inbound path. Requires {@see IJwkResolvingSignatoryManager}. + * + * @throws IncomingRequestException + * @throws SignatureException + * @throws SignatureNotFoundException + */ + private function getRfc9421IncomingSignedRequest( + ISignatoryManager $signatoryManager, + string $body, + array $options, + ): IIncomingSignedRequest { + if (!($signatoryManager instanceof IJwkResolvingSignatoryManager)) { + throw new IncomingRequestException('RFC 9421 inbound is not supported by ' . get_class($signatoryManager)); + } + + $signedRequest = new Rfc9421IncomingSignedRequest($body, $this->request, $options); + + try { + $key = $signatoryManager->getRemoteKey($signedRequest->getOrigin(), $signedRequest->getKeyId()); + if ($key === null) { + throw new SignatoryNotFoundException('no JWK resolved for keyid ' . $signedRequest->getKeyId()); + } + $signedRequest->setKey($key); + $signedRequest->verify(); + } catch (SignatureException $e) { + $this->logger->warning( + 'RFC 9421 signature could not be verified', [ + 'exception' => $e, + 'signedRequest' => $signedRequest, + 'signatoryManager' => get_class($signatoryManager), + ] + ); + throw $e; + } + + return $signedRequest; + } + /** * confirm that the Signature is signed using the correct private key, using * clear version of the Signature and the public key linked to the keyId @@ -199,13 +246,22 @@ public function getOutgoingSignedRequest( string $method, string $uri, ): IOutgoingSignedRequest { - $signedRequest = new OutgoingSignedRequest( - $content, - $signatoryManager, - $this->extractIdentityFromUri($uri), - $method, - parse_url($uri, PHP_URL_PATH) ?? '/' - ); + $options = $signatoryManager->getOptions(); + $signedRequest = ($options['rfc9421.format'] ?? false) + ? new Rfc9421OutgoingSignedRequest( + $content, + $signatoryManager, + $this->extractIdentityFromUri($uri), + $method, + $uri, + ) + : new OutgoingSignedRequest( + $content, + $signatoryManager, + $this->extractIdentityFromUri($uri), + $method, + parse_url($uri, PHP_URL_PATH) ?? '/', + ); $signedRequest->sign(); diff --git a/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php new file mode 100644 index 0000000000000..5f4285f14ccde --- /dev/null +++ b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php @@ -0,0 +1,316 @@ +ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = '{"hello":"world"}'; + $method = 'POST'; + $uri = 'https://receiver.example.org/ocm/shares'; + + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', $method, $uri); + $out->sign(); + + $req = $this->mockRequestFromOutgoing($out, $method, '/ocm/shares', 'receiver.example.org'); + $in = new Rfc9421IncomingSignedRequest($body, $req); + $in->setKey($jwk); + + $this->assertSame($out->getSignatureBaseString(), $in->getSignatureBaseString()); + $in->verify(); // throws on failure + $this->addToAssertionCount(1); + } + + public function testTamperedBodyRejected(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'original'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $req = $this->mockRequestFromOutgoing($out, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest('tampered', $req); + } + + public function testTamperedSignatureRejected(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $headers = $out->getHeaders(); + // Replace the inner base64 of the signature with a different valid base64. + $headers['Signature'] = preg_replace('/=:[^:]+:/', '=:' . base64_encode(random_bytes(64)) . ':', (string)$headers['Signature']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $in = new Rfc9421IncomingSignedRequest($body, $req); + $in->setKey($jwk); + + $this->expectException(InvalidSignatureException::class); + $in->verify(); + } + + public function testOutgoingUsesOcmLabel(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $headers = $out->getHeaders(); + $this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']); + $this->assertStringStartsWith('ocm=:', (string)$headers['Signature']); + } + + public function testRequestWithoutOcmLabelRejected(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Rename the OCM label to something else; verifier MUST reject. + $headers = $out->getHeaders(); + $headers['Signature-Input'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature-Input']); + $headers['Signature'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(SignatureNotFoundException::class); + new Rfc9421IncomingSignedRequest('msg', $req); + } + + public function testDuplicateOcmLabelRejected(): void { + // RFC 8941 §4.2 last-wins on duplicate dictionary keys, but OCM + // mandates that duplicate `ocm` entries cause the request to be + // rejected outright. The model layer enforces that. + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + $headers = $out->getHeaders(); + $headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', ' . (string)$headers['Signature-Input']; + $headers['Signature'] = (string)$headers['Signature'] . ', ' . (string)$headers['Signature']; + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest('msg', $req); + } + + public function testForeignSiblingLabelIgnored(): void { + [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Splice in a sibling proxy_sig1 entry; the verifier must ignore it + // and still verify the ocm-labeled signature successfully. + $headers = $out->getHeaders(); + $proxyParams = '("@method");created=1;keyid="proxy"'; + $proxySig = base64_encode(random_bytes(64)); + $headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', proxy_sig1=' . $proxyParams; + $headers['Signature'] = (string)$headers['Signature'] . ', proxy_sig1=:' . $proxySig . ':'; + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $in = new Rfc9421IncomingSignedRequest('msg', $req); + $in->setKey($jwk); + $in->verify(); + $this->addToAssertionCount(1); + } + + public function testTooOldSignatureRejected(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Backdate `created` in Signature-Input by 10 minutes. + $headers = $out->getHeaders(); + $pastCreated = time() - 600; + $headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $pastCreated, (string)$headers['Signature-Input']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req, ['ttl' => 300]); + } + + public function testFutureCreatedRejected(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Push `created` 10 minutes into the future, well past the + // 60-second skew tolerance. + $headers = $out->getHeaders(); + $futureCreated = time() + 600; + $headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $futureCreated, (string)$headers['Signature-Input']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req); + } + + public function testMissingCreatedRejected(): void { + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManager($signatory); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + + // Strip the `;created=...` parameter so the signature loses its + // freshness anchor. + $headers = $out->getHeaders(); + $headers['Signature-Input'] = preg_replace('/;created=\d+/', '', (string)$headers['Signature-Input']); + + $req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req); + } + + public function testSignatureNotCoveringRequiredComponentsRejected(): void { + // A peer that signs only `@method` and `@target-uri`: the body and + // freshness window aren't bound. Even with a valid signature we + // must refuse it. + [$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519'); + $signatoryManager = $this->makeSignatoryManagerWithComponents( + $signatory, + ['@method', '@target-uri'], + ); + + $body = 'msg'; + $out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares'); + $out->sign(); + $req = $this->mockRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org'); + + $this->expectException(IncomingRequestException::class); + new Rfc9421IncomingSignedRequest($body, $req); + } + + private function makeSignatoryManagerWithComponents(Signatory $signatory, array $components): ISignatoryManager { + return new class($signatory, $components) implements ISignatoryManager { + public function __construct( + private Signatory $sig, + private array $components, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + 'rfc9421.coveredComponents' => $this->components, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->sig; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + } + + private function ed25519Material(string $kid): array { + $keypair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keypair); + $secretKey = sodium_crypto_sign_secretkey($keypair); + $signatory = new Signatory(true); + $signatory->setKeyId($kid); + $signatory->setPublicKey($publicKey); + $signatory->setPrivateKey($secretKey); + $key = JWK::parseKey([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => $kid, + 'alg' => 'EdDSA', + 'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='), + ], 'EdDSA'); + return [$signatory, $key]; + } + + private function makeSignatoryManager(Signatory $signatory): ISignatoryManager { + return new class($signatory) implements ISignatoryManager { + public function __construct( + private Signatory $sig, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->sig; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + } + + private function mockRequestFromOutgoing(Rfc9421OutgoingSignedRequest $out, string $method, string $path, string $host): IRequest { + return $this->mockRequest($out->getHeaders(), $method, $path, $host); + } + + private function mockRequest(array $headers, string $method, string $path, string $host): IRequest { + $lowered = []; + foreach ($headers as $name => $value) { + $lowered[strtolower($name)] = (string)$value; + } + $mock = $this->createMock(IRequest::class); + $mock->method('getHeader')->willReturnCallback(static fn (string $h) => $lowered[strtolower($h)] ?? ''); + $mock->method('getMethod')->willReturn($method); + $mock->method('getRequestUri')->willReturn($path); + $mock->method('getServerProtocol')->willReturn('https'); + $mock->method('getServerHost')->willReturn($host); + return $mock; + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php new file mode 100644 index 0000000000000..ba117ca99baf4 --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php @@ -0,0 +1,197 @@ +assertSame('ed25519', Algorithm::normalize('ed25519')); + $this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256')); + $this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ecdsa-p256-sha256')); + } + + public function testNormalizeJoseAliases(): void { + $this->assertSame('ed25519', Algorithm::normalize('EdDSA')); + $this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ES256')); + $this->assertSame('ecdsa-p384-sha384', Algorithm::normalize('ES384')); + $this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('RS256')); + } + + public function testNormalizeRejectsUnknown(): void { + $this->expectException(SignatureException::class); + Algorithm::normalize('totally-not-real'); + } + + public function testNormalizeRejectsRsaPss(): void { + $this->expectException(SignatureException::class); + Algorithm::normalize('rsa-pss-sha512'); + } + + public function testNormalizeRejectsJosePsAlias(): void { + $this->expectException(SignatureException::class); + Algorithm::normalize('PS512'); + } + + public function testDeriveJoseAlgFromJwk(): void { + $this->assertSame('EdDSA', Algorithm::deriveJoseAlgFromJwk(['kty' => 'OKP', 'crv' => 'Ed25519'])); + $this->assertSame('ES256', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-256'])); + $this->assertSame('ES384', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-384'])); + // RSA: hash function isn't determined by key shape. + $this->assertNull(Algorithm::deriveJoseAlgFromJwk(['kty' => 'RSA'])); + $this->assertNull(Algorithm::deriveJoseAlgFromJwk([])); + } + + public function testEd25519RoundTrip(): void { + [$priv, $key] = $this->ed25519KeyPair(); + $base = 'arbitrary signature base'; + $sig = Algorithm::sign($base, $priv, 'ed25519'); + $this->assertSame(64, strlen($sig)); + $this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519')); + // JOSE alias accepted. + $this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA')); + // alg-omitted path resolves through Key alg. + $this->assertTrue(Algorithm::verify($base, $sig, $key, null)); + // tamper detection + $this->assertFalse(Algorithm::verify($base . 'x', $sig, $key, 'ed25519')); + } + + public function testRsaPkcs1RoundTrip(): void { + [$priv, $key] = $this->rsaKeyPair(); + $sig = Algorithm::sign('payload', $priv, 'rsa-v1_5-sha256'); + $this->assertSame(256, strlen($sig)); + $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'rsa-v1_5-sha256')); + $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'RS256')); + } + + public function testEcdsaP256RoundTrip(): void { + [$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256'); + $sig = Algorithm::sign('payload', $priv, 'ecdsa-p256-sha256'); + $this->assertSame(64, strlen($sig)); + $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p256-sha256')); + $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ES256')); + } + + public function testEcdsaP384RoundTrip(): void { + [$priv, $key] = $this->ecKeyPair('secp384r1', 'P-384', 'ES384'); + $sig = Algorithm::sign('payload', $priv, 'ecdsa-p384-sha384'); + $this->assertSame(96, strlen($sig)); + $this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p384-sha384')); + } + + public function testKeyTypeMismatchFailsClosed(): void { + [, $rsaKey] = $this->rsaKeyPair(); + $this->expectException(SignatureException::class); + Algorithm::verify('payload', random_bytes(64), $rsaKey, 'ed25519'); + } + + public function testAlgHintConflictsWithJwkAlgRejected(): void { + // Ed25519 JWK, request claims ES256: RFC 9421 §3.2 step 6 disagreement. + [, $key] = $this->ed25519KeyPair(); + $this->expectException(SignatureException::class); + Algorithm::verify('payload', random_bytes(64), $key, 'ES256'); + } + + public function testParseKeyRejectsContradictoryAlg(): void { + // kty=OKP/crv=Ed25519 with alg=ES256 is contradictory; firebase's + // parseKey rejects it before we ever build a Key. + $keypair = sodium_crypto_sign_keypair(); + $this->expectException(\Throwable::class); + JWK::parseKey([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => 'k', + 'alg' => 'ES256', + 'x' => self::b64url(sodium_crypto_sign_publickey($keypair)), + ], null); + } + + public function testAlgHintAgreesViaJoseAlias(): void { + [$priv, $key] = $this->ed25519KeyPair(); + $base = 'agreement check'; + $sig = Algorithm::sign($base, $priv, 'ed25519'); + $this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519')); + $this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA')); + } + + public function testEcdsaRawToDerProducesValidSignature(): void { + [$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256'); + $rawSig = Algorithm::sign('msg', $priv, 'ecdsa-p256-sha256'); + $der = Algorithm::ecdsaRawToDer($rawSig, 32); + $this->assertNotNull($der); + $this->assertTrue(Algorithm::verify('msg', $rawSig, $key, 'ecdsa-p256-sha256')); + } + + public function testEcdsaRawToDerWrongLength(): void { + $this->assertNull(Algorithm::ecdsaRawToDer('short', 32)); + } + + /** + * @return array{0: string, 1: Key} + */ + private function ed25519KeyPair(): array { + $keypair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keypair); + $secretKey = sodium_crypto_sign_secretkey($keypair); + $key = JWK::parseKey([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => 'k', + 'alg' => 'EdDSA', + 'x' => self::b64url($publicKey), + ], 'EdDSA'); + return [$secretKey, $key]; + } + + /** + * @return array{0: string, 1: Key} + */ + private function rsaKeyPair(): array { + $pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $priv = ''; + openssl_pkey_export($pkey, $priv); + $details = openssl_pkey_get_details($pkey); + $key = JWK::parseKey([ + 'kty' => 'RSA', + 'kid' => 'k', + 'alg' => 'RS256', + 'n' => self::b64url($details['rsa']['n']), + 'e' => self::b64url($details['rsa']['e']), + ], 'RS256'); + return [$priv, $key]; + } + + /** + * @return array{0: string, 1: Key} + */ + private function ecKeyPair(string $opensslCurve, string $jwkCurve, string $joseAlg): array { + $pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => $opensslCurve]); + $priv = ''; + openssl_pkey_export($pkey, $priv); + $details = openssl_pkey_get_details($pkey); + $key = JWK::parseKey([ + 'kty' => 'EC', + 'crv' => $jwkCurve, + 'kid' => 'k', + 'alg' => $joseAlg, + 'x' => self::b64url($details['ec']['x']), + 'y' => self::b64url($details['ec']['y']), + ], $joseAlg); + return [$priv, $key]; + } + + private static function b64url(string $bin): string { + return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php new file mode 100644 index 0000000000000..4198acec5342b --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php @@ -0,0 +1,76 @@ +assertStringStartsWith('sha-256=:', $header); + $this->assertStringEndsWith(':', $header); + $this->assertTrue(ContentDigest::verify($header, $body)); + } + + public function testDifferentBodyFails(): void { + $header = ContentDigest::compute('hello', ContentDigest::ALGO_SHA256); + $this->assertFalse(ContentDigest::verify($header, 'goodbye')); + } + + public function testSha512(): void { + $header = ContentDigest::compute('payload', ContentDigest::ALGO_SHA512); + $this->assertStringStartsWith('sha-512=:', $header); + $this->assertTrue(ContentDigest::verify($header, 'payload')); + } + + public function testParseMultipleAlgorithmsAcceptsAnyMatch(): void { + $body = 'data'; + $sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256); + $sha512 = ContentDigest::compute($body, ContentDigest::ALGO_SHA512); + $header = $sha256 . ', ' . $sha512; + $this->assertTrue(ContentDigest::verify($header, $body)); + } + + public function testFailsIfAnyRecognisedAlgorithmMismatches(): void { + // All recognised digests must agree. A correct sha-256 alongside a + // wrong sha-512 is treated as an attack on the weaker algorithm, + // not as a successful match on the stronger one. + $body = 'data'; + $sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256); + $wrongSha512 = 'sha-512=:' . base64_encode(hash('sha512', 'tampered', true)) . ':'; + $this->assertFalse(ContentDigest::verify($sha256 . ', ' . $wrongSha512, $body)); + // And the inverse ordering. + $this->assertFalse(ContentDigest::verify($wrongSha512 . ', ' . $sha256, $body)); + } + + public function testUnknownAlgorithmIsIgnored(): void { + $body = 'data'; + $sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256); + $header = 'md5=:abcd:, ' . $sha256; + $this->assertTrue(ContentDigest::verify($header, $body)); + } + + public function testEmptyHeaderFails(): void { + $this->assertFalse(ContentDigest::verify('', 'body')); + } + + public function testGarbageHeaderFails(): void { + $this->assertFalse(ContentDigest::verify('not a digest', 'body')); + } + + public function testParseExtractsRawBytes(): void { + $header = ContentDigest::compute('abc', ContentDigest::ALGO_SHA256); + $parsed = ContentDigest::parse($header); + $this->assertArrayHasKey('sha-256', $parsed); + $this->assertSame(hash('sha256', 'abc', true), $parsed['sha-256']); + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php new file mode 100644 index 0000000000000..d5aed5e9ab679 --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php @@ -0,0 +1,85 @@ + 'sha-256=:abcd:', + 'date' => 'Mon, 04 May 2026 12:00:00 GMT', + ], + components: ['@method', '@target-uri', 'content-digest', 'date'], + signatureParamsLine: '("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"', + ); + + $expected = '"@method": POST' . "\n" + . '"@target-uri": https://example.org/foo?bar=baz' . "\n" + . '"content-digest": sha-256=:abcd:' . "\n" + . '"date": Mon, 04 May 2026 12:00:00 GMT' . "\n" + . '"@signature-params": ("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"'; + $this->assertSame($expected, $base); + } + + public function testAuthorityStripsDefaultPort(): void { + $base = SignatureBase::build('GET', 'https://EXAMPLE.org:443/x', [], ['@authority'], '()'); + $this->assertStringContainsString('"@authority": example.org' . "\n", $base); + } + + public function testAuthorityKeepsCustomPort(): void { + $base = SignatureBase::build('GET', 'https://example.org:8443/x', [], ['@authority'], '()'); + $this->assertStringContainsString('"@authority": example.org:8443' . "\n", $base); + } + + public function testQueryComponent(): void { + $base = SignatureBase::build('GET', 'https://example.org/x?a=1', [], ['@query'], '()'); + $this->assertStringContainsString('"@query": ?a=1' . "\n", $base); + } + + public function testMissingFieldThrows(): void { + $this->expectException(SignatureException::class); + SignatureBase::build('GET', 'https://example.org/', [], ['x-missing'], '()'); + } + + public function testFieldValueIsTrimmed(): void { + $base = SignatureBase::build( + 'GET', + 'https://example.org/', + ['date' => ' Mon, 04 May 2026 12:00:00 GMT '], + ['date'], + '()' + ); + $this->assertStringContainsString('"date": Mon, 04 May 2026 12:00:00 GMT' . "\n", $base); + } + + public function testSerializeSignatureParams(): void { + $line = SignatureBase::serializeSignatureParams( + ['@method', '@target-uri'], + ['created' => 100, 'keyid' => 'kid', 'expires' => 200], + ); + $this->assertSame('("@method" "@target-uri");created=100;keyid="kid";expires=200', $line); + } + + public function testSerializeBareItemEscapesQuotes(): void { + $this->assertSame('"\\"hi\\""', SignatureBase::serializeBareItem('"hi"')); + $this->assertSame('"\\\\"', SignatureBase::serializeBareItem('\\')); + } + + public function testSerializeBareItemBoolean(): void { + $this->assertSame('?1', SignatureBase::serializeBareItem(true)); + $this->assertSame('?0', SignatureBase::serializeBareItem(false)); + } +} diff --git a/tests/lib/Security/Signature/SignatureManagerDispatchTest.php b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php new file mode 100644 index 0000000000000..ae5945fd9b5a9 --- /dev/null +++ b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php @@ -0,0 +1,262 @@ +request = $this->createMock(IRequest::class); + $this->mapper = $this->createMock(SignatoryMapper::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->signatureManager = new SignatureManager( + $this->request, + $this->mapper, + $this->appConfig, + $this->logger, + ); + } + + public function testOutgoingDispatchesToCavageByDefault(): void { + // Cavage signs with an RSA PEM, so we need a real RSA keypair here; + // the Ed25519 helper would produce libsodium bytes that openssl_sign + // can't consume. + $signatoryManager = $this->rsaSignatoryManager(); + + $signed = $this->signatureManager->getOutgoingSignedRequest( + $signatoryManager, + '{}', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + + $this->assertNotInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed); + } + + public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void { + [$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true); + + $signed = $this->signatureManager->getOutgoingSignedRequest( + $signatoryManager, + '{}', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + + $this->assertInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed); + $headers = $signed->getHeaders(); + $this->assertArrayHasKey('Signature-Input', $headers); + $this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']); + } + + public function testInboundDispatchesToRfc9421WhenSignatureInputPresent(): void { + [$signatoryManager, $jwk, $secret] = $this->ed25519SignatoryManager(rfc9421Format: true); + + // Build a real signed request and replay its headers as the inbound + // request to exercise the full inbound path including verification. + $body = '{"hello":"world"}'; + $out = new Rfc9421OutgoingSignedRequest( + $body, + $signatoryManager, + 'receiver.example.org', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + $out->sign(); + $headers = $out->getHeaders(); + + $this->primeRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org'); + + $resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ed25519'); + + $signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body); + $this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed); + } + + public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void { + [$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true); + + $body = '{"hello":"world"}'; + $out = new Rfc9421OutgoingSignedRequest( + $body, + $signatoryManager, + 'receiver.example.org', + 'POST', + 'https://receiver.example.org/ocm/shares', + ); + $out->sign(); + $this->primeRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org'); + + // $signatoryManager does NOT implement IJwkResolvingSignatoryManager. + $this->expectException(IncomingRequestException::class); + $this->signatureManager->getIncomingSignedRequest($signatoryManager, $body); + } + + private function rsaSignatoryManager(): ISignatoryManager { + $key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $priv = ''; + openssl_pkey_export($key, $priv); + $pub = openssl_pkey_get_details($key)['key']; + + $signatory = new Signatory(true); + $signatory->setKeyId('https://sender.example.org/ocm#signature'); + $signatory->setPublicKey($pub); + $signatory->setPrivateKey($priv); + + return new class($signatory) implements ISignatoryManager { + public function __construct( + private Signatory $signatory, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->signatory; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + } + + /** + * @return array{ISignatoryManager, Key, string} [manager, parsed verification key, raw secret key] + */ + private function ed25519SignatoryManager(bool $rfc9421Format): array { + $keypair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keypair); + $secretKey = sodium_crypto_sign_secretkey($keypair); + $kid = 'https://sender.example.org/ocm#ed25519'; + + $signatory = new Signatory(true); + $signatory->setKeyId($kid); + $signatory->setPublicKey($publicKey); + $signatory->setPrivateKey($secretKey); + + $key = JWK::parseKey([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => $kid, + 'alg' => 'EdDSA', + 'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='), + ], 'EdDSA'); + + $manager = new class($signatory, $rfc9421Format) implements ISignatoryManager { + public function __construct( + private Signatory $signatory, + private bool $rfc9421, + ) { + } + + public function getProviderId(): string { + return 'test'; + } + + public function getOptions(): array { + return [ + 'algorithm' => SignatureAlgorithm::RSA_SHA256, + 'digestAlgorithm' => DigestAlgorithm::SHA256, + 'rfc9421.format' => $this->rfc9421, + ]; + } + + public function getLocalSignatory(): Signatory { + return $this->signatory; + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return null; + } + }; + return [$manager, $key, $secretKey]; + } + + private function makeKeyResolver(ISignatoryManager $delegate, Key $key, string $kid): IJwkResolvingSignatoryManager { + return new class($delegate, $key, $kid) implements IJwkResolvingSignatoryManager { + public function __construct( + private ISignatoryManager $delegate, + private Key $key, + private string $kid, + ) { + } + + public function getProviderId(): string { + return $this->delegate->getProviderId(); + } + + public function getOptions(): array { + return $this->delegate->getOptions(); + } + + public function getLocalSignatory(): Signatory { + return $this->delegate->getLocalSignatory(); + } + + public function getRemoteSignatory(string $remote): ?Signatory { + return $this->delegate->getRemoteSignatory($remote); + } + + public function getRemoteKey(string $origin, string $keyId): ?Key { + return $keyId === $this->kid ? $this->key : null; + } + }; + } + + private function primeRequest(array $headers, string $method, string $path, string $host): void { + $lowered = []; + foreach ($headers as $name => $value) { + $lowered[strtolower($name)] = (string)$value; + } + $this->request->method('getHeader') + ->willReturnCallback(static fn (string $name) => $lowered[strtolower($name)] ?? ''); + $this->request->method('getMethod')->willReturn($method); + $this->request->method('getRequestUri')->willReturn($path); + $this->request->method('getServerProtocol')->willReturn('https'); + $this->request->method('getServerHost')->willReturn($host); + } +} From 787902ed53c81705811e1e814e6bbd163861a3ef Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:32 +0200 Subject: [PATCH 4/6] feat(identityproof): Ed25519 app keys Add Manager::generateEd25519AppKey: persist a sodium-generated Ed25519 keypair (raw 32-byte public, 64-byte secret) under the same appdata layout the existing RSA path uses. Used by OCMSignatoryManager for the slotted RFC 9421 signing keys. Signed-off-by: Micke Nordin --- .../Security/IdentityProof/Manager.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/private/Security/IdentityProof/Manager.php b/lib/private/Security/IdentityProof/Manager.php index ef0faeb6ad632..d6ebe3813b21e 100644 --- a/lib/private/Security/IdentityProof/Manager.php +++ b/lib/private/Security/IdentityProof/Manager.php @@ -178,6 +178,30 @@ public function generateAppKey(string $app, string $name, array $options = []): return $this->generateKey($this->generateAppKeyId($app, $name), $options); } + /** + * Generate an Ed25519 keypair via libsodium. Returns raw 32-byte public + * + 64-byte secret (sodium seed||publickey), no PEM. Overwrites if + * already present. + */ + public function generateEd25519AppKey(string $app, string $name): Key { + $keyPair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keyPair); + $privateKey = sodium_crypto_sign_secretkey($keyPair); + + $id = $this->generateAppKeyId($app, $name); + try { + $this->appData->newFolder($id); + } catch (\Exception) { + } + $folder = $this->appData->getFolder($id); + $folder->newFile('private') + ->putContent($this->crypto->encrypt($privateKey)); + $folder->newFile('public') + ->putContent($publicKey); + + return new Key($publicKey, $privateKey); + } + public function deleteAppKey(string $app, string $name): bool { try { $folder = $this->appData->getFolder($this->generateAppKeyId($app, $name)); From 6be4654c2766c16fd0a3a81eb5b34f368d087673 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:45 +0200 Subject: [PATCH 5/6] feat(http-sig): OCM Ed25519 keys, JWKS endpoint, http-sig capability OCM dual-stack integration of RFC 9421 alongside the existing cavage publicKey path: - OCMSignatoryManager: Ed25519 active/pending/retiring slot rotation backed by numbered pool appkeys, getRemoteKey for inbound JWK lookup with per-origin cache + cache-miss refetch, and getLocalEd25519Jwks for the JWKS endpoint. - Rfc9421SignatoryManager: per-call wrapper that swaps in the Ed25519 signatory and toggles `rfc9421.format`. - OCMJwksHandler: serves /.well-known/jwks.json (RFC 7517) when signing is enabled. - OCMDiscoveryService: advertises `http-sig` in capabilities when signing is enabled, and picks the signature scheme on outbound based on the remote's advertised capabilities. - Application.php: register the JWKS well-known handler. Signed-off-by: Micke Nordin --- core/AppInfo/Application.php | 2 + lib/private/OCM/OCMDiscoveryService.php | 42 +- lib/private/OCM/OCMJwksHandler.php | 49 +++ lib/private/OCM/OCMSignatoryManager.php | 414 +++++++++++++++++- lib/private/OCM/Rfc9421SignatoryManager.php | 56 +++ tests/lib/OCM/DiscoveryServiceTest.php | 7 + tests/lib/OCM/OCMJwksHandlerTest.php | 117 +++++ tests/lib/OCM/OCMSignatoryManagerJwksTest.php | 177 ++++++++ .../OCM/OCMSignatoryManagerRotationTest.php | 273 ++++++++++++ tests/lib/OCM/Rfc9421SignatoryManagerTest.php | 78 ++++ 10 files changed, 1185 insertions(+), 30 deletions(-) create mode 100644 lib/private/OCM/OCMJwksHandler.php create mode 100644 lib/private/OCM/Rfc9421SignatoryManager.php create mode 100644 tests/lib/OCM/OCMJwksHandlerTest.php create mode 100644 tests/lib/OCM/OCMSignatoryManagerJwksTest.php create mode 100644 tests/lib/OCM/OCMSignatoryManagerRotationTest.php create mode 100644 tests/lib/OCM/Rfc9421SignatoryManagerTest.php diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index 15cf42c4a5505..cd655ac386f98 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -23,6 +23,7 @@ use OC\Core\Listener\PasswordUpdatedListener; use OC\Core\Notification\CoreNotifier; use OC\OCM\OCMDiscoveryHandler; +use OC\OCM\OCMJwksHandler; use OC\TagManager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -88,6 +89,7 @@ public function register(IRegistrationContext $context): void { $context->registerConfigLexicon(ConfigLexicon::class); $context->registerWellKnownHandler(OCMDiscoveryHandler::class); + $context->registerWellKnownHandler(OCMJwksHandler::class); $context->registerCapability(Capabilities::class); } diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 9459e9a03f043..77b7d63ec0d53 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -199,10 +199,15 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { return $provider; } + $signingEnabled = !$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true); + $provider->setEnabled(true); $provider->setApiVersion(self::API_VERSION); $provider->setEndPoint(substr($url, 0, $pos)); $provider->setCapabilities(['invite-accepted', 'notifications', 'shares']); + if ($signingEnabled) { + $provider->setCapabilities(['http-sig']); + } // The inviteAcceptDialog is available from the contacts app, if this config value is set $inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG); @@ -217,9 +222,8 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { $provider->addResourceType($resource); if ($fullDetails) { - // Adding a public key to the ocm discovery try { - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + if ($signingEnabled) { /** * @experimental 31.0.0 * @psalm-suppress UndefinedInterfaceMethod @@ -342,10 +346,11 @@ public function requestRemoteOcmEndpoint( } /** - * add entries to the payload to auth the whole request + * Sign the outgoing payload using the scheme the remote advertises + * (RFC 9421 if `http-sig`, else cavage if a `publicKey` is present). + * APPCONFIG_SIGN_ENFORCED / APPCONFIG_SIGN_DISABLED still apply. * * @throws OCMProviderException - * @return array */ private function prepareOcmPayload(string $uri, string $method, array $options, string $payload, bool $signed): array { $payload = array_merge($this->generateRequestOptions($options), ['body' => $payload]); @@ -353,20 +358,31 @@ private function prepareOcmPayload(string $uri, string $method, array $options, return $payload; } - if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true) - && $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) { + $origin = $this->signatureManager->extractIdentityFromUri($uri); + $ocmProvider = $this->discover($origin); + + $useRfc9421 = $ocmProvider->hasCapability('http-sig'); + $hasPublicKey = $this->signatoryManager->getRemoteSignatory($origin) !== null; + + if (!$useRfc9421 && !$hasPublicKey + && $this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { throw new OCMProviderException('remote endpoint does not support signed request'); } - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { - $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( - $this->signatoryManager, - $payload, - $method, $uri - ); + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + return $payload; } - return $signedPayload ?? $payload; + $signatoryManager = $useRfc9421 + ? new Rfc9421SignatoryManager($this->signatoryManager) + : $this->signatoryManager; + + return $this->signatureManager->signOutgoingRequestIClientPayload( + $signatoryManager, + $payload, + $method, + $uri, + ); } private function generateRequestOptions(array $options): array { diff --git a/lib/private/OCM/OCMJwksHandler.php b/lib/private/OCM/OCMJwksHandler.php new file mode 100644 index 0000000000000..281c3eaab2d88 --- /dev/null +++ b/lib/private/OCM/OCMJwksHandler.php @@ -0,0 +1,49 @@ +appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + try { + foreach ($this->signatoryManager->getLocalEd25519Jwks() as $jwk) { + $keys[] = $jwk; + } + } catch (Throwable $e) { + $this->logger->warning('failed to build local Ed25519 JWKs', ['exception' => $e]); + } + } + + return new GenericResponse(new JSONResponse(['keys' => $keys])); + } +} diff --git a/lib/private/OCM/OCMSignatoryManager.php b/lib/private/OCM/OCMSignatoryManager.php index b239a4d1bceca..d60dc845e4ab7 100644 --- a/lib/private/OCM/OCMSignatoryManager.php +++ b/lib/private/OCM/OCMSignatoryManager.php @@ -9,21 +9,31 @@ namespace OC\OCM; +use Firebase\JWT\JWK; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use JsonException; use OC\Security\IdentityProof\Manager; +use OC\Security\Signature\Rfc9421\Algorithm; +use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager; +use OCP\Http\Client\IClientService; use OCP\IAppConfig; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; use OCP\IURLGenerator; use OCP\OCM\Exceptions\OCMProviderException; use OCP\Security\Signature\Enum\DigestAlgorithm; use OCP\Security\Signature\Enum\SignatoryType; use OCP\Security\Signature\Enum\SignatureAlgorithm; use OCP\Security\Signature\Exceptions\IdentityNotFoundException; -use OCP\Security\Signature\ISignatoryManager; use OCP\Security\Signature\ISignatureManager; use OCP\Security\Signature\Model\Signatory; use OCP\Server; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; +use Throwable; /** * @inheritDoc @@ -33,19 +43,41 @@ * * @since 31.0.0 */ -class OCMSignatoryManager implements ISignatoryManager { +class OCMSignatoryManager implements IJwkResolvingSignatoryManager { public const PROVIDER_ID = 'ocm'; public const APPCONFIG_SIGN_IDENTITY_EXTERNAL = 'ocm_signed_request_identity_external'; public const APPCONFIG_SIGN_DISABLED = 'ocm_signed_request_disabled'; public const APPCONFIG_SIGN_ENFORCED = 'ocm_signed_request_enforced'; + private const APPKEY_CAVAGE = 'ocm_external'; + private const KEYID_FRAGMENT_CAVAGE = 'signature'; + private const KEYID_FRAGMENT_ED25519 = 'ed25519'; + /** Ed25519 keypairs live in numbered pool appkeys; slots point to them by id. */ + private const APPKEY_ED25519_POOL_PREFIX = 'ocm_ed25519_pool_'; + private const APPCONFIG_ED25519_POOL_COUNTER = 'ocm_ed25519_pool_counter'; + private const APPCONFIG_ED25519_POOL_KID_PREFIX = 'ocm_ed25519_pool_kid_'; + /** Stable kid identity portion, reused across rotations so kids stay on one hostname. */ + private const APPCONFIG_ED25519_KID_BASE = 'ocm_ed25519_kid_base'; + public const SLOT_ACTIVE = 'active'; + public const SLOT_PENDING = 'pending'; + public const SLOT_RETIRING = 'retiring'; + /** All slots in advertise order. */ + public const ED25519_SLOTS = [self::SLOT_ACTIVE, self::SLOT_PENDING, self::SLOT_RETIRING]; + /** Remote JWKS cache TTL (seconds). */ + private const JWKS_CACHE_TTL = 3600; + + private readonly ICache $jwksCache; public function __construct( private readonly IAppConfig $appConfig, private readonly ISignatureManager $signatureManager, private readonly IURLGenerator $urlGenerator, private readonly Manager $identityProofManager, + private readonly IClientService $clientService, + private readonly IConfig $config, + ICacheFactory $cacheFactory, private readonly LoggerInterface $logger, ) { + $this->jwksCache = $cacheFactory->createDistributed('ocm-jwks'); } /** @@ -91,21 +123,16 @@ public function getLocalSignatory(): Signatory { * TODO: manage multiple identity (external, internal, ...) to allow a limitation * based on the requested interface (ie. only accept shares from globalscale) */ - if ($this->appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) { - $identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true); - $keyId = 'https://' . $identity . '/ocm#signature'; - } else { - $keyId = $this->generateKeyId(); - } + $keyId = $this->buildLocalKeyId(self::KEYID_FRAGMENT_CAVAGE); - if (!$this->identityProofManager->hasAppKey('core', 'ocm_external')) { - $this->identityProofManager->generateAppKey('core', 'ocm_external', [ + if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_CAVAGE)) { + $this->identityProofManager->generateAppKey('core', self::APPKEY_CAVAGE, [ 'algorithm' => 'rsa', 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); } - $keyPair = $this->identityProofManager->getAppKey('core', 'ocm_external'); + $keyPair = $this->identityProofManager->getAppKey('core', self::APPKEY_CAVAGE); $signatory = new Signatory(true); $signatory->setKeyId($keyId); @@ -115,28 +142,263 @@ public function getLocalSignatory(): Signatory { } + /** Active Ed25519 signing key, lazily provisioned. */ + public function getLocalEd25519Signatory(): ?Signatory { + $poolId = $this->getSlotPool(self::SLOT_ACTIVE); + if ($poolId === null) { + $poolId = $this->generatePool($this->nextEd25519PoolKid()); + $this->setSlotPool(self::SLOT_ACTIVE, $poolId); + } + return $this->signatoryFromPool($poolId); + } + + /** + * JWKs for the active/pending/retiring slots, in advertise order. The + * active slot is provisioned if missing so first-hit returns a key. + * + * @return list> + */ + public function getLocalEd25519Jwks(): array { + if ($this->getSlotPool(self::SLOT_ACTIVE) === null) { + $this->getLocalEd25519Signatory(); + } + + $jwks = []; + foreach (self::ED25519_SLOTS as $slot) { + $poolId = $this->getSlotPool($slot); + if ($poolId === null) { + continue; + } + $signatory = $this->signatoryFromPool($poolId); + if ($signatory !== null) { + $jwks[] = self::buildEd25519JwkArray($signatory->getPublicKey(), $signatory->getKeyId()); + } + } + return $jwks; + } + + /** + * Generate a pending Ed25519 keypair (advertised in JWKS, not yet used + * for outbound signing). + * + * @throws \RuntimeException if pending is already populated + */ + public function stageEd25519Key(): Signatory { + if ($this->getSlotPool(self::SLOT_PENDING) !== null) { + throw new \RuntimeException('a pending Ed25519 key already exists; activate or retire it first'); + } + // Need an active key first; staging a next from nothing makes no sense. + if ($this->getSlotPool(self::SLOT_ACTIVE) === null) { + $this->getLocalEd25519Signatory(); + } + $poolId = $this->generatePool($this->nextEd25519PoolKid()); + $this->setSlotPool(self::SLOT_PENDING, $poolId); + $signatory = $this->signatoryFromPool($poolId); + if ($signatory === null) { + throw new \RuntimeException('failed to materialise newly staged Ed25519 key'); + } + return $signatory; + } + + /** + * pending -> active, previous active -> retiring. The retiring slot + * stays in JWKS until {@see retireEd25519Key} is run. + * + * @throws \RuntimeException if no pending key is staged, or retiring is occupied + */ + public function activateStagedEd25519Key(): void { + $pending = $this->getSlotPool(self::SLOT_PENDING); + if ($pending === null) { + throw new \RuntimeException('no pending Ed25519 key to activate; run `ocm:keys:stage` first'); + } + if ($this->getSlotPool(self::SLOT_RETIRING) !== null) { + throw new \RuntimeException('a retiring Ed25519 key still exists; retire it before activating a new one'); + } + $active = $this->getSlotPool(self::SLOT_ACTIVE); + + $this->setSlotPool(self::SLOT_ACTIVE, $pending); + $this->clearSlot(self::SLOT_PENDING); + if ($active !== null) { + $this->setSlotPool(self::SLOT_RETIRING, $active); + } + } + + /** + * Delete the retiring key. In-flight signatures referencing its kid + * stop verifying after this returns. + * + * @throws \RuntimeException if retiring is empty + */ + public function retireEd25519Key(): void { + $poolId = $this->getSlotPool(self::SLOT_RETIRING); + if ($poolId === null) { + throw new \RuntimeException('no retiring Ed25519 key to remove'); + } + $this->identityProofManager->deleteAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId); + $this->appConfig->deleteKey('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId); + $this->clearSlot(self::SLOT_RETIRING); + } + + /** + * Diagnostics snapshot. `slot` is null for orphaned pools. + * + * @return list + */ + public function listEd25519Keys(): array { + $bySlot = []; + foreach (self::ED25519_SLOTS as $slot) { + $id = $this->getSlotPool($slot); + if ($id !== null) { + $bySlot[$id] = $slot; + } + } + + $max = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0); + $entries = []; + for ($id = 1; $id <= $max; $id++) { + if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $id)) { + continue; + } + $entries[] = [ + 'poolId' => $id, + 'kid' => $this->canonicalKid( + $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $id, ''), + ), + 'slot' => $bySlot[$id] ?? null, + ]; + } + return $entries; + } + + /** + * Generate keypair into a new pool. Kid is canonicalised through + * {@see Signatory::setKeyId} so admin output and wire form agree. + */ + private function generatePool(string $kid): int { + $poolId = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0) + 1; + $this->appConfig->setValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, $poolId); + + $this->identityProofManager->generateEd25519AppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId); + $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid)); + return $poolId; + } + + /** Canonical wire-form via a transient {@see Signatory::setKeyId} round-trip. */ + private function canonicalKid(string $kid): string { + $probe = new Signatory(true); + $probe->setKeyId($kid); + return $probe->getKeyId(); + } + /** - * - tries to generate a keyId using global configuration (from signature manager) if available - * - generate a keyId using the current route to ocm shares + * Build the next kid. Identity portion is derived once and persisted so + * CLI-triggered rotations stay on the same hostname. * + * @throws \RuntimeException if no instance identity can be derived + */ + private function nextEd25519PoolKid(): string { + $base = $this->resolveEd25519KidBase(); + $next = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0) + 1; + return $base . '-' . $next; + } + + /** + * Stable identity portion (before the `-N` suffix). Resolution order: + * stored APPCONFIG_ED25519_KID_BASE > active pool's kid sans suffix > + * fresh from {@see buildLocalKeyId}. Persisted so CLI rotations stay + * on one hostname. + * + * @throws \RuntimeException if no instance identity can be derived + */ + private function resolveEd25519KidBase(): string { + $base = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_KID_BASE, ''); + if ($base !== '') { + return $base; + } + + $activePool = $this->getSlotPool(self::SLOT_ACTIVE); + if ($activePool !== null) { + $kid = $this->canonicalKid( + $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $activePool, ''), + ); + $pos = strrpos($kid, '-'); + if ($pos !== false) { + $base = substr($kid, 0, $pos); + } + } + + if ($base === '') { + try { + $base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_ED25519)); + } catch (IdentityNotFoundException $e) { + throw new \RuntimeException('cannot derive instance identity for Ed25519 kid', 0, $e); + } + } + + $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_KID_BASE, $base); + return $base; + } + + private function getSlotPool(string $slot): ?int { + $key = 'ocm_ed25519_slot_' . $slot; + if (!$this->appConfig->hasKey('core', $key)) { + return null; + } + $value = $this->appConfig->getValueInt('core', $key, 0); + return $value > 0 ? $value : null; + } + + private function setSlotPool(string $slot, int $poolId): void { + $this->appConfig->setValueInt('core', 'ocm_ed25519_slot_' . $slot, $poolId); + } + + private function clearSlot(string $slot): void { + $this->appConfig->deleteKey('core', 'ocm_ed25519_slot_' . $slot); + } + + /** Returns null if the underlying appkey was manually deleted. */ + private function signatoryFromPool(int $poolId): ?Signatory { + $appKey = self::APPKEY_ED25519_POOL_PREFIX . $poolId; + if (!$this->identityProofManager->hasAppKey('core', $appKey)) { + return null; + } + $kid = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, ''); + if ($kid === '') { + return null; + } + $keyPair = $this->identityProofManager->getAppKey('core', $appKey); + $signatory = new Signatory(true); + $signatory->setKeyId($kid); + $signatory->setPublicKey($keyPair->getPublic()); + $signatory->setPrivateKey($keyPair->getPrivate()); + return $signatory; + } + + /** + * @param string $fragment URL fragment (e.g. 'signature', 'ed25519') * @return string * @throws IdentityNotFoundException */ - private function generateKeyId(): string { + private function buildLocalKeyId(string $fragment): string { + if ($this->appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) { + $identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true); + return 'https://' . $identity . '/ocm#' . $fragment; + } + try { - return $this->signatureManager->generateKeyIdFromConfig('/ocm#signature'); + return $this->signatureManager->generateKeyIdFromConfig('/ocm#' . $fragment); } catch (IdentityNotFoundException) { } $url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare'); $identity = $this->signatureManager->extractIdentityFromUri($url); - // catching possible subfolder to create a keyId like 'https://hostname/subfolder/ocm#signature + // catching possible subfolder to create a keyId like 'https://hostname/subfolder/ocm#' $path = parse_url($url, PHP_URL_PATH); $pos = strpos($path, '/ocm/shares'); $sub = ($pos) ? substr($path, 0, $pos) : ''; - return 'https://' . $identity . $sub . '/ocm#signature'; + return 'https://' . $identity . $sub . '/ocm#' . $fragment; } /** @@ -163,4 +425,122 @@ public function getRemoteSignatory(string $remote): ?Signatory { return null; } } + + /** + * Resolve a peer's JWK by kid. Cached per-origin for {@see JWKS_CACHE_TTL}s + * with a single refetch on cache-hit-but-kid-missing so rotations propagate. + */ + #[\Override] + public function getRemoteKey(string $origin, string $keyId): ?Key { + $keys = $this->readCachedJwks($origin); + $fromCache = $keys !== null; + if (!$fromCache) { + $keys = $this->fetchJwks($origin); + if ($keys !== null) { + $this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL); + } + } + + $key = $this->findKid($keys, $keyId); + if ($key !== null) { + return $key; + } + // Only refetch when the miss came from cache; fresh is authoritative. + if (!$fromCache) { + return null; + } + + $keys = $this->fetchJwks($origin); + if ($keys === null) { + return null; + } + $this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL); + return $this->findKid($keys, $keyId); + } + + /** @return list>|null null on cold/corrupt cache */ + private function readCachedJwks(string $origin): ?array { + $cached = $this->jwksCache->get($origin); + if (!is_string($cached)) { + return null; + } + try { + $decoded = json_decode($cached, true, 8, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + if (!is_array($decoded)) { + return null; + } + /** @var list> $decoded */ + return array_values(array_filter($decoded, 'is_array')); + } + + /** + * @return list>|null + */ + private function fetchJwks(string $origin): ?array { + $url = 'https://' . $origin . '/.well-known/jwks.json'; + $options = [ + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates') === true) { + $options['verify'] = false; + } + + try { + $response = $this->clientService->newClient()->get($url, $options); + } catch (Throwable $e) { + $this->logger->warning('failed to fetch remote JWKS', ['exception' => $e, 'url' => $url]); + return null; + } + + try { + $decoded = json_decode((string)$response->getBody(), true, 8, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->warning('remote JWKS is not valid JSON', ['exception' => $e, 'url' => $url]); + return null; + } + + if (!is_array($decoded) || !is_array($decoded['keys'] ?? null)) { + return null; + } + return array_values(array_filter($decoded['keys'], 'is_array')); + } + + /** + * @param list>|null $keys + */ + private function findKid(?array $keys, string $keyId): ?Key { + if ($keys === null) { + return null; + } + foreach ($keys as $entry) { + if (($entry['kid'] ?? null) !== $keyId) { + continue; + } + try { + return JWK::parseKey($entry, Algorithm::deriveJoseAlgFromJwk($entry)); + } catch (Throwable $e) { + $this->logger->warning('failed to parse remote JWK', ['exception' => $e, 'kid' => $keyId]); + return null; + } + } + return null; + } + + /** + * @return array + */ + private static function buildEd25519JwkArray(string $rawPublicKey, string $kid): array { + return [ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => $kid, + 'alg' => 'EdDSA', + 'use' => 'sig', + 'x' => JWT::urlsafeB64Encode($rawPublicKey), + ]; + } } diff --git a/lib/private/OCM/Rfc9421SignatoryManager.php b/lib/private/OCM/Rfc9421SignatoryManager.php new file mode 100644 index 0000000000000..f0756d9ca6eff --- /dev/null +++ b/lib/private/OCM/Rfc9421SignatoryManager.php @@ -0,0 +1,56 @@ +delegate->getProviderId(); + } + + #[\Override] + public function getOptions(): array { + return array_merge($this->delegate->getOptions(), ['rfc9421.format' => true]); + } + + #[\Override] + public function getLocalSignatory(): Signatory { + $signatory = $this->delegate->getLocalEd25519Signatory(); + if ($signatory === null) { + throw new IdentityNotFoundException('no Ed25519 signatory available'); + } + return $signatory; + } + + #[\Override] + public function getRemoteSignatory(string $remote): ?Signatory { + return $this->delegate->getRemoteSignatory($remote); + } + + #[\Override] + public function getRemoteKey(string $origin, string $keyId): ?Key { + return $this->delegate->getRemoteKey($origin, $keyId); + } +} diff --git a/tests/lib/OCM/DiscoveryServiceTest.php b/tests/lib/OCM/DiscoveryServiceTest.php index 1cf026a64bc0b..58a22a07bd166 100644 --- a/tests/lib/OCM/DiscoveryServiceTest.php +++ b/tests/lib/OCM/DiscoveryServiceTest.php @@ -128,6 +128,13 @@ public function testLocalBaseCapability(): void { $this->assertEmpty(array_diff(['notifications', 'shares'], $local->getCapabilities())); } + public function testLocalCapabilitiesAdvertiseHttpSigByDefault(): void { + // `http-sig` is the OCM-spec flag signalling RFC 9421 support backed + // by /.well-known/jwks.json. Advertised whenever signing is not + // disabled outright. + $local = $this->discoveryService->getLocalOCMProvider(); + $this->assertTrue($local->hasCapability('http-sig')); + } public function testLocalAddedCapability(): void { $this->context->for('ocm-capability-app')->registerEventListener(LocalOCMDiscoveryEvent::class, LocalOCMDiscoveryTestEvent::class); diff --git a/tests/lib/OCM/OCMJwksHandlerTest.php b/tests/lib/OCM/OCMJwksHandlerTest.php new file mode 100644 index 0000000000000..7040b19f67537 --- /dev/null +++ b/tests/lib/OCM/OCMJwksHandlerTest.php @@ -0,0 +1,117 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->context = $this->createMock(IRequestContext::class); + + $this->handler = new OCMJwksHandler( + $this->appConfig, + $this->signatoryManager, + $this->logger, + ); + } + + public function testIgnoresUnrelatedService(): void { + $previous = new JrdResponse('foo'); + $result = $this->handler->handle('webfinger', $this->context, $previous); + $this->assertSame($previous, $result); + } + + public function testEmptyKeySetWhenSigningDisabled(): void { + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, false, true) + ->willReturn(true); + $this->signatoryManager->expects($this->never())->method('getLocalEd25519Jwks'); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => []], $body); + } + + public function testPublishesEd25519JwksWhenAvailable(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $jwk = [ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'kid' => 'https://example.org/ocm#ed25519', + 'alg' => 'EdDSA', + 'use' => 'sig', + 'x' => 'AAAA', + ]; + $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$jwk]); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => [$jwk]], $body); + } + + public function testPublishesAllSlotsAdvertisedDuringRotation(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $active = [ + 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-1', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'AAAA', + ]; + $pending = [ + 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-2', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'BBBB', + ]; + $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$active, $pending]); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => [$active, $pending]], $body); + } + + public function testEmptyKeySetWhenSignatoryUnavailable(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([]); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => []], $body); + } + + public function testFailingJwkBuildIsLoggedAndYieldsEmptyKeySet(): void { + $this->appConfig->method('getValueBool')->willReturn(false); + $this->signatoryManager->method('getLocalEd25519Jwks') + ->willThrowException(new \RuntimeException('boom')); + $this->logger->expects($this->once())->method('warning'); + + $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null)); + $this->assertSame(['keys' => []], $body); + } + + private function jsonBody(?IResponse $response): array { + $this->assertInstanceOf(GenericResponse::class, $response); + $http = $response->toHttpResponse(); + $this->assertInstanceOf(JSONResponse::class, $http); + return $http->getData(); + } +} diff --git a/tests/lib/OCM/OCMSignatoryManagerJwksTest.php b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php new file mode 100644 index 0000000000000..7fcc0818e31fc --- /dev/null +++ b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php @@ -0,0 +1,177 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->identityProofManager = $this->createMock(IdentityProofManager::class); + $this->clientService = $this->createMock(IClientService::class); + $this->config = $this->createMock(IConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->client = $this->createMock(IClient::class); + + $this->clientService->method('newClient')->willReturn($this->client); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache('')); + + $this->signatoryManager = new OCMSignatoryManager( + $this->appConfig, + $this->signatureManager, + $this->urlGenerator, + $this->identityProofManager, + $this->clientService, + $this->config, + $cacheFactory, + $this->logger, + ); + } + + public function testGetRemoteKeyFetchesAndMatchesByKid(): void { + $kid = 'sender.example.org#key1'; + $jwks = [ + 'keys' => [ + ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'other', 'x' => 'AAAA'], + ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'BBBB'], + ], + ]; + $this->respondWith($jwks); + + $key = $this->signatoryManager->getRemoteKey('sender.example.org', $kid); + $this->assertNotNull($key); + $this->assertSame('EdDSA', $key->getAlgorithm()); + // Key stores OKP material as plain base64 of the raw bytes. + $this->assertSame('BBBB', $key->getKeyMaterial()); + } + + public function testGetRemoteKeyReturnsNullWhenKidMissing(): void { + $this->respondWith(['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'unrelated', 'x' => 'AAAA']]]); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'other-kid')); + } + + public function testGetRemoteKeyReturnsNullOnHttpError(): void { + $this->client->method('get')->willThrowException(new \RuntimeException('boom')); + $this->logger->expects($this->once())->method('warning'); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyReturnsNullOnInvalidJson(): void { + $response = $this->createMock(IResponse::class); + $response->method('getBody')->willReturn('not json'); + $this->client->method('get')->willReturn($response); + $this->logger->expects($this->once())->method('warning'); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyReturnsNullWhenKeysMissing(): void { + $this->respondWith(['no-keys-here' => []]); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyReturnsNullOnUnparseableJwk(): void { + // JWK with kty=OKP but no crv: parseKey rejects. + $this->respondWith(['keys' => [['kty' => 'OKP', 'kid' => 'kid', 'x' => 'AAAA']]]); + $this->logger->expects($this->once())->method('warning'); + $this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid')); + } + + public function testGetRemoteKeyUsesWellKnownPath(): void { + $this->client->expects($this->once()) + ->method('get') + ->with( + $this->equalTo('https://sender.example.org/.well-known/jwks.json'), + $this->isType('array'), + ) + ->willReturn($this->jsonResponse(['keys' => []])); + + $this->signatoryManager->getRemoteKey('sender.example.org', 'kid'); + } + + public function testGetRemoteKeyPassesSelfSignedFlagThrough(): void { + $this->config->method('getSystemValueBool') + ->with('sharing.federation.allowSelfSignedCertificates') + ->willReturn(true); + + $this->client->expects($this->once()) + ->method('get') + ->with( + $this->anything(), + $this->callback(static fn (array $opts): bool => ($opts['verify'] ?? null) === false), + ) + ->willReturn($this->jsonResponse(['keys' => []])); + + $this->signatoryManager->getRemoteKey('sender.example.org', 'kid'); + } + + public function testJwksCachedAcrossCallsToTheSameOrigin(): void { + $kid = 'sender.example.org#key1'; + $jwks = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'AAAA']]]; + $this->client->expects($this->once()) + ->method('get') + ->willReturn($this->jsonResponse($jwks)); + + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', $kid)); + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', $kid)); + } + + public function testCacheMissOnNewKidTriggersRefetchOnce(): void { + $first = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'old', 'x' => 'AAAA']]]; + $second = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'new', 'x' => 'BBBB']]]; + $this->client->expects($this->exactly(2)) + ->method('get') + ->willReturnOnConsecutiveCalls( + $this->jsonResponse($first), + $this->jsonResponse($second), + ); + + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', 'old')); + $this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', 'new')); + } + + private function respondWith(array $body): void { + $this->client->method('get')->willReturn($this->jsonResponse($body)); + } + + private function jsonResponse(array $body): IResponse { + $response = $this->createMock(IResponse::class); + $response->method('getBody')->willReturn(json_encode($body, JSON_THROW_ON_ERROR)); + return $response; + } +} diff --git a/tests/lib/OCM/OCMSignatoryManagerRotationTest.php b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php new file mode 100644 index 0000000000000..9b52d88c61f7b --- /dev/null +++ b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php @@ -0,0 +1,273 @@ + in-memory backing store for IAppConfig core/* */ + private array $appConfigStore = []; + /** @var array in-memory backing store for IdentityProofManager appkeys */ + private array $appKeyStore = []; + + #[\Override] + protected function setUp(): void { + parent::setUp(); + + $this->appConfig = $this->createMock(IAppConfig::class); + $this->identityProofManager = $this->createMock(IdentityProofManager::class); + + $this->wireAppConfig(); + $this->wireIdentityProofManager(); + + $signatureManager = $this->createMock(ISignatureManager::class); + $signatureManager->method('generateKeyIdFromConfig') + ->willReturnCallback(static fn (string $suffix): string => 'https://alice.example/' . ltrim($suffix, '/')); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache('')); + + $this->signatoryManager = new OCMSignatoryManager( + $this->appConfig, + $signatureManager, + $this->createMock(IURLGenerator::class), + $this->identityProofManager, + $this->stubClientService(), + $this->createMock(IConfig::class), + $cacheFactory, + $this->createMock(LoggerInterface::class), + ); + } + + public function testJwksBootstrapsActiveKeyOnFirstFetch(): void { + // Fresh instance: first JWKS hit must provision the active key. + $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $this->assertCount(1, $jwks); + $this->assertSame('https://alice.example/ocm#ed25519-1', $jwks[0]['kid']); + + // And the bootstrapped key is the active one for outbound signing. + $signatory = $this->signatoryManager->getLocalEd25519Signatory(); + $this->assertSame($jwks[0]['kid'], $signatory->getKeyId()); + } + + public function testFirstCallProvisionsActiveKey(): void { + $signatory = $this->signatoryManager->getLocalEd25519Signatory(); + $this->assertNotNull($signatory); + $this->assertSame('https://alice.example/ocm#ed25519-1', $signatory->getKeyId()); + + $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $this->assertCount(1, $jwks); + $this->assertSame($signatory->getKeyId(), $jwks[0]['kid']); + + $listed = $this->signatoryManager->listEd25519Keys(); + $this->assertSame([['poolId' => 1, 'kid' => $signatory->getKeyId(), 'slot' => 'active']], $listed); + } + + public function testStageDoesNotChangeActiveSignerButPublishesNewJwk(): void { + $initial = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + $this->assertNotSame($initial->getKeyId(), $staged->getKeyId()); + + // Active signer is unchanged. + $this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId()); + + // JWKS now advertises both kids, active first then pending. + $jwks = $this->signatoryManager->getLocalEd25519Jwks(); + $this->assertSame([$initial->getKeyId(), $staged->getKeyId()], array_column($jwks, 'kid')); + } + + public function testStageRefusesIfPendingAlreadyExists(): void { + $this->signatoryManager->stageEd25519Key(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/pending Ed25519 key already exists/'); + $this->signatoryManager->stageEd25519Key(); + } + + public function testActivatePromotesPendingAndDemotesActive(): void { + $first = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->activateStagedEd25519Key(); + + // New signer is the formerly-staged key. + $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId()); + + // JWKS still advertises the former active key as retiring so peers + // verifying in-flight signatures with its kid don't fail. + $kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid'); + $this->assertContains($first->getKeyId(), $kids); + $this->assertContains($staged->getKeyId(), $kids); + } + + public function testActivateRefusesIfRetiringStillPopulated(): void { + $this->signatoryManager->getLocalEd25519Signatory(); + $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->activateStagedEd25519Key(); + // Retiring slot is now populated; staging again is allowed but + // activating must refuse until the admin explicitly retires the old + // key. + $this->signatoryManager->stageEd25519Key(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/retiring Ed25519 key still exists/'); + $this->signatoryManager->activateStagedEd25519Key(); + } + + public function testActivateRefusesWithoutPendingKey(): void { + $this->signatoryManager->getLocalEd25519Signatory(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/no pending Ed25519 key/'); + $this->signatoryManager->activateStagedEd25519Key(); + } + + public function testRetireRemovesRetiringKeyFromJwks(): void { + $first = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + $this->signatoryManager->activateStagedEd25519Key(); + $this->signatoryManager->retireEd25519Key(); + + $kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid'); + $this->assertSame([$staged->getKeyId()], $kids); + // listEd25519Keys also drops the retired pool. + $listed = $this->signatoryManager->listEd25519Keys(); + $this->assertCount(1, $listed); + $this->assertSame($staged->getKeyId(), $listed[0]['kid']); + $this->assertNotContains($first->getKeyId(), array_column($listed, 'kid')); + } + + public function testRetireRefusesWhenNothingToRetire(): void { + $this->signatoryManager->getLocalEd25519Signatory(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/no retiring Ed25519 key/'); + $this->signatoryManager->retireEd25519Key(); + } + + public function testKidStaysStableThroughLifecycle(): void { + $first = $this->signatoryManager->getLocalEd25519Signatory(); + $staged = $this->signatoryManager->stageEd25519Key(); + // kid for the staged key must stay the same once it is activated; + // peers that cached it during the stage window must still resolve it. + $this->signatoryManager->activateStagedEd25519Key(); + $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId()); + + $this->signatoryManager->retireEd25519Key(); + $this->signatoryManager->stageEd25519Key(); + // And every newly minted kid must differ from prior ones, no pool + // counter rewinding. + $kids = array_column($this->signatoryManager->listEd25519Keys(), 'kid'); + $this->assertNotContains($first->getKeyId(), $kids); + $this->assertSame($kids, array_unique($kids)); + } + + public function testSignerReturnsNullWhenIdentityCannotBeDerived(): void { + // Replace the signature manager with one that cannot derive an + // identity at all; provisioning the first key should fail loudly so + // the admin gets a clear message instead of a corrupt half-state. + $signatureManager = $this->createMock(ISignatureManager::class); + $signatureManager->method('generateKeyIdFromConfig') + ->willThrowException(new IdentityNotFoundException('no identity')); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRouteAbsolute') + ->willThrowException(new IdentityNotFoundException('no url either')); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache('')); + + $manager = new OCMSignatoryManager( + $this->appConfig, + $signatureManager, + $urlGenerator, + $this->identityProofManager, + $this->stubClientService(), + $this->createMock(IConfig::class), + $cacheFactory, + $this->createMock(LoggerInterface::class), + ); + + $this->expectException(\RuntimeException::class); + $manager->getLocalEd25519Signatory(); + } + + private function wireAppConfig(): void { + $this->appConfig->method('hasKey')->willReturnCallback( + fn (string $app, string $key): bool => $app === 'core' && array_key_exists($key, $this->appConfigStore) + ); + $this->appConfig->method('getValueInt')->willReturnCallback( + fn (string $app, string $key, int $default = 0): int => (int)($this->appConfigStore[$key] ?? $default) + ); + $this->appConfig->method('setValueInt')->willReturnCallback( + function (string $app, string $key, int $value): bool { + $this->appConfigStore[$key] = (string)$value; + return true; + } + ); + $this->appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '') => $this->appConfigStore[$key] ?? $default + ); + $this->appConfig->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value): bool { + $this->appConfigStore[$key] = $value; + return true; + } + ); + $this->appConfig->method('getValueBool')->willReturn(false); + $this->appConfig->method('deleteKey')->willReturnCallback( + function (string $app, string $key): void { + unset($this->appConfigStore[$key]); + } + ); + } + + private function wireIdentityProofManager(): void { + $this->identityProofManager->method('hasAppKey')->willReturnCallback( + fn (string $app, string $name): bool => isset($this->appKeyStore[$app . '/' . $name]) + ); + $this->identityProofManager->method('generateEd25519AppKey')->willReturnCallback( + function (string $app, string $name): Key { + $keyPair = sodium_crypto_sign_keypair(); + $key = new Key(sodium_crypto_sign_publickey($keyPair), sodium_crypto_sign_secretkey($keyPair)); + $this->appKeyStore[$app . '/' . $name] = $key; + return $key; + } + ); + $this->identityProofManager->method('getAppKey')->willReturnCallback( + fn (string $app, string $name): Key => $this->appKeyStore[$app . '/' . $name] + ); + $this->identityProofManager->method('deleteAppKey')->willReturnCallback( + function (string $app, string $name): bool { + $existed = isset($this->appKeyStore[$app . '/' . $name]); + unset($this->appKeyStore[$app . '/' . $name]); + return $existed; + } + ); + } + + private function stubClientService(): IClientService&MockObject { + $service = $this->createMock(IClientService::class); + $service->method('newClient')->willReturn($this->createMock(IClient::class)); + return $service; + } +} diff --git a/tests/lib/OCM/Rfc9421SignatoryManagerTest.php b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php new file mode 100644 index 0000000000000..f186986cf81a2 --- /dev/null +++ b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php @@ -0,0 +1,78 @@ +delegate = $this->createMock(OCMSignatoryManager::class); + $this->wrapper = new Rfc9421SignatoryManager($this->delegate); + } + + public function testGetOptionsForcesRfc9421Format(): void { + $this->delegate->method('getOptions')->willReturn([ + 'algorithm' => 'rsa-sha512', + 'rfc9421.format' => false, + ]); + + $options = $this->wrapper->getOptions(); + $this->assertTrue($options['rfc9421.format']); + $this->assertSame('rsa-sha512', $options['algorithm']); + } + + public function testGetLocalSignatoryReturnsEd25519Key(): void { + $signatory = $this->createMock(Signatory::class); + $this->delegate->method('getLocalEd25519Signatory')->willReturn($signatory); + + $this->assertSame($signatory, $this->wrapper->getLocalSignatory()); + } + + public function testGetLocalSignatoryThrowsWhenEd25519Unavailable(): void { + $this->delegate->method('getLocalEd25519Signatory')->willReturn(null); + + $this->expectException(IdentityNotFoundException::class); + $this->wrapper->getLocalSignatory(); + } + + public function testProviderIdDelegated(): void { + $this->delegate->method('getProviderId')->willReturn('ocm'); + $this->assertSame('ocm', $this->wrapper->getProviderId()); + } + + public function testRemoteSignatoryDelegated(): void { + $signatory = $this->createMock(Signatory::class); + $this->delegate->expects($this->once()) + ->method('getRemoteSignatory') + ->with('sender.example.org') + ->willReturn($signatory); + $this->assertSame($signatory, $this->wrapper->getRemoteSignatory('sender.example.org')); + } + + public function testRemoteKeyDelegated(): void { + $key = $this->createMock(Key::class); + $this->delegate->expects($this->once()) + ->method('getRemoteKey') + ->with('sender.example.org', 'kid-1') + ->willReturn($key); + $this->assertSame($key, $this->wrapper->getRemoteKey('sender.example.org', 'kid-1')); + } +} From 36c2647407fc1d38f666ef06c6ef6c07b7ab76b8 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:54 +0200 Subject: [PATCH 6/6] feat(http-sig): occ commands to manage Ed25519 keys ocm:keys:list list known keys with their slot and kid ocm:keys:stage generate a pending key, advertise via JWKS ocm:keys:activate promote pending -> active, demote previous active ocm:keys:retire delete the retiring key (kid stops resolving) Plus the autoloader regen covering the new classes from this branch. Signed-off-by: Micke Nordin --- core/Command/OCM/ActivateKey.php | 42 ++++++++++++++++ core/Command/OCM/ListKeys.php | 54 +++++++++++++++++++++ core/Command/OCM/RetireKey.php | 41 ++++++++++++++++ core/Command/OCM/StageKey.php | 42 ++++++++++++++++ core/register_command.php | 9 ++++ lib/composer/composer/autoload_classmap.php | 12 +++++ lib/composer/composer/autoload_static.php | 12 +++++ 7 files changed, 212 insertions(+) create mode 100644 core/Command/OCM/ActivateKey.php create mode 100644 core/Command/OCM/ListKeys.php create mode 100644 core/Command/OCM/RetireKey.php create mode 100644 core/Command/OCM/StageKey.php diff --git a/core/Command/OCM/ActivateKey.php b/core/Command/OCM/ActivateKey.php new file mode 100644 index 0000000000000..090538e00248d --- /dev/null +++ b/core/Command/OCM/ActivateKey.php @@ -0,0 +1,42 @@ +setName('ocm:keys:activate') + ->setDescription('promote the staged Ed25519 key to active; the previous active key moves to retiring'); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->signatoryManager->activateStagedEd25519Key(); + } catch (\RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Staged key promoted to active.'); + $output->writeln('Run occ ocm:keys:retire once any in-flight signatures using the previous key have been verified.'); + return 0; + } +} diff --git a/core/Command/OCM/ListKeys.php b/core/Command/OCM/ListKeys.php new file mode 100644 index 0000000000000..f73a476311134 --- /dev/null +++ b/core/Command/OCM/ListKeys.php @@ -0,0 +1,54 @@ +setName('ocm:keys:list') + ->setDescription('list Ed25519 keys used by OCM RFC 9421 HTTP Message Signatures'); + parent::configure(); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + $keys = $this->signatoryManager->listEd25519Keys(); + $format = $input->getOption('output'); + if ($format === self::OUTPUT_FORMAT_JSON || $format === self::OUTPUT_FORMAT_JSON_PRETTY) { + $output->writeln(json_encode($keys, $format === self::OUTPUT_FORMAT_JSON_PRETTY ? JSON_PRETTY_PRINT : 0)); + return 0; + } + + if ($keys === []) { + $output->writeln('No Ed25519 keys yet; one will be generated on first OCM request.'); + return 0; + } + + $table = new Table($output); + $table->setHeaders(['Pool', 'Slot', 'Key ID']); + foreach ($keys as $key) { + $table->addRow([$key['poolId'], $key['slot'] ?? '-', $key['kid']]); + } + $table->render(); + return 0; + } +} diff --git a/core/Command/OCM/RetireKey.php b/core/Command/OCM/RetireKey.php new file mode 100644 index 0000000000000..58db976077c5f --- /dev/null +++ b/core/Command/OCM/RetireKey.php @@ -0,0 +1,41 @@ +setName('ocm:keys:retire') + ->setDescription('delete the retiring Ed25519 key; signatures that referenced its kid can no longer be verified'); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->signatoryManager->retireEd25519Key(); + } catch (\RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Retiring key deleted.'); + return 0; + } +} diff --git a/core/Command/OCM/StageKey.php b/core/Command/OCM/StageKey.php new file mode 100644 index 0000000000000..75437f460bfc2 --- /dev/null +++ b/core/Command/OCM/StageKey.php @@ -0,0 +1,42 @@ +setName('ocm:keys:stage') + ->setDescription('generate a new Ed25519 key and advertise it via JWKS without using it for signing yet'); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $signatory = $this->signatoryManager->stageEd25519Key(); + } catch (\RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Staged new Ed25519 key: ' . $signatory->getKeyId() . ''); + $output->writeln('Wait for federated peers to refresh their JWKS cache before activating.'); + return 0; + } +} diff --git a/core/register_command.php b/core/register_command.php index d28c1633c62bb..856894b5c4c77 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -74,6 +74,10 @@ use OC\Core\Command\Memcache\DistributedGet; use OC\Core\Command\Memcache\DistributedSet; use OC\Core\Command\Memcache\RedisCommand; +use OC\Core\Command\OCM\ActivateKey as OCMActivateKey; +use OC\Core\Command\OCM\ListKeys as OCMListKeys; +use OC\Core\Command\OCM\RetireKey as OCMRetireKey; +use OC\Core\Command\OCM\StageKey as OCMStageKey; use OC\Core\Command\Preview\Generate; use OC\Core\Command\Preview\ResetRenderedTexts; use OC\Core\Command\Router\ListRoutes; @@ -251,6 +255,11 @@ $application->add(Server::get(SnowflakeDecodeId::class)); $application->add(Server::get(Get::class)); + $application->add(Server::get(OCMListKeys::class)); + $application->add(Server::get(OCMStageKey::class)); + $application->add(Server::get(OCMActivateKey::class)); + $application->add(Server::get(OCMRetireKey::class)); + $application->add(Server::get(GetCommand::class)); $application->add(Server::get(EnabledCommand::class)); $application->add(Server::get(Command\TaskProcessing\ListCommand::class)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 28373a5af8597..7ca7f6837ce4c 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1402,6 +1402,10 @@ 'OC\\Core\\Command\\Memcache\\DistributedGet' => $baseDir . '/core/Command/Memcache/DistributedGet.php', 'OC\\Core\\Command\\Memcache\\DistributedSet' => $baseDir . '/core/Command/Memcache/DistributedSet.php', 'OC\\Core\\Command\\Memcache\\RedisCommand' => $baseDir . '/core/Command/Memcache/RedisCommand.php', + 'OC\\Core\\Command\\OCM\\ActivateKey' => $baseDir . '/core/Command/OCM/ActivateKey.php', + 'OC\\Core\\Command\\OCM\\ListKeys' => $baseDir . '/core/Command/OCM/ListKeys.php', + 'OC\\Core\\Command\\OCM\\RetireKey' => $baseDir . '/core/Command/OCM/RetireKey.php', + 'OC\\Core\\Command\\OCM\\StageKey' => $baseDir . '/core/Command/OCM/StageKey.php', 'OC\\Core\\Command\\Preview\\Cleanup' => $baseDir . '/core/Command/Preview/Cleanup.php', 'OC\\Core\\Command\\Preview\\Generate' => $baseDir . '/core/Command/Preview/Generate.php', 'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => $baseDir . '/core/Command/Preview/ResetRenderedTexts.php', @@ -1947,7 +1951,9 @@ 'OC\\OCM\\Model\\OCMResource' => $baseDir . '/lib/private/OCM/Model/OCMResource.php', 'OC\\OCM\\OCMDiscoveryHandler' => $baseDir . '/lib/private/OCM/OCMDiscoveryHandler.php', 'OC\\OCM\\OCMDiscoveryService' => $baseDir . '/lib/private/OCM/OCMDiscoveryService.php', + 'OC\\OCM\\OCMJwksHandler' => $baseDir . '/lib/private/OCM/OCMJwksHandler.php', 'OC\\OCM\\OCMSignatoryManager' => $baseDir . '/lib/private/OCM/OCMSignatoryManager.php', + 'OC\\OCM\\Rfc9421SignatoryManager' => $baseDir . '/lib/private/OCM/Rfc9421SignatoryManager.php', 'OC\\OCS\\ApiHelper' => $baseDir . '/lib/private/OCS/ApiHelper.php', 'OC\\OCS\\CoreCapabilities' => $baseDir . '/lib/private/OCS/CoreCapabilities.php', 'OC\\OCS\\DiscoveryService' => $baseDir . '/lib/private/OCS/DiscoveryService.php', @@ -2151,7 +2157,13 @@ 'OC\\Security\\Signature\\Db\\SignatoryMapper' => $baseDir . '/lib/private/Security/Signature/Db/SignatoryMapper.php', 'OC\\Security\\Signature\\Model\\IncomingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/IncomingSignedRequest.php', 'OC\\Security\\Signature\\Model\\OutgoingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/OutgoingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\Rfc9421IncomingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\Rfc9421OutgoingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php', 'OC\\Security\\Signature\\Model\\SignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/SignedRequest.php', + 'OC\\Security\\Signature\\Rfc9421\\Algorithm' => $baseDir . '/lib/private/Security/Signature/Rfc9421/Algorithm.php', + 'OC\\Security\\Signature\\Rfc9421\\ContentDigest' => $baseDir . '/lib/private/Security/Signature/Rfc9421/ContentDigest.php', + 'OC\\Security\\Signature\\Rfc9421\\IJwkResolvingSignatoryManager' => $baseDir . '/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php', + 'OC\\Security\\Signature\\Rfc9421\\SignatureBase' => $baseDir . '/lib/private/Security/Signature/Rfc9421/SignatureBase.php', 'OC\\Security\\Signature\\SignatureManager' => $baseDir . '/lib/private/Security/Signature/SignatureManager.php', 'OC\\Security\\TrustedDomainHelper' => $baseDir . '/lib/private/Security/TrustedDomainHelper.php', 'OC\\Security\\VerificationToken\\CleanUpJob' => $baseDir . '/lib/private/Security/VerificationToken/CleanUpJob.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index e28dc90763681..f50fce4223260 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1443,6 +1443,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', @@ -1988,7 +1992,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', @@ -2192,7 +2198,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',