Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
79
src/Framework/Security/CsrfToken.php
Normal file
79
src/Framework/Security/CsrfToken.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Value object representing a CSRF token.
|
||||
*
|
||||
* Ensures that CSRF tokens are always valid 64-character hexadecimal strings
|
||||
* and provides type safety for CSRF token operations.
|
||||
*/
|
||||
final readonly class CsrfToken
|
||||
{
|
||||
private const int TOKEN_LENGTH = 64;
|
||||
|
||||
public function __construct(private string $value)
|
||||
{
|
||||
$this->validate($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CSRF token from a string value.
|
||||
*/
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token value as a string.
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token value as a string (magic method).
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this token equals another token.
|
||||
*/
|
||||
public function equals(CsrfToken $other): bool
|
||||
{
|
||||
return hash_equals($this->value, $other->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this token equals a string value (time-safe comparison).
|
||||
*/
|
||||
public function equalsString(string $value): bool
|
||||
{
|
||||
return hash_equals($this->value, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the token value is a valid CSRF token format.
|
||||
*/
|
||||
private function validate(string $value): void
|
||||
{
|
||||
if (strlen($value) !== self::TOKEN_LENGTH) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('CSRF token must be exactly %d characters long, got %d', self::TOKEN_LENGTH, strlen($value))
|
||||
);
|
||||
}
|
||||
|
||||
if (! ctype_xdigit($value)) {
|
||||
throw new InvalidArgumentException('CSRF token must be a hexadecimal string');
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/Framework/Security/CsrfTokenData.php
Normal file
148
src/Framework/Security/CsrfTokenData.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Clock;
|
||||
|
||||
/**
|
||||
* Value Object for CSRF token data with creation time and usage tracking
|
||||
*/
|
||||
final readonly class CsrfTokenData
|
||||
{
|
||||
public function __construct(
|
||||
public CsrfToken $token,
|
||||
public Timestamp $createdAt,
|
||||
public ?Timestamp $usedAt = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token has expired based on lifetime
|
||||
*/
|
||||
public function isExpired(Clock $clock, Duration $lifetime): bool
|
||||
{
|
||||
return $clock->time()->diff($this->createdAt)->greaterThan($lifetime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token was used recently within the given window
|
||||
*/
|
||||
public function wasUsedRecently(Clock $clock, Duration $window): bool
|
||||
{
|
||||
if (! $this->usedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $clock->time()->diff($this->usedAt)->lessThan($window);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token should be kept (not expired or used recently)
|
||||
*/
|
||||
public function shouldKeep(Clock $clock, Duration $lifetime, Duration $usedWindow): bool
|
||||
{
|
||||
return ! $this->isExpired($clock, $lifetime) || $this->wasUsedRecently($clock, $usedWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the token as used at the current time
|
||||
*/
|
||||
public function markAsUsed(Clock $clock): self
|
||||
{
|
||||
return new self($this->token, $this->createdAt, $clock->time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the age of the token
|
||||
*/
|
||||
public function getAge(Clock $clock): Duration
|
||||
{
|
||||
return $clock->time()->diff($this->createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this token matches the given token string
|
||||
*/
|
||||
public function matches(string $tokenString): bool
|
||||
{
|
||||
return $this->token->toString() === $tokenString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to array for session storage
|
||||
* @return array<string, string|float|null>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'token' => $this->token->toString(),
|
||||
'created_at' => $this->createdAt->toFloat(),
|
||||
'used_at' => $this->usedAt?->toFloat(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize from array (handles both new and legacy formats)
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data, Clock $clock): self
|
||||
{
|
||||
$token = CsrfToken::fromString($data['token']);
|
||||
|
||||
// Handle Timestamp objects or serialized data
|
||||
$createdAtRaw = $data['created_at'];
|
||||
if (is_array($createdAtRaw)) {
|
||||
// Handle serialized Timestamp object
|
||||
$createdAt = Timestamp::fromFloat($createdAtRaw['microTimestamp'] ?? $clock->time()->toFloat());
|
||||
} elseif (is_float($createdAtRaw) || is_int($createdAtRaw)) {
|
||||
$createdAt = Timestamp::fromFloat((float) $createdAtRaw);
|
||||
} elseif ($createdAtRaw instanceof Timestamp) {
|
||||
// Already a Timestamp object
|
||||
$createdAt = $createdAtRaw;
|
||||
} else {
|
||||
// Fallback to current time if invalid type
|
||||
$createdAt = Timestamp::fromFloat($clock->time()->toFloat());
|
||||
}
|
||||
|
||||
$usedAt = null;
|
||||
if (isset($data['used_at'])) {
|
||||
$usedAtData = $data['used_at'];
|
||||
if (is_array($usedAtData)) {
|
||||
$usedAt = Timestamp::fromFloat($usedAtData['microTimestamp'] ?? $clock->time()->toFloat());
|
||||
} elseif (is_float($usedAtData) || is_int($usedAtData)) {
|
||||
$usedAt = Timestamp::fromFloat((float) $usedAtData);
|
||||
} elseif ($usedAtData instanceof Timestamp) {
|
||||
$usedAt = $usedAtData;
|
||||
}
|
||||
}
|
||||
|
||||
return new self($token, $createdAt, $usedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new token data instance with current timestamp
|
||||
*/
|
||||
public static function create(CsrfToken $token, Clock $clock): self
|
||||
{
|
||||
return new self($token, $clock->time());
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation for debugging
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
$usedAt = $this->usedAt ? $this->usedAt->format('Y-m-d H:i:s') : 'never';
|
||||
|
||||
return sprintf(
|
||||
'CsrfTokenData[token=%s, created=%s, used=%s]',
|
||||
substr($this->token->toString(), 0, 8) . '...',
|
||||
$this->createdAt->format('Y-m-d H:i:s'),
|
||||
$usedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/Framework/Security/CsrfTokenGenerator.php
Normal file
27
src/Framework/Security/CsrfTokenGenerator.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security;
|
||||
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
|
||||
final readonly class CsrfTokenGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private RandomGenerator $random
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token.
|
||||
*
|
||||
* @return CsrfToken New CSRF token value object
|
||||
*/
|
||||
public function generate(): CsrfToken
|
||||
{
|
||||
$tokenValue = bin2hex($this->random->bytes(32));
|
||||
|
||||
return CsrfToken::fromString($tokenValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\DateTime\Clock;
|
||||
|
||||
/**
|
||||
* Entity Manager based implementation of signing key repository
|
||||
*/
|
||||
final class EntityManagerSigningKeyRepository implements SigningKeyRepository
|
||||
{
|
||||
private const CACHE_PREFIX = 'signing_key:';
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManager $entityManager,
|
||||
private readonly Clock $clock,
|
||||
private readonly Cache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function findByKeyId(string $keyId): ?SigningKey
|
||||
{
|
||||
// Try cache first
|
||||
$cached = $this->cache->get(self::CACHE_PREFIX . $keyId);
|
||||
if ($cached instanceof SigningKey) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$key = $this->entityManager->find(SigningKey::class, $keyId);
|
||||
|
||||
// Cache the result if found
|
||||
if ($key !== null) {
|
||||
$this->cache->set(self::CACHE_PREFIX . $keyId, $key, self::CACHE_TTL);
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function store(SigningKey $key): void
|
||||
{
|
||||
// Set timestamps if not set
|
||||
$now = $this->clock->now();
|
||||
|
||||
if ($key->createdAt === null || $key->updatedAt === null) {
|
||||
$key = new SigningKey(
|
||||
keyId: $key->keyId,
|
||||
keyMaterial: $key->keyMaterial,
|
||||
algorithm: $key->algorithm,
|
||||
expiresAt: $key->expiresAt,
|
||||
isActive: $key->isActive,
|
||||
createdAt: $key->createdAt ?? $now,
|
||||
updatedAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($key);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Invalidate cache
|
||||
$this->cache->delete(self::CACHE_PREFIX . $key->keyId);
|
||||
}
|
||||
|
||||
public function remove(string $keyId): void
|
||||
{
|
||||
$key = $this->findByKeyId($keyId);
|
||||
if ($key !== null) {
|
||||
$this->entityManager->remove($key);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
$this->cache->delete(self::CACHE_PREFIX . $keyId);
|
||||
}
|
||||
|
||||
public function getAllActive(): array
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Use EntityManager's query capabilities
|
||||
$query = $this->entityManager->createQuery(
|
||||
"SELECT k FROM " . SigningKey::class . " k
|
||||
WHERE k.isActive = true
|
||||
AND (k.expiresAt IS NULL OR k.expiresAt > :now)
|
||||
ORDER BY k.createdAt DESC"
|
||||
);
|
||||
$query->setParameter('now', $now);
|
||||
|
||||
return $query->getResult();
|
||||
}
|
||||
|
||||
public function rotateKey(string $keyId, SigningKey $newKey): void
|
||||
{
|
||||
$this->entityManager->beginTransaction();
|
||||
|
||||
try {
|
||||
// Deactivate old key
|
||||
$oldKey = $this->findByKeyId($keyId);
|
||||
if ($oldKey !== null) {
|
||||
$deactivatedKey = new SigningKey(
|
||||
keyId: $oldKey->keyId,
|
||||
keyMaterial: $oldKey->keyMaterial,
|
||||
algorithm: $oldKey->algorithm,
|
||||
expiresAt: $oldKey->expiresAt,
|
||||
isActive: false,
|
||||
createdAt: $oldKey->createdAt,
|
||||
updatedAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->entityManager->persist($deactivatedKey);
|
||||
}
|
||||
|
||||
// Store new key
|
||||
$this->store($newKey);
|
||||
|
||||
$this->entityManager->commit();
|
||||
|
||||
// Invalidate cache for old key
|
||||
$this->cache->delete(self::CACHE_PREFIX . $keyId);
|
||||
} catch (\Exception $e) {
|
||||
$this->entityManager->rollback();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys expiring within the specified time window
|
||||
*/
|
||||
public function getExpiringKeys(int $withinSeconds = 86400): array
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
$expiryThreshold = $now->modify("+{$withinSeconds} seconds");
|
||||
|
||||
$query = $this->entityManager->createQuery(
|
||||
"SELECT k FROM " . SigningKey::class . " k
|
||||
WHERE k.isActive = true
|
||||
AND k.expiresAt IS NOT NULL
|
||||
AND k.expiresAt > :now
|
||||
AND k.expiresAt <= :threshold
|
||||
ORDER BY k.expiresAt ASC"
|
||||
);
|
||||
$query->setParameter('now', $now);
|
||||
$query->setParameter('threshold', $expiryThreshold);
|
||||
|
||||
return $query->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired keys
|
||||
*/
|
||||
public function cleanupExpired(): int
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
|
||||
$query = $this->entityManager->createQuery(
|
||||
"DELETE FROM " . SigningKey::class . " k
|
||||
WHERE k.expiresAt IS NOT NULL
|
||||
AND k.expiresAt < :now"
|
||||
);
|
||||
$query->setParameter('now', $now);
|
||||
|
||||
return $query->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\HttpClientMiddleware;
|
||||
|
||||
/**
|
||||
* HTTP Client middleware for automatically signing outgoing requests
|
||||
*/
|
||||
final readonly class HttpClientSigningMiddleware implements HttpClientMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private SigningKey $signingKey,
|
||||
private array $headersToSign = ['(request-target)', 'host', 'date', 'digest'],
|
||||
private bool $addDigest = true,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ClientRequest $request, callable $next): ClientResponse
|
||||
{
|
||||
$signedRequest = $this->signRequest($request);
|
||||
|
||||
return $next($signedRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the outgoing request
|
||||
*/
|
||||
private function signRequest(ClientRequest $request): ClientRequest
|
||||
{
|
||||
$headers = $request->headers;
|
||||
|
||||
// Add Date header if not present
|
||||
if ($headers->getFirst('Date') === null) {
|
||||
$headers = $headers->with('Date', gmdate('D, d M Y H:i:s T'));
|
||||
}
|
||||
|
||||
// Add Host header if not present
|
||||
if ($headers->getFirst('Host') === null) {
|
||||
$parsedUrl = parse_url($request->url);
|
||||
$host = $parsedUrl['host'] ?? '';
|
||||
if (isset($parsedUrl['port']) && ! in_array($parsedUrl['port'], [80, 443])) {
|
||||
$host .= ':' . $parsedUrl['port'];
|
||||
}
|
||||
$headers = $headers->with('Host', $host);
|
||||
}
|
||||
|
||||
// Add Digest header for requests with body
|
||||
if ($this->addDigest && $this->hasBody($request)) {
|
||||
$body = is_string($request->body) ? $request->body : json_encode($request->body);
|
||||
$digest = $this->createDigest($body);
|
||||
$headers = $headers->with('Digest', $digest);
|
||||
}
|
||||
|
||||
// Create a temporary HttpRequest for signing
|
||||
$httpRequest = new \App\Framework\Http\HttpRequest(
|
||||
method: $request->method,
|
||||
headers: $headers,
|
||||
body: is_string($request->body) ? $request->body : json_encode($request->body),
|
||||
path: parse_url($request->url, PHP_URL_PATH) ?: '/',
|
||||
queryParams: $this->parseQueryParams($request->url),
|
||||
);
|
||||
|
||||
// Create signature
|
||||
$signingString = new SigningString($httpRequest);
|
||||
$stringToSign = $signingString->build($this->headersToSign);
|
||||
|
||||
$signature = $this->createSignature($stringToSign);
|
||||
|
||||
$requestSignature = new RequestSignature(
|
||||
signature: $signature,
|
||||
keyId: $this->signingKey->keyId,
|
||||
algorithm: $this->signingKey->algorithm,
|
||||
headers: $this->headersToSign,
|
||||
timestamp: new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
// Add signature header
|
||||
$headers = $headers->with('Signature', $requestSignature->toHttpSignatureHeader());
|
||||
|
||||
return $request->with(['headers' => $headers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request has a body
|
||||
*/
|
||||
private function hasBody(ClientRequest $request): bool
|
||||
{
|
||||
if (is_string($request->body)) {
|
||||
return $request->body !== '';
|
||||
}
|
||||
|
||||
return ! empty($request->body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create digest for request body
|
||||
*/
|
||||
private function createDigest(string $body): string
|
||||
{
|
||||
$hash = hash('sha256', $body, true);
|
||||
|
||||
return 'SHA256=' . base64_encode($hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query parameters from URL
|
||||
*/
|
||||
private function parseQueryParams(string $url): array
|
||||
{
|
||||
$parsedUrl = parse_url($url);
|
||||
if (! isset($parsedUrl['query'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
parse_str($parsedUrl['query'], $params);
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signature using the signing key
|
||||
*/
|
||||
private function createSignature(string $data): string
|
||||
{
|
||||
return match ($this->signingKey->algorithm) {
|
||||
SigningAlgorithm::HMAC_SHA256 => $this->createHmacSignature($data, 'sha256'),
|
||||
SigningAlgorithm::HMAC_SHA512 => $this->createHmacSignature($data, 'sha512'),
|
||||
SigningAlgorithm::RSA_SHA256 => $this->createRsaSignature($data),
|
||||
SigningAlgorithm::ED25519 => throw new \InvalidArgumentException('ED25519 not yet implemented'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HMAC signature
|
||||
*/
|
||||
private function createHmacSignature(string $data, string $algorithm): string
|
||||
{
|
||||
$signature = hash_hmac($algorithm, $data, $this->signingKey->keyMaterial, true);
|
||||
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RSA signature
|
||||
*/
|
||||
private function createRsaSignature(string $data): string
|
||||
{
|
||||
$privateKey = openssl_pkey_get_private($this->signingKey->keyMaterial);
|
||||
if ($privateKey === false) {
|
||||
throw new \RuntimeException('Failed to load RSA private key');
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
$result = openssl_sign($data, $signature, $privateKey, $this->signingKey->algorithm->getOpenSSLSignatureAlgorithm());
|
||||
|
||||
if (! $result) {
|
||||
throw new \RuntimeException('Failed to create RSA signature');
|
||||
}
|
||||
|
||||
return base64_encode($signature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
/**
|
||||
* In-memory implementation of signing key repository for development/testing
|
||||
*/
|
||||
final class InMemorySigningKeyRepository implements SigningKeyRepository
|
||||
{
|
||||
/** @var array<string, SigningKey> */
|
||||
private array $keys = [];
|
||||
|
||||
public function findByKeyId(string $keyId): ?SigningKey
|
||||
{
|
||||
return $this->keys[$keyId] ?? null;
|
||||
}
|
||||
|
||||
public function store(SigningKey $key): void
|
||||
{
|
||||
$this->keys[$key->keyId] = $key;
|
||||
}
|
||||
|
||||
public function remove(string $keyId): void
|
||||
{
|
||||
unset($this->keys[$keyId]);
|
||||
}
|
||||
|
||||
public function getAllActive(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->keys,
|
||||
fn (SigningKey $key) => $key->isValid()
|
||||
);
|
||||
}
|
||||
|
||||
public function rotateKey(string $keyId, SigningKey $newKey): void
|
||||
{
|
||||
if (isset($this->keys[$keyId])) {
|
||||
$this->remove($keyId);
|
||||
}
|
||||
$this->store($newKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a default key for testing purposes
|
||||
*/
|
||||
public function addDefaultTestKey(): SigningKey
|
||||
{
|
||||
$key = SigningKey::generateHmac(
|
||||
keyId: 'test-key-1',
|
||||
algorithm: SigningAlgorithm::HMAC_SHA256
|
||||
);
|
||||
|
||||
$this->store($key);
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
|
||||
final class CreateSigningKeysTable implements Migration
|
||||
{
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS signing_keys (
|
||||
key_id VARCHAR(255) NOT NULL,
|
||||
key_material TEXT NOT NULL,
|
||||
algorithm VARCHAR(50) NOT NULL,
|
||||
expires_at TIMESTAMP NULL DEFAULT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY (key_id),
|
||||
INDEX idx_signing_keys_active_expires (is_active, expires_at),
|
||||
INDEX idx_signing_keys_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL;
|
||||
|
||||
$connection->query($sql);
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$connection->execute("DROP TABLE IF EXISTS signing_keys");
|
||||
}
|
||||
|
||||
public function getVersion(): MigrationVersion
|
||||
{
|
||||
return MigrationVersion::fromTimestamp("2024_01_20_000001");
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Create signing keys table for request signing functionality";
|
||||
}
|
||||
}
|
||||
95
src/Framework/Security/RequestSigning/RequestSignature.php
Normal file
95
src/Framework/Security/RequestSigning/RequestSignature.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
/**
|
||||
* Represents a request signature with metadata
|
||||
*/
|
||||
final readonly class RequestSignature
|
||||
{
|
||||
public function __construct(
|
||||
public string $signature,
|
||||
public string $keyId,
|
||||
public SigningAlgorithm $algorithm,
|
||||
public array $headers,
|
||||
public \DateTimeImmutable $timestamp,
|
||||
public ?int $expires = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the signature has expired
|
||||
*/
|
||||
public function isExpired(?\DateTimeImmutable $now = null): bool
|
||||
{
|
||||
if ($this->expires === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now ??= new \DateTimeImmutable();
|
||||
$expirationTime = $this->timestamp->modify("+{$this->expires} seconds");
|
||||
|
||||
return $now > $expirationTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from HTTP Signature header
|
||||
* Format: keyId="key-1",algorithm="hmac-sha256",headers="(request-target) host date",signature="base64signature"
|
||||
*/
|
||||
public static function fromHttpSignatureHeader(string $header): self
|
||||
{
|
||||
$matches = [];
|
||||
if (! preg_match_all('/(\w+)="([^"]+)"/', $header, $matches, PREG_SET_ORDER)) {
|
||||
throw new \InvalidArgumentException('Invalid HTTP Signature header format');
|
||||
}
|
||||
|
||||
$params = [];
|
||||
foreach ($matches as $match) {
|
||||
$params[$match[1]] = $match[2];
|
||||
}
|
||||
|
||||
$requiredParams = ['keyId', 'algorithm', 'signature'];
|
||||
foreach ($requiredParams as $param) {
|
||||
if (! isset($params[$param])) {
|
||||
throw new \InvalidArgumentException("Missing required parameter: {$param}");
|
||||
}
|
||||
}
|
||||
|
||||
$algorithm = SigningAlgorithm::tryFrom($params['algorithm']);
|
||||
if ($algorithm === null) {
|
||||
throw new \InvalidArgumentException('Unsupported signing algorithm: ' . $params['algorithm']);
|
||||
}
|
||||
|
||||
$headers = isset($params['headers']) ? explode(' ', $params['headers']) : ['date'];
|
||||
|
||||
return new self(
|
||||
signature: $params['signature'],
|
||||
keyId: $params['keyId'],
|
||||
algorithm: $algorithm,
|
||||
headers: $headers,
|
||||
timestamp: new \DateTimeImmutable(),
|
||||
expires: isset($params['expires']) ? (int)$params['expires'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to HTTP Signature header format
|
||||
*/
|
||||
public function toHttpSignatureHeader(): string
|
||||
{
|
||||
$parts = [
|
||||
'keyId="' . $this->keyId . '"',
|
||||
'algorithm="' . $this->algorithm->value . '"',
|
||||
'headers="' . implode(' ', $this->headers) . '"',
|
||||
'signature="' . $this->signature . '"',
|
||||
];
|
||||
|
||||
if ($this->expires !== null) {
|
||||
$parts[] = 'expires="' . $this->expires . '"';
|
||||
}
|
||||
|
||||
return implode(',', $parts);
|
||||
}
|
||||
}
|
||||
132
src/Framework/Security/RequestSigning/RequestSigner.php
Normal file
132
src/Framework/Security/RequestSigning/RequestSigner.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestManipulator;
|
||||
|
||||
/**
|
||||
* Signs HTTP requests using various cryptographic algorithms
|
||||
*/
|
||||
final readonly class RequestSigner
|
||||
{
|
||||
public function __construct(
|
||||
private RequestManipulator $requestManipulator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a request using the specified key and headers
|
||||
*/
|
||||
public function sign(
|
||||
Request $request,
|
||||
SigningKey $key,
|
||||
?array $headers = null,
|
||||
?int $expires = null,
|
||||
): RequestSignature {
|
||||
if (! $key->isValid()) {
|
||||
throw new \InvalidArgumentException('Signing key is not valid');
|
||||
}
|
||||
|
||||
$headers ??= SigningString::getDefaultHeaders();
|
||||
$signingString = new SigningString($request);
|
||||
$stringToSign = $signingString->build($headers);
|
||||
|
||||
$signature = $this->createSignature($stringToSign, $key);
|
||||
|
||||
return new RequestSignature(
|
||||
signature: $signature,
|
||||
keyId: $key->keyId,
|
||||
algorithm: $key->algorithm,
|
||||
headers: $headers,
|
||||
timestamp: new \DateTimeImmutable(),
|
||||
expires: $expires,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signature using the appropriate algorithm
|
||||
*/
|
||||
private function createSignature(string $data, SigningKey $key): string
|
||||
{
|
||||
return match ($key->algorithm) {
|
||||
SigningAlgorithm::HMAC_SHA256 => $this->createHmacSignature($data, $key, 'sha256'),
|
||||
SigningAlgorithm::HMAC_SHA512 => $this->createHmacSignature($data, $key, 'sha512'),
|
||||
SigningAlgorithm::RSA_SHA256 => $this->createRsaSignature($data, $key),
|
||||
SigningAlgorithm::ED25519 => throw new \InvalidArgumentException('ED25519 not yet implemented'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HMAC signature
|
||||
*/
|
||||
private function createHmacSignature(string $data, SigningKey $key, string $algorithm): string
|
||||
{
|
||||
$signature = hash_hmac($algorithm, $data, $key->keyMaterial, true);
|
||||
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RSA signature
|
||||
*/
|
||||
private function createRsaSignature(string $data, SigningKey $key): string
|
||||
{
|
||||
$privateKey = openssl_pkey_get_private($key->keyMaterial);
|
||||
if ($privateKey === false) {
|
||||
throw new \RuntimeException('Failed to load RSA private key');
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
$result = openssl_sign($data, $signature, $privateKey, $key->algorithm->getOpenSSLSignatureAlgorithm());
|
||||
|
||||
if (! $result) {
|
||||
throw new \RuntimeException('Failed to create RSA signature');
|
||||
}
|
||||
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a digest header for request body
|
||||
*/
|
||||
public function createDigest(string $body, string $algorithm = 'sha256'): string
|
||||
{
|
||||
$hash = hash($algorithm, $body, true);
|
||||
|
||||
return strtoupper($algorithm) . '=' . base64_encode($hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a request and return new request with signature headers
|
||||
*/
|
||||
public function signRequest(
|
||||
HttpRequest $request,
|
||||
SigningKey $key,
|
||||
?array $headers = null,
|
||||
?int $expires = null,
|
||||
): HttpRequest {
|
||||
$modifiedRequest = $request;
|
||||
|
||||
// Add digest header if request has body
|
||||
if ($request->body !== '') {
|
||||
$digest = $this->createDigest($request->body);
|
||||
$modifiedRequest = $this->requestManipulator->withHeader($modifiedRequest, 'Digest', $digest);
|
||||
}
|
||||
|
||||
// Add date header if not present
|
||||
if ($modifiedRequest->headers->getFirst('Date') === null) {
|
||||
$date = gmdate('D, d M Y H:i:s T');
|
||||
$modifiedRequest = $this->requestManipulator->withHeader($modifiedRequest, 'Date', $date);
|
||||
}
|
||||
|
||||
// Create signature
|
||||
$signature = $this->sign($modifiedRequest, $key, $headers, $expires);
|
||||
|
||||
// Add signature header and return final request
|
||||
return $this->requestManipulator->withHeader($modifiedRequest, 'Signature', $signature->toHttpSignatureHeader());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
/**
|
||||
* Configuration for request signing functionality
|
||||
*/
|
||||
final readonly class RequestSigningConfig
|
||||
{
|
||||
public function __construct(
|
||||
public bool $enabled = false,
|
||||
public bool $requireSignature = false,
|
||||
public array $exemptPaths = ['/health', '/metrics'],
|
||||
public array $defaultHeaders = ['(request-target)', 'host', 'date'],
|
||||
public array $securityHeaders = ['(request-target)', '(created)', 'host', 'date', 'digest'],
|
||||
public int $maxClockSkew = 300, // 5 minutes
|
||||
public int $defaultExpiry = 3600, // 1 hour
|
||||
public SigningAlgorithm $defaultAlgorithm = SigningAlgorithm::HMAC_SHA256,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create configuration from environment variables
|
||||
*/
|
||||
public static function fromEnvironment(): self
|
||||
{
|
||||
$enabled = filter_var($_ENV['REQUEST_SIGNING_ENABLED'] ?? 'false', FILTER_VALIDATE_BOOLEAN);
|
||||
$requireSignature = filter_var($_ENV['REQUEST_SIGNING_REQUIRED'] ?? 'false', FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$exemptPaths = [];
|
||||
if (isset($_ENV['REQUEST_SIGNING_EXEMPT_PATHS'])) {
|
||||
$exemptPaths = array_filter(array_map('trim', explode(',', $_ENV['REQUEST_SIGNING_EXEMPT_PATHS'])));
|
||||
}
|
||||
|
||||
$defaultHeaders = ['(request-target)', 'host', 'date'];
|
||||
if (isset($_ENV['REQUEST_SIGNING_DEFAULT_HEADERS'])) {
|
||||
$defaultHeaders = array_filter(array_map('trim', explode(',', $_ENV['REQUEST_SIGNING_DEFAULT_HEADERS'])));
|
||||
}
|
||||
|
||||
$maxClockSkew = (int) ($_ENV['REQUEST_SIGNING_MAX_CLOCK_SKEW'] ?? 300);
|
||||
$defaultExpiry = (int) ($_ENV['REQUEST_SIGNING_DEFAULT_EXPIRY'] ?? 3600);
|
||||
|
||||
$algorithm = SigningAlgorithm::tryFrom($_ENV['REQUEST_SIGNING_ALGORITHM'] ?? 'hmac-sha256')
|
||||
?? SigningAlgorithm::HMAC_SHA256;
|
||||
|
||||
return new self(
|
||||
enabled: $enabled,
|
||||
requireSignature: $requireSignature,
|
||||
exemptPaths: $exemptPaths,
|
||||
defaultHeaders: $defaultHeaders,
|
||||
maxClockSkew: $maxClockSkew,
|
||||
defaultExpiry: $defaultExpiry,
|
||||
defaultAlgorithm: $algorithm,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create development configuration
|
||||
*/
|
||||
public static function development(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: true,
|
||||
requireSignature: false,
|
||||
exemptPaths: ['/health', '/metrics', '/debug'],
|
||||
defaultHeaders: ['(request-target)', 'host', 'date'],
|
||||
maxClockSkew: 600, // 10 minutes for development
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create production configuration
|
||||
*/
|
||||
public static function production(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: true,
|
||||
requireSignature: true,
|
||||
exemptPaths: ['/health', '/metrics'],
|
||||
defaultHeaders: ['(request-target)', '(created)', 'host', 'date', 'digest'],
|
||||
maxClockSkew: 300, // 5 minutes
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Http\RequestManipulator;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Initializer for request signing components
|
||||
*/
|
||||
final readonly class RequestSigningInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function __invoke(): RequestSigningService
|
||||
{
|
||||
// Get configuration
|
||||
$config = $this->getConfig();
|
||||
|
||||
// Create key repository based on environment
|
||||
$keyRepository = $this->createKeyRepository($config);
|
||||
|
||||
// Initialize default keys if needed
|
||||
$this->initializeDefaultKeys($keyRepository, $config);
|
||||
|
||||
// Create signer and verifier
|
||||
$signer = new RequestSigner(
|
||||
$this->container->get(RequestManipulator::class)
|
||||
);
|
||||
|
||||
$verifier = new RequestVerifier($keyRepository);
|
||||
|
||||
$this->container->instance(SigningKeyRepository::class, $keyRepository);
|
||||
|
||||
// Create main service
|
||||
return new RequestSigningService(
|
||||
$signer,
|
||||
$verifier,
|
||||
$keyRepository,
|
||||
$config,
|
||||
$this->container->get(Logger::class),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration from environment
|
||||
*/
|
||||
private function getConfig(): RequestSigningConfig
|
||||
{
|
||||
$isProduction = ($_ENV['APP_ENV'] ?? 'development') === 'production';
|
||||
|
||||
return $isProduction
|
||||
? RequestSigningConfig::production()
|
||||
: RequestSigningConfig::development();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create appropriate key repository based on configuration
|
||||
*/
|
||||
private function createKeyRepository(RequestSigningConfig $config): SigningKeyRepository
|
||||
{
|
||||
$isProduction = ($_ENV['APP_ENV'] ?? 'development') === 'production';
|
||||
|
||||
if ($isProduction && $this->container->has(EntityManager::class)) {
|
||||
return new EntityManagerSigningKeyRepository(
|
||||
$this->container->get(EntityManager::class),
|
||||
$this->container->get(Clock::class),
|
||||
$this->container->get(Cache::class),
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to in-memory repository for development
|
||||
return new InMemorySigningKeyRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default keys for development/testing
|
||||
*/
|
||||
private function initializeDefaultKeys(SigningKeyRepository $keyRepository, RequestSigningConfig $config): void
|
||||
{
|
||||
if (! $config->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a default development key
|
||||
if (($_ENV['APP_ENV'] ?? 'development') === 'development') {
|
||||
if ($keyRepository instanceof InMemorySigningKeyRepository) {
|
||||
$keyRepository->addDefaultTestKey();
|
||||
}
|
||||
}
|
||||
|
||||
// Load keys from environment variables
|
||||
$this->loadKeysFromEnvironment($keyRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load signing keys from environment variables
|
||||
*/
|
||||
private function loadKeysFromEnvironment(SigningKeyRepository $keyRepository): void
|
||||
{
|
||||
// Load HMAC keys from environment
|
||||
$hmacKeys = $_ENV['REQUEST_SIGNING_HMAC_KEYS'] ?? '';
|
||||
if ($hmacKeys) {
|
||||
$keys = json_decode($hmacKeys, true);
|
||||
if (is_array($keys)) {
|
||||
foreach ($keys as $keyData) {
|
||||
if (isset($keyData['key_id'], $keyData['secret'])) {
|
||||
$algorithm = isset($keyData['algorithm'])
|
||||
? SigningAlgorithm::tryFrom($keyData['algorithm']) ?? SigningAlgorithm::HMAC_SHA256
|
||||
: SigningAlgorithm::HMAC_SHA256;
|
||||
|
||||
$expiresAt = isset($keyData['expires_at'])
|
||||
? new \DateTimeImmutable($keyData['expires_at'])
|
||||
: null;
|
||||
|
||||
$key = SigningKey::createHmac(
|
||||
$keyData['key_id'],
|
||||
$keyData['secret'],
|
||||
$algorithm,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
$keyRepository->store($key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load RSA keys from environment
|
||||
$rsaKeys = $_ENV['REQUEST_SIGNING_RSA_KEYS'] ?? '';
|
||||
if ($rsaKeys) {
|
||||
$keys = json_decode($rsaKeys, true);
|
||||
if (is_array($keys)) {
|
||||
foreach ($keys as $keyData) {
|
||||
if (isset($keyData['key_id'], $keyData['private_key'])) {
|
||||
$expiresAt = isset($keyData['expires_at'])
|
||||
? new \DateTimeImmutable($keyData['expires_at'])
|
||||
: null;
|
||||
|
||||
$key = SigningKey::createRsa(
|
||||
$keyData['key_id'],
|
||||
$keyData['private_key'],
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
$keyRepository->store($key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Middleware for validating HTTP request signatures
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, -10)]
|
||||
final readonly class RequestSigningMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private RequestVerifier $verifier,
|
||||
private Logger $logger,
|
||||
private bool $requireSignature = false,
|
||||
private array $exemptPaths = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
|
||||
// Check if path is exempt from signature validation
|
||||
if ($this->isExemptPath($request->path)) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
// Check if signature header is present
|
||||
$signatureHeader = $request->headers->getFirst('Signature');
|
||||
|
||||
if ($signatureHeader === null) {
|
||||
if ($this->requireSignature) {
|
||||
$this->logger->warning('Request signature required but not provided', [
|
||||
'path' => $request->path,
|
||||
'method' => $request->method->value,
|
||||
'remote_addr' => $request->server->get('REMOTE_ADDR'),
|
||||
]);
|
||||
|
||||
return $context->withResponse(
|
||||
new HttpResponse(
|
||||
status: Status::UNAUTHORIZED,
|
||||
body: json_encode(['error' => 'Request signature required']),
|
||||
headers: ['Content-Type' => 'application/json']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Signature not required, continue
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
$result = $this->verifier->verify($request);
|
||||
|
||||
if ($result->isFailure()) {
|
||||
$this->logger->warning('Request signature verification failed', [
|
||||
'error' => $result->errorMessage,
|
||||
'path' => $request->path,
|
||||
'method' => $request->method->value,
|
||||
'remote_addr' => $request->server->get('REMOTE_ADDR'),
|
||||
]);
|
||||
|
||||
return $context->withResponse(
|
||||
new HttpResponse(
|
||||
status: Status::UNAUTHORIZED,
|
||||
body: json_encode(['error' => 'Invalid request signature']),
|
||||
headers: ['Content-Type' => 'application/json']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify digest if present
|
||||
if (! $this->verifier->verifyDigest($request)) {
|
||||
$this->logger->warning('Request digest verification failed', [
|
||||
'path' => $request->path,
|
||||
'method' => $request->method->value,
|
||||
'remote_addr' => $request->server->get('REMOTE_ADDR'),
|
||||
]);
|
||||
|
||||
return $context->withResponse(
|
||||
new HttpResponse(
|
||||
status: Status::UNAUTHORIZED,
|
||||
body: json_encode(['error' => 'Invalid request digest']),
|
||||
headers: ['Content-Type' => 'application/json']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Store verification result in request state for use by other middleware/controllers
|
||||
$stateManager->set('signature_verification', $result);
|
||||
|
||||
$this->logger->info('Request signature verified successfully', [
|
||||
'key_id' => $result->signature->keyId,
|
||||
'algorithm' => $result->signature->algorithm->value,
|
||||
'path' => $request->path,
|
||||
]);
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request path is exempt from signature validation
|
||||
*/
|
||||
private function isExemptPath(string $path): bool
|
||||
{
|
||||
foreach ($this->exemptPaths as $exemptPath) {
|
||||
if (str_starts_with($path, $exemptPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
192
src/Framework/Security/RequestSigning/RequestSigningService.php
Normal file
192
src/Framework/Security/RequestSigning/RequestSigningService.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* High-level service for request signing functionality
|
||||
*/
|
||||
final readonly class RequestSigningService
|
||||
{
|
||||
public function __construct(
|
||||
private RequestSigner $signer,
|
||||
private RequestVerifier $verifier,
|
||||
private SigningKeyRepository $keyRepository,
|
||||
private RequestSigningConfig $config,
|
||||
private Logger $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an outgoing HTTP request
|
||||
*/
|
||||
public function signOutgoingRequest(
|
||||
HttpRequest $request,
|
||||
?string $keyId = null,
|
||||
?array $headers = null,
|
||||
?int $expires = null,
|
||||
): HttpRequest {
|
||||
if (! $this->config->enabled) {
|
||||
return $request;
|
||||
}
|
||||
|
||||
$key = $this->getSigningKey($keyId);
|
||||
if ($key === null) {
|
||||
$this->logger->warning('No signing key available for outgoing request', [
|
||||
'key_id' => $keyId,
|
||||
'path' => $request->path,
|
||||
]);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
$headers ??= $this->config->defaultHeaders;
|
||||
$expires ??= $this->config->defaultExpiry;
|
||||
|
||||
try {
|
||||
return $this->signer->signRequest($request, $key, $headers, $expires);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to sign outgoing request', [
|
||||
'error' => $e->getMessage(),
|
||||
'key_id' => $key->keyId,
|
||||
'path' => $request->path,
|
||||
]);
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an incoming HTTP request
|
||||
*/
|
||||
public function verifyIncomingRequest(HttpRequest $request): VerificationResult
|
||||
{
|
||||
if (! $this->config->enabled) {
|
||||
return VerificationResult::success(
|
||||
new RequestSignature('', '', SigningAlgorithm::HMAC_SHA256, [], new \DateTimeImmutable()),
|
||||
SigningKey::generateHmac('disabled', SigningAlgorithm::HMAC_SHA256)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->verifier->verify($request);
|
||||
|
||||
if ($result->isSuccess()) {
|
||||
$this->logger->info('Request signature verified successfully', [
|
||||
'key_id' => $result->signature->keyId,
|
||||
'algorithm' => $result->signature->algorithm->value,
|
||||
'path' => $request->path,
|
||||
]);
|
||||
} else {
|
||||
$this->logger->warning('Request signature verification failed', [
|
||||
'error' => $result->errorMessage,
|
||||
'path' => $request->path,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Request signature verification error', [
|
||||
'error' => $e->getMessage(),
|
||||
'path' => $request->path,
|
||||
]);
|
||||
|
||||
return VerificationResult::failure('Verification error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new signing key
|
||||
*/
|
||||
public function addSigningKey(SigningKey $key): void
|
||||
{
|
||||
$this->keyRepository->store($key);
|
||||
|
||||
$this->logger->info('Signing key added', [
|
||||
'key_id' => $key->keyId,
|
||||
'algorithm' => $key->algorithm->value,
|
||||
'expires_at' => $key->expiresAt?->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a signing key
|
||||
*/
|
||||
public function removeSigningKey(string $keyId): void
|
||||
{
|
||||
$this->keyRepository->remove($keyId);
|
||||
|
||||
$this->logger->info('Signing key removed', [
|
||||
'key_id' => $keyId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a signing key
|
||||
*/
|
||||
public function rotateSigningKey(string $keyId, SigningKey $newKey): void
|
||||
{
|
||||
$this->keyRepository->rotateKey($keyId, $newKey);
|
||||
|
||||
$this->logger->info('Signing key rotated', [
|
||||
'old_key_id' => $keyId,
|
||||
'new_key_id' => $newKey->keyId,
|
||||
'algorithm' => $newKey->algorithm->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active signing keys
|
||||
*/
|
||||
public function getActiveKeys(): array
|
||||
{
|
||||
return $this->keyRepository->getAllActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new HMAC signing key
|
||||
*/
|
||||
public function generateHmacKey(
|
||||
string $keyId,
|
||||
SigningAlgorithm $algorithm = SigningAlgorithm::HMAC_SHA256,
|
||||
?\DateTimeImmutable $expiresAt = null,
|
||||
): SigningKey {
|
||||
$key = SigningKey::generateHmac($keyId, $algorithm, $expiresAt);
|
||||
$this->addSigningKey($key);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an RSA signing key
|
||||
*/
|
||||
public function createRsaKey(
|
||||
string $keyId,
|
||||
string $privateKey,
|
||||
?\DateTimeImmutable $expiresAt = null,
|
||||
): SigningKey {
|
||||
$key = SigningKey::createRsa($keyId, $privateKey, $expiresAt);
|
||||
$this->addSigningKey($key);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signing key by ID or return the default key
|
||||
*/
|
||||
private function getSigningKey(?string $keyId = null): ?SigningKey
|
||||
{
|
||||
if ($keyId !== null) {
|
||||
return $this->keyRepository->findByKeyId($keyId);
|
||||
}
|
||||
|
||||
// Get the first active key as default
|
||||
$activeKeys = $this->keyRepository->getAllActive();
|
||||
|
||||
return reset($activeKeys) ?: null;
|
||||
}
|
||||
}
|
||||
150
src/Framework/Security/RequestSigning/RequestVerifier.php
Normal file
150
src/Framework/Security/RequestSigning/RequestVerifier.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Http\Request;
|
||||
|
||||
/**
|
||||
* Verifies HTTP request signatures
|
||||
*/
|
||||
final readonly class RequestVerifier
|
||||
{
|
||||
public function __construct(
|
||||
private SigningKeyRepository $keyRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a request signature
|
||||
*/
|
||||
public function verify(Request $request): VerificationResult
|
||||
{
|
||||
try {
|
||||
// Extract signature from header
|
||||
$signatureHeader = $request->headers->getFirst('Signature');
|
||||
if ($signatureHeader === null) {
|
||||
return VerificationResult::failure('Missing Signature header');
|
||||
}
|
||||
|
||||
$signature = RequestSignature::fromHttpSignatureHeader($signatureHeader);
|
||||
|
||||
// Check if signature is expired
|
||||
if ($signature->isExpired()) {
|
||||
return VerificationResult::failure('Signature has expired');
|
||||
}
|
||||
|
||||
// Get signing key
|
||||
$key = $this->keyRepository->findByKeyId($signature->keyId);
|
||||
if ($key === null) {
|
||||
return VerificationResult::failure('Unknown key ID: ' . $signature->keyId);
|
||||
}
|
||||
|
||||
if (! $key->isValid()) {
|
||||
return VerificationResult::failure('Signing key is not valid');
|
||||
}
|
||||
|
||||
// Verify algorithm matches
|
||||
if ($key->algorithm !== $signature->algorithm) {
|
||||
return VerificationResult::failure('Algorithm mismatch');
|
||||
}
|
||||
|
||||
// Build signing string
|
||||
$signingString = new SigningString($request);
|
||||
$stringToSign = $signingString->build($signature->headers);
|
||||
|
||||
// Verify signature
|
||||
$isValid = $this->verifySignature($stringToSign, $signature->signature, $key);
|
||||
|
||||
if ($isValid) {
|
||||
return VerificationResult::success($signature, $key);
|
||||
} else {
|
||||
return VerificationResult::failure('Invalid signature');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return VerificationResult::failure('Signature verification error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify signature using the appropriate algorithm
|
||||
*/
|
||||
private function verifySignature(string $data, string $signature, SigningKey $key): bool
|
||||
{
|
||||
return match ($key->algorithm) {
|
||||
SigningAlgorithm::HMAC_SHA256 => $this->verifyHmacSignature($data, $signature, $key, 'sha256'),
|
||||
SigningAlgorithm::HMAC_SHA512 => $this->verifyHmacSignature($data, $signature, $key, 'sha512'),
|
||||
SigningAlgorithm::RSA_SHA256 => $this->verifyRsaSignature($data, $signature, $key),
|
||||
SigningAlgorithm::ED25519 => throw new \InvalidArgumentException('ED25519 not yet implemented'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature
|
||||
*/
|
||||
private function verifyHmacSignature(string $data, string $signature, SigningKey $key, string $algorithm): bool
|
||||
{
|
||||
$expectedSignature = hash_hmac($algorithm, $data, $key->keyMaterial, true);
|
||||
$expectedSignatureBase64 = base64_encode($expectedSignature);
|
||||
|
||||
return hash_equals($expectedSignatureBase64, $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify RSA signature
|
||||
*/
|
||||
private function verifyRsaSignature(string $data, string $signature, SigningKey $key): bool
|
||||
{
|
||||
// Extract public key from private key
|
||||
$privateKey = openssl_pkey_get_private($key->keyMaterial);
|
||||
if ($privateKey === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyDetails = openssl_pkey_get_details($privateKey);
|
||||
if ($keyDetails === false || ! isset($keyDetails['key'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$publicKey = openssl_pkey_get_public($keyDetails['key']);
|
||||
if ($publicKey === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$signatureBinary = base64_decode($signature);
|
||||
if ($signatureBinary === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = openssl_verify($data, $signatureBinary, $publicKey, $key->algorithm->getOpenSSLSignatureAlgorithm());
|
||||
|
||||
return $result === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify digest header if present
|
||||
*/
|
||||
public function verifyDigest(Request $request): bool
|
||||
{
|
||||
$digestHeader = $request->headers->getFirst('Digest');
|
||||
if ($digestHeader === null) {
|
||||
return true; // No digest to verify
|
||||
}
|
||||
|
||||
$body = $request->body;
|
||||
|
||||
// Parse digest header: "SHA256=base64hash"
|
||||
if (! preg_match('/^([A-Z0-9]+)=(.+)$/', $digestHeader, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$algorithm = strtolower($matches[1]);
|
||||
$expectedHash = $matches[2];
|
||||
|
||||
$actualHash = base64_encode(hash($algorithm, $body, true));
|
||||
|
||||
return hash_equals($expectedHash, $actualHash);
|
||||
}
|
||||
}
|
||||
44
src/Framework/Security/RequestSigning/SigningAlgorithm.php
Normal file
44
src/Framework/Security/RequestSigning/SigningAlgorithm.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
/**
|
||||
* Supported signing algorithms for request signatures
|
||||
*/
|
||||
enum SigningAlgorithm: string
|
||||
{
|
||||
case HMAC_SHA256 = 'hmac-sha256';
|
||||
case HMAC_SHA512 = 'hmac-sha512';
|
||||
case RSA_SHA256 = 'rsa-sha256';
|
||||
case ED25519 = 'ed25519';
|
||||
|
||||
public function getHashAlgorithm(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HMAC_SHA256 => 'sha256',
|
||||
self::HMAC_SHA512 => 'sha512',
|
||||
self::RSA_SHA256 => 'sha256',
|
||||
self::ED25519 => 'sha256',
|
||||
};
|
||||
}
|
||||
|
||||
public function isSymmetric(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::HMAC_SHA256, self::HMAC_SHA512 => true,
|
||||
self::RSA_SHA256, self::ED25519 => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function getOpenSSLSignatureAlgorithm(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::RSA_SHA256 => OPENSSL_ALGO_SHA256,
|
||||
self::HMAC_SHA256, self::HMAC_SHA512, self::ED25519 => throw new \InvalidArgumentException(
|
||||
'OpenSSL signature algorithm not applicable for ' . $this->value
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
117
src/Framework/Security/RequestSigning/SigningKey.php
Normal file
117
src/Framework/Security/RequestSigning/SigningKey.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Database\Attributes\Column;
|
||||
use App\Framework\Database\Attributes\Entity;
|
||||
|
||||
/**
|
||||
* Represents a cryptographic key for request signing
|
||||
*/
|
||||
#[Entity(tableName: 'signing_keys')]
|
||||
final readonly class SigningKey
|
||||
{
|
||||
public function __construct(
|
||||
#[Column(name: 'key_id', primary: true)]
|
||||
public string $keyId,
|
||||
#[Column(name: 'key_material')]
|
||||
public string $keyMaterial,
|
||||
#[Column(name: 'algorithm', type: SigningAlgorithm::class)]
|
||||
public SigningAlgorithm $algorithm,
|
||||
#[Column(name: 'expires_at')]
|
||||
public ?\DateTimeImmutable $expiresAt = null,
|
||||
#[Column(name: 'is_active')]
|
||||
public bool $isActive = true,
|
||||
#[Column(name: 'created_at')]
|
||||
public ?\DateTimeImmutable $createdAt = null,
|
||||
#[Column(name: 'updated_at')]
|
||||
public ?\DateTimeImmutable $updatedAt = null,
|
||||
) {
|
||||
if (strlen($keyMaterial) < 32) {
|
||||
throw new \InvalidArgumentException('Key material must be at least 32 bytes long');
|
||||
}
|
||||
}
|
||||
|
||||
public function isExpired(?\DateTimeImmutable $now = null): bool
|
||||
{
|
||||
if ($this->expiresAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now ??= new \DateTimeImmutable();
|
||||
|
||||
return $now > $this->expiresAt;
|
||||
}
|
||||
|
||||
public function isValid(?\DateTimeImmutable $now = null): bool
|
||||
{
|
||||
return $this->isActive && ! $this->isExpired($now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new HMAC signing key
|
||||
*/
|
||||
public static function createHmac(
|
||||
string $keyId,
|
||||
string $secret,
|
||||
SigningAlgorithm $algorithm = SigningAlgorithm::HMAC_SHA256,
|
||||
?\DateTimeImmutable $expiresAt = null,
|
||||
): self {
|
||||
if (! $algorithm->isSymmetric()) {
|
||||
throw new \InvalidArgumentException('Algorithm must be symmetric for HMAC keys');
|
||||
}
|
||||
|
||||
return new self(
|
||||
keyId: $keyId,
|
||||
keyMaterial: $secret,
|
||||
algorithm: $algorithm,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new RSA signing key
|
||||
*/
|
||||
public static function createRsa(
|
||||
string $keyId,
|
||||
string $privateKey,
|
||||
?\DateTimeImmutable $expiresAt = null,
|
||||
): self {
|
||||
// Validate the private key
|
||||
$resource = openssl_pkey_get_private($privateKey);
|
||||
if ($resource === false) {
|
||||
throw new \InvalidArgumentException('Invalid RSA private key');
|
||||
}
|
||||
|
||||
return new self(
|
||||
keyId: $keyId,
|
||||
keyMaterial: $privateKey,
|
||||
algorithm: SigningAlgorithm::RSA_SHA256,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random HMAC key
|
||||
*/
|
||||
public static function generateHmac(
|
||||
string $keyId,
|
||||
SigningAlgorithm $algorithm = SigningAlgorithm::HMAC_SHA256,
|
||||
?\DateTimeImmutable $expiresAt = null,
|
||||
): self {
|
||||
if (! $algorithm->isSymmetric()) {
|
||||
throw new \InvalidArgumentException('Algorithm must be symmetric for HMAC keys');
|
||||
}
|
||||
|
||||
$keyMaterial = bin2hex(random_bytes(32));
|
||||
|
||||
return new self(
|
||||
keyId: $keyId,
|
||||
keyMaterial: $keyMaterial,
|
||||
algorithm: $algorithm,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
/**
|
||||
* Repository interface for managing signing keys
|
||||
*/
|
||||
interface SigningKeyRepository
|
||||
{
|
||||
/**
|
||||
* Find a signing key by its ID
|
||||
*/
|
||||
public function findByKeyId(string $keyId): ?SigningKey;
|
||||
|
||||
/**
|
||||
* Store a signing key
|
||||
*/
|
||||
public function store(SigningKey $key): void;
|
||||
|
||||
/**
|
||||
* Remove a signing key
|
||||
*/
|
||||
public function remove(string $keyId): void;
|
||||
|
||||
/**
|
||||
* Get all active signing keys
|
||||
*/
|
||||
public function getAllActive(): array;
|
||||
|
||||
/**
|
||||
* Rotate keys (deactivate old, add new)
|
||||
*/
|
||||
public function rotateKey(string $keyId, SigningKey $newKey): void;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DI\Initializer;
|
||||
|
||||
final readonly class SigningKeyRepositoryInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function __invoke(
|
||||
EntityManager $entityManager,
|
||||
Cache $cache,
|
||||
Clock $clock
|
||||
): SigningKeyRepository {
|
||||
// IMMER EntityManager-basierte Implementierung verwenden
|
||||
// Konsistenz zwischen Development, Testing und Production
|
||||
return new EntityManagerSigningKeyRepository($entityManager, $clock, $cache);
|
||||
}
|
||||
}
|
||||
147
src/Framework/Security/RequestSigning/SigningString.php
Normal file
147
src/Framework/Security/RequestSigning/SigningString.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Http\Request;
|
||||
|
||||
/**
|
||||
* Builds the canonical string for request signing
|
||||
*/
|
||||
final readonly class SigningString
|
||||
{
|
||||
public function __construct(
|
||||
private Request $request,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the signing string from specified headers
|
||||
*/
|
||||
public function build(array $headers): string
|
||||
{
|
||||
$lines = [];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
$lines[] = $this->buildHeaderLine($header);
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single header line for the signing string
|
||||
*/
|
||||
private function buildHeaderLine(string $header): string
|
||||
{
|
||||
$header = strtolower($header);
|
||||
|
||||
return match ($header) {
|
||||
'(request-target)' => $this->buildRequestTarget(),
|
||||
'(created)' => $this->buildCreated(),
|
||||
'(expires)' => $this->buildExpires(),
|
||||
default => $this->buildStandardHeader($header),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the (request-target) pseudo-header
|
||||
*/
|
||||
private function buildRequestTarget(): string
|
||||
{
|
||||
$method = strtolower($this->request->method->value);
|
||||
$path = $this->request->path;
|
||||
|
||||
$target = $path;
|
||||
if (! empty($this->request->queryParams)) {
|
||||
$query = http_build_query($this->request->queryParams);
|
||||
$target .= '?' . $query;
|
||||
}
|
||||
|
||||
return "(request-target): {$method} {$target}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the (created) pseudo-header
|
||||
*/
|
||||
private function buildCreated(): string
|
||||
{
|
||||
$timestamp = time();
|
||||
|
||||
return "(created): {$timestamp}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the (expires) pseudo-header
|
||||
*/
|
||||
private function buildExpires(): string
|
||||
{
|
||||
$timestamp = time() + 300; // 5 minutes from now
|
||||
|
||||
return "(expires): {$timestamp}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standard HTTP header line
|
||||
*/
|
||||
private function buildStandardHeader(string $header): string
|
||||
{
|
||||
$headerName = $this->normalizeHeaderName($header);
|
||||
$headerValue = $this->request->headers->getFirst($headerName);
|
||||
|
||||
if ($headerValue === null) {
|
||||
throw new \InvalidArgumentException("Header '{$headerName}' not found in request");
|
||||
}
|
||||
|
||||
return "{$header}: {$headerValue}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize header name for lookup
|
||||
*/
|
||||
private function normalizeHeaderName(string $header): string
|
||||
{
|
||||
return str_replace(' ', '-', ucwords(str_replace('-', ' ', $header)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default headers for signing
|
||||
*/
|
||||
public static function getDefaultHeaders(): array
|
||||
{
|
||||
return ['(request-target)', 'host', 'date'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended headers for signing
|
||||
*/
|
||||
public static function getRecommendedHeaders(): array
|
||||
{
|
||||
return [
|
||||
'(request-target)',
|
||||
'host',
|
||||
'date',
|
||||
'digest',
|
||||
'content-type',
|
||||
'content-length',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security headers for signing
|
||||
*/
|
||||
public static function getSecurityHeaders(): array
|
||||
{
|
||||
return [
|
||||
'(request-target)',
|
||||
'(created)',
|
||||
'host',
|
||||
'date',
|
||||
'digest',
|
||||
'content-type',
|
||||
'content-length',
|
||||
'authorization',
|
||||
];
|
||||
}
|
||||
}
|
||||
58
src/Framework/Security/RequestSigning/VerificationResult.php
Normal file
58
src/Framework/Security/RequestSigning/VerificationResult.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Security\RequestSigning;
|
||||
|
||||
/**
|
||||
* Result of request signature verification
|
||||
*/
|
||||
final readonly class VerificationResult
|
||||
{
|
||||
private function __construct(
|
||||
public bool $isValid,
|
||||
public ?string $errorMessage = null,
|
||||
public ?RequestSignature $signature = null,
|
||||
public ?SigningKey $key = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a successful verification result
|
||||
*/
|
||||
public static function success(RequestSignature $signature, SigningKey $key): self
|
||||
{
|
||||
return new self(
|
||||
isValid: true,
|
||||
signature: $signature,
|
||||
key: $key,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed verification result
|
||||
*/
|
||||
public static function failure(string $errorMessage): self
|
||||
{
|
||||
return new self(
|
||||
isValid: false,
|
||||
errorMessage: $errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if verification was successful
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if verification failed
|
||||
*/
|
||||
public function isFailure(): bool
|
||||
{
|
||||
return ! $this->isValid;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user