Files
nextcloud-server-mirror/tests/lib/Security/Signature/SignatureManagerDispatchTest.php
Micke Nordin dae8a36460 fix(ocm): treat RFC 9421 keyid as opaque, verify by sender origin
RFC 7517 §4.5 leaves the keyid structure unspecified, so the receiver
must not parse it. The signer origin now comes from the trusted OCM
share/sender identity; the JWK Set is resolved against that origin and
the keyid is matched opaquely to the JWKS kid. Aligns with the OCM
verification procedure and fixes the origin-mismatch / flaky-kid
failures in the two-port integration rig.

Reverts the per-request host-based kid back to a stable persisted kid,
drops keyid->host parsing, threads a sender-origin parameter through
verification, and resolves that origin in every OCM inbound entry point
(notifications, shares, token exchange, OCM requests) and the federation
rate limiter. Cavage is unchanged.

Assisted-by: ClaudeCode:glm-5.2

Signed-off-by: Micke Nordin <kano@sunet.se>
2026-08-09 16:21:06 +02:00

289 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Security\Signature;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
use OC\Security\Signature\Db\SignatoryMapper;
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OC\Security\Signature\SignatureManager;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\ISignatoryManager;
use OCP\Security\Signature\Model\Signatory;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class SignatureManagerDispatchTest extends TestCase {
private IRequest&MockObject $request;
private SignatoryMapper&MockObject $mapper;
private IAppConfig&MockObject $appConfig;
private LoggerInterface&MockObject $logger;
private SignatureManager $signatureManager;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->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 {
$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->ecdsaP256SignatoryManager(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] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
// Build a real signed request and replay its headers as the inbound
// request to exercise the full inbound path including verification.
$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#ecdsa-p256-sha256');
// RFC 9421 verification needs the sender origin from the caller.
$signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body, 'sender.example.org');
$this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed);
}
public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void {
[$signatoryManager,] = $this->ecdsaP256SignatoryManager(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);
}
public function testInboundRejectsRfc9421WhenNoKeyResolvedForKeyid(): void {
[$signatoryManager, $jwk] = $this->ecdsaP256SignatoryManager(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');
// resolver knows a different kid only: a present signature whose key
// cannot be resolved is a verification failure, not an unsigned
// request
$resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://other.example.org/ocm#nomatch');
$this->expectException(IncomingRequestException::class);
$this->signatureManager->getIncomingSignedRequest($resolver, $body, 'sender.example.org');
}
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} [manager, parsed verification key]
*/
private function ecdsaP256SignatoryManager(bool $rfc9421Format): array {
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$privatePem = '';
openssl_pkey_export($pkey, $privatePem);
$details = openssl_pkey_get_details($pkey);
$publicPem = $details['key'];
$kid = 'https://sender.example.org/ocm#ecdsa-p256-sha256';
$signatory = new Signatory(true);
$signatory->setKeyId($kid);
$signatory->setPublicKey($publicPem);
$signatory->setPrivateKey($privatePem);
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
$key = JWK::parseKey([
'kty' => 'EC',
'crv' => 'P-256',
'kid' => $kid,
'alg' => 'ES256',
'x' => rtrim(strtr(base64_encode($x), '+/', '-_'), '='),
'y' => rtrim(strtr(base64_encode($y), '+/', '-_'), '='),
], 'ES256');
$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];
}
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);
}
}