*/ 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(); } 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' => '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; } }