diff --git a/README.md b/README.md index 9ca7275..bee71ac 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # HTTP Message Signatures (RFC 9421) -A PHP 8.4+ implementation of [HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421.html) as specified in RFC 9421. +A PHP 8.1+ implementation of [HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421.html) as specified in RFC 9421. ## Features -- ✅ Full RFC 9421 compliance +- ✅ RFC 9421 signing and verification support - ✅ **PSR-7 compliant** - Works with any PSR-7 HTTP message implementation - ✅ Support for multiple signature algorithms: - HMAC-SHA256 @@ -23,7 +23,7 @@ composer require craftcms/http-message-signatures ## Requirements -- PHP 8.4 or higher +- PHP 8.1 or higher - PSR-7 HTTP message implementation (e.g., `guzzlehttp/psr7`, `nyholm/psr7`, `slim/psr7`) ## Dependencies diff --git a/composer.json b/composer.json index 836170a..7a4b4bd 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "guzzlehttp/psr7": "^2.0", "http-interop/http-factory-guzzle": "^1.0", "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5" }, "autoload": { diff --git a/src/ComponentDeriver.php b/src/ComponentDeriver.php index c64fe28..60fe611 100644 --- a/src/ComponentDeriver.php +++ b/src/ComponentDeriver.php @@ -7,7 +7,6 @@ use Bakame\Http\StructuredFields\Item; use Bakame\Http\StructuredFields\Token; use InvalidArgumentException; -use League\Uri\Components\Query; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -71,7 +70,7 @@ private function deriveDerivedComponent( '@request-target' => $this->deriveRequestTarget($this->resolveRequest($message, $originalRequest, $name)), '@path' => $this->derivePath($this->resolveRequest($message, $originalRequest, $name)), '@query' => $this->deriveQuery($this->resolveRequest($message, $originalRequest, $name)), - '@query-param' => $this->deriveQueryParam($componentId, $this->resolveRequest( + '@query-param' => QueryParamComponent::derive($componentId, $this->resolveRequest( $message, $originalRequest, $name, @@ -108,7 +107,7 @@ private function resolveRequest( */ private function deriveMethod(RequestInterface $request): string { - return strtoupper($request->getMethod()); + return $request->getMethod(); } /** @@ -178,31 +177,6 @@ private function deriveQuery(RequestInterface $request): string return '?' . $query; } - /** - * RFC 9421 Section 2.2.8: Derive a specific query parameter value. - * The parameter name is taken from the component identifier's "name" parameter. - * - * @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.2.8 - */ - private function deriveQueryParam(Item $componentId, RequestInterface $request): string - { - $paramName = $componentId->parameterByKey('name'); - - if ($paramName === null || !is_string($paramName)) { - throw new InvalidArgumentException('@query-param requires a "name" parameter'); - } - - // Parse using League URI (RFC3986 semantics), compare decoded name, - // then canonicalize by percent-encoding the decoded value. - foreach (Query::fromUri($request->getUri())->pairs() as $name => $value) { - if ($name === $paramName) { - return rawurlencode($value ?? ''); - } - } - - throw new InvalidArgumentException("Query parameter \"{$paramName}\" not found in request"); - } - /** * RFC 9421 Section 2.2.9: The value is the status code of the response. * diff --git a/src/QueryParamComponent.php b/src/QueryParamComponent.php new file mode 100644 index 0000000..39aab3e --- /dev/null +++ b/src/QueryParamComponent.php @@ -0,0 +1,39 @@ +parameterByKey('name'); + + if ($paramName === null || !is_string($paramName)) { + throw new InvalidArgumentException('@query-param requires a "name" parameter'); + } + + $matchedValues = URLSearchParams::fromUri($request->getUri())->getAll(rawurldecode($paramName)); + + if (count($matchedValues) > 1) { + throw new InvalidArgumentException("Query parameter \"{$paramName}\" occurs more than once in request"); + } + + if (count($matchedValues) === 1) { + return rawurlencode($matchedValues[0]); + } + + throw new InvalidArgumentException("Query parameter \"{$paramName}\" not found in request"); + } +} diff --git a/src/Signer.php b/src/Signer.php index 9a43f61..4aa66c3 100644 --- a/src/Signer.php +++ b/src/Signer.php @@ -99,11 +99,8 @@ public static function parseComponentIdentifier(string $identifier): Item // Ensure the base identifier is quoted for structured field parsing // User may pass @query-param;name="foo" but we need "@query-param";name="foo" if (!str_starts_with($identifier, '"')) { - /** @var int $semiPos — guaranteed by str_contains check above */ - $semiPos = (int) strpos($identifier, ';'); - $base = substr($identifier, 0, $semiPos); - $params = substr($identifier, $semiPos); - $identifier = '"' . strtolower($base) . '"' . $params; + [$base, $params] = explode(';', $identifier, 2); + $identifier = '"' . strtolower($base) . '";' . $params; } return Item::fromHttpValue($identifier); diff --git a/src/Url/Base64Url.php b/src/Url/Base64Url.php new file mode 100644 index 0000000..c6afd36 --- /dev/null +++ b/src/Url/Base64Url.php @@ -0,0 +1,38 @@ +components); + + return InnerList::fromAssociative($componentItems, self::parameters($config, $algorithm)); + } + + private static function parameters(UrlSigningConfig $config, AlgorithmInterface $algorithm): Parameters + { + $algId = $algorithm->getAlgorithmId(); + + $params = array_filter([ + 'created' => $config->created, + 'expires' => $config->created !== null && $config->expiresAfter !== null + ? $config->created + $config->expiresAfter + : null, + 'nonce' => $config->nonce, + 'alg' => $algId !== '' ? $algId : null, + 'keyid' => $config->keyid, + 'tag' => $config->tag, + ], static fn (mixed $value): bool => $value !== null); + + return Parameters::fromAssociative($params); + } +} diff --git a/src/Url/UrlSigner.php b/src/Url/UrlSigner.php index 3315984..fdf697b 100644 --- a/src/Url/UrlSigner.php +++ b/src/Url/UrlSigner.php @@ -4,12 +4,9 @@ namespace HttpMessageSignatures\Url; -use Bakame\Http\StructuredFields\InnerList; -use Bakame\Http\StructuredFields\Parameters; use HttpMessageSignatures\Algorithm\AlgorithmInterface; use HttpMessageSignatures\Exception\SignatureException; use HttpMessageSignatures\SignatureBase; -use HttpMessageSignatures\Signer; use League\Uri\Modifier; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; @@ -28,7 +25,7 @@ public function __construct( } /** - * Sign a URL and return the URL with signature query parameters appended. + * Sign a URL and return the URL with a signature query parameter appended. * * Accepts a plain URL string or a PSR-7 RequestInterface. * When a RequestInterface is passed, its method is preserved — this allows @@ -38,7 +35,7 @@ public function __construct( * When a string is passed, a synthetic GET request is created internally. * * @param string|RequestInterface $url The URL (or request) to sign - * @return string The signed URL with signature and signature-input query parameters + * @return string The signed URL with a signature query parameter * * @throws SignatureException */ @@ -57,22 +54,15 @@ public function sign(string|RequestInterface $url): string $uriString = $url; } - // Strip any existing signature params + // Strip any existing signature param $cleanUrl = Modifier::wrap($uriString) - ->removeQueryPairsByKey($this->config->signatureParam, $this->config->signatureInputParam) + ->removeQueryPairsByKey($this->config->signatureParam) ->toString(); // Create request with resolved method and clean URL $request = $this->requestFactory->createRequest($method, $cleanUrl); - // Parse component identifiers - $componentItems = array_map(Signer::parseComponentIdentifier(...), $this->config->components); - - // Build signature parameters - $signatureParameters = $this->buildSignatureParameters(); - - // Create InnerList - $signatureInput = InnerList::fromAssociative($componentItems, $signatureParameters); + $signatureInput = UrlSignatureInput::fromConfig($this->config, $this->algorithm); // Build signature base string $signatureBaseString = $this->signatureBase->build($signatureInput, $request); @@ -80,57 +70,11 @@ public function sign(string|RequestInterface $url): string // Sign $rawSignature = $this->algorithm->sign($signatureBaseString); - // Append signature-input and signature query params + // Append signature query param return Modifier::wrap($cleanUrl) ->appendQueryParameters([ - $this->config->signatureInputParam => $signatureInput->toHttpValue(), - $this->config->signatureParam => self::base64urlEncode($rawSignature), + $this->config->signatureParam => Base64Url::encode($rawSignature), ]) ->toString(); } - - /** - * @see Signer::buildSignatureParameters() for a similar implementation — consider - * extracting a shared builder if more signing surfaces are added. - */ - private function buildSignatureParameters(): Parameters - { - $params = []; - - if ($this->config->created !== null) { - $params['created'] = $this->config->created; - } - - if ($this->config->expiresAfter !== null && $this->config->created !== null) { - $params['expires'] = $this->config->created + $this->config->expiresAfter; - } - - if ($this->config->nonce !== null) { - $params['nonce'] = $this->config->nonce; - } - - $algId = $this->algorithm->getAlgorithmId(); - - if ($algId !== '') { - $params['alg'] = $algId; - } - - if ($this->config->keyid !== null) { - $params['keyid'] = $this->config->keyid; - } - - if ($this->config->tag !== null) { - $params['tag'] = $this->config->tag; - } - - return Parameters::fromAssociative($params); - } - - /** - * Base64url encode (RFC 4648 Section 5), no padding. - */ - private static function base64urlEncode(string $data): string - { - return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); - } } diff --git a/src/Url/UrlSigningConfig.php b/src/Url/UrlSigningConfig.php index 54cb83d..e8c2408 100644 --- a/src/Url/UrlSigningConfig.php +++ b/src/Url/UrlSigningConfig.php @@ -9,7 +9,6 @@ final class UrlSigningConfig /** * @param array $components Component identifiers to cover (default: full URL) * @param string $signatureParam Query parameter name for the signature - * @param string $signatureInputParam Query parameter name for the signature input * @param int|null $created Unix timestamp for 'created' param (null = omit) * @param int|null $expiresAfter Seconds after $created until expiration (null = no expiry, requires $created) * @param string|null $keyid Key identifier @@ -19,7 +18,6 @@ final class UrlSigningConfig public function __construct( public readonly array $components = ['@target-uri'], public readonly string $signatureParam = 'signature', - public readonly string $signatureInputParam = 'signature-input', public readonly ?int $created = null, public readonly ?int $expiresAfter = null, public readonly ?string $keyid = null, @@ -38,7 +36,6 @@ public function __construct( public static function withCurrentTime( array $components = ['@target-uri'], string $signatureParam = 'signature', - string $signatureInputParam = 'signature-input', ?int $expiresAfter = null, ?string $keyid = null, ?string $nonce = null, @@ -47,7 +44,6 @@ public static function withCurrentTime( return new self( components: $components, signatureParam: $signatureParam, - signatureInputParam: $signatureInputParam, created: time(), expiresAfter: $expiresAfter, keyid: $keyid, diff --git a/src/Url/UrlVerifier.php b/src/Url/UrlVerifier.php index 58e8274..d0f0798 100644 --- a/src/Url/UrlVerifier.php +++ b/src/Url/UrlVerifier.php @@ -60,25 +60,20 @@ public function verify(string|RequestInterface $url): bool throw new VerificationException("Signature parameter '{$this->config->signatureParam}' not found in URL"); } - $rawSignature = self::base64urlDecode((string) $encodedSignature); + $rawSignature = Base64Url::decode((string) $encodedSignature); - // Extract and parse signature-input - $signatureInputValue = $query->parameter($this->config->signatureInputParam); - - if ($signatureInputValue === null) { - throw new VerificationException( - "Signature input parameter '{$this->config->signatureInputParam}' not found in URL", - ); + if ($this->config->components === []) { + throw new VerificationException('At least one component must be specified'); } - $signatureInput = $this->parseSignatureInput((string) $signatureInputValue); + $signatureInput = UrlSignatureInput::fromConfig($this->config, $this->algorithm); // Check expiration $this->ensureNotExpired($signatureInput); - // Strip signature params to get the clean URL + // Strip signature param to get the clean URL $cleanUrl = Modifier::wrap($uriString) - ->removeQueryPairsByKey($this->config->signatureParam, $this->config->signatureInputParam) + ->removeQueryPairsByKey($this->config->signatureParam) ->toString(); // Create request with resolved method and clean URL @@ -95,19 +90,6 @@ public function verify(string|RequestInterface $url): bool return true; } - private function parseSignatureInput(string $value): InnerList - { - try { - return InnerList::fromHttpValue($value); - } catch (\Throwable $e) { - throw new VerificationException( - 'Failed to parse signature-input as structured field inner list: ' . $e->getMessage(), - 0, - $e, - ); - } - } - private function ensureNotExpired(InnerList $signatureInput): void { $expires = $signatureInput->parameterByKey('expires'); @@ -125,19 +107,4 @@ private function ensureNotExpired(InnerList $signatureInput): void } } - /** - * Base64url decode (RFC 4648 Section 5). - * - * @throws VerificationException - */ - private static function base64urlDecode(string $data): string - { - $decoded = base64_decode(strtr($data, '-_', '+/'), true); - - if ($decoded === false) { - throw new VerificationException('Invalid base64url data'); - } - - return $decoded; - } } diff --git a/tests/Unit/ComponentDeriverTest.php b/tests/Unit/ComponentDeriverTest.php index afbfa81..8ccb7b9 100644 --- a/tests/Unit/ComponentDeriverTest.php +++ b/tests/Unit/ComponentDeriverTest.php @@ -10,6 +10,7 @@ use HttpMessageSignatures\ComponentDeriver; use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; final class ComponentDeriverTest extends TestCase { @@ -32,9 +33,14 @@ protected function setUp(): void ); } - public function testMethodReturnsUppercaseMethod(): void + public function testMethodReturnsMethodWithoutChangingCase(): void { $this->assertSame('POST', $this->deriver->deriveComponent(Item::fromString('@method'), $this->request)); + + $request = $this->createStub(RequestInterface::class); + $request->method('getMethod')->willReturn('custom'); + + $this->assertSame('custom', $this->deriver->deriveComponent(Item::fromString('@method'), $request)); } public function testPathReturnsTheRequestPath(): void @@ -118,12 +124,31 @@ public function testQueryParamExtractsNamedQueryParameter(): void $this->assertSame('value', $this->deriver->deriveComponent($component, $this->request)); } - public function testQueryParamTreatsPlusAsLiteralPlus(): void + public function testQueryParamTreatsPlusAsSpace(): void { $request = new Request('GET', 'https://example.com/path?param=a+b'); $component = Item::fromHttpValue('"@query-param";name="param"'); - $this->assertSame('a%2Bb', $this->deriver->deriveComponent($component, $request)); + $this->assertSame('a%20b', $this->deriver->deriveComponent($component, $request)); + } + + public function testQueryParamMatchesEncodedParameterName(): void + { + $request = new Request('GET', 'https://example.com/path?fa%C3%A7ade%22%3A%20=something'); + $component = Item::fromHttpValue('"@query-param";name="fa%C3%A7ade%22%3A%20"'); + + $this->assertSame('something', $this->deriver->deriveComponent($component, $request)); + } + + public function testQueryParamThrowsWhenParameterOccursMoreThanOnce(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('occurs more than once'); + + $request = new Request('GET', 'https://example.com/path?param=one¶m=two'); + $component = Item::fromHttpValue('"@query-param";name="param"'); + + $this->deriver->deriveComponent($component, $request); } public function testQueryParamThrowsWhenParameterIsMissing(): void diff --git a/tests/Unit/Url/UrlSignerTest.php b/tests/Unit/Url/UrlSignerTest.php index a12018d..ba52a6b 100644 --- a/tests/Unit/Url/UrlSignerTest.php +++ b/tests/Unit/Url/UrlSignerTest.php @@ -23,13 +23,13 @@ protected function setUp(): void $this->signer = new UrlSigner(new HmacSha256('test-secret-key'), $this->requestFactory); } - public function test_sign_appends_signature_params(): void + public function test_sign_appends_signature_param(): void { $signed = $this->signer->sign('https://example.com/path'); $params = $this->extractQueryParams($signed); $this->assertArrayHasKey('signature', $params); - $this->assertArrayHasKey('signature-input', $params); + $this->assertArrayNotHasKey('signature-input', $params); } public function test_sign_preserves_existing_query_params(): void @@ -44,10 +44,11 @@ public function test_sign_preserves_existing_query_params(): void public function test_sign_strips_existing_signature_params(): void { - $signed = $this->signer->sign('https://example.com/path?signature=old&signature-input=old&keep=1'); + $signed = $this->signer->sign('https://example.com/path?signature=old&signature-input=keep&keep=1'); $params = $this->extractQueryParams($signed); $this->assertSame('1', $params['keep']); + $this->assertSame('keep', $params['signature-input']); $this->assertNotSame('old', $params['signature']); } @@ -58,13 +59,18 @@ public function test_sign_preserves_fragment(): void $this->assertStringContainsString('#section', $signed); } - public function test_sign_includes_signature_input_with_components(): void + public function test_sign_uses_configured_components(): void { - $signed = $this->signer->sign('https://example.com/path'); - $params = $this->extractQueryParams($signed); + $pathConfig = new UrlSigningConfig(components: ['@path'], created: 1000000); + $queryConfig = new UrlSigningConfig(components: ['@path', '@query'], created: 1000000); + + $pathSigner = new UrlSigner(new HmacSha256('test-secret-key'), $this->requestFactory, $pathConfig); + $querySigner = new UrlSigner(new HmacSha256('test-secret-key'), $this->requestFactory, $queryConfig); + + $pathParams = $this->extractQueryParams($pathSigner->sign('https://example.com/path?foo=bar')); + $queryParams = $this->extractQueryParams($querySigner->sign('https://example.com/path?foo=bar')); - $this->assertArrayHasKey('signature-input', $params); - $this->assertStringContainsString('@target-uri', $params['signature-input']); + $this->assertNotSame($pathParams['signature'], $queryParams['signature']); } public function test_sign_with_custom_config(): void @@ -72,7 +78,6 @@ public function test_sign_with_custom_config(): void $config = new UrlSigningConfig( components: ['@path', '@query'], signatureParam: 'sig', - signatureInputParam: 'sig-input', tag: 'custom-tag', ); @@ -82,22 +87,26 @@ public function test_sign_with_custom_config(): void $params = $this->extractQueryParams($signed); $this->assertArrayHasKey('sig', $params); - $this->assertArrayHasKey('sig-input', $params); $this->assertArrayNotHasKey('signature', $params); + $this->assertArrayNotHasKey('signature-input', $params); } - public function test_sign_with_expiration(): void + public function test_sign_with_expiration_affects_signature(): void { $config = new UrlSigningConfig(created: 1000000, expiresAfter: 3600); + $configWithoutExpiration = new UrlSigningConfig(created: 1000000); $signer = new UrlSigner(new HmacSha256('test-secret-key'), new RequestFactory(), $config); + $signerWithoutExpiration = new UrlSigner(new HmacSha256('test-secret-key'), new RequestFactory(), $configWithoutExpiration); $signed = $signer->sign('https://example.com/path'); + $signedWithoutExpiration = $signerWithoutExpiration->sign('https://example.com/path'); $params = $this->extractQueryParams($signed); + $paramsWithoutExpiration = $this->extractQueryParams($signedWithoutExpiration); - $this->assertArrayHasKey('signature-input', $params); - $this->assertStringContainsString('expires=1003600', $params['signature-input']); - $this->assertStringContainsString('created=1000000', $params['signature-input']); + $this->assertArrayHasKey('signature', $params); + $this->assertArrayNotHasKey('signature-input', $params); + $this->assertNotSame($paramsWithoutExpiration['signature'], $params['signature']); } public function test_sign_throws_on_empty_components(): void @@ -132,8 +141,8 @@ public function test_sign_with_created_null_omits_created(): void $signed = $signer->sign('https://example.com/path'); $params = $this->extractQueryParams($signed); - $this->assertArrayHasKey('signature-input', $params); - $this->assertStringNotContainsString('created=', $params['signature-input']); + $this->assertArrayHasKey('signature', $params); + $this->assertArrayNotHasKey('signature-input', $params); } public function test_sign_accepts_request_interface(): void @@ -180,9 +189,8 @@ public function test_with_current_time_factory(): void $signed = $signer->sign('https://example.com/path'); $params = $this->extractQueryParams($signed); - $this->assertArrayHasKey('signature-input', $params); - $this->assertStringContainsString('created=', $params['signature-input']); - $this->assertStringContainsString('expires=', $params['signature-input']); + $this->assertArrayHasKey('signature', $params); + $this->assertArrayNotHasKey('signature-input', $params); // Verify the created timestamp is within the expected range $this->assertGreaterThanOrEqual($before, $config->created); diff --git a/tests/Unit/Url/UrlVerifierTest.php b/tests/Unit/Url/UrlVerifierTest.php index 09fddc6..f8124cb 100644 --- a/tests/Unit/Url/UrlVerifierTest.php +++ b/tests/Unit/Url/UrlVerifierTest.php @@ -52,12 +52,17 @@ public function test_verify_throws_when_signature_missing(): void $this->verifier->verify('https://example.com/path'); } - public function test_verify_throws_when_signature_input_missing(): void + public function test_verify_uses_configured_components_without_signature_input_param(): void { - $this->expectException(VerificationException::class); - $this->expectExceptionMessage('Signature input parameter'); + $config = new UrlSigningConfig(components: ['@path'], created: 1000000); + $algorithm = new HmacSha256('test-secret-key'); + $signer = new UrlSigner($algorithm, $this->requestFactory, $config); + $verifier = new UrlVerifier($algorithm, $this->requestFactory, $config); - $this->verifier->verify('https://example.com/path?signature=abc123'); + $signed = $signer->sign('https://example.com/path?foo=bar'); + $tampered = str_replace('foo=bar', 'foo=baz', $signed); + + $this->assertTrue($verifier->verify($tampered)); } public function test_verify_throws_on_tampered_path(): void @@ -109,6 +114,19 @@ public function test_verify_throws_on_wrong_key(): void $wrongVerifier->verify($signed); } + public function test_verify_throws_on_signature_with_invalid_base64url_alphabet(): void + { + $signed = $this->signer->sign('https://example.com/path'); + $tampered = preg_replace('/([?&]signature=)[^&]*/', '$1abcd/', $signed); + + $this->assertNotNull($tampered); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Invalid base64url data'); + + $this->verifier->verify($tampered); + } + public function test_verify_throws_on_expired_signature(): void { $config = new UrlSigningConfig(created: 1000000, expiresAfter: 1); // 1 second — already expired relative to timestamp 1000000 @@ -129,7 +147,7 @@ public function test_verify_throws_on_expired_signature(): void public function test_verify_with_custom_param_names(): void { - $config = new UrlSigningConfig(signatureParam: 'sig', signatureInputParam: 'sig-input', created: 1000000); + $config = new UrlSigningConfig(signatureParam: 'sig', created: 1000000); $algorithm = new HmacSha256('test-secret-key'); $requestFactory = new RequestFactory();