fetus-hina/totp

View on GitHub
src/Totp.php

Summary

Maintainability
A
2 hrs
Test Coverage
<?php

/**
 * @author AIZAWA Hina <hina@fetus.jp>
 * @copyright 2015 by AIZAWA Hina <hina@fetus.jp>
 * @license https://github.com/fetus-hina/totp/blob/master/LICENSE MIT
 * @since 1.0.0
 */

declare(strict_types=1);

namespace jp3cki\totp;

use Base32\Base32;
use DateTimeInterface;
use Exception;
use InvalidArgumentException;

/**
 * TOTP: Time-Based One-Time Password Algorithm
 */
class Totp
{
    /** Default key size: 80 bits */
    public const DEFAULT_KEY_SIZE_BITS = 80;

    /** Default hash algorithm: SHA1 */
    public const DEFAULT_HASH_ALGORITHM = 'sha1';

    /** Default digits: 6 digits */
    public const DEFAULT_DIGITS = 6;

    /** Default time step: 30 sec */
    public const DEFAULT_TIME_STEP_SEC = 30;

    /**
     * Generate user key
     *
     * @param  int<8, max> $sizeBits Generate size(bits, must multiples of 8)
     * @return string Base32 encoded generated key
     * @throws Exception if $sizeBits is not multiples of 8 or system does not support strong random generating
     */
    public static function generateKey(int $sizeBits = self::DEFAULT_KEY_SIZE_BITS): string
    {
        // @phpstan-ignore-next-line
        if ($sizeBits < 8 || $sizeBits % 8 !== 0) {
            throw new Exception('$sizeBits is not multiples of 8');
        }

        $sizeBytes = (int)round($sizeBits / 8);
        assert($sizeBytes > 0);

        return Base32::encode(Random::generate($sizeBytes));
    }

    /**
     * Calculate TOTP
     *
     * @param  string        $key      Base32 encoded key
     * @param  int|DateTimeInterface $time A value that reflects a time
     * @param  int           $digits   Number of digits to return
     * @param  string        $hash     Hash algorithm such as "sha1", "sha256" or "sha512"
     * @param  int           $timeStep Time-step
     * @return string                  TOTP value like "012345"
     * @throws InvalidArgumentException Throw exception if not-acceptable parameter given.
     */
    public static function calc(
        string $key,
        $time,
        int $digits = self::DEFAULT_DIGITS,
        string $hash = self::DEFAULT_HASH_ALGORITHM,
        int $timeStep = self::DEFAULT_TIME_STEP_SEC
    ): string {
        if (!self::isValidBase32($key)) {
            throw new InvalidArgumentException("Invalid shared secret key given");
        }

        if (!self::isValidDigitCount($digits)) {
            throw new InvalidArgumentException("Digit-of-return value is out of range");
        }

        if (!self::isValidHash($hash)) {
            throw new InvalidArgumentException("Unsupported hash algorithm");
        }

        return self::calcMain(
            Base32::decode(strtoupper($key)),
            self::makeTimeStepCount($time, $timeStep),
            (int)$digits,
            strtolower($hash)
        );
    }

    /**
     * Calculate TOTP (Implementation)
     *
     * @param  string $keyBinary shared secret key (binary)
     * @param  int    $stepCount A value that reflects a time
     * @param  int    $digits    Number of digits to return
     * @param  string $hash      Hash algorithm such as "sha1", "sha256" or "sha512"
     * @return string TOTP value like "012345"
     */
    private static function calcMain(string $keyBinary, int $stepCount, int $digits, string $hash): string
    {
        $timeStep = self::pack64($stepCount);
        $hmac = hash_hmac($hash, $timeStep, $keyBinary, true);
        $offset = ord($hmac[strlen($hmac) - 1]) & 0x0f;
        $intValue = ((ord($hmac[$offset]) & 0x7f) << 24) +
                    ((ord($hmac[$offset + 1])) << 16) +
                    ((ord($hmac[$offset + 2])) << 8) +
                    ((ord($hmac[$offset + 3])) << 0);
        $otp = (string)($intValue % pow(10, $digits));
        return substr(str_repeat('0', $digits) . $otp, -$digits);
    }

