diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a96124c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/test export-ignore +/phpunit.xml export-ignore +/psalm.xml export-ignore diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index ca4015f..a13e6d7 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,9 +1,5 @@ name: PHP Composer - -on: - push: ~ - pull_request: ~ - +on: [push, pull_request] jobs: build: name: Run tests on ${{ matrix.php }} @@ -11,21 +7,27 @@ jobs: strategy: matrix: - php: [ '7.2', '7.3', '7.4', '8.0' ] + php: [ '7.4', '8.0', '8.1', '8.2' ] steps: - - uses: actions/checkout@v2 - - uses: shivammathur/setup-php@v2 + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - tools: composer:v2, psalm + tools: psalm - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - run: composer install --no-progress --prefer-dist --no-suggest + - name: Install Composer dependencies + run: composer install --no-progress - - run: psalm --output-format=github - if: ${{ matrix.php == '8.0' }} + - name: Run Psalm + run: psalm --output-format=github + if: ${{ matrix.php == '8.2' }} - - run: vendor/bin/phpunit + - name: Run PHPUnit + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 525642d..c5aa734 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.idea/ /vendor/ /composer.lock .phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..42c80d0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [Unreleased] +### Added +- Support for PHP 8. +- Native property type declarations. +- `GoogleAuth::getQRCode()` and `GoogleAuth::makeQRCodeMessage()` methods. +- HiddenString support for secret key. +- `GoogleAuth->defaultQRCodeSize` property (replaces the removed width and height properties). + +### Changed +- PHP 7.4+ is now required. +- Renamed `FIDOU2F` class to `OneTime`. +- Updated [BaconQrCode](https://github.com/Bacon/BaconQrCode) dependency to v2. + This version has a slightly different API for rendering QR code images. +- Test files are now excluded from Composer package. +- Unified internal code for HOTP value generation. + +### Removed +- `GoogleAuth->defaultQRCodeWidth` and `GoogleAuth->defaultQRCodeHeight` properties. +- Unused internal `rawOutput` option. + + +## [0.2.2] - 2016-06-17 +### Changed +- Appended HTTP query string in QR code. + + +## [0.2.1] - 2016-06-17 +### Changed +- `TOTP` and `HOTP` classes now implement `OTPInterface`. + + +## [0.2.0] - 2016-06-16 +### Added +- Support for HOTP and Google Authenticator. +- Range check to ensure that code length is between 1 and 10. + +### Changed +- Replaced giant switch statement with `**` operator. +- Improved readme. + + +## [0.1.0] - 2016-06-13 +- Initial pre-release + + +[Unreleased]: https://github.com/paragonie/multi_factor/compare/v0.2.2...HEAD +[0.2.2]: https://github.com/paragonie/multi_factor/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/paragonie/multi_factor/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/paragonie/multi_factor/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/paragonie/multi_factor/tree/v0.1.0 diff --git a/README.md b/README.md index 3a9f72f..ba88ba5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ needs. ## Requirements -* PHP 7.2+ +* PHP 7.4+ * As per [Paragon Initiative Enterprise's commitment to open source](https://paragonie.com/blog/2016/04/go-php-7-our-commitment-maintaining-our-open-source-projects), all new software will no longer be written for PHP 5. @@ -22,12 +22,23 @@ composer require paragonie/multi-factor ## Example Usage +### Display QR code + ```php makeQRCode(null, 'php://output', 'email@example.com', 'Issuer', 'Label'); +``` + +### Validate two-factor code + +```php +=7.2", + "php": ">=7.4", "bacon/bacon-qr-code": "^2", "paragonie/constant_time_encoding": "^2", - "paragonie/hidden-string": "^1" + "paragonie/hidden-string": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^8", - "psalm/plugin-phpunit": "^0.15", - "vimeo/psalm": "^4.4" + "phpunit/phpunit": "^9.5", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.4" }, "autoload": { "psr-4": { diff --git a/psalm.xml b/psalm.xml index 2124cd8..03f4d03 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,6 +1,6 @@ length = $length; $this->algo = $algo; } @@ -47,24 +36,54 @@ public function __construct( * @return string * @throws \OutOfRangeException */ - public function getCode( - $sharedSecret, - int $counterValue - ): string { - if ($this->length < 1 || $this->length > 10) { + public function getCode($sharedSecret, int $counterValue): string + { + $key = is_string($sharedSecret) ? $sharedSecret : $sharedSecret->getString(); + $msg = $this->getTValue($counterValue); + return self::generateHOTPValue($this->length, $key, $this->algo, $msg); + } + + public function getLength(): int + { + return $this->length; + } + + /** + * Get the binary T value + */ + protected function getTValue(int $counter): string + { + $hex = \str_pad( + \dechex($counter), + 16, + '0', + STR_PAD_LEFT + ); + + return Hex::decode($hex); + } + + /** + * @internal + * @ref https://tools.ietf.org/html/rfc4226 + */ + public static function generateHOTPValue(int $length, string $key, string $algo, string $data): string + { + if ($length < 1 || $length > 10) { throw new \OutOfRangeException( 'Length must be between 1 and 10, as a consequence of RFC 6238.' ); } - $msg = $this->getTValue($counterValue, true); - $bytes = \hash_hmac($this->algo, $msg, is_string($sharedSecret) ? $sharedSecret : $sharedSecret->getString(), true); + $bytes = \hash_hmac($algo, $data, $key, true); $byteLen = Binary::safeStrlen($bytes); // Per the RFC + /** @var int $offset */ $offset = \unpack('C', $bytes[$byteLen - 1])[1]; $offset &= 0x0f; + /** @var array{0: int, 1: int, 2: int, 3: int} $unpacked */ $unpacked = \array_values( \unpack('C*', Binary::safeSubstr($bytes, $offset, 4)) ); @@ -76,44 +95,13 @@ public function getCode( | (($unpacked[3] & 0xff) ) ); - $intValue %= 10 ** $this->length; + $intValue %= 10 ** $length; return \str_pad( (string) $intValue, - $this->length, + $length, '0', \STR_PAD_LEFT ); } - - /** - * @return int - */ - public function getLength(): int - { - return $this->length; - } - - /** - * Get the binary T value - * - * @param int $unixTimestamp - * @param bool $rawOutput - * @return string - */ - protected function getTValue( - int $counter, - bool $rawOutput = false - ): string { - $hex = \str_pad( - \dechex($counter), - 16, - '0', - STR_PAD_LEFT - ); - if ($rawOutput) { - return Hex::decode($hex); - } - return $hex; - } } diff --git a/src/OTP/TOTP.php b/src/OTP/TOTP.php index b65c683..1d1e659 100644 --- a/src/OTP/TOTP.php +++ b/src/OTP/TOTP.php @@ -2,10 +2,7 @@ declare(strict_types=1); namespace ParagonIE\MultiFactor\OTP; -use ParagonIE\ConstantTime\{ - Binary, - Hex -}; +use ParagonIE\ConstantTime\Hex; use ParagonIE\HiddenString\HiddenString; /** @@ -14,29 +11,12 @@ */ class TOTP implements OTPInterface { - /** - * @var string - */ - protected $algo; - - /** - * @var int - */ - protected $length; - - /** - * @var int - */ - protected $timeStep; + protected string $algo; + protected int $length; + protected int $timeStep; + protected int $timeZero; /** - * @var int - */ - protected $timeZero; - - /** - * TOTP constructor. - * * @param int $timeZero The start time for calculating the TOTP * @param int $timeStep How many seconds should each TOTP live? * @param int $length How many digits should each TOTP be? @@ -63,56 +43,18 @@ public function __construct( * @return string * @throws \OutOfRangeException */ - public function getCode( - $sharedSecret, - int $counterValue - ): string { - if ($this->length < 1 || $this->length > 10) { - throw new \OutOfRangeException( - 'Length must be between 1 and 10, as a consequence of RFC 6238.' - ); - } - $msg = $this->getTValue($counterValue, true); - $bytes = \hash_hmac($this->algo, $msg, is_string($sharedSecret) ? $sharedSecret : $sharedSecret->getString(), true); - - $byteLen = Binary::safeStrlen($bytes); - - // Per the RFC - $offset = \unpack('C', $bytes[$byteLen - 1])[1]; - $offset &= 0x0f; - - $unpacked = \array_values( - \unpack('C*', Binary::safeSubstr($bytes, $offset, 4)) - ); - - $intValue = ( - (($unpacked[0] & 0x7f) << 24) - | (($unpacked[1] & 0xff) << 16) - | (($unpacked[2] & 0xff) << 8) - | (($unpacked[3] & 0xff) ) - ); - - $intValue %= 10 ** $this->length; - - return \str_pad( - (string) $intValue, - $this->length, - '0', - \STR_PAD_LEFT - ); + public function getCode($sharedSecret, int $counterValue): string + { + $key = is_string($sharedSecret) ? $sharedSecret : $sharedSecret->getString(); + $msg = $this->getTValue($counterValue); + return HOTP::generateHOTPValue($this->length, $key, $this->algo, $msg); } - /** - * @return int - */ public function getLength(): int { return $this->length; } - /** - * @return int - */ public function getTimeStep(): int { return $this->timeStep; @@ -120,30 +62,23 @@ public function getTimeStep(): int /** * Get the binary T value - * - * @param int $unixTimestamp - * @param bool $rawOutput - * @return string */ - protected function getTValue( - int $unixTimestamp, - bool $rawOutput = false - ): string { + protected function getTValue(int $unixTimestamp): string + { $value = \intdiv( $unixTimestamp - $this->timeZero, $this->timeStep !== 0 ? $this->timeStep : 1 ); + $hex = \str_pad( \dechex($value), 16, '0', STR_PAD_LEFT ); - if ($rawOutput) { - return Hex::decode($hex); - } - return $hex; + + return Hex::decode($hex); } } diff --git a/src/OneTime.php b/src/OneTime.php index 0679f50..adb0d49 100644 --- a/src/OneTime.php +++ b/src/OneTime.php @@ -15,28 +15,19 @@ */ class OneTime implements MultiFactorInterface { - /** - * @var OTPInterface - */ - protected $otp; - - /** - * @var HiddenString - */ - protected $secretKey; + protected OTPInterface $otp; + protected HiddenString $secretKey; /** - * FIDOU2F constructor. - * * @param string|HiddenString $secretKey - * @param OTPInterface $otp + * @param OTPInterface|null $otp */ public function __construct( $secretKey = '', - OTPInterface $otp = null + ?OTPInterface $otp = null ) { $this->secretKey = ($secretKey instanceof HiddenString) ? $secretKey : new HiddenString($secretKey); - if (!$otp) { + if ($otp === null) { $otp = new TOTP(); } $this->otp = $otp; @@ -44,9 +35,6 @@ public function __construct( /** * Generate a TOTP code for 2FA - * - * @param int $counterValue - * @return string */ public function generateCode(int $counterValue = 0): string { @@ -58,10 +46,6 @@ public function generateCode(int $counterValue = 0): string /** * Validate a user-provided code - * - * @param string $code - * @param int $counterValue - * @return bool */ public function validateCode(string $code, int $counterValue = 0): bool { diff --git a/src/Vendor/GoogleAuth.php b/src/Vendor/GoogleAuth.php index 559ec6e..a51ebd3 100644 --- a/src/Vendor/GoogleAuth.php +++ b/src/Vendor/GoogleAuth.php @@ -19,25 +19,21 @@ */ class GoogleAuth extends OneTime { - /** - * @var int - */ - public $defaultQRCodeSize = 384; + public int $defaultQRCodeSize = 384; /** * Create a QR code to load the key onto the device * - * @param Writer $qrCodeWriter + * @param Writer|null $qrCodeWriter * @param string $outFile Where to store the QR code? * @param string $username Username or email address * @param string $issuer Optional * @param string $label Optional * @param int $initialCounter Initial counter value - * @return void * @throws \Exception */ public function makeQRCode( - Writer $qrCodeWriter = null, + ?Writer $qrCodeWriter = null, string $outFile = 'php://output', string $username = '', string $issuer = '', @@ -50,7 +46,7 @@ public function makeQRCode( } public function getQRCode( - Writer $qrCodeWriter = null, + ?Writer $qrCodeWriter = null, string $username = '', string $issuer = '', string $label = '', @@ -74,6 +70,7 @@ public function makeQRCodeMessage( } else { throw new \Exception('Not implemented'); } + if ($label) { $message .= \urlencode( \str_replace(':', '', $label) @@ -84,21 +81,25 @@ public function makeQRCodeMessage( $args = [ 'secret' => Base32::encode($this->secretKey->getString()) ]; + if ($issuer) { $args['issuer'] = $issuer; } + $args['digits'] = $this->otp->getLength(); + if ($this->otp instanceof TOTP) { $args['period'] = $this->otp->getTimeStep(); } else { $args['counter'] = $initialCounter; } + $message .= '?' . \http_build_query($args); return $message; } - protected function makeQRCodeWriteOrDefault(Writer $qrCodeWriter = null) : Writer + protected function makeQRCodeWriteOrDefault(?Writer $qrCodeWriter): Writer { // Sane default; You can dependency-inject a replacement: if (!$qrCodeWriter) { diff --git a/test/GoogleAuthTest.php b/test/GoogleAuthTest.php index b139ef0..4b5cbc3 100644 --- a/test/GoogleAuthTest.php +++ b/test/GoogleAuthTest.php @@ -12,7 +12,7 @@ class GoogleAuthTest extends TestCase { /** - * @psalm-return Generator + * @return Generator */ public function dataProviderMakeQRCodeMessage() : Generator { diff --git a/test/HOTPTest.php b/test/HOTPTest.php index e89a178..e8fde30 100644 --- a/test/HOTPTest.php +++ b/test/HOTPTest.php @@ -35,7 +35,7 @@ public function testTOTP(): void /** * @dataProvider dataProviderFailureOfGetCode * - * @psalm-param class-string<\Throwable> $expectedException + * @param class-string $expectedException */ public function testFailureOfGetCode( int $length, @@ -55,7 +55,7 @@ public function testFailureOfGetCode( } /** - * @psalm-return array, 2:string, 3:string, 4:int}> + * @return array, 2:string, 3:string, 4:int}> */ public function dataProviderFailureOfGetCode(): array { diff --git a/test/TOTPTest.php b/test/TOTPTest.php index 151c3ba..56c41b7 100644 --- a/test/TOTPTest.php +++ b/test/TOTPTest.php @@ -9,7 +9,7 @@ /** * Class TOTPTest */ -class TOPTTest extends TestCase +class TOTPTest extends TestCase { /** * Test vectors from RFC 6238 @@ -32,7 +32,7 @@ public function testTOTP(): void ); /** - * @psalm-var array + * @var array $testVectors */ $testVectors = [ [ @@ -173,8 +173,7 @@ public function testTOTP(): void * @dataProvider dataProviderFailureOfGetCode * * @param array{0:int, 1:int, 2:int, 3:string} $constructorArgs - * - * @psalm-param class-string<\Throwable> $expectedException + * @param class-string $expectedException */ public function testFailureOfGetCode( array $constructorArgs, @@ -195,7 +194,7 @@ public function testFailureOfGetCode( } /** - * @psalm-return Generator, 2:string, 3:string, 4:int}, mixed, void> + * @return Generator, 2:string, 3:string, 4:int}, mixed, void> */ public function dataProviderFailureOfGetCode(): \Generator {