Files
Git'Fellow 4920bd1725 refactor: add PHP 8.3 types to BC-safe class constants
PHP 8.3 allows class constants to declare a type. Adopt it where doing so
cannot break third-party apps.

Typing an inheritable constant is a hard BC break: a subclass that
redeclares it untyped fails to load with "Type of C::FOO must be
compatible with P::FOO of type string". Changes are therefore limited to
constants that cannot be redeclared by a subclass:

  - private const (not inherited)
  - final public/protected const
  - constants declared in a final class or an enum

Interface constants, trait constants, and public/protected constants in
non-final (including abstract) classes are left untyped, as is all of
lib/public (OCP) and lib/unstable (NCU).

One review-requested exception: the public OBJECT_PREFIX/OBJECT_SUFFIX
constants of the app-internal CalDAV import helpers (TextImporter,
XmlImporter) are typed as well; these classes are not public API and
have no subclasses.

Only string, int and array are used. float is avoided because it would
silently coerce an int literal and change === comparisons. No constant
name or value is modified: every changed line adds only the type token,
so runtime behaviour is unchanged.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
2026-08-11 13:52:52 +01:00

491 lines
15 KiB
PHP

<?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\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;
/**
* RFC 9421 implementation of {@see IIncomingSignedRequest}. Parses the
* inbound Signature-Input / Signature dictionaries, picks the single entry
* carrying the `tag="ocm"` signature parameter (disregarding dictionary
* labels, as mandated by the OCM spec), 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`.
* The `Date` header is deliberately not part of the required set:
* freshness is anchored on the `created` signature parameter.
*/
public const REQUIRED_COMPONENTS = [
'@method',
'@target-uri',
'content-digest',
'content-length',
];
/** Max clock skew (seconds) for `created`. Override via `rfc9421.maxClockSkew`. */
private const int DEFAULT_MAX_FUTURE_SKEW = 60;
private string $origin = '';
private string $label;
/** @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);
// The OCM signature is identified by its integrity-protected
// `tag="ocm"` parameter, disregarding dictionary labels. A message
// carrying more than one such signature MUST be rejected; one
// without any is unsigned as far as OCM is concerned.
$tagged = [];
foreach ($inputs as $label => $entry) {
if (($entry['params']['tag'] ?? null) === 'ocm') {
$tagged[] = $label;
}
}
if (count($tagged) > 1) {
throw new IncomingRequestException('multiple signatures carrying tag="ocm" in Signature-Input');
}
if ($tagged === []) {
throw new SignatureNotFoundException('no signature carrying tag="ocm" in Signature-Input');
}
$this->label = $tagged[0];
// A duplicated dictionary label is collapsed to its last entry by
// RFC 8941 §4.2 parsing; that ambiguity on the OCM entry is
// rejected outright.
if (self::countLabel($signatureInputHeader, $this->label) > 1
|| self::countLabel($signatureHeader, $this->label) > 1) {
throw new IncomingRequestException(
'multiple "' . $this->label . '" entries in signature headers'
);
}
if (!isset($signatures[$this->label])) {
throw new IncomingRequestException('missing "' . $this->label . '" entry in Signature');
}
$entry = $inputs[$this->label];
$this->components = $entry['components'];
$this->signatureParams = $entry['params'];
$this->rawSignature = $signatures[$this->label];
$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');
}
// keyid is opaque; the signer origin is set by the caller via setOrigin().
$paramsLine = SignatureBase::serializeSignatureParams($this->components, $this->signatureParams);
$this->signatureBaseString = SignatureBase::build(
$request->getMethod(),
$this->reconstructTargetUri(),
$this->collectHeaders(),
$this->components,
$paramsLine,
);
$this->setSigningElements([
'label' => $this->label,
'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;
}
/**
* Signer origin, established by the caller from the share/sender identity.
*
* @param string $origin
*/
public function setOrigin(string $origin): void {
$this->origin = $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::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<lowercase-string, non-empty-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();
}
if ($value === '') {
throw new IncomingRequestException('covered header is missing or empty: ' . $component);
}
$out[strtolower($component)] = $value;
}
return $out;
}
#[\Override]
public function jsonSerialize(): array {
return array_merge(
parent::jsonSerialize(),
[
'origin' => $this->origin,
'label' => $this->label,
'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;
}
}