feat(http-sig): RFC 9421 protocol primitives

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 <kano@sunet.se>
This commit is contained in:
Micke Nordin 2026-05-05 16:29:24 +02:00 committed by Micke Nordin
parent ea9bbe64c1
commit 0eb927e617
12 changed files with 2120 additions and 7 deletions

View file

@ -0,0 +1,465 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Model;
use Firebase\JWT\Key;
use gapple\StructuredFields\Bytes;
use gapple\StructuredFields\InnerList;
use gapple\StructuredFields\Item;
use gapple\StructuredFields\Parameters;
use gapple\StructuredFields\ParseException;
use gapple\StructuredFields\Parser;
use gapple\StructuredFields\Token;
use JsonSerializable;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\ContentDigest;
use OC\Security\Signature\Rfc9421\SignatureBase;
use OC\Security\Signature\SignatureManager;
use OCP\IRequest;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\InvalidSignatureException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\Exceptions\SignatureException;
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
use OCP\Security\Signature\IIncomingSignedRequest;
use OCP\Security\Signature\Model\Signatory;
/**
* RFC 9421 implementation of {@see IIncomingSignedRequest}. Parses the
* inbound Signature-Input / Signature dictionaries, picks the OCM-labeled
* entry (RFC 9421 §3.2 lets verifiers scope by policy), and rebuilds the
* signature base per RFC 9421 §2.5. Crypto is deferred to {@see verify()},
* which needs a {@see Key} attached via {@see setKey()}. Body integrity
* (RFC 9530 content-digest) is checked before verify() if covered.
*/
class Rfc9421IncomingSignedRequest extends SignedRequest implements
IIncomingSignedRequest,
JsonSerializable {
/** Baseline cover for OCM. Override via `rfc9421.requiredComponents`. */
private const DEFAULT_REQUIRED_COMPONENTS = [
'@method',
'@target-uri',
'content-digest',
'content-length',
'date',
];
/** Max clock skew (seconds) for `created`. Override via `rfc9421.maxClockSkew`. */
private const DEFAULT_MAX_FUTURE_SKEW = 60;
private string $origin = '';
/** @var list<string> */
private array $components;
/** @var array<string, scalar> */
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 `<fqdn>#<id>`; 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<string, scalar>
*/
public function getSignatureParams(): array {
return $this->signatureParams;
}
/**
* @return list<string>
*/
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<string> $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<string, string>
*/
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<string, array{components: list<string>, params: array<string, scalar>}>
* @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<string, string> 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<string, mixed> $parameters
* @return array<string, scalar>
*/
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;
}
}

View file

@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Model;
use JsonSerializable;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\ContentDigest;
use OC\Security\Signature\Rfc9421\SignatureBase;
use OC\Security\Signature\SignatureManager;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\SignatoryException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\IOutgoingSignedRequest;
use OCP\Security\Signature\ISignatoryManager;
/**
* RFC 9421 implementation of {@see IOutgoingSignedRequest}, sibling to the
* draft-cavage {@see OutgoingSignedRequest}. Default Ed25519 with the `alg`
* parameter omitted (RFC 9421 §3.3.7); verifier resolves it from the JWK.
*
* Options from {@see ISignatoryManager::getOptions()}: `rfc9421.signingAlgorithm`,
* `rfc9421.coveredComponents`, `rfc9421.contentDigestAlgorithm`,
* `rfc9421.includeAlgParameter`, `dateHeader`.
*/
class Rfc9421OutgoingSignedRequest extends SignedRequest implements
IOutgoingSignedRequest,
JsonSerializable {
private const DEFAULT_COMPONENTS = ['@method', '@target-uri', 'content-digest', 'content-length', 'date'];
private string $host = '';
private array $headers = [];
/** @var list<string> $headerList */
private array $headerList = [];
private SignatureAlgorithm $algorithm;
private string $signingAlgorithm;
/** @var array<string, scalar> */
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<string> $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<string, string>
*/
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,
]
);
}
}

