Skip to content

Commit

Permalink
Merge pull request #2 from abmmhasan/feature/enhancement
Browse files Browse the repository at this point in the history
Feature/enhancement
  • Loading branch information
abmmhasan authored Nov 24, 2023
2 parents 877bb66 + 0647016 commit 33800c0
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 90 deletions.
41 changes: 19 additions & 22 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
@@ -1,36 +1,33 @@
name: PHP Composer
name: "Stability Test"

on:
push:
branches: [ main ]
branches: [ '*' ]
pull_request:
branches: [ main ]
branches: [ "main" ]

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- name: Checkout
uses: actions/checkout@v4

- name: Validate composer.json and composer.lock
run: composer validate --strict
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
tools: composer:v2
coverage: xdebug

- name: Cache Composer packages
id: composer-cache
uses: actions/cache@v2
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-
- name: Check PHP Version
run: php -v

- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Validate Composer
run: composer validate --strict

# Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit"
# Docs: https://getcomposer.org/doc/articles/scripts.md
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader

# - name: Run test suite
# run: composer run-script test
- name: Package Audit
run: composer audit
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ $secret = \AbmmHasan\OTP\HOTP::generateSecret();
/**
* Get QR Code Image for secret $secret
*/
(new \AbmmHasan\OTP\HOTP($secret))->getQRImage('TestName', 'TestTitle');
(new \AbmmHasan\OTP\HOTP($secret))// supports digit count in 2nd parameter, recommended to be either 6 or 8 (default 6)
->setCounter(3) // only required if the counter is being imported from another system or if it is old, & for QR only
->setAlgorithm('sha256') // default is sha1; Caution: many app (in fact, most of them) have algorithm limitation
->getProvisioningUriQR('TestName', '[email protected]'); // or `getProvisioningUri` just to get the URI

/**
* Get current OTP for a given counter
Expand All @@ -72,7 +75,9 @@ $secret = \AbmmHasan\OTP\TOTP::generateSecret();
/**
* Get QR Code Image for secret $secret
*/
(new \AbmmHasan\OTP\TOTP($secret))->getQRImage('TestName', 'TestTitle');
(new \AbmmHasan\OTP\TOTP($secret)) // supports digit count in 2nd parameter, recommended to be either 6 or 8 (default 6)
->setAlgorithm('sha256') // default is sha1; Caution: many app (in fact, most of them) have algorithm limitation
->getProvisioningUriQR('TestName', '[email protected]'); // or `getProvisioningUri` just to get the URI

/**
* Get current OTP
Expand Down
105 changes: 76 additions & 29 deletions src/Common.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Exception;
use InvalidArgumentException;
use ParagonIE\ConstantTime\Base32;

trait Common
{
private string $secret;
private int $steps;
private int $digitCount;
private int $period = 0;
private int $counter = 0;
private int $digitCount = 6;
private ?string $issuer = null;
private string $algorithm = 'sha1';
private string $type = 'totp';
private ?string $label = null;


/**
* Generates a secret string
Expand All @@ -23,59 +30,99 @@ trait Common
*/
public static function generateSecret(): string
{
$string = bin2hex(random_bytes(5));
$string = Base32::encodeUpper($string);
return trim(strtoupper($string), '=');
return rtrim(Base32::encodeUpper(random_bytes(64)), '=');
}

/**
* Retrieves the image Resource for the given OTP type, name, and title.
* Set the algorithm for the OTP generation.
*
* @param string $otpType The type of OTP.
* @param string $name The name for the OTP.
* @param string|null $title (Optional) The title for the OTP.
* @return string The SVG image resource.
* @param string $algorithm The algorithm to set.
* @return static
*/
private function getImage(string $otpType, string $name, ?string $title): string
public function setAlgorithm(string $algorithm): static
{
$url = "otpauth://$otpType/$name?secret=$this->secret";
if (!empty($title)) {
$url .= '&issuer=' . $title;
}
$this->algorithm = $algorithm;
return $this;
}

