Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
30 changes: 2 additions & 28 deletions src/ComponentDeriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -108,7 +107,7 @@ private function resolveRequest(
*/
private function deriveMethod(RequestInterface $request): string
{
return strtoupper($request->getMethod());
return $request->getMethod();
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
39 changes: 39 additions & 0 deletions src/QueryParamComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace HttpMessageSignatures;

use Bakame\Http\StructuredFields\Item;
use InvalidArgumentException;
use League\Uri\Components\URLSearchParams;
use Psr\Http\Message\RequestInterface;

final class QueryParamComponent
{
/**
* RFC 9421 Section 2.2.8: Derive a specific query parameter value.
*
* @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.2.8
*/
public static function derive(Item $componentId, RequestInterface $request): string
{
$paramName = $componentId->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");
}
}
7 changes: 2 additions & 5 deletions src/Signer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions src/Url/Base64Url.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace HttpMessageSignatures\Url;

use HttpMessageSignatures\Exception\VerificationException;

final class Base64Url
{
public static function encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/**
* @throws VerificationException
*/
public static function decode(string $data): string
{
if ($data === '' || preg_match('/\A[A-Za-z0-9_-]+\z/', $data) !== 1 || strlen($data) % 4 === 1) {
throw new VerificationException('Invalid base64url data');
}

// PHP only supports standard base64 natively, so normalize RFC 4648
// base64url into padded base64 before decoding. If this needs more
// surface area, prefer a focused package like spomky-labs/base64url.
$paddingLength = (4 - strlen($data) % 4) % 4;
$paddedData = $data . str_repeat('=', $paddingLength);
$decoded = base64_decode(strtr($paddedData, '-_', '+/'), true);

if ($decoded === false) {
throw new VerificationException('Invalid base64url data');
}

return $decoded;
}
}
38 changes: 38 additions & 0 deletions src/Url/UrlSignatureInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace HttpMessageSignatures\Url;

use Bakame\Http\StructuredFields\InnerList;
use Bakame\Http\StructuredFields\Parameters;
use HttpMessageSignatures\Algorithm\AlgorithmInterface;
use HttpMessageSignatures\Signer;

final class UrlSignatureInput
{
public static function fromConfig(UrlSigningConfig $config, AlgorithmInterface $algorithm): InnerList
{
$componentItems = array_map(Signer::parseComponentIdentifier(...), $config->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);
}
}
70 changes: 7 additions & 63 deletions src/Url/UrlSigner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
*/
Expand All @@ -57,80 +54,27 @@ 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();
Comment on lines +57 to 60

// 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);

// 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), '+/', '-_'), '=');
}
}
4 changes: 0 additions & 4 deletions src/Url/UrlSigningConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ final class UrlSigningConfig
/**
* @param array<string> $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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -47,7 +44,6 @@ public static function withCurrentTime(
return new self(
components: $components,
signatureParam: $signatureParam,
signatureInputParam: $signatureInputParam,
created: time(),
expiresAfter: $expiresAfter,
keyid: $keyid,
Expand Down
Loading