From ed63be4d4f9a709b02c9f8dbcbe13ba0ef22fee3 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 12 Sep 2026 04:06:46 -0600 Subject: [PATCH] Update HttpSignature, improve signing --- app/Util/ActivityPub/HttpSignature.php | 1003 ++++++++++++++++++++---- 1 file changed, 851 insertions(+), 152 deletions(-) diff --git a/app/Util/ActivityPub/HttpSignature.php b/app/Util/ActivityPub/HttpSignature.php index 39c1fb61f..af80328f9 100644 --- a/app/Util/ActivityPub/HttpSignature.php +++ b/app/Util/ActivityPub/HttpSignature.php @@ -4,43 +4,88 @@ namespace App\Util\ActivityPub; use App\Models\InstanceActor; use App\Models\Profile; -use DateTime; +use DateTimeImmutable; +use DateTimeZone; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; +use InvalidArgumentException; +use JsonException; +use RuntimeException; +use Stringable; +use Throwable; class HttpSignature { /* - * source: https://github.com/aaronpk/Nautilus/blob/master/app/ActivityPub/HTTPSignature.php - * thanks aaronpk! + * Originally based on: + * https://github.com/aaronpk/Nautilus/blob/master/app/ActivityPub/HTTPSignature.php + * + * Legacy HTTP Signatures / draft-cavage implementation used for + * ActivityPub interoperability. */ - public static function sign(Profile $profile, $url, $body = false, $addlHeaders = []) - { - if ($body) { - $digest = self::_digest($body); - } - $user = $profile; - $headers = self::_headersToSign($url, $body ? $digest : false); - $headers = array_merge($headers, $addlHeaders); - $stringToSign = self::_headersToSigningString($headers); - $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); - $key = openssl_pkey_get_private($user->private_key); - if (empty($key)) { + private const SIGNATURE_ALGORITHM = 'rsa-sha256'; + + private const OPENSSL_ALGORITHM = OPENSSL_ALGO_SHA256; + + private const SUPPORTED_METHODS = [ + 'get', + 'post', + ]; + + /** + * Sign a request using a local Pixelfed profile. + * + * Returns headers in cURL format: + * + * [ + * "Host: example.org", + * "Date: ...", + * "Digest: SHA-256=...", + * "Signature: ...", + * ] + * + * @param array $addlHeaders + * @return array + * + * @throws JsonException + */ + public static function sign( + Profile $profile, + string $url, + mixed $body = false, + array $addlHeaders = [] + ): array { + if (empty($profile->private_key)) { return []; } - openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); - if (empty($signature)) { + + try { + $headers = self::signRequest( + privateKey: $profile->private_key, + keyId: $profile->keyId(), + url: $url, + body: $body, + addlHeaders: $addlHeaders, + method: 'post' + ); + } catch (Throwable) { return []; } - $signature = base64_encode($signature); - $signatureHeader = 'keyId="'.$user->keyId().'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; - unset($headers['(request-target)']); - $headers['Signature'] = $signatureHeader; return self::_headersToCurlArray($headers); } + /** + * Sign using a pre-computed SHA-256 digest. + * + * $digest must be the base64 digest value WITHOUT the "SHA-256=" prefix. + * + * Returns an associative header array. + * + * @param array $addlHeaders + * @return array + */ public static function signRawWithDigest( string $privateKey, string $keyId, @@ -49,220 +94,874 @@ class HttpSignature array $addlHeaders = [], string $method = 'post' ): array { - $headers = self::_headersToSign($url, $digest, $method); - $headers = array_merge($headers, $addlHeaders); - $stringToSign = self::_headersToSigningString($headers); - $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); - - $key = openssl_pkey_get_private($privateKey); - if (empty($key)) { - throw new \RuntimeException('Private key is missing or invalid'); + if ($privateKey === '') { + throw new RuntimeException( + 'Private key is missing.' + ); } - openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); - if (empty($signature)) { - throw new \RuntimeException('Failed to generate signature'); + if ($keyId === '') { + throw new RuntimeException( + 'Key ID is missing.' + ); } - $signature = base64_encode($signature); - $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; - - unset($headers['(request-target)']); - $headers['Signature'] = $signatureHeader; + if ($digest === '') { + throw new RuntimeException( + 'Digest is missing.' + ); + } - return $headers; + return self::signRequest( + privateKey: $privateKey, + keyId: $keyId, + url: $url, + body: false, + addlHeaders: $addlHeaders, + method: $method, + digest: $digest + ); } - public static function signRaw($privateKey, $keyId, $url, $body = false, $addlHeaders = []) - { + /** + * Sign using an explicitly supplied private key and key ID. + * + * Returns headers in cURL format for backwards compatibility. + * + * @param array $addlHeaders + * @return array + * + * @throws JsonException + */ + public static function signRaw( + ?string $privateKey, + ?string $keyId, + string $url, + mixed $body = false, + array $addlHeaders = [] + ): array { if (empty($privateKey) || empty($keyId)) { return []; } - if ($body) { - $digest = self::_digest($body); - } - $headers = self::_headersToSign($url, $body ? $digest : false); - $headers = array_merge($headers, $addlHeaders); - $stringToSign = self::_headersToSigningString($headers); - $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); - $key = openssl_pkey_get_private($privateKey); - if (empty($key)) { - return []; - } - openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); - if (empty($signature)) { + + try { + $headers = self::signRequest( + privateKey: $privateKey, + keyId: $keyId, + url: $url, + body: $body, + addlHeaders: $addlHeaders, + method: 'post' + ); + } catch (Throwable) { return []; } - $signature = base64_encode($signature); - $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; - unset($headers['(request-target)']); - $headers['Signature'] = $signatureHeader; return self::_headersToCurlArray($headers); } - public static function instanceActorSign($url, $body = false, $addlHeaders = [], $method = 'post') - { - $keyId = config('app.url').'/i/actor#main-key'; - $privateKey = Cache::rememberForever(InstanceActor::PKI_PRIVATE, function () { - $instance = InstanceActor::first() ?: tap(Artisan::call('instance:actor'), fn () => sleep(10)) && InstanceActor::first(); - if (! $instance) { - throw new \Exception('Failed to generate or retrieve InstanceActor.'); - } + /** + * Sign a request as the Pixelfed instance actor. + * + * Returns an associative header array. + * + * @param array $addlHeaders + * @return array + * + * @throws JsonException + */ + public static function instanceActorSign( + string $url, + mixed $body = false, + array $addlHeaders = [], + string $method = 'post' + ): array { + return self::signRequest( + privateKey: self::instanceActorPrivateKey(), + keyId: self::instanceActorKeyId(), + url: $url, + body: $body, + addlHeaders: $addlHeaders, + method: $method + ); + } - return $instance->private_key; - }); - abort_if(! $privateKey, 400, 'Missing instance actor key, please run php artisan instance:actor'); - if ($body) { - $digest = self::_digest($body); + /** + * Sign as the instance actor using a pre-computed digest. + * + * $digest must be the base64 digest value WITHOUT the "SHA-256=" prefix. + * + * @param array $addlHeaders + * @return array + */ + public static function instanceActorSignWithDigest( + string $url, + string $digest, + array $addlHeaders = [], + string $method = 'post' + ): array { + if ($digest === '') { + throw new RuntimeException( + 'Digest is missing.' + ); } - $headers = self::_headersToSign($url, $body ? $digest : false, $method); - $headers = array_merge($headers, $addlHeaders); - $stringToSign = self::_headersToSigningString($headers); - $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); - $key = openssl_pkey_get_private($privateKey); - openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); - $signature = base64_encode($signature); - $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; - unset($headers['(request-target)']); - $headers['Signature'] = $signatureHeader; - return $headers; + return self::signRequest( + privateKey: self::instanceActorPrivateKey(), + keyId: self::instanceActorKeyId(), + url: $url, + body: false, + addlHeaders: $addlHeaders, + method: $method, + digest: $digest + ); } - public static function parseSignatureHeader($signature) + /** + * Parse a legacy Signature HTTP header. + * + * @return array + */ + public static function parseSignatureHeader(string $signature): array { - $parts = explode(',', $signature); + $signature = trim($signature); + + if ($signature === '') { + return [ + 'error' => 'Signature header is empty.', + ]; + } + $signatureData = []; - foreach ($parts as $part) { - if (preg_match('/(.+)="(.+)"/', $part, $match)) { - $signatureData[$match[1]] = $match[2]; - } + /* + * Signature headers normally look like: + * + * keyId="...", + * headers="(request-target) host date digest", + * algorithm="rsa-sha256", + * signature="..." + * + * Avoid explode(',') so parsing is not unnecessarily dependent on + * commas never appearing inside quoted values. + */ + preg_match_all( + '/(?:^|,)\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"/', + $signature, + $matches, + PREG_SET_ORDER + ); + + foreach ($matches as $match) { + $signatureData[$match[1]] = $match[2]; } if (! isset($signatureData['keyId'])) { return [ - 'error' => 'No keyId was found in the signature header. Found: '.implode(', ', array_keys($signatureData)), + 'error' => 'No keyId was found in the signature header. Found: '. + implode(', ', array_keys($signatureData)), ]; } - if (! filter_var($signatureData['keyId'], FILTER_VALIDATE_URL)) { + $keyId = $signatureData['keyId']; + + if (! filter_var($keyId, FILTER_VALIDATE_URL)) { return [ - 'error' => 'keyId is not a URL: '.$signatureData['keyId'], + 'error' => 'keyId is not a valid URL: '.$keyId, ]; } - if (! Helpers::validateUrl($signatureData['keyId'])) { + if (! Helpers::validateUrl($keyId)) { return [ - 'error' => 'keyId is not a URL: '.$signatureData['keyId'], + 'error' => 'keyId URL failed validation: '.$keyId, ]; } - if (! isset($signatureData['headers']) || ! isset($signatureData['signature'])) { + if ( + ! isset($signatureData['headers']) || + trim($signatureData['headers']) === '' + ) { return [ - 'error' => 'Signature is missing headers or signature parts', + 'error' => 'Signature is missing the headers parameter.', + ]; + } + + if ( + ! isset($signatureData['signature']) || + trim($signatureData['signature']) === '' + ) { + return [ + 'error' => 'Signature is missing the signature parameter.', ]; } return $signatureData; } - public static function verify($publicKey, $signatureData, $inputHeaders, $path, $body) - { - $digest = 'SHA-256='.base64_encode(hash('sha256', $body, true)); + /** + * Verify an incoming legacy HTTP Signature. + * + * Existing five-argument callers remain compatible. $method was added as + * an optional final argument so GET signatures can also be verified. + * + * $path should contain the complete request target including the query + * string when present, for example: + * + * /users/dan/inbox?foo=bar + * + * @param array $signatureData + * @param array $inputHeaders + * @return array{0: int|false, 1: string} + */ + public static function verify( + mixed $publicKey, + array $signatureData, + array $inputHeaders, + string $path, + string $body, + string $method = 'post' + ): array { + $method = self::normalizeMethod($method); + + if ( + ! isset($signatureData['headers']) || + ! isset($signatureData['signature']) + ) { + return [0, '']; + } + + $inputHeaders = self::normalizeInputHeaders( + $inputHeaders + ); + + $digest = 'SHA-256='.self::_digest($body); + $headersToSign = []; - foreach (explode(' ', $signatureData['headers']) as $h) { - if ($h == '(request-target)') { - $headersToSign[$h] = 'post '.$path; - } elseif ($h == 'digest') { - $headersToSign[$h] = $digest; - } elseif (isset($inputHeaders[$h][0])) { - $headersToSign[$h] = $inputHeaders[$h][0]; + + $signedHeaderNames = preg_split( + '/\s+/', + trim((string) $signatureData['headers']) + ); + + if (! is_array($signedHeaderNames)) { + return [0, '']; + } + + foreach ($signedHeaderNames as $headerName) { + $headerName = strtolower( + trim($headerName) + ); + + if ($headerName === '') { + continue; + } + + if ($headerName === '(request-target)') { + $headersToSign[$headerName] = + $method.' '.self::normalizeRequestPath($path); + + continue; } + + /* + * Calculate Digest ourselves from the exact received body. + * + * This means a signature using Digest can only verify if it was + * generated against the body Pixelfed actually received. + */ + if ($headerName === 'digest') { + $headersToSign[$headerName] = $digest; + + continue; + } + + if (! array_key_exists($headerName, $inputHeaders)) { + /* + * A signed header is missing from the incoming request. + * + * There's no valid signing string we can reconstruct, so the + * verification must fail. + */ + return [ + 0, + self::_headersToSigningString($headersToSign), + ]; + } + + $headersToSign[$headerName] = + $inputHeaders[$headerName]; + } + + if (empty($headersToSign)) { + return [0, '']; } - $signingString = self::_headersToSigningString($headersToSign); - $verified = openssl_verify($signingString, base64_decode($signatureData['signature']), $publicKey, OPENSSL_ALGO_SHA256); + $signingString = self::_headersToSigningString( + $headersToSign + ); - return [$verified, $signingString]; + $signature = base64_decode( + (string) $signatureData['signature'], + true + ); + + if ($signature === false) { + return [0, $signingString]; + } + + try { + $verified = openssl_verify( + $signingString, + $signature, + $publicKey, + self::OPENSSL_ALGORITHM + ); + } catch (Throwable) { + return [0, $signingString]; + } + + return [ + $verified, + $signingString, + ]; } - public static function instanceActorSignWithDigest( + /** + * Build and cryptographically sign the HTTP signature headers. + * + * @param array $addlHeaders + * @return array + * + * @throws JsonException + */ + private static function signRequest( + string $privateKey, + string $keyId, string $url, - string $digest, + mixed $body = false, array $addlHeaders = [], - string $method = 'post' + string $method = 'post', + ?string $digest = null ): array { - $keyId = config('app.url').'/i/actor#main-key'; - $privateKey = Cache::rememberForever(InstanceActor::PKI_PRIVATE, function () { - $instance = InstanceActor::first() - ?: tap(Artisan::call('instance:actor'), fn () => sleep(10)) && InstanceActor::first(); - if (! $instance) { - throw new \Exception('Failed to generate or retrieve InstanceActor.'); - } + if ($privateKey === '') { + throw new RuntimeException( + 'Private key is missing.' + ); + } - return $instance->private_key; - }); + if ($keyId === '') { + throw new RuntimeException( + 'Key ID is missing.' + ); + } - abort_if(! $privateKey, 400, 'Missing instance actor key, please run php artisan instance:actor'); + $method = self::normalizeMethod($method); + + /* + * false / null mean "request has no body". + * + * This is intentionally different from: + * + * if ($body) + * + * because valid payloads such as "", "0", or [] should not + * accidentally disable Digest generation when explicitly supplied. + */ + if ( + $digest === null && + self::hasBody($body) + ) { + $digest = self::_digest($body); + } - $headers = self::_headersToSign($url, $digest, $method); - $headers = array_merge($headers, $addlHeaders); - $stringToSign = self::_headersToSigningString($headers); - $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); + $headers = self::_headersToSign( + $url, + $digest, + $method + ); + + $headers = self::mergeAdditionalHeaders( + $headers, + $addlHeaders + ); + + $stringToSign = self::_headersToSigningString( + $headers + ); + + $signedHeaders = implode( + ' ', + array_map( + static fn (string $header): string => strtolower($header), + array_keys($headers) + ) + ); + + $key = openssl_pkey_get_private( + $privateKey + ); + + if ($key === false) { + throw new RuntimeException( + 'Private key is missing or invalid.' + ); + } - $key = openssl_pkey_get_private($privateKey); - openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); - $signature = base64_encode($signature); + $signature = null; + + $signed = openssl_sign( + $stringToSign, + $signature, + $key, + self::OPENSSL_ALGORITHM + ); + + if ( + $signed !== true || + ! is_string($signature) || + $signature === '' + ) { + throw new RuntimeException( + 'Failed to generate HTTP signature.' + ); + } - $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; + $signatureHeader = sprintf( + 'keyId="%s",headers="%s",algorithm="%s",signature="%s"', + $keyId, + $signedHeaders, + self::SIGNATURE_ALGORITHM, + base64_encode($signature) + ); + + /* + * (request-target) is a pseudo-header used only when constructing + * the signing string. It must never be transmitted as an HTTP header. + */ unset($headers['(request-target)']); + $headers['Signature'] = $signatureHeader; return $headers; } - private static function _headersToSigningString($headers) - { - return implode("\n", array_map(function ($k, $v) { - return strtolower($k).': '.$v; - }, array_keys($headers), $headers)); + /** + * Build the base headers used for signing. + * + * @return array + */ + protected static function _headersToSign( + string $url, + ?string $digest = null, + string $method = 'post' + ): array { + $method = self::normalizeMethod($method); + + $parts = self::parseUrl($url); + + $date = new DateTimeImmutable( + 'now', + new DateTimeZone('UTC') + ); + + $headers = [ + '(request-target)' => $method.' '.self::requestTargetFromParts($parts), + 'Host' => self::hostFromParts($parts), + 'Date' => $date->format('D, d M Y H:i:s \G\M\T'), + ]; + + if ($digest !== null && $digest !== '') { + $headers['Digest'] = 'SHA-256='.$digest; + } + + return $headers; } - private static function _headersToCurlArray($headers) + /** + * Create the signing string. + * + * @param array $headers + */ + private static function _headersToSigningString(array $headers): string { - return array_map(function ($k, $v) { - return "$k: $v"; - }, array_keys($headers), $headers); + $lines = []; + + foreach ($headers as $name => $value) { + $lines[] = strtolower($name).': '.$value; + } + + return implode("\n", $lines); } - private static function _digest($body) + /** + * Convert associative headers into cURL header syntax. + * + * @param array $headers + * @return array + */ + private static function _headersToCurlArray(array $headers): array { - if (is_array($body)) { - $body = json_encode($body); + $result = []; + + foreach ($headers as $name => $value) { + $result[] = $name.': '.$value; } - return base64_encode(hash('sha256', $body, true)); + return $result; } - protected static function _headersToSign($url, $digest = false, $method = 'post') + /** + * Calculate the base64 encoded SHA-256 digest of the exact body. + * + * When an array/object is supplied directly it is encoded once using + * PHP's normal JSON representation. Federation delivery code should + * preferably serialize once itself and pass the resulting string here + * so the signed digest and transmitted bytes are guaranteed identical. + * + * @throws JsonException + */ + private static function _digest(mixed $body): string { - $date = new DateTime('UTC'); - - if (! in_array($method, ['post', 'get'])) { - throw new \Exception('Invalid method used to sign headers in HttpSignature'); + if (! is_string($body)) { + $body = json_encode( + $body, + JSON_THROW_ON_ERROR + ); } - $headers = [ - '(request-target)' => $method.' '.parse_url($url, PHP_URL_PATH), - 'Host' => parse_url($url, PHP_URL_HOST), - 'Date' => $date->format('D, d M Y H:i:s \G\M\T'), + + return base64_encode( + hash( + 'sha256', + $body, + true + ) + ); + } + + /** + * Merge additional headers without allowing callers to replace headers + * whose values define the request signature itself. + * + * Additional headers are matched case-insensitively. + * + * @param array $headers + * @param array $additional + * @return array + */ + private static function mergeAdditionalHeaders( + array $headers, + array $additional + ): array { + $protected = [ + '(request-target)', + 'host', + 'date', + 'digest', + 'signature', ]; - if ($digest) { - $headers['Digest'] = 'SHA-256='.$digest; + $existing = []; + + foreach (array_keys($headers) as $name) { + $existing[strtolower($name)] = $name; + } + + foreach ($additional as $name => $value) { + if (! is_string($name)) { + continue; + } + + $name = trim($name); + + if ($name === '') { + continue; + } + + $normalizedName = strtolower($name); + + /* + * Host, Date, Digest, etc. are derived from the actual request + * being signed and must not be overridden by arbitrary headers. + */ + if (in_array($normalizedName, $protected, true)) { + continue; + } + + $value = self::normalizeHeaderValue($value); + + if ($value === null) { + continue; + } + + /* + * Preserve the first header's original casing while allowing an + * additional header to update an existing non-protected value + * case-insensitively. + */ + if (isset($existing[$normalizedName])) { + $headers[$existing[$normalizedName]] = $value; + + continue; + } + + $headers[$name] = $value; + $existing[$normalizedName] = $name; } return $headers; } + + /** + * Normalize incoming request headers into: + * + * [ + * 'host' => 'example.org', + * 'date' => '...', + * ] + * + * @param array $headers + * @return array + */ + private static function normalizeInputHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $value) { + if (! is_string($name)) { + continue; + } + + $headerName = strtolower( + trim($name) + ); + + if ($headerName === '') { + continue; + } + + if (is_array($value)) { + $values = []; + + foreach ($value as $item) { + $normalizedValue = self::normalizeHeaderValue($item); + + if ($normalizedValue !== null) { + $values[] = $normalizedValue; + } + } + + if (! empty($values)) { + $normalized[$headerName] = implode(', ', $values); + } + + continue; + } + + $normalizedValue = self::normalizeHeaderValue( + $value + ); + + if ($normalizedValue !== null) { + $normalized[$headerName] = $normalizedValue; + } + } + + return $normalized; + } + + private static function normalizeHeaderValue(mixed $value): ?string + { + if (is_string($value)) { + return trim($value); + } + + if ( + is_int($value) || + is_float($value) || + is_bool($value) + ) { + return (string) $value; + } + + if ($value instanceof Stringable) { + return (string) $value; + } + + return null; + } + + /** + * Parse and validate a URL used for HTTP signing. + * + * @return array + */ + private static function parseUrl(string $url): array + { + $parts = parse_url($url); + + if (! is_array($parts)) { + throw new InvalidArgumentException( + 'Unable to parse HTTP signature URL.' + ); + } + + $scheme = strtolower( + (string) ($parts['scheme'] ?? '') + ); + + if (! in_array($scheme, ['http', 'https'], true)) { + throw new InvalidArgumentException( + 'HTTP signature URL must use HTTP or HTTPS.' + ); + } + + if ( + ! isset($parts['host']) || + ! is_string($parts['host']) || + $parts['host'] === '' + ) { + throw new InvalidArgumentException( + 'HTTP signature URL is missing a host.' + ); + } + + return $parts; + } + + /** + * Generate the RFC request-target from a parsed URL. + * + * The query string is part of (request-target) and must be included: + * + * POST /inbox?page=2 + * + * Fragments are intentionally excluded because they are never sent in an + * HTTP request. + * + * @param array $parts + */ + private static function requestTargetFromParts(array $parts): string + { + $path = $parts['path'] ?? '/'; + + if (! is_string($path) || $path === '') { + $path = '/'; + } + + if ( + array_key_exists('query', $parts) && + is_string($parts['query']) + ) { + $path .= '?'.$parts['query']; + } + + return $path; + } + + /** + * Build the Host header, preserving an explicitly supplied port. + * + * @param array $parts + */ + private static function hostFromParts(array $parts): string + { + $host = (string) $parts['host']; + + if ( + str_contains($host, ':') && + ! str_starts_with($host, '[') + ) { + $host = '['.$host.']'; + } + + if (isset($parts['port'])) { + $host .= ':'.(int) $parts['port']; + } + + return $host; + } + + /** + * Normalize a request path supplied during verification. + */ + private static function normalizeRequestPath(string $path): string + { + $path = trim($path); + + if ($path === '') { + return '/'; + } + + if ( + str_starts_with($path, 'http://') || + str_starts_with($path, 'https://') + ) { + return self::requestTargetFromParts( + self::parseUrl($path) + ); + } + + if (! str_starts_with($path, '/')) { + $path = '/'.$path; + } + + return $path; + } + + private static function normalizeMethod(string $method): string + { + $method = strtolower( + trim($method) + ); + + if (! in_array($method, self::SUPPORTED_METHODS, true)) { + throw new InvalidArgumentException( + 'Invalid method used to sign HTTP request: '.$method + ); + } + + return $method; + } + + private static function hasBody(mixed $body): bool + { + return $body !== false && + $body !== null; + } + + private static function instanceActorKeyId(): string + { + return rtrim( + (string) config('app.url'), + '/' + ).'/i/actor#main-key'; + } + + private static function instanceActorPrivateKey(): string + { + return Cache::rememberForever( + InstanceActor::PKI_PRIVATE, + function (): string { + $instance = InstanceActor::first(); + + if (! $instance) { + Artisan::call('instance:actor'); + + $instance = InstanceActor::first(); + } + + if ( + ! $instance || + empty($instance->private_key) + ) { + throw new RuntimeException( + 'Failed to generate or retrieve the InstanceActor private key. '. + 'Run: php artisan instance:actor' + ); + } + + return $instance->private_key; + } + ); + } }