From b48074b0692b939a540a865e2fe93360e39ef736 Mon Sep 17 00:00:00 2001 From: LeonBeckamp Date: Wed, 3 Jun 2026 15:23:49 +0200 Subject: [PATCH 1/4] Improve observability for the app registration flow by adding structured logging around key registration phases This adds log events for: - registration start before signature verification - registration preparation after shop creation/update - confirmation start before confirmation signature verification - secret rotation during confirmation - final registration confirmation A small `registrationLogContext()` helper keeps the logged context consistent across these events. The context includes shop identity and state flags such as whether the shop exists,is confirmed, has a pending secret, or has a previous secret. - Ran `php -l src/Registration/RegistrationService.php` - Confirmed no syntax errors --- src/Registration/RegistrationService.php | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Registration/RegistrationService.php b/src/Registration/RegistrationService.php index e7638b1..3fa514f 100644 --- a/src/Registration/RegistrationService.php +++ b/src/Registration/RegistrationService.php @@ -55,6 +55,11 @@ public function register(RequestInterface $request): ResponseInterface $shop = $this->shopRepository->getShopFromId($queries['shop-id']); + $this->logger->info( + 'Shop registration started', + $this->registrationLogContext($queries['shop-id'], $queries['shop-url'], $shop) + ); + $this->dualSignatureVerifier->authenticateRegistrationRequest( $request, $this->appConfiguration, @@ -138,6 +143,11 @@ public function registerConfirm(RequestInterface $request): ResponseInterface throw new ShopNotFoundException($requestContent['shopId']); } + $this->logger->info( + 'Shop registration confirmation started', + $this->registrationLogContext($requestContent['shopId'], $shop->getShopUrl(), $shop) + ); + $request->getBody()->rewind(); // Use dual signature verifier for registration confirmation @@ -151,6 +161,11 @@ public function registerConfirm(RequestInterface $request): ResponseInterface $shop->setPreviousShopSecret($shop->getShopSecret()) ->setShopSecret($pendingSecret) ->setSecretsRotatedAt(new \DateTimeImmutable()); + + $this->logger->info( + 'Shop secret rotated during registration confirmation', + $this->registrationLogContext($shop->getShopId(), $shop->getShopUrl(), $shop) + ); } $pendingUrl = $shop->getPendingShopUrl(); @@ -187,6 +202,21 @@ private function getSanitizedShop(ShopInterface $shop): ShopInterface return $shop->setShopUrl($this->sanitizeShopUrl($shop->getShopUrl())); } + /** + * @return array + */ + private function registrationLogContext(string $shopId, string $shopUrl, ?ShopInterface $shop = null): array + { + return [ + 'shop-id' => $shopId, + 'shop-url' => $shopUrl, + 'shop-exists' => $shop !== null, + 'registration-confirmed' => $shop?->isRegistrationConfirmed(), + 'has-pending-secret' => $shop?->getPendingShopSecret() !== null, + 'has-previous-secret' => $shop?->getPreviousShopSecret() !== null, + ]; + } + /** * @deprecated tag:v6.0.0 - Will be removed. Double signature verification will always be enforced. */ From 9c186fd1d6c2c88ba27394d3933ca5d58658f363 Mon Sep 17 00:00:00 2001 From: LeonBeckamp Date: Wed, 10 Jun 2026 12:21:03 +0200 Subject: [PATCH 2/4] added signature-payload to logging added signature-payload for logging --- src/Registration/RegistrationService.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Registration/RegistrationService.php b/src/Registration/RegistrationService.php index 3fa514f..5ed8087 100644 --- a/src/Registration/RegistrationService.php +++ b/src/Registration/RegistrationService.php @@ -98,6 +98,11 @@ public function register(RequestInterface $request): ResponseInterface $this->logger->info('Shop registration request received', [ 'shop-id' => $shop->getShopId(), 'shop-url' => $shop->getShopUrl(), + 'signature-payload' => implode('', [ + $proofParameters['shop-id'], + $proofParameters['shop-url'], + $this->appConfiguration->getAppName(), + ]), ]); $psrFactory = new Psr17Factory(); From 99234088853a6890e99e733aec120dcfb87d1ff2 Mon Sep 17 00:00:00 2001 From: Ghaith Olabi Date: Tue, 30 Jun 2026 12:05:06 +0200 Subject: [PATCH 3/4] feat: structured secret-free logging for registration and signature verification Adds structured log events across the registration and signature-verification flow behind a shared, secret-free log context, and exposes verificationStage as a public readonly property on the signature exceptions so the failing verification leg is captured in the logs. --- composer.json | 1 + .../DualSignatureRequestVerifier.php | 86 ++++-- src/Exception/SignatureInvalidException.php | 13 +- src/Exception/SignatureNotFoundException.php | 17 +- src/Registration/RegistrationService.php | 62 +++- .../DualSignatureRequestVerifierTest.php | 200 ++++++++++++- .../SignatureInvalidExceptionTest.php | 10 + .../SignatureNotFoundExceptionTest.php | 10 + .../Registration/RegistrationServiceTest.php | 266 +++++++++++++++++- 9 files changed, 611 insertions(+), 54 deletions(-) diff --git a/composer.json b/composer.json index 7b02032..d4389e8 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "psr/http-factory": "^1.0", "psr/http-factory-implementation": "*", "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^3.0", "strobotti/php-jwk": "^1.4" }, diff --git a/src/Authentication/DualSignatureRequestVerifier.php b/src/Authentication/DualSignatureRequestVerifier.php index f61db36..0ce93ef 100644 --- a/src/Authentication/DualSignatureRequestVerifier.php +++ b/src/Authentication/DualSignatureRequestVerifier.php @@ -8,6 +8,8 @@ use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use Psr\Clock\ClockInterface; use Psr\Http\Message\RequestInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Shopware\App\SDK\AppConfiguration; use Shopware\App\SDK\Exception\SignatureInvalidException; use Shopware\App\SDK\Exception\SignatureNotFoundException; @@ -21,8 +23,11 @@ class DualSignatureRequestVerifier private readonly ClockInterface $clock; - public function __construct(private readonly RequestVerifier $primaryVerifier = new RequestVerifier(), ?ClockInterface $clock = null) - { + public function __construct( + private readonly RequestVerifier $primaryVerifier = new RequestVerifier(), + ?ClockInterface $clock = null, + private readonly LoggerInterface $logger = new NullLogger() + ) { $this->clock = $clock ?? new SystemClock(new \DateTimeZone('UTC')); } @@ -35,7 +40,7 @@ public function authenticatePostRequest(RequestInterface $request, ShopInterface try { $this->primaryVerifier->authenticatePostRequest($request, $shop->getShopSecret()); } catch (SignatureInvalidException $exception) { - $this->authenticateWithPreviousSecret($request, $shop, $exception, function (RequestInterface $request, string $secret) { + $this->authenticateWithPreviousSecret($request, $shop, $exception, function (RequestInterface $request, string $secret): void { $this->primaryVerifier->authenticatePostRequest($request, $secret); }); } @@ -50,7 +55,7 @@ public function authenticateGetRequest(RequestInterface $request, ShopInterface try { $this->primaryVerifier->authenticateGetRequest($request, $shop->getShopSecret()); } catch (SignatureInvalidException $exception) { - $this->authenticateWithPreviousSecret($request, $shop, $exception, function (RequestInterface $request, string $secret) { + $this->authenticateWithPreviousSecret($request, $shop, $exception, function (RequestInterface $request, string $secret): void { $this->primaryVerifier->authenticateGetRequest($request, $secret); }); } @@ -59,23 +64,27 @@ public function authenticateGetRequest(RequestInterface $request, ShopInterface /** * @throws SignatureInvalidException * @throws SignatureNotFoundException + * @throws RequiredConstraintsViolated thrown by JWT validation on the signed storefront URL */ public function authenticateStorefrontRequest(RequestInterface $request, string $shopId, ShopInterface $shop): void { try { $this->primaryVerifier->authenticateStorefrontRequest($request, $shopId, $shop->getShopSecret()); } catch (RequiredConstraintsViolated $exception) { - $this->authenticateWithPreviousSecret($request, $shop, $exception, function (RequestInterface $request, string $secret) use ($shopId) { + $this->authenticateWithPreviousSecret($request, $shop, $exception, function (RequestInterface $request, string $secret) use ($shopId): void { $this->primaryVerifier->authenticateStorefrontRequest($request, $shopId, $secret); }); } } /** - * Helper method to authenticate with the previous secret during rotation window + * Authenticate with the previous secret during the rotation window. Past the allowance the request is + * rejected; but if the rotated-out secret still matches, we log how late it arrived so the allowance — + * a heuristic — can be tuned against real traffic. * * @param callable(RequestInterface, string): void $authenticator * @throws SignatureInvalidException + * @throws RequiredConstraintsViolated when the rethrown $exception originates from JWT validation */ private function authenticateWithPreviousSecret( RequestInterface $request, @@ -86,22 +95,49 @@ private function authenticateWithPreviousSecret( $rotatedAt = $shop->getSecretsRotatedAt(); $previousSecret = $shop->getPreviousShopSecret(); - // No previous secret or rotation timestamp available if ($previousSecret === null || $rotatedAt === null) { throw $exception; } - // Check if we're still within the inflight allowance window - $allowanceEnd = $rotatedAt->modify(sprintf("+%d seconds", self::INFLIGHT_ALLOWANCE)); + $allowanceEnd = $rotatedAt->modify(sprintf('+%d seconds', self::INFLIGHT_ALLOWANCE)); + $now = $this->clock->now(); + + if ($now >= $allowanceEnd) { + // The HMAC is cheap, so we still check: a match is a valid request that arrived late. Log how far + // past the window it is (to tune the allowance), then reject. A non-match throws and stays silent. + $authenticator($request, $previousSecret); + + $this->logger->info('Request signed with the rotated-out secret arrived after the in-flight allowance', [ + 'shop-id' => $shop->getShopId(), + 'secrets-rotated-at' => $rotatedAt->format(\DateTimeInterface::ATOM), + 'inflight-allowance-seconds' => self::INFLIGHT_ALLOWANCE, + 'seconds-after-rotation' => $now->getTimestamp() - $rotatedAt->getTimestamp(), + 'shopware-version' => self::incomingShopwareVersion($request), + ]); - if ($this->clock->now() >= $allowanceEnd) { throw $exception; } - // Try authenticating with the previous secret $authenticator($request, $previousSecret); } + /** + * The Shopware version that sent the request — the `sw-version` header on webhook (POST) requests, + * or the `sw-version` query parameter on signed GET requests. Null when absent. + */ + private static function incomingShopwareVersion(RequestInterface $request): ?string + { + $header = $request->getHeaderLine('sw-version'); + if ($header !== '') { + return $header; + } + + \parse_str($request->getUri()->getQuery(), $query); + $version = $query['sw-version'] ?? null; + + return \is_string($version) && $version !== '' ? $version : null; + } + /** * Authenticate registration confirmation request * @@ -116,18 +152,19 @@ public function authenticateRegistrationConfirmation(RequestInterface $request, $pendingSecret = $shop->getPendingShopSecret(); // Missing registration step, during registration confirmation the pending secret must be set. if ($pendingSecret === null) { - throw new SignatureInvalidException($request); + throw new SignatureInvalidException($request, verificationStage: 'missing-pending-secret'); } // New registration: that is not yet confirmed from shop, verify with secret shared during registration handshake is sufficient. - $this->primaryVerifier->authenticatePostRequest($request, $pendingSecret); + $this->verifyLeg('pending-secret', fn () => $this->primaryVerifier->authenticatePostRequest($request, $pendingSecret)); + if (! $shop->isRegistrationConfirmed()) { return; } // OLD SHOP RE-REGISTRATION: If double signature is enforced, also verify with OLD current secret (the secret that the shop is actively using). if ($this->shouldEnforceDoubleSignatureForRegisterConfirm($appConfiguration, $shop, $request)) { - $this->primaryVerifier->authenticatePostRequest($request, $shop->getShopSecret(), self::SHOPWARE_SHOP_SIGNATURE_PREVIOUS_HEADER); + $this->verifyLeg('previous-signature', fn () => $this->primaryVerifier->authenticatePostRequest($request, $shop->getShopSecret(), self::SHOPWARE_SHOP_SIGNATURE_PREVIOUS_HEADER)); } } @@ -146,11 +183,28 @@ public function authenticateRegistrationRequest( ?ShopInterface $shop = null ): void { // Always verify app signature first - $this->primaryVerifier->authenticateRegistrationRequest($request, $appConfiguration->getAppSecret()); + $this->verifyLeg('app-signature', fn () => $this->primaryVerifier->authenticateRegistrationRequest($request, $appConfiguration->getAppSecret())); // If there's a confirmed registration and double signature is enforced, also verify with shop's current secret if ($shop?->isRegistrationConfirmed() === true && $this->shouldEnforceDoubleSignatureForRegister($appConfiguration, $shop, $request)) { - $this->primaryVerifier->authenticateRegistrationRequestWithShopSignature($request, $shop->getShopSecret()); + $this->verifyLeg('shop-signature', fn () => $this->primaryVerifier->authenticateRegistrationRequestWithShopSignature($request, $shop->getShopSecret())); + } + } + + /** + * Run one verification leg; on failure, re-tag the exception with the leg that produced it (a non-secret + * label for the registration log), keeping the original as `previous`. + * + * @param callable(): void $leg + */ + private function verifyLeg(string $stage, callable $leg): void + { + try { + $leg(); + } catch (SignatureInvalidException|SignatureNotFoundException $e) { + throw $e instanceof SignatureNotFoundException + ? new SignatureNotFoundException($e->getRequest(), $e, $stage) + : new SignatureInvalidException($e->getRequest(), $e, $stage); } } diff --git a/src/Exception/SignatureInvalidException.php b/src/Exception/SignatureInvalidException.php index b7a81c3..3734ad4 100644 --- a/src/Exception/SignatureInvalidException.php +++ b/src/Exception/SignatureInvalidException.php @@ -10,9 +10,18 @@ class SignatureInvalidException extends \Exception { public function __construct( private readonly RequestInterface $request, - ?\Throwable $previous = null + ?\Throwable $previous = null, + /** + * Which verification leg failed (e.g. app-signature, shop-signature), or null when not tagged. + */ + public readonly ?string $verificationStage = null ) { - parent::__construct('Signature could not be verified', 0, $previous); + $message = 'Signature could not be verified'; + if ($verificationStage !== null) { + $message = \sprintf('%s (verification stage: %s)', $message, $verificationStage); + } + + parent::__construct($message, 0, $previous); } public function getRequest(): RequestInterface diff --git a/src/Exception/SignatureNotFoundException.php b/src/Exception/SignatureNotFoundException.php index 91add87..0f8bc83 100644 --- a/src/Exception/SignatureNotFoundException.php +++ b/src/Exception/SignatureNotFoundException.php @@ -8,9 +8,20 @@ class SignatureNotFoundException extends \RuntimeException { - public function __construct(private readonly RequestInterface $request, ?\Throwable $previous = null) - { - parent::__construct('Signature is not present in request', 0, $previous); + public function __construct( + private readonly RequestInterface $request, + ?\Throwable $previous = null, + /** + * Which verification leg failed (e.g. app-signature, shop-signature), or null when not tagged. + */ + public readonly ?string $verificationStage = null + ) { + $message = 'Signature is not present in request'; + if ($verificationStage !== null) { + $message = \sprintf('%s (verification stage: %s)', $message, $verificationStage); + } + + parent::__construct($message, 0, $previous); } public function getRequest(): RequestInterface diff --git a/src/Registration/RegistrationService.php b/src/Registration/RegistrationService.php index 5ed8087..bfc6b0b 100644 --- a/src/Registration/RegistrationService.php +++ b/src/Registration/RegistrationService.php @@ -57,14 +57,24 @@ public function register(RequestInterface $request): ResponseInterface $this->logger->info( 'Shop registration started', - $this->registrationLogContext($queries['shop-id'], $queries['shop-url'], $shop) + $this->registrationLogContext($request, $queries['shop-id'], $queries['shop-url'], $shop) ); - $this->dualSignatureVerifier->authenticateRegistrationRequest( - $request, - $this->appConfiguration, - $shop - ); + try { + $this->dualSignatureVerifier->authenticateRegistrationRequest( + $request, + $this->appConfiguration, + $shop + ); + } catch (SignatureInvalidException|SignatureNotFoundException $e) { + $this->logger->warning( + 'Shop registration signature verification failed', + $this->registrationLogContext($request, $queries['shop-id'], $queries['shop-url'], $shop) + + ['exception' => $e::class, 'verification-stage' => $e->verificationStage] + ); + + throw $e; + } $secret = $this->shopSecretGeneratorInterface->generate(); @@ -98,6 +108,9 @@ public function register(RequestInterface $request): ResponseInterface $this->logger->info('Shop registration request received', [ 'shop-id' => $shop->getShopId(), 'shop-url' => $shop->getShopUrl(), + // Raw URL as signed into the proof; differs from the sanitized shop-url when the path is normalized. + 'signed-shop-url' => $proofParameters['shop-url'], + 'shopware-version' => self::incomingShopwareVersion($request), 'signature-payload' => implode('', [ $proofParameters['shop-id'], $proofParameters['shop-url'], @@ -150,13 +163,23 @@ public function registerConfirm(RequestInterface $request): ResponseInterface $this->logger->info( 'Shop registration confirmation started', - $this->registrationLogContext($requestContent['shopId'], $shop->getShopUrl(), $shop) + $this->registrationLogContext($request, $requestContent['shopId'], $shop->getShopUrl(), $shop) ); $request->getBody()->rewind(); // Use dual signature verifier for registration confirmation - $this->dualSignatureVerifier->authenticateRegistrationConfirmation($request, $shop, $this->appConfiguration); + try { + $this->dualSignatureVerifier->authenticateRegistrationConfirmation($request, $shop, $this->appConfiguration); + } catch (SignatureInvalidException|SignatureNotFoundException $e) { + $this->logger->warning( + 'Shop registration confirmation signature verification failed', + $this->registrationLogContext($request, $shop->getShopId(), $shop->getShopUrl(), $shop) + + ['exception' => $e::class, 'verification-stage' => $e->verificationStage] + ); + + throw $e; + } $this->eventDispatcher?->dispatch(new BeforeRegistrationCompletedEvent($shop, $request, $requestContent)); $pendingSecret = $shop->getPendingShopSecret(); @@ -169,7 +192,7 @@ public function registerConfirm(RequestInterface $request): ResponseInterface $this->logger->info( 'Shop secret rotated during registration confirmation', - $this->registrationLogContext($shop->getShopId(), $shop->getShopUrl(), $shop) + $this->registrationLogContext($request, $shop->getShopId(), $shop->getShopUrl(), $shop) ); } @@ -186,6 +209,7 @@ public function registerConfirm(RequestInterface $request): ResponseInterface $this->logger->info('Shop registration confirmed', [ 'shop-id' => $shop->getShopId(), 'shop-url' => $shop->getShopUrl(), + 'shopware-version' => self::incomingShopwareVersion($request), ]); $this->eventDispatcher?->dispatch(new RegistrationCompletedEvent($request, $shop)); @@ -210,18 +234,32 @@ private function getSanitizedShop(ShopInterface $shop): ShopInterface /** * @return array */ - private function registrationLogContext(string $shopId, string $shopUrl, ?ShopInterface $shop = null): array + private function registrationLogContext(RequestInterface $request, string $shopId, string $shopUrl, ?ShopInterface $shop = null): array { return [ 'shop-id' => $shopId, 'shop-url' => $shopUrl, 'shop-exists' => $shop !== null, 'registration-confirmed' => $shop?->isRegistrationConfirmed(), - 'has-pending-secret' => $shop?->getPendingShopSecret() !== null, - 'has-previous-secret' => $shop?->getPreviousShopSecret() !== null, + 'has-pending-secret' => $shop !== null ? $shop->getPendingShopSecret() !== null : null, + 'has-previous-secret' => $shop !== null ? $shop->getPreviousShopSecret() !== null : null, + 'enforce-double-signature' => $this->appConfiguration->enforceDoubleSignature(), + 'has-verified-with-double-signature' => $shop?->hasVerifiedWithDoubleSignature(), + 'shopware-version' => self::incomingShopwareVersion($request), ]; } + /** + * The Shopware version that sent the registration request, read from the `sw-version` header + * (Shopware sends it as a header on the register and confirm calls). Null when absent. + */ + private static function incomingShopwareVersion(RequestInterface $request): ?string + { + $version = $request->getHeaderLine('sw-version'); + + return $version !== '' ? $version : null; + } + /** * @deprecated tag:v6.0.0 - Will be removed. Double signature verification will always be enforced. */ diff --git a/tests/Authentication/DualSignatureRequestVerifierTest.php b/tests/Authentication/DualSignatureRequestVerifierTest.php index 7079180..175bb72 100644 --- a/tests/Authentication/DualSignatureRequestVerifierTest.php +++ b/tests/Authentication/DualSignatureRequestVerifierTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; +use Psr\Log\LoggerInterface; use Shopware\App\SDK\AppConfiguration; use Shopware\App\SDK\Authentication\DualSignatureRequestVerifier; use Shopware\App\SDK\Authentication\RequestVerifier; @@ -345,8 +346,12 @@ public function testAuthenticateRegistrationConfirmationOldShopInvalidPreviousSi $appConfig = new AppConfiguration('My App', 'app-secret', 'http://localhost', true); // enforceDoubleSignature = true $verifier = new DualSignatureRequestVerifier(new RequestVerifier()); - $this->expectException(SignatureInvalidException::class); - $verifier->authenticateRegistrationConfirmation($request, $shop, $appConfig); + try { + $verifier->authenticateRegistrationConfirmation($request, $shop, $appConfig); + static::fail('Expected SignatureInvalidException'); + } catch (SignatureInvalidException $e) { + static::assertSame('previous-signature', $e->verificationStage); + } } /** @@ -384,8 +389,12 @@ public function testAuthenticateRegistrationConfirmationOldShopInvalidPendingSig $appConfig = new AppConfiguration('My App', 'app-secret', 'http://localhost', true); // enforceDoubleSignature = true $verifier = new DualSignatureRequestVerifier(new RequestVerifier()); - $this->expectException(SignatureInvalidException::class); - $verifier->authenticateRegistrationConfirmation($request, $shop, $appConfig); + try { + $verifier->authenticateRegistrationConfirmation($request, $shop, $appConfig); + static::fail('Expected SignatureInvalidException'); + } catch (SignatureInvalidException $e) { + static::assertSame('pending-secret', $e->verificationStage); + } } #[DoesNotPerformAssertions] @@ -511,8 +520,13 @@ public function testAuthenticateRegistrationRequestOldShopInvalidShopSignature() $verifier = new DualSignatureRequestVerifier(new RequestVerifier()); - $this->expectException(SignatureInvalidException::class); - $verifier->authenticateRegistrationRequest($request, $appConfig, $shop); + // App signature is valid, so the failing leg is the shop signature. + try { + $verifier->authenticateRegistrationRequest($request, $appConfig, $shop); + static::fail('Expected SignatureInvalidException'); + } catch (SignatureInvalidException $e) { + static::assertSame('shop-signature', $e->verificationStage); + } } public function testAuthenticateRegistrationRequestForcesOldShopThatUsedDoubleVerificationToUseDoubleVerification(): void @@ -705,4 +719,178 @@ public function testAuthenticateRegistrationConfirmationThrowsWhenAShopThatUsedD $verifier = new DualSignatureRequestVerifier(new RequestVerifier()); $verifier->authenticateRegistrationConfirmation($request, $shop, $appConfig); } + + public function testPreviousSecretFallbackAuthenticatesAnInFlightRequestWithinTheWindow(): void + { + $shop = new MockShop('shop-1', 'https://example.com', 'new-secret'); + $rotatedAt = new \DateTimeImmutable('2026-03-30T08:00:00+00:00'); + $shop->setPreviousShopSecret('old-secret') + ->setSecretsRotatedAt($rotatedAt); + + // Real HMAC: a POST signed with the OLD secret, arriving 30s into the 60s window, must authenticate. + $request = new Request('POST', 'https://my-app.com/webhook', [], 'body'); + $request = $request->withHeader('shopware-shop-signature', hash_hmac('sha256', 'body', 'old-secret')); + + $logger = $this->createMock(LoggerInterface::class); + // A rescued in-flight request is a success: it must not raise a failure warning. + $logger->expects($this->never())->method('warning'); + + $verifier = new DualSignatureRequestVerifier( + new RequestVerifier(), + new FrozenClock($rotatedAt->modify('+30 seconds')), + $logger + ); + + $verifier->authenticatePostRequest($request, $shop); + } + + public function testFailureOfALateOldSecretRequestIsFlaggedOutsideTheRotationWindow(): void + { + $shop = new MockShop('shop-1', 'https://example.com', 'new-secret'); + $rotatedAt = new \DateTimeImmutable('2026-03-30T08:00:00+00:00'); + $shop->setPreviousShopSecret('old-secret') + ->setSecretsRotatedAt($rotatedAt); + + // Signed with the OLD secret, arriving 90s after rotation -> 30s past the 60s allowance. + $request = new Request('POST', 'https://my-app.com/webhook', [], 'body'); + $request = $request->withHeader('shopware-shop-signature', hash_hmac('sha256', 'body', 'old-secret')) + ->withHeader('sw-version', '6.6.10.0'); + + $logger = $this->createMock(LoggerInterface::class); + // A valid request that arrived late is not a webhook failure to warn on; it is logged at info to tune the allowance. + $logger->expects($this->never())->method('warning'); + $logger->expects($this->once()) + ->method('info') + ->with('Request signed with the rotated-out secret arrived after the in-flight allowance', static::callback(function (array $context) use ($rotatedAt): bool { + static::assertSame('shop-1', $context['shop-id']); + static::assertSame($rotatedAt->format(\DateTimeInterface::ATOM), $context['secrets-rotated-at']); + static::assertSame(60, $context['inflight-allowance-seconds']); + static::assertSame(90, $context['seconds-after-rotation']); + static::assertSame('6.6.10.0', $context['shopware-version']); + static::assertSecretFree($context); + + return true; + })); + + $verifier = new DualSignatureRequestVerifier( + new RequestVerifier(), + new FrozenClock($rotatedAt->modify('+90 seconds')), + $logger + ); + + $this->expectException(SignatureInvalidException::class); + $verifier->authenticatePostRequest($request, $shop); + } + + public function testNoSignaturePostWebhookIsRejectedSilently(): void + { + $shop = new MockShop('shop-1', 'https://example.com', 'current-secret'); + + // No signature header -> SignatureNotFoundException. Like a wrong signature, this is not logged; the + // host returns a 4xx and that is the signal. + $request = (new Request('POST', 'https://my-app.com/webhook', [], 'body')) + ->withHeader('sw-version', '6.6.10.0'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + $logger->expects($this->never())->method('info'); + + $verifier = new DualSignatureRequestVerifier(new RequestVerifier(), null, $logger); + + $this->expectException(SignatureNotFoundException::class); + $verifier->authenticatePostRequest($request, $shop); + } + + public function testWrongSignatureOnAWebhookIsRejectedSilently(): void + { + $shop = new MockShop('shop-1', 'https://example.com', 'current-secret'); + + // A wrong (but present) signature is the common webhook failure: rejected, but never logged — logging + // every bad webhook would be unacceptable noise. + $request = (new Request('POST', 'https://my-app.com/webhook', [], 'body')) + ->withHeader('shopware-shop-signature', 'invalid-signature') + ->withHeader('sw-version', '6.6.10.0'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + $logger->expects($this->never())->method('info'); + + $verifier = new DualSignatureRequestVerifier(new RequestVerifier(), null, $logger); + + $this->expectException(SignatureInvalidException::class); + $verifier->authenticatePostRequest($request, $shop); + } + + public function testInWindowRequestMatchingNeitherSecretIsRejectedSilently(): void + { + $shop = new MockShop('shop-1', 'https://example.com', 'new-secret'); + $rotatedAt = new \DateTimeImmutable('2026-03-30T08:00:00+00:00'); + $shop->setPreviousShopSecret('old-secret') + ->setSecretsRotatedAt($rotatedAt); + + // Inside the window but matching neither secret: the previous secret is tried, fails, and the request + // is rejected without any log. + $request = (new Request('POST', 'https://my-app.com/webhook', [], 'body')) + ->withHeader('shopware-shop-signature', hash_hmac('sha256', 'body', 'unknown-key')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + $logger->expects($this->never())->method('info'); + + $verifier = new DualSignatureRequestVerifier(new RequestVerifier(), new FrozenClock($rotatedAt->modify('+30 seconds')), $logger); + + $this->expectException(SignatureInvalidException::class); + $verifier->authenticatePostRequest($request, $shop); + } + + public function testLateRequestMatchingNeitherSecretIsRejectedSilently(): void + { + $shop = new MockShop('shop-1', 'https://example.com', 'new-secret'); + $rotatedAt = new \DateTimeImmutable('2026-03-30T08:00:00+00:00'); + $shop->setPreviousShopSecret('old-secret') + ->setSecretsRotatedAt($rotatedAt); + + // Past the window AND matching neither secret: rejected, with no strand log (the old secret didn't match). + $request = (new Request('POST', 'https://my-app.com/webhook', [], 'body')) + ->withHeader('shopware-shop-signature', hash_hmac('sha256', 'body', 'unknown-key')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + $logger->expects($this->never())->method('info'); + + $verifier = new DualSignatureRequestVerifier(new RequestVerifier(), new FrozenClock($rotatedAt->modify('+90 seconds')), $logger); + + $this->expectException(SignatureInvalidException::class); + $verifier->authenticatePostRequest($request, $shop); + } + + /** + * Guards the hard security constraint: no log context value may carry secret material. + * + * @param array $context + */ + private static function assertSecretFree(array $context): void + { + $forbidden = [ + 'current-secret', + 'new-secret', + 'old-secret', + 'invalid-signature', + 'body', + ]; + + foreach ($context as $key => $value) { + if (!is_scalar($value)) { + continue; + } + + foreach ($forbidden as $needle) { + static::assertStringNotContainsString( + $needle, + (string) $value, + sprintf('Context key "%s" leaked a secret value.', $key) + ); + } + } + } } diff --git a/tests/Exception/SignatureInvalidExceptionTest.php b/tests/Exception/SignatureInvalidExceptionTest.php index 3bc3d04..c709a7b 100644 --- a/tests/Exception/SignatureInvalidExceptionTest.php +++ b/tests/Exception/SignatureInvalidExceptionTest.php @@ -20,5 +20,15 @@ public function testException(): void static::assertSame($request, $exception->getRequest()); static::assertSame('Signature could not be verified', $exception->getMessage()); static::assertSame(0, $exception->getCode()); + static::assertNull($exception->verificationStage); + } + + public function testVerificationStageIsAppendedToMessage(): void + { + $request = new Request('GET', 'http://localhost'); + $exception = new SignatureInvalidException($request, null, 'app-signature'); + + static::assertSame('app-signature', $exception->verificationStage); + static::assertSame('Signature could not be verified (verification stage: app-signature)', $exception->getMessage()); } } diff --git a/tests/Exception/SignatureNotFoundExceptionTest.php b/tests/Exception/SignatureNotFoundExceptionTest.php index 2f053ee..a065ae2 100644 --- a/tests/Exception/SignatureNotFoundExceptionTest.php +++ b/tests/Exception/SignatureNotFoundExceptionTest.php @@ -20,5 +20,15 @@ public function testException(): void static::assertSame($request, $exception->getRequest()); static::assertSame('Signature is not present in request', $exception->getMessage()); static::assertSame(0, $exception->getCode()); + static::assertNull($exception->verificationStage); + } + + public function testVerificationStageIsAppendedToMessage(): void + { + $request = new Request('GET', 'http://localhost'); + $exception = new SignatureNotFoundException($request, null, 'app-signature'); + + static::assertSame('app-signature', $exception->verificationStage); + static::assertSame('Signature is not present in request (verification stage: app-signature)', $exception->getMessage()); } } diff --git a/tests/Registration/RegistrationServiceTest.php b/tests/Registration/RegistrationServiceTest.php index d44cf32..198b2f3 100644 --- a/tests/Registration/RegistrationServiceTest.php +++ b/tests/Registration/RegistrationServiceTest.php @@ -451,14 +451,24 @@ public function testWithoutEventDispatcher(): void public function testRegisterMessageIsLogged(): void { - $logger = static::createMock(LoggerInterface::class); - $logger - ->expects(static::once()) + // register() logs "started" then "request received"; assert the second. + $logger = $this->createMock(LoggerInterface::class); + $matcher = $this->exactly(2); + $logger->expects($matcher) ->method('info') - ->with('Shop registration request received', [ - 'shop-id' => '123', - 'shop-url' => 'https://my-shop.com', - ]); + ->willReturnCallback(function (string $message, array $context) use ($matcher): void { + if ($matcher->numberOfInvocations() !== 2) { + return; + } + + static::assertSame('Shop registration request received', $message); + static::assertSame('123', $context['shop-id']); + static::assertSame('https://my-shop.com', $context['shop-url']); + static::assertSame('https://my-shop.com', $context['signed-shop-url']); + static::assertSame('6.6.10.0', $context['shopware-version']); + // The signed payload is shop-id + shop-url + app name, in that order. + static::assertStringStartsWith('123https://my-shop.com', $context['signature-payload']); + }); $registrationService = new RegistrationService( $this->appConfiguration, @@ -470,20 +480,28 @@ public function testRegisterMessageIsLogged(): void null ); - $request = new Request('GET', 'http://localhost?shop-id=123&shop-url=https://my-shop.com×tamp=1234567890'); + $request = (new Request('GET', 'http://localhost?shop-id=123&shop-url=https://my-shop.com×tamp=1234567890')) + ->withHeader('sw-version', '6.6.10.0'); $registrationService->register($request); } public function testRegisterConfirmMessageIsLogged(): void { - $logger = static::createMock(LoggerInterface::class); - $logger - ->expects(static::once()) + // registerConfirm() logs "confirmation started" then "confirmed" (no rotation here); assert the second. + $logger = $this->createMock(LoggerInterface::class); + $matcher = $this->exactly(2); + $logger->expects($matcher) ->method('info') - ->with('Shop registration confirmed', [ - 'shop-id' => '123', - 'shop-url' => 'https://my-shop.com', - ]); + ->willReturnCallback(function (string $message, array $context) use ($matcher): void { + if ($matcher->numberOfInvocations() !== 2) { + return; + } + + static::assertSame('Shop registration confirmed', $message); + static::assertSame('123', $context['shop-id']); + static::assertSame('https://my-shop.com', $context['shop-url']); + static::assertSame('6.6.10.0', $context['shopware-version']); + }); $registrationService = new RegistrationService( $this->appConfiguration, @@ -499,8 +517,226 @@ public function testRegisterConfirmMessageIsLogged(): void $shop->setPendingShopSecret('1234567890'); $shop->setPendingShopUrl('https://my-shop.com'); $this->shopRepository->createShop($shop); + $request = (new Request('POST', 'http://localhost', [], '{"shopId": "123", "apiKey": "1", "secretKey": "2"}')) + ->withHeader('sw-version', '6.6.10.0'); + + $registrationService->registerConfirm($request); + } + + public function testRegisterConfirmRotatesTheSecretAndLogsForAReRegistration(): void + { + // A re-registration confirm logs "confirmation started", then "Shop secret rotated", then "confirmed". + $logger = $this->createMock(LoggerInterface::class); + $matcher = $this->exactly(3); + $logger->expects($matcher) + ->method('info') + ->willReturnCallback(function (string $message, array $context) use ($matcher): void { + if ($matcher->numberOfInvocations() !== 2) { + return; + } + + static::assertSame('Shop secret rotated during registration confirmation', $message); + static::assertSame('123', $context['shop-id']); + static::assertTrue($context['has-previous-secret']); + }); + + $registrationService = new RegistrationService( + $this->appConfiguration, + $this->shopRepository, + new DualSignatureRequestVerifier($this->createMock(RequestVerifier::class)), + new ResponseSigner(), + new RandomStringShopSecretGenerator(), + $logger, + null + ); + + // An already-confirmed shop whose pending secret differs from the active one: confirming it rotates. + $shop = new MockShop('123', 'https://foo.com', 'current-secret'); + $shop->setPendingShopSecret('rotated-in-secret') + ->setPendingShopUrl('https://my-shop.com') + ->setRegistrationConfirmed(); + $this->shopRepository->createShop($shop); + + $request = (new Request('POST', 'http://localhost', [], '{"shopId": "123", "apiKey": "1", "secretKey": "2"}')) + ->withHeader('sw-version', '6.6.10.0'); + + $registrationService->registerConfirm($request); + + // The pending secret becomes the active one; the old active secret is kept as the previous secret. + $rotated = $this->shopRepository->getShopFromId('123'); + static::assertNotNull($rotated); + static::assertSame('rotated-in-secret', $rotated->getShopSecret()); + static::assertSame('current-secret', $rotated->getPreviousShopSecret()); + static::assertNotNull($rotated->getSecretsRotatedAt()); + } + + public function testRegisterStartedLogIncludesDoubleSignatureContext(): void + { + // "Shop registration started" is the first info log register() emits. + $logger = $this->createMock(LoggerInterface::class); + $matcher = $this->exactly(2); + $logger->expects($matcher) + ->method('info') + ->willReturnCallback(function (string $message, array $context) use ($matcher): void { + if ($matcher->numberOfInvocations() !== 1) { + return; + } + + static::assertSame('Shop registration started', $message); + // appConfiguration in setUp() enforces double signature; no shop exists yet, so shop-derived flags are null/false. + static::assertFalse($context['shop-exists']); + static::assertTrue($context['enforce-double-signature']); + static::assertNull($context['has-verified-with-double-signature']); + static::assertNull($context['has-previous-secret']); + static::assertSame('6.6.10.0', $context['shopware-version']); + }); + + $registrationService = new RegistrationService( + $this->appConfiguration, + $this->shopRepository, + new DualSignatureRequestVerifier($this->createMock(RequestVerifier::class)), + new ResponseSigner(), + new RandomStringShopSecretGenerator(), + $logger, + null + ); + + $request = (new Request('GET', 'http://localhost?shop-id=123&shop-url=https://my-shop.com×tamp=1234567890')) + ->withHeader('sw-version', '6.6.10.0'); + $registrationService->register($request); + } + + public function testRegisterLogsWarningAndRethrowsOnSignatureFailure(): void + { + $query = 'shop-id=123&shop-url=https://my-shop.com×tamp=1234567890'; + $request = new Request('GET', 'http://localhost?' . $query); + $request = $request->withHeader('shopware-app-signature', 'invalid-signature') + ->withHeader('sw-version', '6.6.10.0'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with('Shop registration signature verification failed', static::callback(function (array $context): bool { + static::assertSame('123', $context['shop-id']); + static::assertSame('https://my-shop.com', $context['shop-url']); + static::assertSame(SignatureInvalidException::class, $context['exception']); + // The app signature is the first leg verified, so it is the failing stage here. + static::assertSame('app-signature', $context['verification-stage']); + static::assertArrayHasKey('enforce-double-signature', $context); + static::assertSame('6.6.10.0', $context['shopware-version']); + + return true; + })); + + $registrationService = new RegistrationService( + $this->appConfiguration, + $this->shopRepository, + new DualSignatureRequestVerifier(), + new ResponseSigner(), + new RandomStringShopSecretGenerator(), + $logger, + null + ); + + $this->expectException(SignatureInvalidException::class); + $registrationService->register($request); + } + + public function testRegisterConfirmLogsWarningAndRethrowsOnSignatureFailure(): void + { + $shop = new MockShop('123', 'https://my-shop.com', 'current-secret'); + // No pending secret set -> confirmation verification throws SignatureInvalidException. + $this->shopRepository->createShop($shop); + + $request = (new Request('POST', 'http://localhost', [], '{"shopId": "123", "apiKey": "1", "secretKey": "2"}')) + ->withHeader('sw-version', '6.6.10.0'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with('Shop registration confirmation signature verification failed', static::callback(function (array $context): bool { + static::assertSame('123', $context['shop-id']); + static::assertSame(SignatureInvalidException::class, $context['exception']); + // No pending secret was ever stored, so confirmation fails at that stage. + static::assertSame('missing-pending-secret', $context['verification-stage']); + static::assertSame('6.6.10.0', $context['shopware-version']); + + return true; + })); + + $registrationService = new RegistrationService( + $this->appConfiguration, + $this->shopRepository, + new DualSignatureRequestVerifier(), + new ResponseSigner(), + new RandomStringShopSecretGenerator(), + $logger, + null + ); + + $this->expectException(SignatureInvalidException::class); + $registrationService->registerConfirm($request); + } + + public function testRegisterLogsNotFoundStageWhenAppSignatureMissing(): void + { + // No shopware-app-signature header -> the app-signature leg raises SignatureNotFoundException, not Invalid. + $request = new Request('GET', 'http://localhost?shop-id=123&shop-url=https://my-shop.com×tamp=1234567890'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with('Shop registration signature verification failed', static::callback(function (array $context): bool { + static::assertSame(SignatureNotFoundException::class, $context['exception']); + static::assertSame('app-signature', $context['verification-stage']); + + return true; + })); + + $registrationService = new RegistrationService( + $this->appConfiguration, + $this->shopRepository, + new DualSignatureRequestVerifier(), + new ResponseSigner(), + new RandomStringShopSecretGenerator(), + $logger, + null + ); + + $this->expectException(SignatureNotFoundException::class); + $registrationService->register($request); + } + + public function testRegisterConfirmLogsNotFoundStageWhenPendingSignatureMissing(): void + { + $shop = new MockShop('123', 'https://my-shop.com', 'current-secret'); + $shop->setPendingShopSecret('pending-secret'); + $this->shopRepository->createShop($shop); + + // Pending secret is set, but the request carries no shop signature -> the pending-secret leg raises NotFound. $request = new Request('POST', 'http://localhost', [], '{"shopId": "123", "apiKey": "1", "secretKey": "2"}'); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with('Shop registration confirmation signature verification failed', static::callback(function (array $context): bool { + static::assertSame(SignatureNotFoundException::class, $context['exception']); + static::assertSame('pending-secret', $context['verification-stage']); + + return true; + })); + + $registrationService = new RegistrationService( + $this->appConfiguration, + $this->shopRepository, + new DualSignatureRequestVerifier(), + new ResponseSigner(), + new RandomStringShopSecretGenerator(), + $logger, + null + ); + + $this->expectException(SignatureNotFoundException::class); $registrationService->registerConfirm($request); } From 51a88e7d344305a4dd5741a869ac9b76f2147cb3 Mon Sep 17 00:00:00 2001 From: Ghaith Olabi Date: Tue, 30 Jun 2026 12:45:02 +0200 Subject: [PATCH 4/4] test: cover sw-version query-param branch on late GET requests Kills the escaped mutants in DualSignatureRequestVerifier::incomingShopwareVersion() where a signed GET request carries sw-version as a query parameter rather than a header, which had no test coverage. --- .../DualSignatureRequestVerifierTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/Authentication/DualSignatureRequestVerifierTest.php b/tests/Authentication/DualSignatureRequestVerifierTest.php index 175bb72..cc10d3b 100644 --- a/tests/Authentication/DualSignatureRequestVerifierTest.php +++ b/tests/Authentication/DualSignatureRequestVerifierTest.php @@ -782,6 +782,41 @@ public function testFailureOfALateOldSecretRequestIsFlaggedOutsideTheRotationWin $verifier->authenticatePostRequest($request, $shop); } + public function testFailureOfALateOldSecretGetRequestReadsShopwareVersionFromQuery(): void + { + $shop = new MockShop('shop-1', 'https://example.com', 'new-secret'); + $rotatedAt = new \DateTimeImmutable('2026-03-30T08:00:00+00:00'); + $shop->setPreviousShopSecret('old-secret') + ->setSecretsRotatedAt($rotatedAt); + + // Signed GET requests carry sw-version as a query parameter, not a header. The old-secret signature + // is computed over the query string with the signature itself removed (see existing GET tests). + $query = 'sw-version=6.6.10.0'; + $signature = hash_hmac('sha256', $query, 'old-secret'); + $request = new Request('GET', sprintf('https://my-app.com/webhook?%s&shopware-shop-signature=%s', $query, $signature)); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + $logger->expects($this->once()) + ->method('info') + ->with('Request signed with the rotated-out secret arrived after the in-flight allowance', static::callback(function (array $context): bool { + // The version must be picked up from the query parameter when no sw-version header is present. + static::assertSame('6.6.10.0', $context['shopware-version']); + static::assertSecretFree($context); + + return true; + })); + + $verifier = new DualSignatureRequestVerifier( + new RequestVerifier(), + new FrozenClock($rotatedAt->modify('+90 seconds')), + $logger + ); + + $this->expectException(SignatureInvalidException::class); + $verifier->authenticateGetRequest($request, $shop); + } + public function testNoSignaturePostWebhookIsRejectedSilently(): void { $shop = new MockShop('shop-1', 'https://example.com', 'current-secret');