ChatGPT's update to harden software and run db updates.

This commit is contained in:
2026-10-21 06:53:45 -04:00
parent 1e63076123
commit acc8cb70f5
171 changed files with 9325 additions and 404 deletions
+46
View File
@@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@spomky-labs.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
+20
View File
@@ -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.
+42
View File
@@ -0,0 +1,42 @@
TOTP / HOTP library in PHP
==========================
[![Build Status](https://github.com/spomky-labs/otphp/actions/workflows/ci.yml/badge.svg)](https://github.com/spomky-labs/otphp/actions/workflows/ci.yml)
[![Latest Stable Version](https://poser.pugx.org/spomky-labs/otphp/v/stable.svg)](https://packagist.org/packages/spomky-labs/otphp)
[![Total Downloads](https://poser.pugx.org/spomky-labs/otphp/downloads.svg)](https://packagist.org/packages/spomky-labs/otphp)
[![Latest Unstable Version](https://poser.pugx.org/spomky-labs/otphp/v/unstable.svg)](https://packagist.org/packages/spomky-labs/otphp)
[![License](https://poser.pugx.org/spomky-labs/otphp/license.svg)](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 fixes and all other ideas to make this project useful are welcome.
Please report all issues in [the repository bug tracker](https://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).
+18
View File
@@ -0,0 +1,18 @@
# Versioning and Release
This document describes the versioning and release process of OTPHP.
This document is a living document, contents will be updated according to each release.
## Releases
OTPHP releases will be versioned using dotted triples, similar to [Semantic Version](http://semver.org/).
For this specific document, we will refer to the respective components of this triple as `<major>.<minor>.<patch>`.
The version number may have additional information, such as "-rc1,-rc2,-rc3" to mark release candidate builds for earlier access.
Such releases will be considered as "pre-releases".
## Minor Release Support Matrix
| Version | Supported |
|----------|--------------------|
| 11.4.x | :white_check_mark: |
| < 11.4.x | :x: |
+74
View File
@@ -0,0 +1,74 @@
# Security Release Process
OTPHP is devoted to providing the best experience for all developers who want to implement OTP authentication in their applications.
Spomky-Labs has adopted this security disclosure and response policy to ensure we responsibly handle critical issues.
## Supported Versions
The OTPHP project maintains release branches for the three most recent minor releases.
Applicable fixes, including security fixes, may be backported to those three release branches, depending on severity and feasibility. Please refer to [RELEASES.md](RELEASES.md) for details.
## Reporting a Vulnerability - Private Disclosure Process
Security is of the highest importance and all security vulnerabilities or suspected security vulnerabilities should be reported to OTPHP privately, to minimize attacks against current users of OTPHP before they are fixed.
Vulnerabilities will be investigated and patched on the next patch (or minor) release as soon as possible.
This information could be kept entirely internal to the project.
If you know of a publicly disclosed security vulnerability for OTPHP, please **IMMEDIATELY** contact security@spomky-labs.com to inform the OTPHP Security Team.
**IMPORTANT: Do not file public issues on GitHub for security vulnerabilities**
To report a vulnerability or a security-related issue, please email the private address security@spomky-labs.com with the details of the vulnerability.
The email will be fielded by the OTPHP Security Team, which is made up of the OTPHP maintainers who have committer and release permissions.
Do not report non-security-impacting bugs through this channel. Use [GitHub issues](https://github.com/Spomky-Labs/otphp/issues/new/choose) instead.
Emails can be encrypted if you wish to share the vulnerability details securely.
The OTPHP Security Team's PGP key is available on the [PGP keyservers](https://keys.openpgp.org/search?q=security%40spomky-labs.com).
### Proposed Email Content
Provide a descriptive subject line and in the body of the email include the following information:
- Basic identity information, such as your name and your affiliation or company.
- Detailed steps to reproduce the vulnerability (POC scripts, screenshots, and compressed packet captures are all helpful to us).
- Description of the effects of the vulnerability on OTPHP and the related hardware and software configurations, so that the OTPHP Security Team can reproduce it.
- How the vulnerability affects OTPHP usage and an estimation of the attack surface, if there is one.
- List other projects or dependencies that were used in conjunction with OTPHP to produce the vulnerability.
## When to report a vulnerability
- When you think OTPHP has a potential security vulnerability.
- When you suspect a potential vulnerability, but you are unsure that it impacts OTPHP.
- When you know of or suspect a potential vulnerability on another project that is used by OTPHP.
## Patch, Release, and Disclosure
The OTPHP Security Team will respond to vulnerability reports as follows:
1. The Security Team will investigate the vulnerability and determine its effects and criticality.
2. If the issue is not deemed to be a vulnerability, the Security Team will follow up with a detailed reason for rejection.
3. The Security Team will initiate a conversation with the reporter as soon as possible.
4. If a vulnerability is acknowledged and the timeline for a fix is determined, the Security Team will work on a plan to communicate with the appropriate community, including identifying mitigating steps that affected users can take to protect themselves until the fix is rolled out.
5. The Security Team will work on fixing the vulnerability and perform internal testing before preparing to roll out the fix.
6. A public disclosure date is negotiated by the OTPHP Security Team and the bug submitter. We prefer to fully disclose the bug as soon as possible once a user mitigation or patch is available. It is reasonable to delay disclosure when the bug or the fix is not yet fully understood, the solution is not well-tested, or for distributor coordination. The timeframe for disclosure is from immediate (especially if it's already publicly known) to a few weeks. For a critical vulnerability with a straightforward mitigation, we expect report date to public disclosure date to be on the order of 14 business days. The OTPHP Security Team holds the final say when setting a public disclosure date.
7. Once the fix is confirmed, the Security Team will patch the vulnerability in the next patch or minor release, and backport a patch release into all earlier supported releases. Upon release of the patched version of OTPHP, we will follow the **Public Disclosure Process**.
### Public Disclosure Process
The Security Team publishes a public [advisory](https://github.com/Spomky-Labs/otphp/security/advisories) to the OTPHP community via GitHub. In most cases, additional communication via Twitter, blog and other channels will assist in educating OTPHP users and rolling out the patched release to affected users.
The Security Team will also publish any mitigating steps users can take until the fix can be applied to their OTPHP instances. OTPHP distributors will handle creating and publishing their own security advisories.
## Mailing lists
- Use security@spomky-labs.com to report security concerns to the OTPHP Security Team, who uses the list to privately discuss security issues and fixes prior to disclosure.
## Early Disclosure to OTPHP Distributors List
This private list is intended to be used primarily to provide actionable information to multiple distributor projects at once. This list is not intended to inform individuals about security issues.
## Confidentiality, integrity and availability
We consider vulnerabilities leading to the compromise of data confidentiality, elevation of privilege, or integrity to be our highest priority concerns.
Availability, in particular in areas relating to DoS and resource exhaustion, is also a serious security concern.
The OTPHP Security Team takes all vulnerabilities, potential vulnerabilities, and suspected vulnerabilities seriously and will investigate them in an urgent and expeditious manner.
+47
View File
@@ -0,0 +1,47 @@
{
"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",
"paragonie/constant_time_encoding": "^2.0 || ^3.0",
"psr/clock": "^1.0",
"symfony/deprecation-contracts": "^3.2"
},
"require-dev": {
"symfony/error-handler": "^6.4|^7.0|^8.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
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace OTPHP\Exception;
use InvalidArgumentException;
use Throwable;
/**
* Exception thrown when a label or issuer format is invalid.
* This includes violations of the Google Authenticator label specification.
*/
final class InvalidLabelException extends InvalidArgumentException implements OTPExceptionInterface
{
public function __construct(
string $message,
public readonly string $labelName = '',
public readonly mixed $labelValue = null,
int $code = 0,
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace OTPHP\Exception;
use InvalidArgumentException;
use Throwable;
/**
* Exception thrown when an OTP parameter has an invalid value.
* This includes: secret, digits, algorithm, period, epoch, counter, secret size.
*/
final class InvalidParameterException extends InvalidArgumentException implements OTPExceptionInterface
{
public function __construct(
string $message,
public readonly string $parameterName = '',
public readonly mixed $parameterValue = null,
int $code = 0,
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace OTPHP\Exception;
use InvalidArgumentException;
/**
* Exception thrown when a provisioning URI cannot be parsed or is invalid.
* This includes: invalid format, wrong scheme, missing required parameters, unsupported OTP type.
*/
final class InvalidProvisioningUriException extends InvalidArgumentException implements OTPExceptionInterface
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace OTPHP\Exception;
use Throwable;
/**
* Marker interface for all OTPHP exceptions.
* This allows catching all OTPHP-specific exceptions while maintaining
* backward compatibility with PHP's built-in exception types.
*/
interface OTPExceptionInterface extends Throwable
{
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace OTPHP\Exception;
use InvalidArgumentException;
use Throwable;
/**
* Exception thrown when attempting to access a parameter that doesn't exist.
*/
final class ParameterNotFoundException extends InvalidArgumentException implements OTPExceptionInterface
{
public function __construct(
string $message,
public readonly string $parameterName = '',
int $code = 0,
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace OTPHP\Exception;
use RuntimeException;
/**
* Exception thrown when a secret cannot be decoded from Base32.
*/
final class SecretDecodingException extends RuntimeException implements OTPExceptionInterface
{
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace OTPHP;
use function count;
use OTPHP\Exception\InvalidProvisioningUriException;
use Psr\Clock\ClockInterface;
use function sprintf;
use Throwable;
/**
* This class is used to load OTP object from a provisioning Uri.
*
* @readonly
*
* @see \OTPHP\Test\FactoryTest
*/
final class Factory implements FactoryInterface
{
public static function loadFromProvisioningUri(string $uri, ?ClockInterface $clock = null): OTPInterface
{
try {
$parsed_url = Url::fromString($uri);
$parsed_url->getScheme() === 'otpauth' || throw new InvalidProvisioningUriException('Invalid scheme.');
} catch (Throwable $throwable) {
throw new InvalidProvisioningUriException(
'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();
}
try {
$otp = self::createOTP($parsed_url, $clock);
self::populateOTP($otp, $parsed_url);
} catch (InvalidProvisioningUriException $exception) {
throw $exception;
} catch (Throwable $throwable) {
throw new InvalidProvisioningUriException(
'Not a valid OTP provisioning URI',
$throwable->getCode(),
$throwable
);
}
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(substr($data->getPath(), 1)));
if (count($result) < 2) {
$otp->setIssuerIncludedAsParameter(false);
return;
}
$issuerFromLabel = $result[0];
$issuerFromParameter = $otp->getIssuer();
if ($issuerFromParameter !== null) {
// Issuer parameter takes precedence over issuer in label
// According to Google Authenticator spec: "they should be equal" but not required to be
$otp->setIssuerIncludedAsParameter(true);
} else {
// No issuer parameter, use the issuer from label
$issuerFromLabel !== '' || throw new InvalidProvisioningUriException(
'Issuer from label must not be empty.'
);
$otp->setIssuer($issuerFromLabel);
}
}
private static function createOTP(Url $parsed_url, ClockInterface $clock): OTPInterface
{
switch ($parsed_url->getHost()) {
case 'totp':
$totp = TOTP::createFromSecret($parsed_url->getSecret(), $clock);
$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 InvalidProvisioningUriException(sprintf('Unsupported "%s" OTP type', $parsed_url->getHost()));
}
}
/**
* @param non-empty-string $data
* @return non-empty-string
*/
private static function getLabel(string $data): string
{
$result = explode(':', rawurldecode(substr($data, 1)));
$label = count($result) === 2 ? $result[1] : $result[0];
$label !== '' || throw new InvalidProvisioningUriException('Label must not be empty.');
return $label;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace OTPHP;
use Psr\Clock\ClockInterface;
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.
*
* @param non-empty-string $uri
*/
public static function loadFromProvisioningUri(string $uri, ?ClockInterface $clock = null): OTPInterface;
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace OTPHP;
use function is_int;
use OTPHP\Exception\InvalidParameterException;
/**
* Note: This class is not marked as readonly because the verify() method
* updates the counter state on successful verification, which is an intentional
* side-effect to prevent OTP reuse.
*
* @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,
?int $secretSize = null
): self {
$htop = $secret !== null
? self::createFromSecret($secret)
: self::generate($secretSize)
;
$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;
}
/**
* @param positive-int|null $secretSize
*/
public static function generate(?int $secretSize = null): self
{
return self::createFromSecret(self::generateSecret($secretSize));
}
/**
* @return 0|positive-int
*/
public function getCounter(): int
{
$value = $this->getParameter('counter');
(is_int($value) && $value >= 0) || throw new InvalidParameterException(
'Invalid "counter" parameter.',
'counter',
$value
);
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.
*
* @param null|0|positive-int $counter
*/
public function verify(string $otp, null|int $counter = null, null|int $window = null): bool
{
$counter >= 0 || throw new InvalidParameterException(
'The counter must be at least 0.',
'counter',
$counter
);
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);
}
public function withCounter(int $counter): self
{
$otp = clone $this;
$otp->setParameter('counter', $counter);
return $otp;
}
/**
* @return array<non-empty-string, callable>
*/
protected function getParameterMap(): array
{
return [...parent::getParameterMap(), ...[
'counter' => static function (mixed $value): int {
$value = (int) $value;
$value >= 0 || throw new InvalidParameterException(
'Counter must be at least 0.',
'counter',
$value
);
return $value;
},
]];
}
private function updateCounter(int $counter): void
{
$this->setCounter($counter);
}
/**
* @param null|0|positive-int $window
*/
private function getWindow(null|int $window): int
{
return abs($window ?? self::DEFAULT_WINDOW);
}
/**
* @param non-empty-string $otp
* @param 0|positive-int $counter
* @param null|0|positive-int $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;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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 0|positive-int $counter
* @param non-empty-string $digest
* @param positive-int $digits
*
* @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;
/**
* @deprecated Deprecated since v11.4, use {@see self::withCounter()} instead
*/
public function setCounter(int $counter): void;
public function withCounter(int $counter): self;
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace OTPHP;
use DateTimeImmutable;
use Psr\Clock\ClockInterface;
/**
* @readonly
*
* @internal
*/
final class InternalClock implements ClockInterface
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
}
+515
View File
@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace OTPHP;
use function array_key_exists;
use function chr;
use function count;
use Exception;
use function in_array;
use function is_int;
use function is_string;
use OTPHP\Exception\InvalidLabelException;
use OTPHP\Exception\InvalidParameterException;
use OTPHP\Exception\ParameterNotFoundException;
use OTPHP\Exception\SecretDecodingException;
use ParagonIE\ConstantTime\Base32;
use function sprintf;
use const STR_PAD_LEFT;
use function strlen;
/**
* @readonly
*/
abstract class OTP implements OTPInterface
{
private const DEFAULT_SECRET_SIZE = 64;
/**
* Minimum digest size, in bytes, required by the RFC 4226 dynamic truncation.
*
* The truncation reads four bytes starting at an offset taken from the low
* nibble of the last digest byte, i.e. an offset in the range [0, 15]. It
* therefore reads up to index "offset + 3" = 18 and needs at least 19 bytes.
* A shorter digest (e.g. MD5, 16 bytes) makes the truncation read past the
* end of the hash, collapsing the output to a small, secret-independent set
* of values. Such algorithms are also outside RFC 4226/6238 and are not
* interoperable with authenticator apps. See {@see self::generateOTP()}.
*/
private const MINIMUM_DIGEST_SIZE = 19;
/**
* @var array<non-empty-string, mixed>
*/
private array $parameters = [];
/**
* @var non-empty-string|null
*/
private null|string $issuer = null;
/**
* @var non-empty-string|null
*/
private null|string $label = null;
private bool $issuer_included_as_parameter = true;
/**
* @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);
}
/**
* @param 0|positive-int $input
*/
public function at(int $input): string
{
return $this->generateOTP($input);
}
/**
* @return array<non-empty-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) && $value !== '') || throw new InvalidParameterException(
'Invalid "secret" parameter.',
'secret',
$value
);
return $value;
}
public function getLabel(): null|string
{
return $this->label;
}
public function setLabel(string $label): void
{
$this->setParameter('label', $label);
}
public function withLabel(string $label): static
{
$otp = clone $this;
$otp->setParameter('label', $label);
return $otp;
}
public function getIssuer(): null|string
{
return $this->issuer;
}
public function setIssuer(string $issuer): void
{
$this->setParameter('issuer', $issuer);
}
public function withIssuer(string $issuer): static
{
$otp = clone $this;
$otp->setParameter('issuer', $issuer);
return $otp;
}
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 withIssuerIncludedAsParameter(bool $issuer_included_as_parameter): static
{
$otp = clone $this;
$otp->issuer_included_as_parameter = $issuer_included_as_parameter;
return $otp;
}
public function getDigits(): int
{
$value = $this->getParameter('digits');
(is_int($value) && $value >= 1 && $value <= self::MAX_DIGITS) || throw new InvalidParameterException(
'Invalid "digits" parameter.',
'digits',
$value
);
return $value;
}
public function getDigest(): string
{
$value = $this->getParameter('algorithm');
(is_string($value) && $value !== '') || throw new InvalidParameterException(
'Invalid "algorithm" parameter.',
'algorithm',
$value
);
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 ParameterNotFoundException(sprintf('Parameter "%s" does not exist', $parameter), $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 (in_array($parameter, ['label', 'issuer'], true)) {
$this->{$parameter} = $value;
} else {
$this->parameters[$parameter] = $value;
}
}
public function withParameter(string $parameter, mixed $value): static
{
$otp = clone $this;
$otp->setParameter($parameter, $value);
return $otp;
}
public function setSecret(string $secret): void
{
$this->setParameter('secret', $secret);
}
public function withSecret(string $secret): static
{
$otp = clone $this;
$otp->setParameter('secret', $secret);
return $otp;
}
public function setDigits(int $digits): void
{
$this->setParameter('digits', $digits);
}
public function withDigits(int $digits): static
{
$otp = clone $this;
$otp->setParameter('digits', $digits);
return $otp;
}
public function setDigest(string $digest): void
{
$this->setParameter('algorithm', $digest);
}
public function withDigest(string $digest): static
{
$otp = clone $this;
$otp->setParameter('algorithm', $digest);
return $otp;
}
/**
* @param positive-int|null $secretSize
*
* @return non-empty-string
*/
final protected static function generateSecret(?int $secretSize = null): string
{
$secretSize ??= self::DEFAULT_SECRET_SIZE;
$secretSize > 0 || throw new InvalidParameterException(
'Secret size must be at least 1.',
'secretSize',
$secretSize
);
return Base32::encodeUpper(random_bytes($secretSize));
}
/**
* The OTP at the specified input.
*
* @param 0|positive-int $input
*
* @return non-empty-string
*/
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 InvalidParameterException('Invalid data.', 'hash', $hash);
/** @var list<int> $hmac */
$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<non-empty-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 non-empty-string $type
* @param array<non-empty-string, mixed> $options
*
* @return non-empty-string
*/
protected function generateURI(string $type, array $options): string
{
$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->buildProvisioningUriLabel()), $params);
}
/**
* @param non-empty-string $safe
* @param non-empty-string $user
*/
protected function compareOTP(string $safe, string $user): bool
{
return hash_equals($safe, $user);
}
/**
* @return array<non-empty-string, callable>
*/
protected function getParameterMap(): array
{
return [
'label' => function (string $value): string {
$value !== '' || throw new InvalidLabelException('Label must not be empty.', 'label', $value);
$this->validateLabel($value);
return $value;
},
'secret' => static fn (string $value): string => strtoupper(trim($value, '=')),
'algorithm' => static function (string $value): string {
$value = strtolower($value);
in_array($value, hash_hmac_algos(), true) || throw new InvalidParameterException(
sprintf('The "%s" digest is not supported.', $value),
'algorithm',
$value
);
$size = strlen(hash($value, '', true));
$size >= self::MINIMUM_DIGEST_SIZE || throw new InvalidParameterException(
sprintf(
'The "%s" digest produces a %d-byte hash which is too short for the RFC 4226 dynamic truncation; at least %d bytes are required.',
$value,
$size,
self::MINIMUM_DIGEST_SIZE
),
'algorithm',
$value
);
return $value;
},
'digits' => static function ($value): int {
$value = (int) $value;
($value >= 1 && $value <= self::MAX_DIGITS) || throw new InvalidParameterException(
sprintf('Digits must be between 1 and %d.', self::MAX_DIGITS),
'digits',
$value
);
return $value;
},
'issuer' => function (string $value): string {
$value !== '' || throw new InvalidLabelException('Issuer must not be empty.', 'issuer', $value);
$this->hasColon($value) === false || throw new InvalidLabelException(
'Issuer must not contain a colon.',
'issuer',
$value
);
return $value;
},
];
}
/**
* @return non-empty-string
*/
private function getDecodedSecret(): string
{
try {
$decoded = Base32::decodeUpper($this->getSecret());
} catch (Exception) {
throw new SecretDecodingException('Unable to decode the secret. Is it correctly base32 encoded?');
}
$decoded !== '' || throw new SecretDecodingException('The decoded secret must not be empty.');
return $decoded;
}
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);
}
/**
* @return non-empty-string
*/
private function buildProvisioningUriLabel(): string
{
$issuer = $this->getIssuer();
$label = $this->getLabel();
return match (true) {
$issuer === null && $label === null => throw new InvalidLabelException(
'The label is not set. Either label or issuer must be set.',
'label'
),
$label !== null && $this->hasColon($label) => throw new InvalidLabelException(
'Label must not contain a colon.',
'label',
$label
),
$issuer !== null && $label !== null => $issuer . ':' . $label,
$issuer !== null => $issuer,
default => $label,
};
}
/**
* Validates a label according to Google Authenticator spec:
* label = accountname / issuer (":" / "%3A") *"%20" accountname
* Neither issuer nor account name may themselves contain a colon.
*
* Valid examples:
* - alice@gmail.com
* - Provider1:Alice%20Smith
* - Big%20Corporation%3A%20alice%40bigco.com
*
* @param non-empty-string $value
*/
private function validateLabel(string $value): void
{
// Check for colon separators (literal or URL-encoded)
$hasLiteralColon = str_contains($value, ':');
$hasEncodedColon = str_contains($value, '%3A') || str_contains($value, '%3a');
if (! $hasLiteralColon && ! $hasEncodedColon) {
// Simple label (account name only) - no colons allowed anywhere
return;
}
// Label contains a separator - validate issuer:account format
// Split by literal or encoded colon
$parts = match (true) {
$hasLiteralColon => explode(':', $value, 2),
default => preg_split('/%3[Aa]/', $value, 2),
};
if ($parts === false || count($parts) !== 2) {
throw new InvalidLabelException('Label must not contain a colon.', 'label', $value);
}
[$issuerPart, $accountPart] = $parts;
// Remove leading %20 (spaces) from account part per spec: *"%20" accountname
$accountPart = ltrim($accountPart, '%20');
// Validate that neither part contains additional colons
if ($this->hasColon($issuerPart) || $this->hasColon($accountPart)) {
throw new InvalidLabelException(
'Neither issuer nor account name in label may contain a colon.',
'label',
$value
);
}
}
/**
* @param non-empty-string $value
*/
private function hasColon(string $value): bool
{
$colons = [':', '%3A', '%3a'];
foreach ($colons as $colon) {
if (str_contains($value, $colon)) {
return true;
}
}
return false;
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace OTPHP;
interface OTPInterface
{
public const DEFAULT_DIGITS = 6;
/**
* Upper bound for the "digits" parameter. The RFC 4226 dynamic truncation
* yields a 31-bit value (max 2 147 483 647, i.e. 10 digits); above this
* bound the extra digits carry no entropy and "10 ** digits" overflows
* PHP's integer range, leading to a DivisionByZeroError during generation.
*/
public const MAX_DIGITS = 10;
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
*
* @deprecated Deprecated since v11.4, use {@see self::withSecret()} instead
*/
public function setSecret(string $secret): void;
/**
* @param non-empty-string $secret
*/
public function withSecret(string $secret): self;
/**
* @deprecated Deprecated since v11.4, use {@see self::withDigits()} instead
*/
public function setDigits(int $digits): void;
public function withDigits(int $digits): self;
/**
* @param non-empty-string $digest
*
* @deprecated Deprecated since v11.4, use {@see self::withDigest()} instead
*/
public function setDigest(string $digest): void;
/**
* @param non-empty-string $digest
*/
public function withDigest(string $digest): self;
/**
* Generate the OTP at the specified input.
*
* @param 0|positive-int $input
*
* @return non-empty-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.
*
* @param non-empty-string $otp
* @param null|0|positive-int $input
* @param null|0|positive-int $window
*/
public function verify(string $otp, null|int $input = null, null|int $window = null): bool;
/**
* @return non-empty-string The secret of the OTP
*/
public function getSecret(): string;
/**
* @param non-empty-string $label The label of the OTP
*
* @deprecated Deprecated since v11.4, use {@see self::withLabel()} instead
*/
public function setLabel(string $label): void;
/**
* @param non-empty-string $label The label of the OTP
*/
public function withLabel(string $label): self;
/**
* @return non-empty-string|null The label of the OTP
*/
public function getLabel(): null|string;
/**
* @return non-empty-string|null The issuer
*/
public function getIssuer(): ?string;
/**
* @param non-empty-string $issuer
*
* @deprecated Deprecated since v11.4, use {@see self::withIssuer()} instead
*/
public function setIssuer(string $issuer): void;
/**
* @param non-empty-string $issuer
*/
public function withIssuer(string $issuer): self;
/**
* @return bool If true, the issuer will be added as a parameter in the provisioning URI
*/
public function isIssuerIncludedAsParameter(): bool;
/**
* @deprecated Deprecated since v11.4, use {@see self::withIssuerIncludedAsParameter()} instead
*/
public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void;
public function withIssuerIncludedAsParameter(bool $issuer_included_as_parameter): self;
/**
* @return positive-int Number of digits in the OTP
*/
public function getDigits(): int;
/**
* @return non-empty-string Digest algorithm used to calculate the OTP. Spec-compliant, interoperable values are 'sha1', 'sha256' and 'sha512'. Any digest shorter than 19 bytes (e.g. 'md5') is rejected as it cannot satisfy the RFC 4226 dynamic truncation.
*/
public function getDigest(): string;
/**
* @param non-empty-string $parameter
*/
public function getParameter(string $parameter): mixed;
/**
* @param non-empty-string $parameter
*/
public function hasParameter(string $parameter): bool;
/**
* @return array<non-empty-string, mixed>
*/
public function getParameters(): array;
/**
* @param non-empty-string $parameter
*
* @deprecated Deprecated since v11.4, use {@see self::withParameter()} instead
*/
public function setParameter(string $parameter, mixed $value): void;
/**
* @param non-empty-string $parameter
*/
public function withParameter(string $parameter, mixed $value): self;
/**
* Get the provisioning URI.
*
* @return non-empty-string
*/
public function getProvisioningUri(): string;
/**
* Get the provisioning URI.
*
* @param non-empty-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 non-empty-string $placeholder the placeholder to be replaced in the QR Code generator URI
*/
public function getQrCodeUri(string $uri, string $placeholder): string;
}
+262
View File
@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace OTPHP;
use function is_int;
use OTPHP\Exception\InvalidParameterException;
use Psr\Clock\ClockInterface;
/**
* @readonly
*
* @see \OTPHP\Test\TOTPTest
*/
final class TOTP extends OTP implements TOTPInterface
{
private readonly ClockInterface $clock;
public function __construct(string $secret, ?ClockInterface $clock = null)
{
parent::__construct($secret);
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();
}
$this->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,
?ClockInterface $clock = null,
?int $secretSize = null
): self {
$totp = $secret !== null
? self::createFromSecret($secret, $clock)
: self::generate($clock, $secretSize)
;
$totp->setPeriod($period);
$totp->setDigest($digest);
$totp->setDigits($digits);
$totp->setEpoch($epoch);
return $totp;
}
public static function createFromSecret(string $secret, ?ClockInterface $clock = null): self
{
$totp = new self($secret, $clock);
$totp->setPeriod(self::DEFAULT_PERIOD);
$totp->setDigest(self::DEFAULT_DIGEST);
$totp->setDigits(self::DEFAULT_DIGITS);
$totp->setEpoch(self::DEFAULT_EPOCH);
return $totp;
}
/**
* @param positive-int|null $secretSize
*/
public static function generate(?ClockInterface $clock = null, ?int $secretSize = null): self
{
return self::createFromSecret(self::generateSecret($secretSize), $clock);
}
public function getPeriod(): int
{
$value = $this->getParameter('period');
(is_int($value) && $value > 0) || throw new InvalidParameterException(
'Invalid "period" parameter.',
'period',
$value
);
return $value;
}
public function getEpoch(): int
{
$value = $this->getParameter('epoch');
(is_int($value) && $value >= 0) || throw new InvalidParameterException(
'Invalid "epoch" parameter.',
'epoch',
$value
);
return $value;
}
public function expiresIn(): int
{
$period = $this->getPeriod();
return $period - (($this->clock->now()->getTimestamp() - $this->getEpoch()) % $period);
}
/**
* The OTP at the specified input.
*
* @param 0|positive-int $input
*/
public function at(int $input): string
{
return $this->generateOTP($this->timecode($input));
}
public function now(): string
{
$timestamp = $this->clock->now()
->getTimestamp();
$timestamp >= 0 || throw new InvalidParameterException(
'The timestamp must return a positive integer.',
'timestamp',
$timestamp
);
return $this->at($timestamp);
}
/**
* 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.
*
* @param 0|positive-int $timestamp
* @param null|0|positive-int $leeway
*/
public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool
{
$timestamp ??= $this->clock->now()
->getTimestamp();
$timestamp >= 0 || throw new InvalidParameterException(
'Timestamp must be at least 0.',
'timestamp',
$timestamp
);
if ($leeway === null) {
return $this->compareOTP($this->at($timestamp), $otp);
}
$leeway = abs($leeway);
$leeway < $this->getPeriod() || throw new InvalidParameterException(
'The leeway must be lower than the TOTP period',
'leeway',
$leeway
);
$timestampMinusLeeway = $timestamp - $leeway;
$timestampMinusLeeway >= 0 || throw new InvalidParameterException(
'The timestamp must be greater than or equal to the leeway.',
'timestamp',
$timestamp
);
return $this->compareOTP($this->at($timestampMinusLeeway), $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 withPeriod(int $period): self
{
$otp = clone $this;
$otp->setParameter('period', $period);
return $otp;
}
public function setEpoch(int $epoch): void
{
$this->setParameter('epoch', $epoch);
}
public function withEpoch(int $epoch): self
{
$otp = clone $this;
$otp->setParameter('epoch', $epoch);
return $otp;
}
/**
* @return array<non-empty-string, callable>
*/
protected function getParameterMap(): array
{
return [
...parent::getParameterMap(),
'period' => static function ($value): int {
(int) $value > 0 || throw new InvalidParameterException('Period must be at least 1.', 'period', $value);
return (int) $value;
},
'epoch' => static function ($value): int {
(int) $value >= 0 || throw new InvalidParameterException(
'Epoch must be greater than or equal to 0.',
'epoch',
$value
);
return (int) $value;
},
];
}
/**
* @param array<non-empty-string, mixed> $options
*/
protected function filterOptions(array &$options): void
{
parent::filterOptions($options);
if (isset($options['epoch']) && $options['epoch'] === 0) {
unset($options['epoch']);
}
ksort($options);
}
/**
* @param 0|positive-int $timestamp
*
* @return 0|positive-int
*/
private function timecode(int $timestamp): int
{
$timecode = (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
$timecode >= 0 || throw new InvalidParameterException(
'Timecode must be at least 0. The timestamp must be greater than or equal to the epoch.',
'timecode',
$timecode
);
return $timecode;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?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 positive-int $period
* @param non-empty-string $digest
* @param positive-int $digits
*
* @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;
/**
* @deprecated Deprecated since v11.4, use {@see self::withPeriod()} instead
*/
public function setPeriod(int $period): void;
public function withPeriod(int $period): self;
/**
* @deprecated Deprecated since v11.4, use {@see self::withEpoch()} instead
*/
public function setEpoch(int $epoch): void;
public function withEpoch(int $epoch): self;
/**
* Return the TOTP at the current time.
*
* @return non-empty-string
*/
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;
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace OTPHP;
use function array_key_exists;
use function is_string;
use OTPHP\Exception\InvalidProvisioningUriException;
/**
* @readonly
*
* @internal
*/
final class Url
{
/**
* @param non-empty-string $scheme
* @param non-empty-string $host
* @param non-empty-string $path
* @param non-empty-string $secret
* @param array<non-empty-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
) {
}
/**
* @return non-empty-string
*/
public function getScheme(): string
{
return $this->scheme;
}
/**
* @return non-empty-string
*/
public function getHost(): string
{
return $this->host;
}
/**
* @return non-empty-string
*/
public function getPath(): string
{
return $this->path;
}
/**
* @return non-empty-string
*/
public function getSecret(): string
{
return $this->secret;
}
/**
* @return array<non-empty-string, mixed>
*/
public function getQuery(): array
{
return $this->query;
}
/**
* @param non-empty-string $uri
*/
public static function fromString(string $uri): self
{
$parsed_url = parse_url($uri);
$parsed_url !== false || throw new InvalidProvisioningUriException('Invalid URI.');
foreach (['scheme', 'host', 'path', 'query'] as $key) {
array_key_exists($key, $parsed_url) || throw new InvalidProvisioningUriException(
'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 InvalidProvisioningUriException('Not a valid OTP provisioning URI');
(is_string($host) && $host !== '') || throw new InvalidProvisioningUriException('Invalid URI.');
(is_string($path) && $path !== '') || throw new InvalidProvisioningUriException('Invalid URI.');
is_string($query) || throw new InvalidProvisioningUriException('Invalid URI.');
$parsedQuery = [];
parse_str($query, $parsedQuery);
array_key_exists('secret', $parsedQuery) || throw new InvalidProvisioningUriException(
'Not a valid OTP provisioning URI'
);
$secret = $parsedQuery['secret'];
(is_string($secret) && $secret !== '') || throw new InvalidProvisioningUriException('Invalid URI.');
unset($parsedQuery['secret']);
return new self($scheme, $host, $path, $secret, $parsedQuery);
}
}