From 5f274850222f12c70a9cca0d4ca8f8c87f27919f Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Thu, 9 Apr 2026 09:19:32 -0700 Subject: [PATCH 1/6] Add Algorithm enum for OTP hashing algorithms Co-Authored-By: Claude Opus 4.5 --- src/Algorithm.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/Algorithm.php diff --git a/src/Algorithm.php b/src/Algorithm.php new file mode 100644 index 0000000..cbfe978 --- /dev/null +++ b/src/Algorithm.php @@ -0,0 +1,12 @@ + Date: Thu, 9 Apr 2026 09:36:23 -0700 Subject: [PATCH 2/6] Modernize Secret.php with readonly typed property Co-Authored-By: Claude Opus 4.5 --- src/Secret.php | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/Secret.php b/src/Secret.php index 197d24d..fdb27d6 100644 --- a/src/Secret.php +++ b/src/Secret.php @@ -24,32 +24,18 @@ */ final class Secret { - /** - * Obfuscated value - * @var string - */ - private $value; + private readonly string $value; - /** - * @param string $string The secret to obscure - */ public function __construct(#[SensitiveParameter] string $string) { $this->value = $this->mask($string, SecretKey::getKey()); } - /** - * @return string The original secret - */ public function reveal(): string { return $this->mask($this->value, SecretKey::getKey()); } - /** - * @return string A hardcoded string, "", so that the actual secret - * is not accidentally revealed. - */ public function __toString(): string { return ''; @@ -63,11 +49,6 @@ public function __debugInfo(): array return ['secret' => '']; } - /** - * @param string $string The string to obfuscate or deobfuscate - * @param string $noise The mask - * @return string The obfuscated or deobfuscated string - */ private function mask(#[SensitiveParameter] string $string, #[SensitiveParameter] string $noise): string { $result = ''; From 6c92dfb69116296ad289f078e3ad8db08a9e19be Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Thu, 9 Apr 2026 10:07:22 -0700 Subject: [PATCH 3/6] Add typed property to SecretKey Co-Authored-By: Claude Opus 4.5 --- src/SecretKey.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/SecretKey.php b/src/SecretKey.php index 894f9f0..c913280 100644 --- a/src/SecretKey.php +++ b/src/SecretKey.php @@ -10,8 +10,7 @@ */ final class SecretKey { - /** @var string */ - private static $key; + private static ?string $key = null; // Private constructor: only access is allowed through getKey() private function __construct() From 3ef7d4a77b11b418202eaa33c642c874ab170215 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Thu, 9 Apr 2026 10:13:09 -0700 Subject: [PATCH 4/6] Modernize OTP.php - Accept Secret|string in constructor with SensitiveParameter - Use Algorithm enum instead of string constants - readonly typed property - Single reveal() call Co-Authored-By: Claude Opus 4.5 --- src/OTP.php | 38 +++++++++++--------------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/src/OTP.php b/src/OTP.php index c7ee833..ae19981 100644 --- a/src/OTP.php +++ b/src/OTP.php @@ -4,8 +4,8 @@ namespace Firehed\Security; -use DomainException; use LengthException; +use SensitiveParameter; use function assert; use function floor; @@ -20,12 +20,7 @@ class OTP { - public const ALGORITHM_SHA1 = 'sha1'; - public const ALGORITHM_SHA256 = 'sha256'; - public const ALGORITHM_SHA512 = 'sha512'; - - /** @var Secret */ - private $secret; + private readonly Secret $secret; /** * Note: Google Authenticator's keys are base32-encoded, and must be decoded @@ -33,9 +28,9 @@ class OTP * key material to avoid format mangling, and ensure that key material is * kept protected at rest and unique to each user. */ - public function __construct(Secret $secret) + public function __construct(#[SensitiveParameter] Secret|string $secret) { - $this->secret = $secret; + $this->secret = $secret instanceof Secret ? $secret : new Secret($secret); } /** @@ -45,22 +40,12 @@ public function __construct(Secret $secret) * * @param int $counter 8-byte counter * @param int<6, 8> $digits = 6 Length of the output code - * @param 'sha1'|'sha256'|'sha512' $algorithm = 'sha1' HMAC algorithm * * @return string The $digits-character numeric code */ - public function getHOTP(int $counter, int $digits = 6, string $algorithm = self::ALGORITHM_SHA1): string + public function getHOTP(int $counter, int $digits = 6, Algorithm $algorithm = Algorithm::SHA1): string { - /** @var string $algorithm (don't rely on build-time types) */ - if ( - $algorithm !== self::ALGORITHM_SHA1 - && $algorithm !== self::ALGORITHM_SHA256 - && $algorithm !== self::ALGORITHM_SHA512 - ) { - throw new DomainException('Invalid algorithm'); - } - - /** @var int $digits (same as above) @phpstan-ignore varTag.type */ + /** @var int $digits @phpstan-ignore varTag.type */ if ($digits < 6 || $digits > 8) { // "Implementations MUST extract a 6-digit code at a minimum and // possibly 7 and 8-digit code." @@ -69,7 +54,9 @@ public function getHOTP(int $counter, int $digits = 6, string $algorithm = self: ); } - if (strlen($this->secret->reveal()) < (128 / 8)) { + $key = $this->secret->reveal(); + + if (strlen($key) < (128 / 8)) { throw new LengthException( 'Key must be at least 128 bits long (160+ recommended)' ); @@ -78,7 +65,7 @@ public function getHOTP(int $counter, int $digits = 6, string $algorithm = self: $counter = pack('J', $counter); // Convert to 8-byte string // 5.3 Step 1: Generate hash value - $hash = hash_hmac($algorithm, $counter, $this->secret->reveal(), true); + $hash = hash_hmac($algorithm->value, $counter, $key, true); $dbc = self::dynamicTruncate($hash); // 5.3 Step 3: Compute HOTP value @@ -100,9 +87,6 @@ public function getHOTP(int $counter, int $digits = 6, string $algorithm = self: * * @param int<6, 8> $digits = 6 The number of digits in the output code * - * @param self::ALGORITHM_* $algorithm = self::ALGORITHM_SHA1 The hashing - * algorithm to use with the key and generated counter. - * * To address clock drift and slow inputs, the $t0 parameter may be used to * check for the next and/or previous code. This will adjust the time by the * given number of seconds; as such, it's advisable to use values that are @@ -117,7 +101,7 @@ public function getTOTP( int $step = 30, int $t0 = 0, int $digits = 6, - string $algorithm = self::ALGORITHM_SHA1 + Algorithm $algorithm = Algorithm::SHA1, ): string { $t = (int) floor((time() - $t0) / $step); return $this->getHOTP($t, $digits, $algorithm); From ddf8e43acc16d49918e58d5165fdeafb7e940b4d Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Thu, 9 Apr 2026 10:47:19 -0700 Subject: [PATCH 5/6] Update tests to use Algorithm enum Co-Authored-By: Claude Opus 4.5 --- tests/HOTPTest.php | 12 ------------ tests/TOTPTest.php | 43 ++++++++++++++++++++----------------------- 2 files changed, 20 insertions(+), 35 deletions(-) diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index 20bfddb..2837d30 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -45,18 +45,6 @@ public function testHOTP(Secret $secret, int $counter, string $out): void ); } - public function testBadAlgorithm(): void - { - $this->expectException(\DomainException::class); - $otp = new OTP(new Secret('abcdefgijklmnopqrstuvwxyz')); - $otp->getHOTP( - 0x1234567890123456, - 6, - // @phpstan-ignore argument.type (testing type mismatch) - 'notalg' - ); - } - public function testTooFewDigits(): void { $this->expectException(\LengthException::class); diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index 7b0c2fa..5d95232 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -13,7 +13,7 @@ class TOTPTest extends \PHPUnit\Framework\TestCase /** * Test vectors provided by RFC 6238, Appendix B * - * @return array{int, string, 'sha1'|'sha256'|'sha512', Secret}[] + * @return array{int, string, Algorithm, Secret}[] */ public static function TOTPvectors(): array { @@ -24,35 +24,32 @@ public static function TOTPvectors(): array $tok_sha256 = new Secret(substr($base, 0, 32)); $tok_sha512 = new Secret(substr($base, 0, 64)); return [ - [ 59, '94287082', 'sha1' , $tok_sha1 ], - [ 59, '46119246', 'sha256', $tok_sha256], - [ 59, '90693936', 'sha512', $tok_sha512], - [ 1111111109, '07081804', 'sha1' , $tok_sha1 ], - [ 1111111109, '68084774', 'sha256', $tok_sha256], - [ 1111111109, '25091201', 'sha512', $tok_sha512], - [ 1111111111, '14050471', 'sha1' , $tok_sha1 ], - [ 1111111111, '67062674', 'sha256', $tok_sha256], - [ 1111111111, '99943326', 'sha512', $tok_sha512], - [ 1234567890, '89005924', 'sha1' , $tok_sha1 ], - [ 1234567890, '91819424', 'sha256', $tok_sha256], - [ 1234567890, '93441116', 'sha512', $tok_sha512], - [ 2000000000, '69279037', 'sha1' , $tok_sha1 ], - [ 2000000000, '90698825', 'sha256', $tok_sha256], - [ 2000000000, '38618901', 'sha512', $tok_sha512], - [20000000000, '65353130', 'sha1' , $tok_sha1 ], - [20000000000, '77737706', 'sha256', $tok_sha256], - [20000000000, '47863826', 'sha512', $tok_sha512], + [ 59, '94287082', Algorithm::SHA1 , $tok_sha1 ], + [ 59, '46119246', Algorithm::SHA256, $tok_sha256], + [ 59, '90693936', Algorithm::SHA512, $tok_sha512], + [ 1111111109, '07081804', Algorithm::SHA1 , $tok_sha1 ], + [ 1111111109, '68084774', Algorithm::SHA256, $tok_sha256], + [ 1111111109, '25091201', Algorithm::SHA512, $tok_sha512], + [ 1111111111, '14050471', Algorithm::SHA1 , $tok_sha1 ], + [ 1111111111, '67062674', Algorithm::SHA256, $tok_sha256], + [ 1111111111, '99943326', Algorithm::SHA512, $tok_sha512], + [ 1234567890, '89005924', Algorithm::SHA1 , $tok_sha1 ], + [ 1234567890, '91819424', Algorithm::SHA256, $tok_sha256], + [ 1234567890, '93441116', Algorithm::SHA512, $tok_sha512], + [ 2000000000, '69279037', Algorithm::SHA1 , $tok_sha1 ], + [ 2000000000, '90698825', Algorithm::SHA256, $tok_sha256], + [ 2000000000, '38618901', Algorithm::SHA512, $tok_sha512], + [20000000000, '65353130', Algorithm::SHA1 , $tok_sha1 ], + [20000000000, '77737706', Algorithm::SHA256, $tok_sha256], + [20000000000, '47863826', Algorithm::SHA512, $tok_sha512], ]; } - /** - * @param 'sha1'|'sha256'|'sha512' $algo - */ #[DataProvider('TOTPvectors')] public function testTOTPVectors( int $ts, string $expectedOut, - string $algo, + Algorithm $algo, Secret $key, ): void { // Note: this isn't really the intended use of T0, as it's really meant From 3a45e967a5fa0c9d20a269df52e1c49ac70975ff Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Thu, 9 Apr 2026 10:48:01 -0700 Subject: [PATCH 6/6] Update CHANGELOG for modernization changes Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b90f66..4b5bfc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,30 @@ ## [Unreleased] +### Added + +- `Algorithm` enum for OTP hashing algorithms (replaces string constants) +- `OTP` constructor now accepts `Secret|string` for convenience + +### Changed + +- **BREAKING**: `OTP::getHOTP()` and `OTP::getTOTP()` now require `Algorithm` enum instead of string for the algorithm parameter +- Modernized codebase to use PHP 8.2+ features (readonly properties, typed properties, constructor property promotion) + ### Removed - **BREAKING**: `HOTP()` and `TOTP()` functions have been removed. Use the `OTP` class directly instead. +- **BREAKING**: `OTP::ALGORITHM_*` string constants have been removed. Use the `Algorithm` enum instead. Before: ```php $code = \Firehed\Security\HOTP($secret, $counter); - $code = \Firehed\Security\TOTP($secret, ['digits' => 8]); + $code = \Firehed\Security\TOTP($secret, ['digits' => 8, 'algorithm' => 'sha256']); ``` After: ```php $otp = new \Firehed\Security\OTP($secret); $code = $otp->getHOTP($counter); - $code = $otp->getTOTP(digits: 8); + $code = $otp->getTOTP(digits: 8, algorithm: Algorithm::SHA256); ```