diff options
Diffstat (limited to 'vendor/spomky-labs/otphp')
-rw-r--r-- | vendor/spomky-labs/otphp/LICENSE | 20 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/README.md | 42 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/SECURITY.md | 87 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/composer.json | 57 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/Factory.php | 85 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/FactoryInterface.php | 14 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/HOTP.php | 124 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/HOTPInterface.php | 34 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/OTP.php | 129 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/OTPInterface.php | 110 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/ParameterTrait.php | 188 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/TOTP.php | 172 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/TOTPInterface.php | 47 | ||||
-rw-r--r-- | vendor/spomky-labs/otphp/src/Url.php | 88 |
14 files changed, 1197 insertions, 0 deletions
diff --git a/vendor/spomky-labs/otphp/LICENSE b/vendor/spomky-labs/otphp/LICENSE new file mode 100644 index 000000000..e6a673e34 --- /dev/null +++ b/vendor/spomky-labs/otphp/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014-2016 Florent Morselli + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/spomky-labs/otphp/README.md b/vendor/spomky-labs/otphp/README.md new file mode 100644 index 000000000..b23282ea1 --- /dev/null +++ b/vendor/spomky-labs/otphp/README.md @@ -0,0 +1,42 @@ +TOTP / HOTP library in PHP +========================== + +![Build Status](https://github.com/spomky-labs/otphp/workflows/Integrate/badge.svg) + +[![Latest Stable Version](https://poser.pugx.org/spomky-labs/otphp/v/stable.png)](https://packagist.org/packages/spomky-labs/otphp) +[![Total Downloads](https://poser.pugx.org/spomky-labs/otphp/downloads.png)](https://packagist.org/packages/spomky-labs/otphp) +[![Latest Unstable Version](https://poser.pugx.org/spomky-labs/otphp/v/unstable.png)](https://packagist.org/packages/spomky-labs/otphp) +[![License](https://poser.pugx.org/spomky-labs/otphp/license.png)](https://packagist.org/packages/spomky-labs/otphp) + +A php library for generating one-time passwords according to [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226) (HOTP Algorithm) and [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) (TOTP Algorithm) + +This library is compatible with Google Authenticator apps available for Android and iPhone. +It is also compatible with other applications such as [FreeOTP](https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp) for example. + +# Documentation + +The documentation of this project is available in the [*doc* folder](doc/index.md). + +# Support + +I bring solutions to your problems and answer your questions. + +If you really love that project, and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! + +[Become a sponsor](https://github.com/sponsors/Spomky) + +Or + +[![Become a Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/FlorentMorselli) + +## Contributing + +Requests for new features, bug fixed and all other ideas to make this project useful are welcome. + +Please report all issues in [the repository bug tracker](hhttps://github.com/Spomky-Labs/otphp/issues). + +Also make sure to [follow these best practices](.github/CONTRIBUTING.md). + +## Licence + +This software is release under the [MIT licence](LICENSE). diff --git a/vendor/spomky-labs/otphp/SECURITY.md b/vendor/spomky-labs/otphp/SECURITY.md new file mode 100644 index 000000000..706ad9a32 --- /dev/null +++ b/vendor/spomky-labs/otphp/SECURITY.md @@ -0,0 +1,87 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- |----------------------------------------| +| 11.0.x | :white_check_mark: | +| 10.0.x | :white_check_mark: (security fix only) | +| < 10.0 | :x: | + +## Reporting a Vulnerability + +Please email `security@spomky-labs.com`. +If deemed necessary, you can encrypt your message using one of the following GPG key + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- +xjMEXTsJVxYJKwYBBAHaRw8BAQdAZCS93eHRx97V+LQbAWuAaeKIdUZ9YIkn +QH5pQ7dDU0TNMWNvbnRhY3RAc3BvbWt5LWxhYnMuY29tIDxjb250YWN0QHNw +b21reS1sYWJzLmNvbT7CdwQQFgoAHwUCXTsJVwYLCQcIAwIEFQgKAgMWAgEC +GQECGwMCHgEACgkQG6hbCDSDj+1/tgEAoy11uHvDV7kkG/iN2/0ylV72hU8y +c/xoqGd7qFaKD6ABANcthlg63OrQVTf0dUPOT9Y2BJpOOA88JJWgILtuUPIO +zjgEXTsJVxIKKwYBBAGXVQEFAQEHQKiX7nldkmICePhzwReZnBPmjpsmNt7V +Y8xHdICKsr8cAwEIB8JhBBgWCAAJBQJdOwlXAhsMAAoJEBuoWwg0g4/t0KgA +/31ucb/bL/MGpWFrpSjTs6uQhZWlBmcFoeMhwCYepIpZAQDd65UBqFDKXJWv +Xy3zoMQQzD9Z6fUATnFrWkzjHwhvDQ== +=j4dw +-----END PGP PUBLIC KEY BLOCK----- +``` + + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- +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 +BUqlX5YJHneD9Q8Sfz/HKDQDCqg= +=o+4z +-----END PGP PUBLIC KEY BLOCK----- +``` diff --git a/vendor/spomky-labs/otphp/composer.json b/vendor/spomky-labs/otphp/composer.json new file mode 100644 index 000000000..30db9729d --- /dev/null +++ b/vendor/spomky-labs/otphp/composer.json @@ -0,0 +1,57 @@ +{ + "name": "spomky-labs/otphp", + "type": "library", + "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator", + "license": "MIT", + "keywords": ["otp", "hotp", "totp", "RFC 4226", "RFC 6238", "Google Authenticator", "FreeOTP"], + "homepage": "https://github.com/Spomky-Labs/otphp", + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/otphp/contributors" + } + ], + "require": { + "php": "^8.1", + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0", + "infection/infection": "^0.26", + "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", + "qossmic/deptrac-shim": "^1.0", + "rector/rector": "^0.14", + "symfony/phpunit-bridge": "^6.1", + "symplify/easy-coding-standard": "^11.0" + }, + "autoload": { + "psr-4": { "OTPHP\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "OTPHP\\Test\\": "tests/" } + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true, + "infection/extension-installer": true, + "composer/package-versions-deprecated": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true + } +} diff --git a/vendor/spomky-labs/otphp/src/Factory.php b/vendor/spomky-labs/otphp/src/Factory.php new file mode 100644 index 000000000..d5c60cc34 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/Factory.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +use function count; +use InvalidArgumentException; +use Throwable; + +/** + * This class is used to load OTP object from a provisioning Uri. + * + * @see \OTPHP\Test\FactoryTest + */ +final class Factory implements FactoryInterface +{ + public static function loadFromProvisioningUri(string $uri): OTPInterface + { + try { + $parsed_url = Url::fromString($uri); + $parsed_url->getScheme() === 'otpauth' || throw new InvalidArgumentException('Invalid scheme.'); + } catch (Throwable $throwable) { + throw new InvalidArgumentException('Not a valid OTP provisioning URI', $throwable->getCode(), $throwable); + } + + $otp = self::createOTP($parsed_url); + + self::populateOTP($otp, $parsed_url); + + return $otp; + } + + private static function populateParameters(OTPInterface $otp, Url $data): void + { + foreach ($data->getQuery() as $key => $value) { + $otp->setParameter($key, $value); + } + } + + private static function populateOTP(OTPInterface $otp, Url $data): void + { + self::populateParameters($otp, $data); + $result = explode(':', rawurldecode(mb_substr($data->getPath(), 1))); + + if (count($result) < 2) { + $otp->setIssuerIncludedAsParameter(false); + + return; + } + + if ($otp->getIssuer() !== null) { + $result[0] === $otp->getIssuer() || throw new InvalidArgumentException( + 'Invalid OTP: invalid issuer in parameter' + ); + $otp->setIssuerIncludedAsParameter(true); + } + $otp->setIssuer($result[0]); + } + + private static function createOTP(Url $parsed_url): OTPInterface + { + switch ($parsed_url->getHost()) { + case 'totp': + $totp = TOTP::createFromSecret($parsed_url->getSecret()); + $totp->setLabel(self::getLabel($parsed_url->getPath())); + + return $totp; + case 'hotp': + $hotp = HOTP::createFromSecret($parsed_url->getSecret()); + $hotp->setLabel(self::getLabel($parsed_url->getPath())); + + return $hotp; + default: + throw new InvalidArgumentException(sprintf('Unsupported "%s" OTP type', $parsed_url->getHost())); + } + } + + private static function getLabel(string $data): string + { + $result = explode(':', rawurldecode(mb_substr($data, 1))); + + return count($result) === 2 ? $result[1] : $result[0]; + } +} diff --git a/vendor/spomky-labs/otphp/src/FactoryInterface.php b/vendor/spomky-labs/otphp/src/FactoryInterface.php new file mode 100644 index 000000000..74386adeb --- /dev/null +++ b/vendor/spomky-labs/otphp/src/FactoryInterface.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +interface FactoryInterface +{ + /** + * This method is the unique public method of the class. It can load a provisioning Uri and convert it into an OTP + * object. + */ + public static function loadFromProvisioningUri(string $uri): OTPInterface; +} diff --git a/vendor/spomky-labs/otphp/src/HOTP.php b/vendor/spomky-labs/otphp/src/HOTP.php new file mode 100644 index 000000000..aa5a22754 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/HOTP.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +use InvalidArgumentException; +use function is_int; + +/** + * @see \OTPHP\Test\HOTPTest + */ +final class HOTP extends OTP implements HOTPInterface +{ + private const DEFAULT_WINDOW = 0; + + public static function create( + null|string $secret = null, + int $counter = self::DEFAULT_COUNTER, + string $digest = self::DEFAULT_DIGEST, + int $digits = self::DEFAULT_DIGITS + ): self { + $htop = $secret !== null + ? self::createFromSecret($secret) + : self::generate() + ; + $htop->setCounter($counter); + $htop->setDigest($digest); + $htop->setDigits($digits); + + return $htop; + } + + public static function createFromSecret(string $secret): self + { + $htop = new self($secret); + $htop->setCounter(self::DEFAULT_COUNTER); + $htop->setDigest(self::DEFAULT_DIGEST); + $htop->setDigits(self::DEFAULT_DIGITS); + + return $htop; + } + + public static function generate(): self + { + return self::createFromSecret(self::generateSecret()); + } + + public function getCounter(): int + { + $value = $this->getParameter('counter'); + is_int($value) || throw new InvalidArgumentException('Invalid "counter" parameter.'); + + return $value; + } + + public function getProvisioningUri(): string + { + return $this->generateURI('hotp', [ + 'counter' => $this->getCounter(), + ]); + } + + /** + * If the counter is not provided, the OTP is verified at the actual counter. + */ + public function verify(string $otp, null|int $counter = null, null|int $window = null): bool + { + $counter >= 0 || throw new InvalidArgumentException('The counter must be at least 0.'); + + if ($counter === null) { + $counter = $this->getCounter(); + } elseif ($counter < $this->getCounter()) { + return false; + } + + return $this->verifyOtpWithWindow($otp, $counter, $window); + } + + public function setCounter(int $counter): void + { + $this->setParameter('counter', $counter); + } + + /** + * @return array<string, callable> + */ + protected function getParameterMap(): array + { + return [...parent::getParameterMap(), ...[ + 'counter' => static function (mixed $value): int { + $value = (int) $value; + $value >= 0 || throw new InvalidArgumentException('Counter must be at least 0.'); + + return $value; + }, + ]]; + } + + private function updateCounter(int $counter): void + { + $this->setCounter($counter); + } + + private function getWindow(null|int $window): int + { + return abs($window ?? self::DEFAULT_WINDOW); + } + + private function verifyOtpWithWindow(string $otp, int $counter, null|int $window): bool + { + $window = $this->getWindow($window); + + for ($i = $counter; $i <= $counter + $window; ++$i) { + if ($this->compareOTP($this->at($i), $otp)) { + $this->updateCounter($i + 1); + + return true; + } + } + + return false; + } +} diff --git a/vendor/spomky-labs/otphp/src/HOTPInterface.php b/vendor/spomky-labs/otphp/src/HOTPInterface.php new file mode 100644 index 000000000..853e76c07 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/HOTPInterface.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +interface HOTPInterface extends OTPInterface +{ + public const DEFAULT_COUNTER = 0; + + /** + * The initial counter (a positive integer). + */ + public function getCounter(): int; + + /** + * Create a new HOTP object. + * + * If the secret is null, a random 64 bytes secret will be generated. + * + * @param null|non-empty-string $secret + * @param non-empty-string $digest + * + * @deprecated Deprecated since v11.1, use ::createFromSecret or ::generate instead + */ + public static function create( + null|string $secret = null, + int $counter = 0, + string $digest = 'sha1', + int $digits = 6 + ): self; + + public function setCounter(int $counter): void; +} diff --git a/vendor/spomky-labs/otphp/src/OTP.php b/vendor/spomky-labs/otphp/src/OTP.php new file mode 100644 index 000000000..2cba067b3 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/OTP.php @@ -0,0 +1,129 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +use function chr; +use function count; +use Exception; +use InvalidArgumentException; +use function is_string; +use ParagonIE\ConstantTime\Base32; +use RuntimeException; +use const STR_PAD_LEFT; + +abstract class OTP implements OTPInterface +{ + use ParameterTrait; + + private const DEFAULT_SECRET_SIZE = 64; + + /** + * @param non-empty-string $secret + */ + protected function __construct(string $secret) + { + $this->setSecret($secret); + } + + public function getQrCodeUri(string $uri, string $placeholder): string + { + $provisioning_uri = urlencode($this->getProvisioningUri()); + + return str_replace($placeholder, $provisioning_uri, $uri); + } + + public function at(int $input): string + { + return $this->generateOTP($input); + } + + /** + * @return non-empty-string + */ + final protected static function generateSecret(): string + { + return Base32::encodeUpper(random_bytes(self::DEFAULT_SECRET_SIZE)); + } + + /** + * The OTP at the specified input. + */ + protected function generateOTP(int $input): string + { + $hash = hash_hmac($this->getDigest(), $this->intToByteString($input), $this->getDecodedSecret(), true); + $unpacked = unpack('C*', $hash); + $unpacked !== false || throw new InvalidArgumentException('Invalid data.'); + $hmac = array_values($unpacked); + + $offset = ($hmac[count($hmac) - 1] & 0xF); + $code = ($hmac[$offset] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF); + $otp = $code % (10 ** $this->getDigits()); + + return str_pad((string) $otp, $this->getDigits(), '0', STR_PAD_LEFT); + } + + /** + * @param array<string, mixed> $options + */ + protected function filterOptions(array &$options): void + { + foreach ([ + 'algorithm' => 'sha1', + 'period' => 30, + 'digits' => 6, + ] as $key => $default) { + if (isset($options[$key]) && $default === $options[$key]) { + unset($options[$key]); + } + } + + ksort($options); + } + + /** + * @param array<string, mixed> $options + */ + protected function generateURI(string $type, array $options): string + { + $label = $this->getLabel(); + is_string($label) || throw new InvalidArgumentException('The label is not set.'); + $this->hasColon($label) === false || throw new InvalidArgumentException('Label must not contain a colon.'); + $options = [...$options, ...$this->getParameters()]; + $this->filterOptions($options); + $params = str_replace(['+', '%7E'], ['%20', '~'], http_build_query($options)); + + return sprintf( + 'otpauth://%s/%s?%s', + $type, + rawurlencode(($this->getIssuer() !== null ? $this->getIssuer() . ':' : '') . $label), + $params + ); + } + + protected function compareOTP(string $safe, string $user): bool + { + return hash_equals($safe, $user); + } + + private function getDecodedSecret(): string + { + try { + return Base32::decodeUpper($this->getSecret()); + } catch (Exception) { + throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?'); + } + } + + private function intToByteString(int $int): string + { + $result = []; + while ($int !== 0) { + $result[] = chr($int & 0xFF); + $int >>= 8; + } + + return str_pad(implode('', array_reverse($result)), 8, "\000", STR_PAD_LEFT); + } +} diff --git a/vendor/spomky-labs/otphp/src/OTPInterface.php b/vendor/spomky-labs/otphp/src/OTPInterface.php new file mode 100644 index 000000000..3b27f5456 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/OTPInterface.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +interface OTPInterface +{ + public const DEFAULT_DIGITS = 6; + + public const DEFAULT_DIGEST = 'sha1'; + + /** + * Create a OTP object from an existing secret. + * + * @param non-empty-string $secret + */ + public static function createFromSecret(string $secret): self; + + /** + * Create a new OTP object. A random 64 bytes secret will be generated. + */ + public static function generate(): self; + + /** + * @param non-empty-string $secret + */ + public function setSecret(string $secret): void; + + public function setDigits(int $digits): void; + + /** + * @param non-empty-string $digest + */ + public function setDigest(string $digest): void; + + /** + * @return string Return the OTP at the specified timestamp + */ + public function at(int $input): string; + + /** + * Verify that the OTP is valid with the specified input. If no input is provided, the input is set to a default + * value or false is returned. + */ + public function verify(string $otp, null|int $input = null, null|int $window = null): bool; + + /** + * @return string The secret of the OTP + */ + public function getSecret(): string; + + /** + * @param string $label The label of the OTP + */ + public function setLabel(string $label): void; + + /** + * @return string|null The label of the OTP + */ + public function getLabel(): null|string; + + /** + * @return string|null The issuer + */ + public function getIssuer(): ?string; + + public function setIssuer(string $issuer): void; + + /** + * @return bool If true, the issuer will be added as a parameter in the provisioning URI + */ + public function isIssuerIncludedAsParameter(): bool; + + public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void; + + /** + * @return int Number of digits in the OTP + */ + public function getDigits(): int; + + /** + * @return string Digest algorithm used to calculate the OTP. Possible values are 'md5', 'sha1', 'sha256' and 'sha512' + */ + public function getDigest(): string; + + public function getParameter(string $parameter): mixed; + + public function hasParameter(string $parameter): bool; + + /** + * @return array<string, mixed> + */ + public function getParameters(): array; + + public function setParameter(string $parameter, mixed $value): void; + + /** + * Get the provisioning URI. + */ + public function getProvisioningUri(): string; + + /** + * Get the provisioning URI. + * + * @param string $uri The Uri of the QRCode generator with all parameters. This Uri MUST contain a placeholder that will be replaced by the method. + * @param string $placeholder the placeholder to be replaced in the QR Code generator URI + */ + public function getQrCodeUri(string $uri, string $placeholder): string; +} diff --git a/vendor/spomky-labs/otphp/src/ParameterTrait.php b/vendor/spomky-labs/otphp/src/ParameterTrait.php new file mode 100644 index 000000000..b05092351 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/ParameterTrait.php @@ -0,0 +1,188 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +use function array_key_exists; +use function in_array; +use InvalidArgumentException; +use function is_int; +use function is_string; + +trait ParameterTrait +{ + /** + * @var array<string, mixed> + */ + private array $parameters = []; + + private null|string $issuer = null; + + private null|string $label = null; + + private bool $issuer_included_as_parameter = true; + + /** + * @return array<string, mixed> + */ + public function getParameters(): array + { + $parameters = $this->parameters; + + if ($this->getIssuer() !== null && $this->isIssuerIncludedAsParameter() === true) { + $parameters['issuer'] = $this->getIssuer(); + } + + return $parameters; + } + + public function getSecret(): string + { + $value = $this->getParameter('secret'); + is_string($value) || throw new InvalidArgumentException('Invalid "secret" parameter.'); + + return $value; + } + + public function getLabel(): null|string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->setParameter('label', $label); + } + + public function getIssuer(): null|string + { + return $this->issuer; + } + + public function setIssuer(string $issuer): void + { + $this->setParameter('issuer', $issuer); + } + + public function isIssuerIncludedAsParameter(): bool + { + return $this->issuer_included_as_parameter; + } + + public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void + { + $this->issuer_included_as_parameter = $issuer_included_as_parameter; + } + + public function getDigits(): int + { + $value = $this->getParameter('digits'); + is_int($value) || throw new InvalidArgumentException('Invalid "digits" parameter.'); + + return $value; + } + + public function getDigest(): string + { + $value = $this->getParameter('algorithm'); + is_string($value) || throw new InvalidArgumentException('Invalid "algorithm" parameter.'); + + return $value; + } + + public function hasParameter(string $parameter): bool + { + return array_key_exists($parameter, $this->parameters); + } + + public function getParameter(string $parameter): mixed + { + if ($this->hasParameter($parameter)) { + return $this->getParameters()[$parameter]; + } + + throw new InvalidArgumentException(sprintf('Parameter "%s" does not exist', $parameter)); + } + + public function setParameter(string $parameter, mixed $value): void + { + $map = $this->getParameterMap(); + + if (array_key_exists($parameter, $map) === true) { + $callback = $map[$parameter]; + $value = $callback($value); + } + + if (property_exists($this, $parameter)) { + $this->{$parameter} = $value; + } else { + $this->parameters[$parameter] = $value; + } + } + + public function setSecret(string $secret): void + { + $this->setParameter('secret', $secret); + } + + public function setDigits(int $digits): void + { + $this->setParameter('digits', $digits); + } + + public function setDigest(string $digest): void + { + $this->setParameter('algorithm', $digest); + } + + /** + * @return array<string, callable> + */ + protected function getParameterMap(): array + { + return [ + 'label' => function ($value) { + $this->hasColon($value) === false || throw new InvalidArgumentException( + 'Label must not contain a colon.' + ); + + return $value; + }, + 'secret' => static fn ($value): string => mb_strtoupper(trim((string) $value, '=')), + 'algorithm' => static function ($value): string { + $value = mb_strtolower($value); + in_array($value, hash_algos(), true) || throw new InvalidArgumentException(sprintf( + 'The "%s" digest is not supported.', + $value + )); + + return $value; + }, + 'digits' => static function ($value): int { + $value > 0 || throw new InvalidArgumentException('Digits must be at least 1.'); + + return (int) $value; + }, + 'issuer' => function ($value) { + $this->hasColon($value) === false || throw new InvalidArgumentException( + 'Issuer must not contain a colon.' + ); + + return $value; + }, + ]; + } + + private function hasColon(string $value): bool + { + $colons = [':', '%3A', '%3a']; + foreach ($colons as $colon) { + if (str_contains($value, $colon)) { + return true; + } + } + + return false; + } +} diff --git a/vendor/spomky-labs/otphp/src/TOTP.php b/vendor/spomky-labs/otphp/src/TOTP.php new file mode 100644 index 000000000..e9bce9e14 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/TOTP.php @@ -0,0 +1,172 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +use InvalidArgumentException; +use function is_int; + +/** + * @see \OTPHP\Test\TOTPTest + */ +final class TOTP extends OTP implements TOTPInterface +{ + 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 + ): self { + $totp = $secret !== null + ? self::createFromSecret($secret) + : self::generate() + ; + $totp->setPeriod($period); + $totp->setDigest($digest); + $totp->setDigits($digits); + $totp->setEpoch($epoch); + + return $totp; + } + + public static function createFromSecret(string $secret): self + { + $totp = new self($secret); + $totp->setPeriod(self::DEFAULT_PERIOD); + $totp->setDigest(self::DEFAULT_DIGEST); + $totp->setDigits(self::DEFAULT_DIGITS); + $totp->setEpoch(self::DEFAULT_EPOCH); + + return $totp; + } + + public static function generate(): self + { + return self::createFromSecret(self::generateSecret()); + } + + public function getPeriod(): int + { + $value = $this->getParameter('period'); + is_int($value) || throw new InvalidArgumentException('Invalid "period" parameter.'); + + return $value; + } + + public function getEpoch(): int + { + $value = $this->getParameter('epoch'); + is_int($value) || throw new InvalidArgumentException('Invalid "epoch" parameter.'); + + return $value; + } + + public function expiresIn(): int + { + $period = $this->getPeriod(); + + return $period - (time() % $this->getPeriod()); + } + + public function at(int $input): string + { + return $this->generateOTP($this->timecode($input)); + } + + public function now(): string + { + return $this->at(time()); + } + + /** + * If no timestamp is provided, the OTP is verified at the actual timestamp. When used, the leeway parameter will + * allow time drift. The passed value is in seconds. + */ + public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool + { + $timestamp ??= time(); + $timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.'); + + if ($leeway === null) { + return $this->compareOTP($this->at($timestamp), $otp); + } + + $leeway = abs($leeway); + $leeway < $this->getPeriod() || throw new InvalidArgumentException( + 'The leeway must be lower than the TOTP period' + ); + + return $this->compareOTP($this->at($timestamp - $leeway), $otp) + || $this->compareOTP($this->at($timestamp), $otp) + || $this->compareOTP($this->at($timestamp + $leeway), $otp); + } + + public function getProvisioningUri(): string + { + $params = []; + if ($this->getPeriod() !== 30) { + $params['period'] = $this->getPeriod(); + } + + if ($this->getEpoch() !== 0) { + $params['epoch'] = $this->getEpoch(); + } + + return $this->generateURI('totp', $params); + } + + public function setPeriod(int $period): void + { + $this->setParameter('period', $period); + } + + public function setEpoch(int $epoch): void + { + $this->setParameter('epoch', $epoch); + } + + /** + * @return array<string, callable> + */ + protected function getParameterMap(): array + { + return array_merge( + parent::getParameterMap(), + [ + 'period' => static function ($value): int { + (int) $value > 0 || throw new InvalidArgumentException('Period must be at least 1.'); + + return (int) $value; + }, + 'epoch' => static function ($value): int { + (int) $value >= 0 || throw new InvalidArgumentException( + 'Epoch must be greater than or equal to 0.' + ); + + return (int) $value; + }, + ] + ); + } + + /** + * @param array<string, mixed> $options + */ + protected function filterOptions(array &$options): void + { + parent::filterOptions($options); + + if (isset($options['epoch']) && $options['epoch'] === 0) { + unset($options['epoch']); + } + + ksort($options); + } + + private function timecode(int $timestamp): int + { + return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod()); + } +} diff --git a/vendor/spomky-labs/otphp/src/TOTPInterface.php b/vendor/spomky-labs/otphp/src/TOTPInterface.php new file mode 100644 index 000000000..afb54e8e9 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/TOTPInterface.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +interface TOTPInterface extends OTPInterface +{ + public const DEFAULT_PERIOD = 30; + + public const DEFAULT_EPOCH = 0; + + /** + * Create a new TOTP object. + * + * If the secret is null, a random 64 bytes secret will be generated. + * + * @param null|non-empty-string $secret + * @param non-empty-string $digest + * + * @deprecated Deprecated since v11.1, use ::createFromSecret or ::generate instead + */ + public static function create( + null|string $secret = null, + int $period = self::DEFAULT_PERIOD, + string $digest = self::DEFAULT_DIGEST, + int $digits = self::DEFAULT_DIGITS + ): self; + + public function setPeriod(int $period): void; + + public function setEpoch(int $epoch): void; + + /** + * Return the TOTP at the current time. + */ + public function now(): string; + + /** + * Get the period of time for OTP generation (a non-null positive integer, in second). + */ + public function getPeriod(): int; + + public function expiresIn(): int; + + public function getEpoch(): int; +} diff --git a/vendor/spomky-labs/otphp/src/Url.php b/vendor/spomky-labs/otphp/src/Url.php new file mode 100644 index 000000000..56ad979c5 --- /dev/null +++ b/vendor/spomky-labs/otphp/src/Url.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +namespace OTPHP; + +use function array_key_exists; +use InvalidArgumentException; +use function is_string; + +/** + * @internal + */ +final class Url +{ + /** + * @param non-empty-string $secret + * @param array<string, mixed> $query + */ + public function __construct( + private readonly string $scheme, + private readonly string $host, + private readonly string $path, + private readonly string $secret, + private readonly array $query + ) { + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPath(): string + { + return $this->path; + } + + /** + * @return non-empty-string + */ + public function getSecret(): string + { + return $this->secret; + } + + /** + * @return array<string, mixed> + */ + public function getQuery(): array + { + return $this->query; + } + + public static function fromString(string $uri): self + { + $parsed_url = parse_url($uri); + $parsed_url !== false || throw new InvalidArgumentException('Invalid URI.'); + foreach (['scheme', 'host', 'path', 'query'] as $key) { + 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; + $path = $parsed_url['path'] ?? null; + $query = $parsed_url['query'] ?? null; + $scheme === 'otpauth' || throw new InvalidArgumentException('Not a valid OTP provisioning URI'); + is_string($host) || throw new InvalidArgumentException('Invalid URI.'); + is_string($path) || throw new InvalidArgumentException('Invalid URI.'); + is_string($query) || throw new InvalidArgumentException('Invalid URI.'); + $parsedQuery = []; + parse_str($query, $parsedQuery); + array_key_exists('secret', $parsedQuery) || throw new InvalidArgumentException( + 'Not a valid OTP provisioning URI' + ); + $secret = $parsedQuery['secret']; + unset($parsedQuery['secret']); + + return new self($scheme, $host, $path, $secret, $parsedQuery); + } +} |