/**
* Generates the provisioning URI.
*
* @param string $label The label for the URI.
* @param string $issuer The issuer for the URI.
* @return string The provisioning URI.
*/
public function getProvisioningUri(string $label, string $issuer): string
{
$query = http_build_query(
array_filter([
'secret' => $this->secret,
'issuer' => $issuer,
'algorithm' => $this->algorithm,
'digits' => $this->digitCount,
'period' => $this->type === 'hotp' ? null : $this->period,
'counter' => $this->counter
]),
encoding_type: PHP_QUERY_RFC3986
);

$label = rawurlencode(($issuer ? $issuer . ':' : '') . $label);

return "otpauth://$this->type/$label?$query";
}

/**
* Generates the provisioning URI QR image.
*
* @param string $label The label for the provisioning URI.
* @param string|null $issuer The issuer for the provisioning URI. Default is null.
* @return string The provisioning URI as a string.
*/
public function getProvisioningUriQR(string $label, string $issuer = null): string
{
$writer = new Writer(
new ImageRenderer(
new RendererStyle(200),
new SvgImageBackEnd()
)
);
return $writer->writeString($url);
return $writer->writeString($this->getProvisioningUri($label, $issuer));
}

/**
* Generates a one-time password (OTP) based on the given input.
*
* @param int $input The input value used to generate the OTP.
* @return int The generated one-time password.
* @return string The generated one-time password.
*/
private function getPassword(int $input): int
private function getPassword(int $input): string
{
$timeCode = ($input * 1000) / ($this->steps * 1000);
$result = $hmac = [];
while ($timeCode != 0) {
$timeCode = ($input * 1000) / ($this->period * 1000);
$result = [];
while ($timeCode !== 0) {
$result[] = chr($timeCode & 0xFF);
$timeCode >>= 8;
}
$intToByteString = str_pad(implode('', array_reverse($result)), 8, "\000", STR_PAD_LEFT);
$hash = hash_hmac('sha1', $intToByteString, Base32::decodeUpper($this->secret));
foreach (str_split($hash, 2) as $hex) {
$hmac[] = hexdec($hex);
}
$offset = $hmac[19] & 0xf;
$code = ($hmac[$offset + 0] & 0x7F) << 24 |
$intToByteString = str_pad(
implode('', array_reverse($result)),
8,
"\000",
STR_PAD_LEFT
);
$hash = hash_hmac($this->algorithm, $intToByteString, Base32::decodeUpper($this->secret), 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);
return $code % pow(10, $this->digitCount);
return str_pad(
$code % pow(10, $this->digitCount),
$this->digitCount,
'0',
STR_PAD_LEFT
);
}
}
29 changes: 16 additions & 13 deletions src/HOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,41 +19,44 @@ public function __construct(
) {
$this->secret = $secret;
$this->digitCount = $digitCount;
$this->steps = 1;
$this->period = 1;
$this->type = 'hotp';
}

/**
* Retrieves the QR image for a given name and title.
* Sets the initial counter value.
*
* @param string $name The name to generate the QR image for.
* @param string|null $title The title to be displayed on the QR image. (optional)
* @return string The QR image URL.
* Required if Provisioning resources need to manipulate based on a specific counter.
*
* @param int $counter The new value for the counter.
* @return static
*/
public function getQRImage(string $name, string $title = null): string
public function setCounter(int $counter): static
{
return $this->getImage('hotp', $name, $title);
$this->counter = $counter;
return $this;
}

/**
* Generates a one-time password (OTP) based on the given Counter.
*
* @param int $counter The input value used to generate the OTP.
* @return int The generated OTP.
* @return string The generated OTP.
*/
public function getOTP(int $counter): int
public function getOTP(int $counter): string
{
return $this->getPassword($counter);
}