View file

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use InvalidArgumentException;
use OCP\Security\Signature\Exceptions\SignatureException;
use Throwable;
/**
* RFC 9421 §3.3 sign/verify primitives.
*
* Asymmetric algorithms only: RSA-PKCS1-v1_5 (SHA-256/384/512), ECDSA P-256
* SHA-256, ECDSA P-384 SHA-384, Ed25519. JOSE aliases (RFC 7518 / RFC 8037)
* accepted per RFC 9421 §3.3.7. RSA-PSS is rejected: OPENSSL_PKCS1_PSS_PADDING
* needs PHP 8.5 and we still support 8.2-8.4.
*
* Sign delegates to {@see JWT::sign}. Verify takes a {@see Key} parsed by
* firebase/php-jwt (which has already validated the JWK's kty/crv/alg
* consistency) and only enforces the cross-source agreement between the JWK
* `alg` and the Signature-Input `alg` parameter (RFC 9421 §3.2 step 6).
*/
final class Algorithm {
public const NATIVE = [
'rsa-v1_5-sha256',
'ecdsa-p256-sha256',
'ecdsa-p384-sha384',
'ed25519',
];
/**
* For Ed25519 $privateKey is the raw 64-byte sodium secret key; otherwise
* a PEM private key. Returns raw signature bytes (R||S for ECDSA).
*
* Ed25519 calls sodium directly: JWT::sign runs the key through
* `validateEdDSAKey` which base64url-decodes it first, which mangles raw
* sodium bytes.
*
* @throws SignatureException
*/
public static function sign(string $signatureBase, string $privateKey, string $algorithm): string {
$normalized = self::normalize($algorithm);
if ($normalized === 'ed25519') {
if (strlen($privateKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
throw new SignatureException('Ed25519 secret key must be ' . SODIUM_CRYPTO_SIGN_SECRETKEYBYTES . ' bytes');
}
return sodium_crypto_sign_detached($signatureBase, $privateKey);
}
try {
return JWT::sign($signatureBase, $privateKey, self::nativeToJose($normalized));
} catch (Throwable $e) {
throw new SignatureException('signing failed for ' . $normalized . ': ' . $e->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<string, mixed> $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;
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use InvalidArgumentException;
/** RFC 9530 `Content-Digest` helpers; covered by RFC 9421 §7.2.5 in OCM signatures. */
final class ContentDigest {
public const ALGO_SHA256 = 'sha-256';
public const ALGO_SHA512 = 'sha-512';
public static function compute(string $body, string $algorithm = self::ALGO_SHA256): string {
$hashAlgorithm = self::hashAlgorithmFor($algorithm);
return $algorithm . '=:' . base64_encode(hash($hashAlgorithm, $body, true)) . ':';
}
/**
* True iff at least one recognised algorithm matches and none mismatch.
* Stricter than RFC 9530 §2's "any-match"; OCM treats mismatches as an
* attack on the weaker algorithm.
*/
public static function verify(string $header, string $body): bool {
$matched = false;
foreach (self::parse($header) as $algorithm => $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<string, string> [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),
};
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use Firebase\JWT\Key;
use OCP\Security\Signature\ISignatoryManager;
/**
* Capability bit for {@see ISignatoryManager} implementations that can resolve
* a remote JWK for RFC 9421 verification. {@see \OC\Security\Signature\SignatureManager}
* checks this via instanceof on the RFC 9421 path; cavage doesn't need it.
*/
interface IJwkResolvingSignatoryManager extends ISignatoryManager {
/**
* Resolve the JWK identified by $keyId for the remote at $origin and
* return it as a parsed {@see Key}. Null when no matching JWK is found.
*
* @param string $origin host of the remote that signed the request
* @param string $keyId raw `keyid` from Signature-Input; matched against JWK `kid`
*/
public function getRemoteKey(string $origin, string $keyId): ?Key;
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use InvalidArgumentException;
use OCP\Security\Signature\Exceptions\SignatureException;
/**
* RFC 9421 §2.5 signature base construction. Implements the derived
* components OCM uses (`@method`, `@target-uri`, `@authority`, `@scheme`,
* `@path`, `@query`, `@request-target`) plus plain HTTP fields.
*/
final class SignatureBase {
/**
* @param array<string,string> $headers headers keyed by lowercase name
* @param list<string> $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<string> $components
* @param array<string, scalar> $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)) ?? '';
}
}

View file

@ -11,6 +11,9 @@ namespace OC\Security\Signature;
use OC\Security\Signature\Db\SignatoryMapper;
use OC\Security\Signature\Model\IncomingSignedRequest;
use OC\Security\Signature\Model\OutgoingSignedRequest;
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OCP\DB\Exception as DBException;
use OCP\IAppConfig;
use OCP\IRequest;
@ -101,6 +104,11 @@ class SignatureManager implements ISignatureManager {
throw new IncomingRequestException('content of request is too big');
}
// `Signature-Input` is unique to RFC 9421; cavage uses `Signature` only.
if ($this->request->getHeader('Signature-Input') !== '') {
return $this->getRfc9421IncomingSignedRequest($signatoryManager, $body, $options);
}
// generate IncomingSignedRequest based on body and request
$signedRequest = new IncomingSignedRequest($body, $this->request, $options);
@ -121,6 +129,45 @@ class SignatureManager implements ISignatureManager {
return $signedRequest;
}
/**
* RFC 9421 inbound path. Requires {@see IJwkResolvingSignatoryManager}.
*
* @throws IncomingRequestException
* @throws SignatureException
* @throws SignatureNotFoundException
*/
private function getRfc9421IncomingSignedRequest(
ISignatoryManager $signatoryManager,
string $body,
array $options,
): IIncomingSignedRequest {
if (!($signatoryManager instanceof IJwkResolvingSignatoryManager)) {
throw new IncomingRequestException('RFC 9421 inbound is not supported by ' . get_class($signatoryManager));
}
$signedRequest = new Rfc9421IncomingSignedRequest($body, $this->request, $options);
try {
$key = $signatoryManager->getRemoteKey($signedRequest->getOrigin(), $signedRequest->getKeyId());
if ($key === null) {
throw new SignatoryNotFoundException('no JWK resolved for keyid ' . $signedRequest->getKeyId());
}
$signedRequest->setKey($key);
$signedRequest->verify();
} catch (SignatureException $e) {
$this->logger->warning(
'RFC 9421 signature could not be verified', [
'exception' => $e,
'signedRequest' => $signedRequest,
'signatoryManager' => get_class($signatoryManager),
]
);
throw $e;
}
return $signedRequest;
}
/**
* confirm that the Signature is signed using the correct private key, using
* clear version of the Signature and the public key linked to the keyId
@ -199,13 +246,22 @@ class SignatureManager implements ISignatureManager {
string $method,
string $uri,
): IOutgoingSignedRequest {
$signedRequest = new OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
parse_url($uri, PHP_URL_PATH) ?? '/'
);
$options = $signatoryManager->getOptions();
$signedRequest = ($options['rfc9421.format'] ?? false)
? new Rfc9421OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
$uri,
)
: new OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
parse_url($uri, PHP_URL_PATH) ?? '/',
);
$signedRequest->sign();

View file

@ -0,0 +1,316 @@
<?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 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 testEd25519RoundTripVerifies(): void {
[$signatory, $jwk] = $this->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;
}
}

View file

@ -0,0 +1,197 @@
<?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\Rfc9421;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
use OC\Security\Signature\Rfc9421\Algorithm;
use OCP\Security\Signature\Exceptions\SignatureException;
use Test\TestCase;
class AlgorithmTest extends TestCase {
public function testNormalizeNativeIsPassThrough(): void {
$this->assertSame('ed25519', Algorithm::normalize('ed25519'));
$this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256'));
$this->assertSame('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), '+/', '-_'), '=');
}
}

View file

@ -0,0 +1,76 @@
<?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\Rfc9421;
use OC\Security\Signature\Rfc9421\ContentDigest;
use Test\TestCase;
class ContentDigestTest extends TestCase {
public function testComputeRoundTrip(): void {
$body = '{"hello":"world"}';
$header = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
$this->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']);
}
}

View file

@ -0,0 +1,85 @@
<?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\Rfc9421;
use OC\Security\Signature\Rfc9421\SignatureBase;
use OCP\Security\Signature\Exceptions\SignatureException;
use Test\TestCase;
class SignatureBaseTest extends TestCase {
public function testBuildBasicComponents(): void {
$base = SignatureBase::build(
method: 'POST',
uri: 'https://example.org/foo?bar=baz',
headers: [
'content-digest' => '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));
}
}

View file

@ -0,0 +1,262 @@
<?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 {
// 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);
}
}