diff --git a/src/HOTP.php b/src/HOTP.php index 5336a605c8f038e18e86c8954069125202acd048..b717e13ac7551f9723c5feb9242e4b85ea594152 100644 --- a/src/HOTP.php +++ b/src/HOTP.php @@ -6,6 +6,7 @@ namespace OTPHP; use InvalidArgumentException; use function is_int; +use ParagonIE\ConstantTime\Base32; /** * @see \OTPHP\Test\HOTPTest @@ -27,6 +28,22 @@ final class HOTP extends OTP implements HOTPInterface return new self($secret, $counter, $digest, $digits); } + public static function createFromSecret( + string $secret, + int $counter = 0, + string $digest = 'sha1', + int $digits = 6 + ): self { + return new self($secret, $counter, $digest, $digits); + } + + public static function generate(int $counter = 0, string $digest = 'sha1', int $digits = 6): self + { + $secret = Base32::encodeUpper(random_bytes(64)); + + return new self($secret, $counter, $digest, $digits); + } + public function getCounter(): int { $value = $this->getParameter('counter'); diff --git a/src/HOTPInterface.php b/src/HOTPInterface.php index 1f9cdd7f8fd863db555beaccad5efbcce46d4b0e..011f86abe9a2ada58bfc39b2f22344ce4c455f3c 100644 --- a/src/HOTPInterface.php +++ b/src/HOTPInterface.php @@ -12,7 +12,7 @@ interface HOTPInterface extends OTPInterface public function getCounter(): int; /** - * Create a new TOTP object. + * Create a new HOTP object. * * If the secret is null, a random 64 bytes secret will be generated. */ @@ -22,4 +22,21 @@ interface HOTPInterface extends OTPInterface string $digest = 'sha1', int $digits = 6 ): self; + + /** + * Create a TOTP object from an existing secret. + * + * @param non-empty-string $secret + */ + public static function createFromSecret( + string $secret, + int $counter = 0, + string $digest = 'sha1', + int $digits = 6 + ): self; + + /** + * Create a new HOTP object. A random 64 bytes secret will be generated. + */ + public static function generate(int $counter = 0, string $digest = 'sha1', int $digits = 6): self; } diff --git a/src/ParameterTrait.php b/src/ParameterTrait.php index cef50f3e53a503fee583637292413b68870b98bf..2a01ca657d6970d74e754c54911f823e5abcab4f 100644 --- a/src/ParameterTrait.php +++ b/src/ParameterTrait.php @@ -9,7 +9,6 @@ use function in_array; use InvalidArgumentException; use function is_int; use function is_string; -use ParagonIE\ConstantTime\Base32; trait ParameterTrait { @@ -136,10 +135,6 @@ trait ParameterTrait return $value; }, 'secret' => static function ($value): string { - if ($value === null) { - $value = Base32::encodeUpper(random_bytes(64)); - } - return mb_strtoupper(trim($value, '=')); }, 'algorithm' => static function ($value): string { diff --git a/src/TOTP.php b/src/TOTP.php index 2e673675e9c15f29f2a4b52ffa480301f258b96a..3835881c70937bd640599bc2f07c155d80d5ff13 100644 --- a/src/TOTP.php +++ b/src/TOTP.php @@ -6,6 +6,7 @@ namespace OTPHP; use InvalidArgumentException; use function is_int; +use ParagonIE\ConstantTime\Base32; /** * @see \OTPHP\Test\TOTPTest @@ -29,6 +30,27 @@ final class TOTP extends OTP implements TOTPInterface return new self($secret, $period, $digest, $digits, $epoch); } + public static function createFromSecret( + string $secret, + int $period = 30, + string $digest = 'sha1', + int $digits = 6, + int $epoch = 0 + ): self { + return new self($secret, $period, $digest, $digits, $epoch); + } + + public static function generate( + int $period = 30, + string $digest = 'sha1', + int $digits = 6, + int $epoch = 0 + ): self { + $secret = Base32::encodeUpper(random_bytes(64)); + + return new self($secret, $period, $digest, $digits, $epoch); + } + public function getPeriod(): int { $value = $this->getParameter('period'); diff --git a/src/TOTPInterface.php b/src/TOTPInterface.php index 3a6f3e7be593d39dc719fd5d0f588caf673bcb91..550e9ff08d7088ddeca9ca760fcc0866ad758181 100644 --- a/src/TOTPInterface.php +++ b/src/TOTPInterface.php @@ -18,6 +18,23 @@ interface TOTPInterface extends OTPInterface int $digits = 6 ): self; + /** + * Create a TOTP object from an existing secret. + * + * @param non-empty-string $secret + */ + public static function createFromSecret( + string $secret, + int $period = 30, + string $digest = 'sha1', + int $digits = 6 + ): self; + + /** + * Create a new TOTP object. A random 64 bytes secret will be generated. + */ + public static function generate(int $period = 30, string $digest = 'sha1', int $digits = 6): self; + /** * Return the TOTP at the current time. */ diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index b0acca0bf723d1bd1164d06da4768208d194c0a9..e21c2d609c85ae4ecbd78cba2c3431e7e2747b8f 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -21,7 +21,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The label is not set.'); - $hotp = HOTP::create(); + $hotp = HOTP::generate(); $hotp->getProvisioningUri(); } @@ -32,7 +32,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Issuer must not contain a colon.'); - $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); $otp->setLabel('alice'); $otp->setIssuer('foo%3Abar'); } @@ -44,7 +44,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Issuer must not contain a colon.'); - $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); $otp->setLabel('alice'); $otp->setIssuer('foo%3abar'); } @@ -56,7 +56,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Label must not contain a colon.'); - $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); $otp->setLabel('foo%3Abar'); $otp->getProvisioningUri(); } @@ -68,7 +68,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Label must not contain a colon.'); - $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); $otp->setLabel('foo:bar'); $otp->getProvisioningUri(); } @@ -80,7 +80,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Digits must be at least 1.'); - HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 0); + HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 0); } /** @@ -90,7 +90,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Counter must be at least 0.'); - HOTP::create('JDDK4U6G3BJLEZ7Y', -500); + HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', -500); } /** @@ -100,7 +100,7 @@ final class HOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The "foo" digest is not supported.'); - HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'foo'); + HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'foo'); } /** @@ -114,7 +114,7 @@ final class HOTPTest extends TestCase $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?'); $secret = random_bytes(32); - $otp = HOTP::create($secret); + $otp = HOTP::createFromSecret($secret); $otp->at(0); } @@ -123,7 +123,7 @@ final class HOTPTest extends TestCase */ public function objectCreationValid(): void { - $otp = HOTP::create(); + $otp = HOTP::generate(); static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret()); } @@ -184,7 +184,9 @@ final class HOTPTest extends TestCase string $label = 'alice@foo.bar', string $issuer = 'My Project' ): HOTP { - $otp = HOTP::create($secret, $counter, $digest, $digits); + static::assertNotSame('', $secret); + + $otp = HOTP::createFromSecret($secret, $counter, $digest, $digits); $otp->setLabel($label); $otp->setIssuer($issuer); diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index 922deb01557543965dd56ab0f7ea9600530588f4..f28b7cb3b5aae839f057227babd6b8d817881335 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -24,7 +24,7 @@ final class TOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The label is not set.'); - $otp = TOTP::create(); + $otp = TOTP::generate(); $otp->getProvisioningUri(); } @@ -33,7 +33,7 @@ final class TOTPTest extends TestCase */ public function customParameter(): void { - $otp = TOTP::create('JDDK4U6G3BJLEZ7Y', 20, 'sha512', 8, 100); + $otp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 20, 'sha512', 8, 100); $otp->setLabel('alice@foo.bar'); $otp->setIssuer('My Project'); $otp->setParameter('foo', 'bar.baz'); @@ -49,7 +49,7 @@ final class TOTPTest extends TestCase */ public function objectCreationValid(): void { - $otp = TOTP::create(); + $otp = TOTP::generate(); static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret()); } @@ -61,7 +61,7 @@ final class TOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Period must be at least 1.'); - TOTP::create('JDDK4U6G3BJLEZ7Y', -20, 'sha512', 8); + TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', -20, 'sha512', 8); } /** @@ -71,7 +71,7 @@ final class TOTPTest extends TestCase { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Epoch must be greater than or equal to 0.'); - TOTP::create('JDDK4U6G3BJLEZ7Y', 30, 'sha512', 8, -1); + TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 30, 'sha512', 8, -1); } /** @@ -83,7 +83,7 @@ final class TOTPTest extends TestCase $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?'); $secret = random_bytes(32); - $otp = TOTP::create($secret); + $otp = TOTP::createFromSecret($secret); $otp->now(); } @@ -402,7 +402,9 @@ final class TOTPTest extends TestCase string $issuer = 'My Project', int $epoch = 0 ): TOTP { - $otp = TOTP::create($secret, $period, $digest, $digits, $epoch); + static::assertNotSame('', $secret); + + $otp = TOTP::createFromSecret($secret, $period, $digest, $digits, $epoch); $otp->setLabel($label); $otp->setIssuer($issuer);