(https?:\/\/[\w\-\.]+[\w]+) (:[\d]+)? ([\w\-\.#\/@]+) )", algorithm="(?P[\w\-]+)", (headers="\(request-target\) (?P[\w\\-\s]+)",)? signature="(?P[\w+\/]+={0,2})" /x'; /** * @var IncomingRequest */ protected ?IncomingRequest $request; public function __construct(IncomingRequest $request = null) { if ($request === null) { $request = Services::request(); } $this->request = $request; } /** * Verify an incoming message based upon its HTTP signature * * @return bool True if signature has been verified. Otherwise false */ public function verify(): bool { if (!($dateHeader = $this->request->header('date'))) { throw new Exception('Request must include a date header.'); } // verify that request has been made within the last hour $currentTime = Time::now(); $requestTime = Time::createFromFormat( 'D, d M Y H:i:s T', $dateHeader->getValue(), ); $diff = $requestTime->difference($currentTime); if ($diff->getSeconds() > 3600) { throw new Exception('Request must be made within the last hour.'); } // check that digest header is set if (!($digestHeader = $this->request->header('digest'))) { throw new Exception('Request must include a digest header'); } // compute body digest and compare with header digest $bodyDigest = hash('sha256', $this->request->getBody(), true); $digest = 'SHA-256=' . base64_encode($bodyDigest); if ($digest !== $digestHeader->getValue()) { throw new Exception('Request digest is incorrect.'); } // read the Signature header if (($signature = $this->request->getHeaderLine('signature')) === '') { // Signature header not found throw new Exception('Request must include a signature header'); } // Split it into its parts (keyId, headers and signature) if (!($parts = $this->splitSignature($signature))) { throw new Exception('Malformed signature string.'); } // set $keyId, $headers and $signature variables $keyId = $parts['keyId']; $headers = $parts['headers']; $signature = $parts['signature']; // Fetch the public key linked from keyId $actorRequest = new ActivityRequest($keyId); $actorResponse = $actorRequest->get(); $actor = json_decode( $actorResponse->getBody(), false, 512, JSON_THROW_ON_ERROR, ); $publicKeyPem = $actor->publicKey->publicKeyPem; // Create a comparison string from the plaintext headers we got // in the same order as was given in the signature header, $data = $this->getPlainText(explode(' ', trim($headers))); // Verify that string using the public key and the original signature. $rsa = new RSA(); $rsa->setHash('sha256'); $rsa->setSignatureMode(RSA::SIGNATURE_PKCS1); $rsa->loadKey($publicKeyPem); return $rsa->verify($data, base64_decode($signature, true)); } /** * Split HTTP signature into its parts (keyId, headers and signature) * * @return array|false */ private function splitSignature(string $signature): array|false { if (!preg_match(self::SIGNATURE_PATTERN, $signature, $matches)) { // Signature pattern failed return false; } // Headers are optional if (!isset($matches['headers']) || $matches['headers'] == '') { $matches['headers'] = 'date'; } return $matches; } /** * Get plain text that has been originally signed * * @param string[] $headers HTTP header keys */ private function getPlainText(array $headers): string { $strings = []; $strings[] = sprintf( '(request-target): %s %s%s', $this->request->getMethod(), '/' . $this->request->uri->getPath(), $this->request->uri->getQuery() !== '' ? '?' . $this->request->uri->getQuery() : '', ); foreach ($headers as $key) { if ($this->request->hasHeader($key)) { $strings[] = "{$key}: {$this->request->getHeaderLine($key)}"; } } return implode("\n", $strings); } }