From 234bb6425021b72f0db71667191b2c36dc593791 Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 8 Mar 2023 10:04:29 +0000 Subject: port totp mfa from streams with some adjustions --- vendor/spomky-labs/otphp/src/Factory.php | 85 ++++++++++ vendor/spomky-labs/otphp/src/FactoryInterface.php | 14 ++ vendor/spomky-labs/otphp/src/HOTP.php | 124 ++++++++++++++ vendor/spomky-labs/otphp/src/HOTPInterface.php | 34 ++++ vendor/spomky-labs/otphp/src/OTP.php | 129 +++++++++++++++ vendor/spomky-labs/otphp/src/OTPInterface.php | 110 +++++++++++++ vendor/spomky-labs/otphp/src/ParameterTrait.php | 188 ++++++++++++++++++++++ vendor/spomky-labs/otphp/src/TOTP.php | 172 ++++++++++++++++++++ vendor/spomky-labs/otphp/src/TOTPInterface.php | 47 ++++++ vendor/spomky-labs/otphp/src/Url.php | 88 ++++++++++ 10 files changed, 991 insertions(+) create mode 100644 vendor/spomky-labs/otphp/src/Factory.php create mode 100644 vendor/spomky-labs/otphp/src/FactoryInterface.php create mode 100644 vendor/spomky-labs/otphp/src/HOTP.php create mode 100644 vendor/spomky-labs/otphp/src/HOTPInterface.php create mode 100644 vendor/spomky-labs/otphp/src/OTP.php create mode 100644 vendor/spomky-labs/otphp/src/OTPInterface.php create mode 100644 vendor/spomky-labs/otphp/src/ParameterTrait.php create mode 100644 vendor/spomky-labs/otphp/src/TOTP.php create mode 100644 vendor/spomky-labs/otphp/src/TOTPInterface.php create mode 100644 vendor/spomky-labs/otphp/src/Url.php (limited to 'vendor/spomky-labs/otphp/src') 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 @@ +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 @@ +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 + */ + 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 @@ +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 $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 $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 @@ + + */ + 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 @@ + + */ + private array $parameters = []; + + private null|string $issuer = null; + + private null|string $label = null; + + private bool $issuer_included_as_parameter = true; + + /** + * @return array + */ + 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 + */ + 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 @@ +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 + */ + 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 $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 @@ + $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 + */ + 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); + } +} -- cgit v1.2.3