diff --git a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php new file mode 100644 index 00000000000..3af3f7c0f8d --- /dev/null +++ b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php @@ -0,0 +1,465 @@ + */ + private array $components; + /** @var array */ + 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 `#`; 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 + */ + public function getSignatureParams(): array { + return $this->signatureParams; + } + + /** + * @return list + */ + 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 $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 + */ + 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, params: array}> + * @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 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 $parameters + * @return array + */ + 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; + } +} diff --git a/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php new file mode 100644 index 00000000000..3a44776ef4a --- /dev/null +++ b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php @@ -0,0 +1,210 @@ + $headerList */ + private array $headerList = []; + private SignatureAlgorithm $algorithm; + private string $signingAlgorithm; + /** @var array */ + 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 $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 + */ + 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, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Rfc9421/Algorithm.php b/lib/private/Security/Signature/Rfc9421/Algorithm.php new file mode 100644 index 00000000000..155aead6013 --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/Algorithm.php @@ -0,0 +1,221 @@ +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 $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; + } +} diff --git a/lib/private/Security/Signature/Rfc9421/ContentDigest.php b/lib/private/Security/Signature/Rfc9421/ContentDigest.php new file mode 100644 index 00000000000..7df49f624d2 --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/ContentDigest.php @@ -0,0 +1,72 @@ + $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 [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), + }; + } +} diff --git a/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php new file mode 100644 index 00000000000..5747ccb43d8 --- /dev/null +++ b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php @@ -0,0 +1,29 @@ + $headers headers keyed by lowercase name + * @param list $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 $components + * @param array $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)) ?? ''; + } +} diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php index 11aff48438d..f094ac5148a 100644 --- a/lib/private/Security/Signature/SignatureManager.php +++ b/lib/private/Security/Signature/SignatureManager.php @@ -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(); diff --git a/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php new file mode 100644 index 00000000000..5f4285f14cc --- /dev/null +++ b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php @@ -0,0 +1,316 @@ +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; + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php new file mode 100644 index 00000000000..ba117ca99ba --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php @@ -0,0 +1,197 @@ +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), '+/', '-_'), '='); + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php new file mode 100644 index 00000000000..4198acec534 --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php @@ -0,0 +1,76 @@ +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']); + } +} diff --git a/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php new file mode 100644 index 00000000000..d5aed5e9ab6 --- /dev/null +++ b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php @@ -0,0 +1,85 @@ + '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)); + } +} diff --git a/tests/lib/Security/Signature/SignatureManagerDispatchTest.php b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php new file mode 100644 index 00000000000..ae5945fd9b5 --- /dev/null +++ b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php @@ -0,0 +1,262 @@ +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); + } +}