    /**
     * Verify TOTP
     *
     * @param  string        $value            TOTP value like "012345" which is specified by the user
     * @param  string        $key              Base32 encoded key
     * @param  int|DateTimeInterface $time     A value that reflects a time
     * @param  int           $acceptStepPast   Acceptable time-step (past)
     * @param  int           $acceptStepFuture Acceptable time-step (future)
     * @param  int           $digits           Number of digits to return
     * @param  string        $hash             Hash algorithm such as "sha1", "sha256" or "sha512"
     * @param  int           $timeStep         Time-step
     * @return bool true if verify successful. false if verify failed.
     *
     * @throws InvalidArgumentException Throw exception if not-acceptable parameter given.
     */
    public static function verify(
        string $value,
        string $key,
        $time,
        int $acceptStepPast = 2,
        int $acceptStepFuture = 1,
        int $digits = self::DEFAULT_DIGITS,
        string $hash = self::DEFAULT_HASH_ALGORITHM,
        int $timeStep = self::DEFAULT_TIME_STEP_SEC
    ): bool {
        if (!self::isValidBase32($key)) {
            throw new InvalidArgumentException("Invalid shared secret key given");
        }

        if (!self::isValidDigitCount($digits)) {
            throw new InvalidArgumentException("Digit-of-return value is out of range");
        }

        if (!self::isValidHash($hash)) {
            throw new InvalidArgumentException("Unsupported hash algorithm");
        }

        $keyBinary = Base32::decode(strtoupper($key));
        $currentStep = self::makeTimeStepCount($time, $timeStep);
        $digits = (int)$digits;
        $hash = strtolower($hash);

        $stepBegin = $currentStep - (int)$acceptStepPast;
        $stepEnd   = $currentStep + (int)$acceptStepFuture + 1;
        for ($testTimeStep = $stepBegin; $testTimeStep < $stepEnd; ++$testTimeStep) {
            $testValue = self::calcMain($keyBinary, $testTimeStep, $digits, $hash);
            if ($testValue === $value) {
                return true;
            }
        }
        return false;
    }

    /**
     * Create URI to automatically set the authenticator application "Google Authenticator"
     *
     * Please note:
     *      Following parameters will ignored in the Google Authenticator which is de facto standard application:
     *          - digits:   6 digits only
     *          - hash:     sha1 only
     *          - timeStep: 30 seconds only
     *
     * @param  string $key         Base32 encoded key
     * @param  string $accountName User account name, e.g. email address
     * @param  string $issuer      Issuer name, e.g. your service name
     * @return string URI
     * @throws InvalidArgumentException Throw exception if not-acceptable parameter given.
     */
    public static function createKeyUriForGoogleAuthenticator(
        string $key,
        string $accountName,
        string $issuer
    ): string {
        if (!self::isValidBase32($key)) {
            throw new InvalidArgumentException("Invalid shared secret key given");
        }

        return self::createKeyUriImpl(
            $key,
            trim($accountName),
            trim($issuer)
        );
    }

    /**
     * Create URI to automatically set the authenticator application (Implemetation)
     *
     * Please note:
     *      Following parameters will ignored in the Google Authenticator which is de facto standard application:
     *          - digits:   6 digits only
     *          - hash:     sha1 only
     *          - timeStep: 30 seconds only
     *
     * @param  string $key         Base32 encoded key
     * @param  string $accountName User account name, e.g. email address
     * @param  string $issuer      Issuer name, e.g. your service name
     * @return string URI
     */
    private static function createKeyUriImpl(
        string $key,
        string $accountName,
        string $issuer
    ): string {
        $params = ['secret' => $key];
        if (strlen((string)$issuer) > 0) {
            $params['issuer'] = $issuer;
        }

        return sprintf(
            'otpauth://totp/%s?%s',
            rawurlencode(
                strlen((string)$issuer) < 1
                ? $accountName
                : sprintf('%s:%s', $issuer, $accountName)
            ),
            http_build_query($params, '', '&', PHP_QUERY_RFC3986)
        );
    }

    /**
     * Pack 64bit integer to bigendian binary
     *
     * @param int $value int64 value
     */
    private static function pack64(int $value): string
    {
        return pack('J', $value);
    }

    /**
     * Get is valid base32 value
     *
     * @param  string $base32 Base32 value
     * @return bool
     */
    private static function isValidBase32(string $base32): bool
    {
        return (bool)preg_match('/^[A-Z2-7]+=*$/', $base32);
    }

    /**
     * Get is valid digit count
     *
     * @param  int  $digits Return digit count
     * @return bool
     */
    private static function isValidDigitCount(int $digits): bool
    {
        return 1 <= $digits && $digits <= 8;
    }

    /**
     * Get is valid hash function
     *
     * @param  string $hash Hash algorithm such as "sha1", "sha256" or "sha512"
     * @return bool
     */
    private static function isValidHash(string $hash): bool
    {
        $hash = strtolower($hash);
        return (bool)in_array($hash, hash_algos(), true);
    }

    /**
     * Make time-step count value
     *
     * @param  int|DateTimeInterface $time A value that reflects a time
     * @param  int           $timeStep Time-step
     * @return int
     * @throws InvalidArgumentException Throw exception if not-acceptable parameter given.
     */
    private static function makeTimeStepCount($time, int $timeStep): int
    {
        if (!is_int($time)) {
            if ($time instanceof DateTimeInterface) {
                $time = $time->getTimestamp();
            } else {
                throw new InvalidArgumentException("Invalid timestamp given");
            }
        }

        if ((int)$timeStep < 1) {
            throw new InvalidArgumentException("Time-step value is out of range");
        }

        return (int)floor((int)$time / $timeStep);
    }
}