Files
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

555 lines
22 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\Model;
use Firebase\JWT\JWK;
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\ContentDigest;
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\Exceptions\InvalidSignatureException;
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
use OCP\Security\Signature\ISignatoryManager;
use OCP\Security\Signature\Model\Signatory;
use Test\TestCase;
class Rfc9421RoundTripTest extends TestCase {
public function testEcdsaP256RoundTripVerifies(): void {
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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());
// the Date header is deliberately not covered by the signature
$this->assertSame(
['@method', '@target-uri', 'content-digest', 'content-length'],
$in->getCoveredComponents(),
);
$in->verify(); // throws on failure
$this->addToAssertionCount(1);
}
public function testEd25519VerifyAcceptedWhenSodiumLoaded(): void {
$this->skipUnlessSodium();
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManagerWithSigningAlgorithm($signatory, 'ed25519');
$body = '{"hello":"world"}';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
// Ed25519 sign() throws via Algorithm::sign; produce the signature directly
// over a manually reconstructed signature base.
$headers = $out->getHeaders();
$paramsLine = '("@method" "@target-uri" "content-digest" "content-length");created=' . time() . ';keyid="' . $signatory->getKeyId() . '";tag="ocm"';
$base = implode("\n", [
'"@method": POST',
'"@target-uri": https://receiver.example.org/ocm/shares',
'"content-digest": ' . $headers['Content-Digest'],
'"content-length": ' . $headers['Content-Length'],
'"@signature-params": ' . $paramsLine,
]);
$rawSig = sodium_crypto_sign_detached($base, $signatory->getPrivateKey());
$headers['Signature-Input'] = 'ocm=' . $paramsLine;
$headers['Signature'] = 'ocm=:' . base64_encode($rawSig) . ':';
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$in = new Rfc9421IncomingSignedRequest($body, $req);
$in->setKey($jwk);
$in->verify();
$this->addToAssertionCount(1);
}
public function testTamperedBodyRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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 testOutgoingCarriesOcmTag(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
$headers = $out->getHeaders();
// the label is cosmetic; the integrity-protected tag parameter is
// what marks the signature as the OCM one
$this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']);
$this->assertStringContainsString(';tag="ocm"', (string)$headers['Signature-Input']);
$this->assertStringStartsWith('ocm=:', (string)$headers['Signature']);
}
public function testArbitraryLabelWithOcmTagVerifies(): void {
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// Rename the dictionary label; the verifier MUST select by the
// tag="ocm" parameter and disregard labels.
$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');
$in = new Rfc9421IncomingSignedRequest('msg', $req);
$in->setKey($jwk);
$in->verify();
$this->addToAssertionCount(1);
}
public function testRequestWithoutOcmTagTreatedAsUnsigned(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// Strip the tag parameter; without tag="ocm" the request carries no
// OCM signature and is handled as unsigned.
$headers = $out->getHeaders();
$headers['Signature-Input'] = str_replace(';tag="ocm"', '', (string)$headers['Signature-Input']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(SignatureNotFoundException::class);
new Rfc9421IncomingSignedRequest('msg', $req);
}
public function testTwoSignaturesCarryingOcmTagRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// A second, differently-labeled signature also carrying tag="ocm":
// the entire message MUST be rejected.
$headers = $out->getHeaders();
$headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', ' . preg_replace('/^ocm=/', 'sig2=', (string)$headers['Signature-Input']);
$headers['Signature'] = (string)$headers['Signature'] . ', ' . preg_replace('/^ocm=/', 'sig2=', (string)$headers['Signature']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest('msg', $req);
}
public function testDuplicateOcmLabelRejected(): void {
// RFC 8941 §4.2 last-wins on duplicate dictionary keys, which would
// silently hide one of two identically-labeled OCM signatures; that
// ambiguity on the selected entry causes outright rejection.
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$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->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$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->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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 testMissingKeyidRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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 `;keyid="..."` parameter; verifiers MUST reject
// signatures without it.
$headers = $out->getHeaders();
$headers['Signature-Input'] = preg_replace('/;keyid="[^"]*"/', '', (string)$headers['Signature-Input']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest($body, $req);
}
public function testExtraCoveredDateStillVerifies(): void {
// covering more than the mandatory components (here: `date`) is
// allowed; only the four baseline components are required
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManagerWithComponents(
$signatory,
['@method', '@target-uri', 'content-digest', 'content-length', 'date'],
);
$body = 'msg';
$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');
$in = new Rfc9421IncomingSignedRequest($body, $req);
$in->setKey($jwk);
$in->verify();
$this->addToAssertionCount(1);
}
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->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$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);
}
public function testKeyIdIsOpaqueAndOriginIsExternal(): void {
// keyid is opaque; the origin is supplied by the caller via setOrigin().
$kid = 'sender.example.org#key1';
[$privatePem, $jwk] = $this->ecdsaP256Jwk($kid);
$body = 'msg';
$digest = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
$paramsLine = '("@method" "@target-uri" "content-digest" "content-length");created=' . time() . ';keyid="' . $kid . '";tag="ocm"';
$base = implode("\n", [
'"@method": POST',
'"@target-uri": https://receiver.example.org/ocm/shares',
'"content-digest": ' . $digest,
'"content-length": ' . strlen($body),
'"@signature-params": ' . $paramsLine,
]);
$rawSig = Algorithm::sign($base, $privatePem, 'ecdsa-p256-sha256');
$headers = [
'Content-Digest' => $digest,
'Content-Length' => (string)strlen($body),
'Signature-Input' => 'sig1=' . $paramsLine,
'Signature' => 'sig1=:' . base64_encode($rawSig) . ':',
];
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$in = new Rfc9421IncomingSignedRequest($body, $req);
// The keyid is not parsed; the origin comes from the caller.
$in->setOrigin('sender.example.org');
$this->assertSame('sender.example.org', $in->getOrigin());
$in->setKey($jwk);
$in->verify();
$this->addToAssertionCount(1);
}
private function skipUnlessSodium(): void {
if (!extension_loaded('sodium')) {
$this->markTestSkipped('ext-sodium is not loaded');
}
}
private function makeSignatoryManagerWithComponents(Signatory $signatory, array $components): ISignatoryManager {
return new class($signatory, $components) implements ISignatoryManager {
public function __construct(
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 makeSignatoryManagerWithSigningAlgorithm(Signatory $signatory, string $signingAlgorithm): ISignatoryManager {
return new class($signatory, $signingAlgorithm) implements ISignatoryManager {
public function __construct(
private Signatory $sig,
private string $signingAlgorithm,
) {
}
public function getProviderId(): string {
return 'test';
}
public function getOptions(): array {
return [
'algorithm' => SignatureAlgorithm::RSA_SHA256,
'digestAlgorithm' => DigestAlgorithm::SHA256,
'rfc9421.signingAlgorithm' => $this->signingAlgorithm,
];
}
public function getLocalSignatory(): Signatory {
return $this->sig;
}
public function getRemoteSignatory(string $remote): ?Signatory {
return null;
}
};
}
/**
* @return array{0: Signatory, 1: \Firebase\JWT\Key}
*/
private function ecdsaP256Material(string $kid): array {
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$privatePem = '';
openssl_pkey_export($pkey, $privatePem);
$details = openssl_pkey_get_details($pkey);
$publicPem = $details['key'];
$signatory = new Signatory(true);
$signatory->setKeyId($kid);
$signatory->setPublicKey($publicPem);
$signatory->setPrivateKey($privatePem);
$key = self::jwkFromEcDetails($details, $kid);
return [$signatory, $key];
}
/**
* Key material for a peer whose kid is not a URL; Nextcloud's Signatory
* model cannot represent those.
*
* @return array{0: string, 1: \Firebase\JWT\Key} [private key PEM, verification key]
*/
private function ecdsaP256Jwk(string $kid): array {
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$privatePem = '';
openssl_pkey_export($pkey, $privatePem);
$details = openssl_pkey_get_details($pkey);
return [$privatePem, self::jwkFromEcDetails($details, $kid)];
}
private static function jwkFromEcDetails(array $details, string $kid): \Firebase\JWT\Key {
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
return JWK::parseKey([
'kty' => 'EC',
'crv' => 'P-256',
'kid' => $kid,
'alg' => 'ES256',
'x' => self::b64url($x),
'y' => self::b64url($y),
], 'ES256');
}
/**
* @return array{0: Signatory, 1: \Firebase\JWT\Key}
*/
private function ed25519Material(string $kid): array {
$keypair = sodium_crypto_sign_keypair();
$publicKey = sodium_crypto_sign_publickey($keypair);
$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' => self::b64url($publicKey),
], 'EdDSA');
return [$signatory, $key];
}
private static function b64url(string $bin): string {
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
private function makeSignatoryManager(Signatory $signatory): ISignatoryManager {
return new class($signatory) implements ISignatoryManager {
public function __construct(
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;
}
}