2026-05-05 10:29:24 -04:00
|
|
|
<?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 {
|
2026-05-11 10:13:13 -04:00
|
|
|
[$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
|
2026-05-05 10:29:24 -04:00
|
|
|
|
|
|
|
|
$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 {
|
2026-05-11 10:13:13 -04:00
|
|
|
[$signatoryManager, $jwk] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
|
2026-05-05 10:29:24 -04:00
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
2026-05-11 10:13:13 -04:00
|
|
|
$resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ecdsa-p256-sha256');
|
2026-05-05 10:29:24 -04:00
|
|
|
|
|
|
|
|
$signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body);
|
|
|
|
|
$this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
[$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
|
2026-05-05 10:29:24 -04:00
|
|
|
|
|
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-11 10:13:13 -04:00
|
|
|
* @return array{ISignatoryManager, Key} [manager, parsed verification key]
|
2026-05-05 10:29:24 -04:00
|
|
|
*/
|
2026-05-11 10:13:13 -04:00
|
|
|
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';
|
2026-05-05 10:29:24 -04:00
|
|
|
|
|
|
|
|
$signatory = new Signatory(true);
|
|
|
|
|
$signatory->setKeyId($kid);
|
2026-05-11 10:13:13 -04:00
|
|
|
$signatory->setPublicKey($publicPem);
|
|
|
|
|
$signatory->setPrivateKey($privatePem);
|
2026-05-05 10:29:24 -04:00
|
|
|
|
2026-05-11 10:13:13 -04:00
|
|
|
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
|
|
|
|
|
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
|
2026-05-05 10:29:24 -04:00
|
|
|
$key = JWK::parseKey([
|
2026-05-11 10:13:13 -04:00
|
|
|
'kty' => 'EC',
|
|
|
|
|
'crv' => 'P-256',
|
2026-05-05 10:29:24 -04:00
|
|
|
'kid' => $kid,
|
2026-05-11 10:13:13 -04:00
|
|
|
'alg' => 'ES256',
|
|
|
|
|
'x' => rtrim(strtr(base64_encode($x), '+/', '-_'), '='),
|
|
|
|
|
'y' => rtrim(strtr(base64_encode($y), '+/', '-_'), '='),
|
|
|
|
|
], 'ES256');
|
2026-05-05 10:29:24 -04:00
|
|
|
|
|
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-05-11 10:13:13 -04:00
|
|
|
return [$manager, $key];
|
2026-05-05 10:29:24 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|