diff --git a/php/CHANGELOG.md b/php/CHANGELOG.md index 9f594fa4..c1b42938 100644 --- a/php/CHANGELOG.md +++ b/php/CHANGELOG.md @@ -4,5 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -[comment]: <> (## 0.1.0) -[comment]: <> (* Added `truelayer_signing::{sign_with_pem, verify_with_pem, extract_jws_header}`.) \ No newline at end of file +## [Unreleased] + +## [0.1.0] - 2022-05-05 +### Added +- Support for verifying a signature using JSON keys. +- Support for verifying a signature using `Jose\Component\Core\JWK` keys. + +### Changed +- Methods that enable signature verification using PEM or PEM files can now receive multiple strings or paths (i.e multiple keys). +The signature is verified if at least one key verification succeeds. + +## [0.0.2] - 2022-01-05 +### Changed +- Excluded build/test files when publishing on packagist. + +## [0.0.1] - 2021-12-09 +### Added +- Support for signing using a PEM string, PEM file, PEM base64 string or `Jose\Component\Core\JWK` key. +- Support for verifying a signature using a PEM string, PEM file, PEM base64 string or `Jose\Component\Core\JWK` key. diff --git a/php/README.md b/php/README.md index 73ec252a..a56c381d 100644 --- a/php/README.md +++ b/php/README.md @@ -83,5 +83,6 @@ Signer::signWithKey('kid-value', new \Jose\Component\Core\JWK()); Verifier::verifyWithPemFile('path/to/publickey'); Verifier::verifyWithPem('publickey-pem-text'); Verifier::verifyWithPemBase64('base64-publickey-pem-text'); +Verifier::verifyWithJsonKeys(...$arrayOfMultipleJsonKeys); Verifier::verifyWithKey(new \Jose\Component\Core\JWK()); ``` \ No newline at end of file diff --git a/php/src/Contracts/Verifier.php b/php/src/Contracts/Verifier.php index f68447d7..a6d1ee01 100644 --- a/php/src/Contracts/Verifier.php +++ b/php/src/Contracts/Verifier.php @@ -5,9 +5,24 @@ namespace TrueLayer\Signing\Contracts; use Jose\Component\Core\JWK; +use TrueLayer\Signing\Exceptions\InvalidArgumentException; interface Verifier extends Jws { + /** + * @param array ...$decodedJsonObjects + * + * @return static + */ + public static function verifyWithJsonKeys(array ...$decodedJsonObjects): self; + + /** + * @param JWK ...$jwks + * + * @return static + */ + public static function verifyWithKeys(JWK ...$jwks): self; + /** * @param JWK $jwk * @@ -16,28 +31,31 @@ interface Verifier extends Jws public static function verifyWithKey(JWK $jwk): self; /** - * @param string $pem + * @param string ...$pems * * @return static + * @throws InvalidArgumentException */ - public static function verifyWithPem(string $pem): self; + public static function verifyWithPem(string ...$pems): self; /** - * @param string $pemBase64 + * @param string ...$pemsBase64 * * @return static + * @throws InvalidArgumentException */ - public static function verifyWithPemBase64(string $pemBase64): self; + public static function verifyWithPemBase64(string ...$pemsBase64): self; /** - * @param string $path + * @param string ...$paths * * @return static + * @throws InvalidArgumentException */ - public static function verifyWithPemFile(string $path): self; + public static function verifyWithPemFile(string ...$paths): self; /** - * @param array $headers + * @param string[] $headers * * @return $this */ diff --git a/php/src/Exceptions/InvalidArgumentException.php b/php/src/Exceptions/InvalidArgumentException.php new file mode 100644 index 00000000..a29ab575 --- /dev/null +++ b/php/src/Exceptions/InvalidArgumentException.php @@ -0,0 +1,9 @@ + + */ + private array $jwks; /** * @var string[] */ private array $requiredHeaders = []; + /** + * @param array ...$jsonObjects + * + * @return IVerifier + * @throws InvalidArgumentException + */ + public static function verifyWithJsonKeys(array ...$jsonObjects): IVerifier + { + $jwks = []; + try { + foreach ($jsonObjects as $jsonObject) { + $encoded = json_encode($jsonObject); + if (!is_string($encoded)) { + throw new InvalidArgumentException('One or multiple keys are invalid'); + } + + $jwks[] = JWK::createFromJson($encoded); + } + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException('One or multiple keys are invalid'); + } + + return new self($jwks); + } + + /** + * @param JWK ...$jwks + * + * @return IVerifier + */ + public static function verifyWithKeys(JWK ...$jwks): IVerifier + { + return new self($jwks); + } + /** * @param JWK $jwk * - * @return Verifier + * @return IVerifier */ - public static function verifyWithKey(JWK $jwk): Verifier + public static function verifyWithKey(JWK $jwk): IVerifier { - return new self($jwk); + return self::verifyWithKeys($jwk); } /** - * @param string $pem + * @param string ...$pems * - * @return Verifier + * @return IVerifier + * @throws InvalidArgumentException */ - public static function verifyWithPem(string $pem): Verifier + public static function verifyWithPem(string ...$pems): IVerifier { - $jwk = JWKFactory::createFromKey($pem, null, [ - 'use' => 'sig', - ]); + $jwks = []; + try { + foreach ($pems as $pem) { + $jwks[] = JWKFactory::createFromKey($pem, null, [ + 'use' => 'sig', + ]); + } + } catch (\Exception $e) { + throw new InvalidArgumentException('One or multiple PEM keys could not be deserialized'); + } - return new self($jwk); + return new self($jwks); } /** - * @param string $pemBase64 + * @param string ...$pemsBase64 * - * @return Verifier + * @return IVerifier + * @throws InvalidArgumentException */ - public static function verifyWithPemBase64(string $pemBase64): Verifier + public static function verifyWithPemBase64(string ...$pemsBase64): IVerifier { - return self::verifyWithPem(\base64_decode($pemBase64)); + $decodedPems = []; + foreach ($pemsBase64 as $pemBase64) { + $decodedPems[] = \base64_decode($pemBase64); + } + + return self::verifyWithPem(...$decodedPems); } /** - * @param string $path + * @param string ...$paths * - * @return Verifier + * @return IVerifier + * @throws InvalidArgumentException */ - public static function verifyWithPemFile(string $path): Verifier + public static function verifyWithPemFile(string ...$paths): IVerifier { - $jwk = JWKFactory::createFromKeyFile($path, null, [ - 'use' => 'sig', - ]); + $jwks = []; - return new self($jwk); + try { + foreach ($paths as $path) { + $jwks[] = JWKFactory::createFromKeyFile($path, null, [ + 'use' => 'sig', + ]); + } + } catch (\Exception $e) { + throw new InvalidArgumentException('One or multiple files contain invalid keys'); + } + + return new self($jwks); } /** - * @param JWK $jwk + * @param array $jwks */ - private function __construct(JWK $jwk) + private function __construct(array $jwks) { - $this->jwk = $jwk; + $this->jwks = $jwks; $this->serializerManager = new JWSSerializerManager([new CompactSerializer()]); $this->verifier = new JWSVerifier(new AlgorithmManager([new ES512()])); } @@ -92,9 +162,9 @@ private function __construct(JWK $jwk) /** * @param string[] $headers * - * @return $this + * @return IVerifier */ - public function requireHeaders(array $headers): Verifier + public function requireHeaders(array $headers): IVerifier { \array_push($this->requiredHeaders, ...$headers); @@ -126,10 +196,14 @@ public function verify(string $signature): void throw new InvalidAlgorithmException(); } - if ($jwsHeaders['tl_version'] !== TrueLayerSignatures::SIGNING_VERSION) { + if (!empty($jwsHeaders['tl_version']) && $jwsHeaders['tl_version'] !== TrueLayerSignatures::SIGNING_VERSION) { throw new InvalidTrueLayerSignatureVersionException(); } + if (empty($jwsHeaders['kid'])) { + throw new InvalidSignatureException('The kid is missing from the signature headers'); + } + $tlHeaders = !empty($jwsHeaders['tl_headers']) ? \explode(',', $jwsHeaders['tl_headers']) : []; $normalisedTlHeaders = Util::normaliseHeaderKeys($tlHeaders); foreach ($this->requiredHeaders as $header) { @@ -138,8 +212,12 @@ public function verify(string $signature): void } } - if (!$this->verifier->verifyWithKey($jws, $this->jwk, TrueLayerSignatures::SIGNATURE_INDEX, $this->buildPayload($tlHeaders))) { - throw new InvalidSignatureException(); + foreach ($this->jwks as $jwk) { + if ($this->verifier->verifyWithKey($jws, $jwk, TrueLayerSignatures::SIGNATURE_INDEX, $this->buildPayload($tlHeaders))) { + return; + } } + + throw new InvalidSignatureException(); } } diff --git a/php/tests/MockData.php b/php/tests/MockData.php index 02949b00..1e48dfd7 100644 --- a/php/tests/MockData.php +++ b/php/tests/MockData.php @@ -6,15 +6,21 @@ use Jose\Component\Core\JWK; use Jose\Component\KeyManagement\JWKFactory; +use Ramsey\Uuid\Uuid; class MockData { /** + * @param ?string $kid + * * @return array */ - public static function generateKeyPair(): array + public static function generateKeyPair(?string $kid = null): array { - $jwk = JWKFactory::createECKey('P-521'); + if (empty($kid)) { + $kid = Uuid::uuid4()->toString(); + } + $jwk = JWKFactory::createECKey('P-521', ['kid' => $kid]); return [ 'private' => $jwk, diff --git a/php/tests/VerifierTest.php b/php/tests/VerifierTest.php index 2df3c79c..3112a214 100644 --- a/php/tests/VerifierTest.php +++ b/php/tests/VerifierTest.php @@ -51,8 +51,9 @@ }); it('should throw when required header is missing', function () { - $keys = MockData::generateKeyPair(); - $signer = Signer::signWithKey(Uuid::uuid4()->toString(), $keys['private']); + $kid = Uuid::uuid4()->toString(); + $keys = MockData::generateKeyPair($kid); + $signer = Signer::signWithKey($kid, $keys['private']); $verifier = Verifier::verifyWithKey($keys['public']); $signature = $signer->method('PUT') @@ -76,8 +77,9 @@ ); it('should throw when the signature is invalid', function () { - $keys = MockData::generateKeyPair(); - $signer = Signer::signWithKey(Uuid::uuid4()->toString(), $keys['private']); + $kid = Uuid::uuid4()->toString(); + $keys = MockData::generateKeyPair($kid); + $signer = Signer::signWithKey($kid, $keys['private']); $verifier = Verifier::verifyWithKey($keys['public']); $signature = $signer->method('PUT') @@ -97,8 +99,9 @@ })->throws(\TrueLayer\Signing\Exceptions\InvalidSignatureException::class); it('should verify header order/casing flexibility', function () { - $keys = MockData::generateKeyPair(); - $signer = Signer::signWithKey(Uuid::uuid4()->toString(), $keys['private']); + $kid = Uuid::uuid4()->toString(); + $keys = MockData::generateKeyPair($kid); + $signer = Signer::signWithKey($kid, $keys['private']); $verifier = Verifier::verifyWithKey($keys['public']); $signature = $signer->method('PUT') @@ -127,8 +130,9 @@ }); it('should not verify the wrong HTTP method', function () { - $keys = MockData::generateKeyPair(); - $signer = Signer::signWithKey(Uuid::uuid4()->toString(), $keys['private']); + $kid = Uuid::uuid4()->toString(); + $keys = MockData::generateKeyPair($kid); + $signer = Signer::signWithKey($kid, $keys['private']); $verifier = Verifier::verifyWithKey($keys['public']); $signature = $signer->method('PUT') @@ -154,8 +158,9 @@ })->throws(\TrueLayer\Signing\Exceptions\InvalidSignatureException::class); it('should verify a signature that has no headers', function () { - $keys = MockData::generateKeyPair(); - $signer = Signer::signWithKey(Uuid::uuid4()->toString(), $keys['private']); + $kid = Uuid::uuid4()->toString(); + $keys = MockData::generateKeyPair($kid); + $signer = Signer::signWithKey($kid, $keys['private']); $verifier = Verifier::verifyWithKey($keys['public']); $signature = $signer->method('POST') @@ -205,3 +210,23 @@ /* @phpstan-ignore-next-line */ expect($verifier->verify($signature))->not->toThrow(Exception::class); }); + +it('should verify a valid signature from decoded json', function () { + $signature = file_get_contents('../test-resources/tl-signature.txt'); + $jwksJson = file_get_contents('../test-resources/jwks.json'); + + /** + * @var array>> $jwks + */ + $jwks = json_decode((string) $jwksJson, true); + $verifier = Verifier::verifyWithJsonKeys(...$jwks['keys']); + + $verifier->method('POST') + ->path('/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping') + ->header('X-Whatever-2', 't2345d') + ->header('Idempotency-Key', 'idemp-2076717c-9005-4811-a321-9e0787fa0382') + ->body('{"currency":"GBP","max_amount_in_minor":5000000}'); + + /* @phpstan-ignore-next-line */ + expect($verifier->verify($signature))->not->toThrow(Exception::class); +});