/**
* Verifies if the given OTP matches the OTP generated based on the given Counter.
*
* @param int $otp The OTP to be verified.
* @param string $otp The OTP to be verified.
* @param int $counter The input used to generate the OTP.
* @return bool Returns true if the OTP matches the generated OTP, otherwise false.
* @return bool Returns true if the OTP matches the generated one, otherwise false.
*/
public function verify(int $otp, int $counter): bool
public function verify(string $otp, int $counter): bool
{
return $otp === $this->getOTP($counter);
return hash_equals($otp, $this->getOTP($counter));
}
}
12 changes: 7 additions & 5 deletions src/OTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class OTP
*/
public function __construct(
private int $digitCount = 6,
private int $validUpto = 30
private int $validUpto = 30,
private string $hashAlgorithm = 'sha256'
) {
$this->cacheAdapter = new FilesystemAdapter();
}
Expand All @@ -34,7 +35,7 @@ public function generate(string $signature): int
{
$otpAdapter = $this->cacheAdapter->getItem('ao-otp_' . base64_encode($signature));
$otp = $this->number($this->digitCount);
$otpAdapter->set(hash('sha256', $otp))->expiresAfter($this->validUpto);
$otpAdapter->set(hash($this->hashAlgorithm, $otp))->expiresAfter($this->validUpto);
$this->cacheAdapter->save($otpAdapter);
return $otp;
}
Expand All @@ -44,19 +45,20 @@ public function generate(string $signature): int
*
* @param string $signature The signature to be verified.
* @param int $otp The one-time password (OTP) to be verified.
* @param bool $deleteIfFound Whether to delete the OTP from the cache if found (disregarding verification).
* @return bool Returns true if the signature and OTP are verified successfully, false otherwise.
* @throws InvalidArgumentException
*/
public function verify(string $signature, int $otp): bool
public function verify(string $signature, int $otp, bool $deleteIfFound = true): bool
{
if ($otp < 0 || strlen($otp) !== $this->digitCount) {
return false;
}
$signature = 'ao-otp_' . base64_encode($signature);
$otpAdapter = $this->cacheAdapter->getItem($signature);
if ($otpAdapter->isHit()) {
$isVerified = hash_equals($otpAdapter->get(), hash('sha256', $otp));
$this->cacheAdapter->deleteItem($signature);
$isVerified = hash_equals($otpAdapter->get(), hash($this->hashAlgorithm, $otp));
$deleteIfFound && $this->cacheAdapter->deleteItem($signature);
return $isVerified;
}
return false;
Expand Down
26 changes: 7 additions & 19 deletions src/TOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,29 @@ public function __construct(
) {
$this->secret = $secret;
$this->digitCount = $digitCount;
$this->steps = $interval;
}

/**
* Retrieves the QR image for a given name and title.
*
* @param string $name The name to generate the QR image for.
* @param string|null $title The title to be displayed on the QR image. (optional)
* @return string The QR image URL.
*/
public function getQRImage(string $name, string $title = null): string
{
return $this->getImage('totp', $name, $title);
$this->period = $interval;
}

/**
* Generates a one-time password (OTP) based on the given timestamp (or Current Timestamp).
*
* @param int|null $input The input value used to generate the OTP. If null, the current timestamp is used.
* @return int The generated OTP.
* @return string The generated OTP.
*/
public function getOTP(int $input = null): int
public function getOTP(int $input = null): string
{
return $this->getPassword($input ?? time());
}

/**
* Verifies if the given OTP matches the OTP generated by the given timestamp (or Current Timestamp).
*
* @param int $otp The OTP to be verified.
* @param string $otp The OTP to be verified.
* @param int|null $input The input used to generate the OTP. Defaults to Current Timestamp.
* @return bool Returns true if the OTP matches the generated OTP, otherwise false.
* @return bool Returns true if the OTP matches the generated one, otherwise false.
*/
public function verify(int $otp, int $input = null): bool
public function verify(string $otp, int $input = null): bool
{
return $otp === $this->getOTP($input);
return hash_equals($otp, $this->getOTP($input));
}
}

0 comments on commit 33800c0

Please sign in to comment.