diff --git a/Makefile b/Makefile index 159530a..aaa2b99 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ .PHONY: mu mu: vendor ## Mutation tests - vendor/bin/infection -s --threads=$$(nproc) --min-msi=30 --min-covered-msi=50 + XDEBUG_MODE=coverage vendor/bin/infection -s --threads=$$(nproc) --min-msi=30 --min-covered-msi=50 .PHONY: tests tests: vendor ## Run all tests @@ -42,11 +42,11 @@ st: vendor ## Run static analyse .PHONY: ci-mu ci-mu: vendor ## Mutation tests (for CI/CD only) - vendor/bin/infection --logger-github -s --threads=$$(nproc) --min-msi=30 --min-covered-msi=50 + XDEBUG_MODE=coverage vendor/bin/infection --logger-github -s --threads=$$(nproc) --min-msi=30 --min-covered-msi=50 .PHONY: ci-cc ci-cc: vendor ## Show test coverage rates (for CI/CD only) - vendor/bin/phpunit --coverage-text + XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text .PHONY: ci-cs ci-cs: vendor ## Check all files using defined ECS rules (for CI/CD only) diff --git a/composer.json b/composer.json index 080df37..0b60cbe 100644 --- a/composer.json +++ b/composer.json @@ -16,22 +16,23 @@ } ], "require": { - "php": "^8.1", + "php": ">=8.1", "ext-mbstring": "*", - "paragonie/constant_time_encoding": "^2.0" + "paragonie/constant_time_encoding": "^2.0", + "psr/clock": "^1.0", + "symfony/deprecation-contracts": "^3.2" }, "require-dev": { "ekino/phpstan-banned-code": "^1.0", - "infection/infection": "^0.26", + "infection/infection": "^0.26.20", "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/phpstan": "^1.0", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5.26", + "phpunit/phpunit": "^10.1", "qossmic/deptrac-shim": "^1.0", "rector/rector": "^0.15", - "symfony/phpunit-bridge": "^6.1", "symplify/easy-coding-standard": "^11.0" }, "autoload": { diff --git a/deptrac.yaml b/deptrac.yaml index 1986e15..b86b1e1 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -9,6 +9,7 @@ parameters: - name: 'Vendors' collectors: - { type: className, regex: '^ParagonIE\\' } + - { type: className, regex: '^Psr\\Clock\\' } ruleset: OTP: - 'Vendors' diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f00f567..84e4608 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,11 +5,6 @@ parameters: count: 1 path: src/HOTP.php - - - message: "#^Comparison operation \"\\>\\=\" between int\\<0, max\\>\\|null and 0 is always true\\.$#" - count: 1 - path: src/HOTP.php - - message: "#^Method OTPHP\\\\OTP\\:\\:generateSecret\\(\\) should return non\\-empty\\-string but returns string\\.$#" count: 1 @@ -20,11 +15,6 @@ parameters: count: 1 path: src/OTP.php - - - message: "#^Comparison operation \"\\>\\=\" between int\\<0, max\\> and 0 is always true\\.$#" - count: 1 - path: src/TOTP.php - - message: "#^Method OTPHP\\\\TOTP\\:\\:expiresIn\\(\\) should return int\\<0, max\\> but returns int\\.$#" count: 1 @@ -37,14 +27,9 @@ parameters: - message: "#^Parameter \\#1 \\$input of method OTPHP\\\\TOTP\\:\\:at\\(\\) expects int\\<0, max\\>, int given\\.$#" - count: 1 + count: 2 path: src/TOTP.php - - - message: "#^Left side of \\|\\| is always true\\.$#" - count: 1 - path: src/Url.php - - message: "#^Parameter \\#2 \\$host of class OTPHP\\\\Url constructor expects non\\-empty\\-string, string given\\.$#" count: 1 @@ -75,12 +60,32 @@ parameters: count: 1 path: tests/HOTPTest.php + - + message: "#^Parameter \\#1 \\$dateTime of method OTPHP\\\\Test\\\\ClockMock\\:\\:setDateTime\\(\\) expects DateTimeImmutable\\|null, DateTimeImmutable\\|false given\\.$#" + count: 5 + path: tests/TOTPTest.php + - message: "#^Parameter \\#1 \\$epoch of method OTPHP\\\\TOTP\\:\\:setEpoch\\(\\) expects int\\<0, max\\>, \\-1 given\\.$#" count: 1 path: tests/TOTPTest.php + - + message: "#^Parameter \\#1 \\$otp of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects non\\-empty\\-string, string given\\.$#" + count: 2 + path: tests/TOTPTest.php + - message: "#^Parameter \\#1 \\$period of method OTPHP\\\\TOTP\\:\\:setPeriod\\(\\) expects int\\<1, max\\>, \\-20 given\\.$#" count: 1 path: tests/TOTPTest.php + + - + message: "#^Parameter \\#3 \\$leeway of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects int\\<0, max\\>\\|null, int given\\.$#" + count: 2 + path: tests/TOTPTest.php + + - + message: "#^Parameter \\#3 \\$period of static method OTPHP\\\\Test\\\\TOTPTest\\:\\:createTOTP\\(\\) expects int\\<1, max\\>, int given\\.$#" + count: 1 + path: tests/TOTPTest.php diff --git a/phpstan.neon b/phpstan.neon index 09ff3da..e1f08f4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,6 +4,7 @@ parameters: - src - tests + treatPhpDocTypesAsCertain: false includes: - vendor/phpstan/phpstan/conf/bleedingEdge.neon - vendor/phpstan/phpstan-strict-rules/rules.neon diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e52c02c..3532828 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,28 +1,14 @@ - - - - ./src - - + ./tests - - - - - OTPHP\TOTP - - - - + + + + ./src + + diff --git a/rector.php b/rector.php index d781ad4..c76df41 100644 --- a/rector.php +++ b/rector.php @@ -30,7 +30,10 @@ PHPUnitSetList::PHPUNIT_YIELD_DATA_PROVIDER, ]); $config->parallel(); - $config->paths([__DIR__ . '/src']); + $config->paths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]); $config->phpVersion(PhpVersion::PHP_81); $config->importNames(); $config->importShortClasses(); diff --git a/src/Factory.php b/src/Factory.php index 409d875..d5ec9c2 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -7,6 +7,7 @@ use function assert; use function count; use InvalidArgumentException; +use Psr\Clock\ClockInterface; use Throwable; /** @@ -16,7 +17,7 @@ */ final class Factory implements FactoryInterface { - public static function loadFromProvisioningUri(string $uri): OTPInterface + public static function loadFromProvisioningUri(string $uri, ?ClockInterface $clock = null): OTPInterface { try { $parsed_url = Url::fromString($uri); @@ -24,8 +25,16 @@ public static function loadFromProvisioningUri(string $uri): OTPInterface } catch (Throwable $throwable) { throw new InvalidArgumentException('Not a valid OTP provisioning URI', $throwable->getCode(), $throwable); } + if ($clock === null) { + trigger_deprecation( + 'spomky-labs/otphp', + '11.3.0', + 'The parameter "$clock" will become mandatory in 12.0.0. Please set a valid PSR Clock implementation instead of "null".' + ); + $clock = new InternalClock(); + } - $otp = self::createOTP($parsed_url); + $otp = self::createOTP($parsed_url, $clock); self::populateOTP($otp, $parsed_url); @@ -62,11 +71,11 @@ private static function populateOTP(OTPInterface $otp, Url $data): void $otp->setIssuer($result[0]); } - private static function createOTP(Url $parsed_url): OTPInterface + private static function createOTP(Url $parsed_url, ClockInterface $clock): OTPInterface { switch ($parsed_url->getHost()) { case 'totp': - $totp = TOTP::createFromSecret($parsed_url->getSecret()); + $totp = TOTP::createFromSecret($parsed_url->getSecret(), $clock); $totp->setLabel(self::getLabel($parsed_url->getPath())); return $totp; diff --git a/src/InternalClock.php b/src/InternalClock.php new file mode 100644 index 0000000..8be4693 --- /dev/null +++ b/src/InternalClock.php @@ -0,0 +1,19 @@ +clock = $clock; + } + public static function create( null|string $secret = null, int $period = self::DEFAULT_PERIOD, string $digest = self::DEFAULT_DIGEST, int $digits = self::DEFAULT_DIGITS, - int $epoch = self::DEFAULT_EPOCH + int $epoch = self::DEFAULT_EPOCH, + ?ClockInterface $clock = null ): self { $totp = $secret !== null - ? self::createFromSecret($secret) - : self::generate() + ? self::createFromSecret($secret, $clock) + : self::generate($clock) ; $totp->setPeriod($period); $totp->setDigest($digest); @@ -32,9 +51,9 @@ public static function create( return $totp; } - public static function createFromSecret(string $secret): self + public static function createFromSecret(string $secret, ?ClockInterface $clock = null): self { - $totp = new self($secret); + $totp = new self($secret, $clock); $totp->setPeriod(self::DEFAULT_PERIOD); $totp->setDigest(self::DEFAULT_DIGEST); $totp->setDigits(self::DEFAULT_DIGITS); @@ -43,9 +62,9 @@ public static function createFromSecret(string $secret): self return $totp; } - public static function generate(): self + public static function generate(?ClockInterface $clock = null): self { - return self::createFromSecret(self::generateSecret()); + return self::createFromSecret(self::generateSecret(), $clock); } public function getPeriod(): int @@ -68,7 +87,7 @@ public function expiresIn(): int { $period = $this->getPeriod(); - return $period - (time() % $this->getPeriod()); + return $period - ($this->clock->now()->getTimestamp() % $this->getPeriod()); } public function at(int $input): string @@ -78,7 +97,7 @@ public function at(int $input): string public function now(): string { - return $this->at(time()); + return $this->at($this->clock->now()->getTimestamp()); } /** @@ -87,7 +106,8 @@ public function now(): string */ public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool { - $timestamp ??= time(); + $timestamp ??= $this->clock->now() + ->getTimestamp(); $timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.'); if ($leeway === null) { diff --git a/src/Url.php b/src/Url.php index 76919d2..8b24a75 100644 --- a/src/Url.php +++ b/src/Url.php @@ -80,7 +80,6 @@ public static function fromString(string $uri): self array_key_exists($key, $parsed_url) || throw new InvalidArgumentException( 'Not a valid OTP provisioning URI' ); - is_string($parsed_url[$key]) || throw new InvalidArgumentException('Not a valid OTP provisioning URI'); } $scheme = $parsed_url['scheme'] ?? null; $host = $parsed_url['host'] ?? null; diff --git a/tests/ClockMock.php b/tests/ClockMock.php new file mode 100644 index 0000000..83c0b16 --- /dev/null +++ b/tests/ClockMock.php @@ -0,0 +1,26 @@ +dateTime ?? new DateTimeImmutable(); + } + + public function setDateTime(?DateTimeImmutable $dateTime): void + { + $this->dateTime = $dateTime; + } +} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index dea4e03..8a90b81 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -7,7 +7,9 @@ use InvalidArgumentException; use OTPHP\Factory; use OTPHP\HOTP; +use OTPHP\InternalClock; use OTPHP\TOTP; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; /** @@ -15,13 +17,11 @@ */ final class FactoryTest extends TestCase { - /** - * @test - */ + #[Test] public function tOTPLoad(): void { $otp = 'otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=8&foo=bar.baz&issuer=My%20Project&period=20&secret=JDDK4U6G3BJLEZ7Y'; - $result = Factory::loadFromProvisioningUri($otp); + $result = Factory::loadFromProvisioningUri($otp, new InternalClock()); static::assertInstanceOf(TOTP::class, $result); static::assertSame('My Project', $result->getIssuer()); @@ -36,26 +36,22 @@ public function tOTPLoad(): void static::assertSame($otp, $result->getProvisioningUri()); } - /** - * @test - */ + #[Test] public function tOTPObjectDoesNotHaveRequestedParameter(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Parameter "image" does not exist'); $otp = 'otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=8&foo=bar.baz&issuer=My%20Project&period=20&secret=JDDK4U6G3BJLEZ7Y'; - $result = Factory::loadFromProvisioningUri($otp); + $result = Factory::loadFromProvisioningUri($otp, new InternalClock()); $result->getParameter('image'); } - /** - * @test - */ + #[Test] public function hOTPLoad(): void { $otp = 'otpauth://hotp/My%20Project%3Aalice%40foo.bar?counter=1000&digits=8&image=https%3A%2F%2Ffoo.bar%2Fbaz&issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y'; - $result = Factory::loadFromProvisioningUri($otp); + $result = Factory::loadFromProvisioningUri($otp, new InternalClock()); static::assertInstanceOf(HOTP::class, $result); static::assertSame('My Project', $result->getIssuer()); @@ -69,79 +65,65 @@ public function hOTPLoad(): void static::assertSame($otp, $result->getProvisioningUri()); } - /** - * @test - */ + #[Test] public function badProvisioningUri1(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Not a valid OTP provisioning URI'); $otp = 'Hello !'; - Factory::loadFromProvisioningUri($otp); + Factory::loadFromProvisioningUri($otp, new InternalClock()); } - /** - * @test - */ + #[Test] public function badProvisioningUri2(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Not a valid OTP provisioning URI'); $otp = 'https://foo.bar/'; - Factory::loadFromProvisioningUri($otp); + Factory::loadFromProvisioningUri($otp, new InternalClock()); } - /** - * @test - */ + #[Test] public function badProvisioningUri3(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unsupported "foo" OTP type'); $otp = 'otpauth://foo/My%20Project%3Aalice%40foo.bar?counter=1000&digits=8&image=https%3A%2F%2Ffoo.bar%2Fbaz&issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y'; - Factory::loadFromProvisioningUri($otp); + Factory::loadFromProvisioningUri($otp, new InternalClock()); } - /** - * @test - */ + #[Test] public function badProvisioningUri4(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Not a valid OTP provisioning URI'); $otp = 'otpauth://hotp:My%20Project%3Aalice%40foo.bar?counter=1000&digits=8&image=https%3A%2F%2Ffoo.bar%2Fbaz&issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y'; - Factory::loadFromProvisioningUri($otp); + Factory::loadFromProvisioningUri($otp, new InternalClock()); } - /** - * @test - */ + #[Test] public function badProvisioningUri5(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Not a valid OTP provisioning URI'); $otp = 'bar://hotp/My%20Project%3Aalice%40foo.bar?counter=1000&digits=8&image=https%3A%2F%2Ffoo.bar%2Fbaz&issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y'; - Factory::loadFromProvisioningUri($otp); + Factory::loadFromProvisioningUri($otp, new InternalClock()); } - /** - * @test - */ + #[Test] public function badProvisioningUri6(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid OTP: invalid issuer in parameter'); $otp = 'otpauth://hotp/My%20Project2%3Aalice%40foo.bar?counter=1000&digits=8&image=https%3A%2F%2Ffoo.bar%2Fbaz&issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y'; - Factory::loadFromProvisioningUri($otp); + Factory::loadFromProvisioningUri($otp, new InternalClock()); } - /** - * @test - */ + #[Test] public function tOTPLoadWithoutIssuer(): void { $otp = 'otpauth://totp/My%20Test%20-%20Auth?secret=JDDK4U6G3BJLEZ7Y'; - $result = Factory::loadFromProvisioningUri($otp); + $result = Factory::loadFromProvisioningUri($otp, new InternalClock()); static::assertInstanceOf(TOTP::class, $result); static::assertNull($result->getIssuer()); @@ -154,13 +136,11 @@ public function tOTPLoadWithoutIssuer(): void static::assertSame($otp, $result->getProvisioningUri()); } - /** - * @test - */ + #[Test] public function tOTPLoadAndRemoveSecretTrailingCharacters(): void { $uri = 'otpauth://totp/My%20Test%20-%20Auth?secret=JDDK4U6G3BJLEQ%3D%3D'; - $totp = Factory::loadFromProvisioningUri($uri); + $totp = Factory::loadFromProvisioningUri($uri, new InternalClock()); static::assertInstanceOf(TOTP::class, $totp); static::assertSame('JDDK4U6G3BJLEQ', $totp->getSecret()); diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index 06ab342..6c65caf 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use OTPHP\HOTP; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -14,9 +15,7 @@ */ final class HOTPTest extends TestCase { - /** - * @test - */ + #[Test] public function labelNotDefined(): void { $this->expectException(InvalidArgumentException::class); @@ -25,9 +24,7 @@ public function labelNotDefined(): void $hotp->getProvisioningUri(); } - /** - * @test - */ + #[Test] public function issuerHasColon(): void { $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); @@ -37,9 +34,7 @@ public function issuerHasColon(): void $otp->setIssuer('foo%3Abar'); } - /** - * @test - */ + #[Test] public function issuerHasColon2(): void { $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); @@ -49,9 +44,7 @@ public function issuerHasColon2(): void $otp->setIssuer('foo%3abar'); } - /** - * @test - */ + #[Test] public function labelHasColon(): void { $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); @@ -61,9 +54,7 @@ public function labelHasColon(): void $otp->setLabel('foo%3Abar'); } - /** - * @test - */ + #[Test] public function labelHasColon2(): void { $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); @@ -73,9 +64,7 @@ public function labelHasColon2(): void $otp->setLabel('foo:bar'); } - /** - * @test - */ + #[Test] public function digitsIsNot1OrMore(): void { $htop = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); @@ -85,9 +74,7 @@ public function digitsIsNot1OrMore(): void $htop->setDigits(0); } - /** - * @test - */ + #[Test] public function counterIsNot1OrMore(): void { $htop = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); @@ -97,9 +84,7 @@ public function counterIsNot1OrMore(): void $htop->setCounter(-500); } - /** - * @test - */ + #[Test] public function digestIsNotSupported(): void { $htop = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); @@ -111,9 +96,8 @@ public function digestIsNotSupported(): void /** * xpectedExceptionMessage. - * - * @test */ + #[Test] public function secretShouldBeBase32Encoded(): void { $otp = HOTP::createFromSecret(random_bytes(32)); @@ -123,9 +107,7 @@ public function secretShouldBeBase32Encoded(): void $otp->at(0); } - /** - * @test - */ + #[Test] public function objectCreationValid(): void { $otp = HOTP::generate(); @@ -133,9 +115,7 @@ public function objectCreationValid(): void static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret()); } - /** - * @test - */ + #[Test] public function getProvisioningUri(): void { $otp = $this->createHOTP(8, 'sha1', 1000); @@ -147,9 +127,7 @@ public function getProvisioningUri(): void ); } - /** - * @test - */ + #[Test] public function verifyCounterInvalid(): void { $otp = $this->createHOTP(8, 'sha1', 1000); @@ -157,9 +135,7 @@ public function verifyCounterInvalid(): void static::assertFalse($otp->verify('98449994', 100)); } - /** - * @test - */ + #[Test] public function verifyCounterChanged(): void { $otp = $this->createHOTP(8, 'sha1', 1100); @@ -169,9 +145,7 @@ public function verifyCounterChanged(): void static::assertSame($otp->getCounter(), 1101); } - /** - * @test - */ + #[Test] public function verifyValidInWindow(): void { $otp = $this->createHOTP(8, 'sha1', 1000); diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index d110940..5370675 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -5,36 +5,36 @@ namespace OTPHP\Test; use function assert; +use DateTimeImmutable; use InvalidArgumentException; +use OTPHP\InternalClock; use OTPHP\TOTP; use OTPHP\TOTPInterface; use ParagonIE\ConstantTime\Base32; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Psr\Clock\ClockInterface; use RuntimeException; -use Symfony\Bridge\PhpUnit\ClockMock; /** * @internal */ final class TOTPTest extends TestCase { - /** - * @test - */ + #[Test] public function labelNotDefined(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The label is not set.'); - $otp = TOTP::generate(); + $otp = TOTP::generate(new InternalClock()); $otp->getProvisioningUri(); } - /** - * @test - */ + #[Test] public function customParameter(): void { - $otp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $otp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', new InternalClock()); $otp->setPeriod(20); $otp->setDigest('sha512'); $otp->setDigits(8); @@ -49,59 +49,49 @@ public function customParameter(): void ); } - /** - * @test - */ + #[Test] public function objectCreationValid(): void { - $otp = TOTP::generate(); + $otp = TOTP::generate(new InternalClock()); static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret()); } - /** - * @test - */ + #[Test] public function periodIsNot1OrMore(): void { - $totp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $totp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', new InternalClock()); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Period must be at least 1.'); $totp->setPeriod(-20); } - /** - * @test - */ + #[Test] public function epochIsNot0OrMore(): void { - $totp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $totp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', new InternalClock()); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Epoch must be greater than or equal to 0.'); $totp->setEpoch(-1); } - /** - * @test - */ + #[Test] public function secretShouldBeBase32Encoded(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?'); $secret = random_bytes(32); - $otp = TOTP::createFromSecret($secret); + $otp = TOTP::createFromSecret($secret, new InternalClock()); $otp->now(); } - /** - * @test - */ + #[Test] public function getProvisioningUri(): void { - $otp = $this->createTOTP(6, 'sha1', 30); + $otp = self::createTOTP(6, 'sha1', 30); static::assertSame( 'otpauth://totp/My%20Project%3Aalice%40foo.bar?issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y', @@ -109,6 +99,8 @@ public function getProvisioningUri(): void ); } + #[Test] + #[DataProvider('dataRemainingTimeBeforeExpiration')] /** * @param positive-int $timestamp * @param positive-int $period @@ -118,43 +110,37 @@ public function getProvisioningUri(): void */ public function getRemainingTimeBeforeExpiration(int $timestamp, int $period, int $expectedRemainder): void { - ClockMock::register(TOTP::class); - ClockMock::withClockMock($timestamp); - $otp = $this->createTOTP(6, 'sha1', $period); + $clock = new ClockMock(); + $clock->setDateTime(DateTimeImmutable::createFromFormat('U', (string) $timestamp)); + $otp = self::createTOTP(6, 'sha1', $period, clock: $clock); static::assertSame($expectedRemainder, $otp->expiresIn()); } - /** - * @test - */ + #[Test] public function generateOtpAt(): void { - $otp = $this->createTOTP(6, 'sha1', 30); + $otp = self::createTOTP(6, 'sha1', 30); static::assertSame('855783', $otp->at(0)); - static::assertSame('762124', $otp->at(319690800)); - static::assertSame('139664', $otp->at(1301012137)); + static::assertSame('762124', $otp->at(319_690_800)); + static::assertSame('139664', $otp->at(1_301_012_137)); } - /** - * @test - */ + #[Test] public function generateOtpWithEpochAt(): void { - $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); + $otp = self::createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); static::assertSame('855783', $otp->at(100)); - static::assertSame('762124', $otp->at(319690900)); - static::assertSame('139664', $otp->at(1301012237)); + static::assertSame('762124', $otp->at(319_690_900)); + static::assertSame('139664', $otp->at(1_301_012_237)); } - /** - * @test - */ + #[Test] public function wrongSizeOtp(): void { - $otp = $this->createTOTP(6, 'sha1', 30); + $otp = self::createTOTP(6, 'sha1', 30); static::assertFalse($otp->verify('0')); static::assertFalse($otp->verify('00')); @@ -163,71 +149,61 @@ public function wrongSizeOtp(): void static::assertFalse($otp->verify('00000')); } - /** - * @test - */ + #[Test] public function generateOtpNow(): void { - ClockMock::register(TOTP::class); - $time = time(); - ClockMock::withClockMock($time); - $otp = $this->createTOTP(6, 'sha1', 30); + $clock = new ClockMock(); + $timestamp = time(); + $clock->setDateTime(DateTimeImmutable::createFromFormat('U', (string) $timestamp)); + $otp = self::createTOTP(6, 'sha1', 30, clock: $clock); - static::assertSame($otp->now(), $otp->at($time)); + static::assertSame($otp->now(), $otp->at($timestamp)); } - /** - * @test - */ + #[Test] public function verifyOtpNow(): void { - ClockMock::register(TOTP::class); - $time = time(); - ClockMock::withClockMock($time); - $otp = $this->createTOTP(6, 'sha1', 30); + $timestamp = time(); + $clock = new ClockMock(); + $clock->setDateTime(DateTimeImmutable::createFromFormat('U', (string) $timestamp)); + $otp = self::createTOTP(6, 'sha1', 30, clock: $clock); - $totp = $otp->at($time); - static::assertTrue($otp->verify($totp, $time)); + $totp = $otp->at($timestamp); + static::assertTrue($otp->verify($totp, $timestamp)); } - /** - * @test - */ + #[Test] public function verifyOtp(): void { - $otp = $this->createTOTP(6, 'sha1', 30); + $otp = self::createTOTP(6, 'sha1', 30); static::assertTrue($otp->verify('855783', 0)); - static::assertTrue($otp->verify('762124', 319690800)); - static::assertTrue($otp->verify('139664', 1301012137)); + static::assertTrue($otp->verify('762124', 319_690_800)); + static::assertTrue($otp->verify('139664', 1_301_012_137)); - static::assertFalse($otp->verify('139664', 1301012107)); - static::assertFalse($otp->verify('139664', 1301012167)); - static::assertFalse($otp->verify('139664', 1301012197)); + static::assertFalse($otp->verify('139664', 1_301_012_107)); + static::assertFalse($otp->verify('139664', 1_301_012_167)); + static::assertFalse($otp->verify('139664', 1_301_012_197)); } - /** - * @test - */ + #[Test] public function verifyOtpWithEpoch(): void { - $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); + $otp = self::createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); static::assertTrue($otp->verify('855783', 100)); - static::assertTrue($otp->verify('762124', 319690900)); - static::assertTrue($otp->verify('139664', 1301012237)); + static::assertTrue($otp->verify('762124', 319_690_900)); + static::assertTrue($otp->verify('139664', 1_301_012_237)); - static::assertFalse($otp->verify('139664', 1301012207)); - static::assertFalse($otp->verify('139664', 1301012267)); - static::assertFalse($otp->verify('139664', 1301012297)); + static::assertFalse($otp->verify('139664', 1_301_012_207)); + static::assertFalse($otp->verify('139664', 1_301_012_267)); + static::assertFalse($otp->verify('139664', 1_301_012_297)); } - /** - * @test - */ + #[Test] public function notCompatibleWithGoogleAuthenticator(): void { - $otp = $this->createTOTP(9, 'sha512', 10); + $otp = self::createTOTP(9, 'sha512', 10); static::assertSame( 'otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=9&issuer=My%20Project&period=10&secret=JDDK4U6G3BJLEZ7Y', @@ -236,14 +212,12 @@ public function notCompatibleWithGoogleAuthenticator(): void } /** - * @dataProvider dataVectors - * * @param TOTPInterface $totp * @param positive-int $timestamp * @param non-empty-string $expected_value - * - * @test */ + #[Test] + #[DataProvider('dataVectors')] public function vectors($totp, $timestamp, $expected_value): void { static::assertSame($expected_value, $totp->at($timestamp)); @@ -256,73 +230,71 @@ public function vectors($totp, $timestamp, $expected_value): void * * @return array */ - public function dataVectors(): array + public static function dataVectors(): array { $sha1key = Base32::encodeUpper('12345678901234567890'); assert($sha1key !== ''); - $totp_sha1 = $this->createTOTP(8, 'sha1', 30, $sha1key); + $totp_sha1 = self::createTOTP(8, 'sha1', 30, $sha1key); $sha256key = Base32::encodeUpper('12345678901234567890123456789012'); assert($sha256key !== ''); - $totp_sha256 = $this->createTOTP(8, 'sha256', 30, $sha256key); + $totp_sha256 = self::createTOTP(8, 'sha256', 30, $sha256key); $sha512key = Base32::encodeUpper('1234567890123456789012345678901234567890123456789012345678901234'); assert($sha512key !== ''); - $totp_sha512 = $this->createTOTP(8, 'sha512', 30, $sha512key); + $totp_sha512 = self::createTOTP(8, 'sha512', 30, $sha512key); return [ [$totp_sha1, 59, '94287082'], [$totp_sha256, 59, '46119246'], [$totp_sha512, 59, '90693936'], - [$totp_sha1, 1111111109, '07081804'], - [$totp_sha256, 1111111109, '68084774'], - [$totp_sha512, 1111111109, '25091201'], - [$totp_sha1, 1111111111, '14050471'], - [$totp_sha256, 1111111111, '67062674'], - [$totp_sha512, 1111111111, '99943326'], - [$totp_sha1, 1234567890, '89005924'], - [$totp_sha256, 1234567890, '91819424'], - [$totp_sha512, 1234567890, '93441116'], - [$totp_sha1, 2000000000, '69279037'], - [$totp_sha256, 2000000000, '90698825'], - [$totp_sha512, 2000000000, '38618901'], - [$totp_sha1, 20000000000, '65353130'], - [$totp_sha256, 20000000000, '77737706'], - [$totp_sha512, 20000000000, '47863826'], + [$totp_sha1, 1_111_111_109, '07081804'], + [$totp_sha256, 1_111_111_109, '68084774'], + [$totp_sha512, 1_111_111_109, '25091201'], + [$totp_sha1, 1_111_111_111, '14050471'], + [$totp_sha256, 1_111_111_111, '67062674'], + [$totp_sha512, 1_111_111_111, '99943326'], + [$totp_sha1, 1_234_567_890, '89005924'], + [$totp_sha256, 1_234_567_890, '91819424'], + [$totp_sha512, 1_234_567_890, '93441116'], + [$totp_sha1, 2_000_000_000, '69279037'], + [$totp_sha256, 2_000_000_000, '90698825'], + [$totp_sha512, 2_000_000_000, '38618901'], + [$totp_sha1, 20_000_000_000, '65353130'], + [$totp_sha256, 20_000_000_000, '77737706'], + [$totp_sha512, 20_000_000_000, '47863826'], ]; } - /** - * @test - */ + #[Test] public function invalidOtpWindow(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The leeway must be lower than the TOTP period'); - $otp = $this->createTOTP(6, 'sha1', 30); + $otp = self::createTOTP(6, 'sha1', 30); $otp->verify('123456', null, 31); } + #[Test] + #[DataProvider('dataLeeway')] /** * @param positive-int $timestamp * @param non-empty-string $input * @param 0|positive-int $leeway - * @test - * @dataProvider dataLeeway */ public function verifyOtpInWindow(int $timestamp, string $input, int $leeway, bool $expectedResult): void { - ClockMock::register(TOTP::class); - ClockMock::withClockMock($timestamp); - $otp = $this->createTOTP(6, 'sha1', 30); + $clock = new ClockMock(); + $clock->setDateTime(DateTimeImmutable::createFromFormat('U', (string) $timestamp)); + $otp = self::createTOTP(6, 'sha1', 30, clock: $clock); static::assertSame($expectedResult, $otp->verify($input, null, $leeway)); } + #[Test] + #[DataProvider('dataLeewayWithEpoch')] /** * @param positive-int $timestamp * @param non-empty-string $input * @param 0|positive-int $leeway - * @test - * @dataProvider dataLeewayWithEpoch */ public function verifyOtpWithEpochInWindow( int $timestamp, @@ -330,9 +302,9 @@ public function verifyOtpWithEpochInWindow( int $leeway, bool $expectedResult ): void { - ClockMock::register(TOTP::class); - ClockMock::withClockMock($timestamp); - $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); + $clock = new ClockMock(); + $clock->setDateTime(DateTimeImmutable::createFromFormat('U', (string) $timestamp)); + $otp = self::createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100, $clock); static::assertSame($expectedResult, $otp->verify($input, null, $leeway)); } @@ -340,29 +312,27 @@ public function verifyOtpWithEpochInWindow( /** * @return array[] */ - public function dataLeewayWithEpoch(): array + public static function dataLeewayWithEpoch(): array { return [ - [319690889, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec - [319690890, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec - [319690899, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec - [319690899, '762124', 0, false], //No leeway, **out** the period - [319690900, '762124', 0, true], //No leeway, in the period - [319690920, '762124', 0, true], //No leeway, in the period - [319690929, '762124', 0, true], //No leeway, in the period - [319690930, '762124', 0, false], //No leeway, **out** the period - [319690930, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec - [319690939, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec - [319690940, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + [319_690_889, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + [319_690_890, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319_690_899, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319_690_899, '762124', 0, false], //No leeway, **out** the period + [319_690_900, '762124', 0, true], //No leeway, in the period + [319_690_920, '762124', 0, true], //No leeway, in the period + [319_690_929, '762124', 0, true], //No leeway, in the period + [319_690_930, '762124', 0, false], //No leeway, **out** the period + [319_690_930, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319_690_939, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319_690_940, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec ]; } - /** - * @test - */ + #[Test] public function qRCodeUri(): void { - $otp = $this->createTOTP(6, 'sha1', 30, 'DJBSWY3DPEHPK3PXP', 'alice@google.com', 'My Big Compagny'); + $otp = self::createTOTP(6, 'sha1', 30, 'DJBSWY3DPEHPK3PXP', 'alice@google.com', 'My Big Compagny'); static::assertSame( 'http://chart.apis.google.com/chart?cht=qr&chs=250x250&chl=otpauth%3A%2F%2Ftotp%2FMy%2520Big%2520Compagny%253Aalice%2540google.com%3Fissuer%3DMy%2520Big%2520Compagny%26secret%3DDJBSWY3DPEHPK3PXP', @@ -383,41 +353,41 @@ public function qRCodeUri(): void /** * @return int[][] */ - public function dataRemainingTimeBeforeExpiration(): array + public static function dataRemainingTimeBeforeExpiration(): array { return [ - [1644926810, 90, 40], - [1644926810, 30, 10], - [1644926810, 20, 10], - [1577833199, 90, 1], - [1577833199, 30, 1], - [1577833199, 20, 1], - [1577833200, 90, 90], - [1577833200, 30, 30], - [1577833200, 20, 20], - [1577833201, 90, 89], - [1577833201, 30, 29], - [1577833201, 20, 19], + [1_644_926_810, 90, 40], + [1_644_926_810, 30, 10], + [1_644_926_810, 20, 10], + [1_577_833_199, 90, 1], + [1_577_833_199, 30, 1], + [1_577_833_199, 20, 1], + [1_577_833_200, 90, 90], + [1_577_833_200, 30, 30], + [1_577_833_200, 20, 20], + [1_577_833_201, 90, 89], + [1_577_833_201, 30, 29], + [1_577_833_201, 20, 19], ]; } /** * @return array[] */ - public function dataLeeway(): array + public static function dataLeeway(): array { return [ - [319690789, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec - [319690790, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec - [319690799, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec - [319690799, '762124', 0, false], //No leeway, **out** the period - [319690800, '762124', 0, true], //No leeway, in the period - [319690820, '762124', 0, true], //No leeway, in the period - [319690829, '762124', 0, true], //No leeway, in the period - [319690830, '762124', 0, false], //No leeway, **out** the period - [319690830, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec - [319690839, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec - [319690840, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + [319_690_789, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + [319_690_790, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319_690_799, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319_690_799, '762124', 0, false], //No leeway, **out** the period + [319_690_800, '762124', 0, true], //No leeway, in the period + [319_690_820, '762124', 0, true], //No leeway, in the period + [319_690_829, '762124', 0, true], //No leeway, in the period + [319_690_830, '762124', 0, false], //No leeway, **out** the period + [319_690_830, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319_690_839, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319_690_840, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec ]; } @@ -430,16 +400,21 @@ public function dataLeeway(): array * @param non-empty-string $issuer * @param 0|positive-int $epoch */ - private function createTOTP( + private static function createTOTP( int $digits, string $digest, int $period, string $secret = 'JDDK4U6G3BJLEZ7Y', string $label = 'alice@foo.bar', string $issuer = 'My Project', - int $epoch = 0 + int $epoch = 0, + ?ClockInterface $clock = null ): TOTP { - $otp = TOTP::createFromSecret($secret); + static::assertNotSame('', $secret); + static::assertNotSame('', $digest); + $clock ??= new InternalClock(); + + $otp = TOTP::createFromSecret($secret, $clock); $otp->setPeriod($period); $otp->setDigest($digest); $otp->setDigits($digits);