dadajuice/zephyrus

View on GitHub
src/Zephyrus/Security/Cryptography.php

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
<?php namespace Zephyrus\Security;

use InvalidArgumentException;
use RuntimeException;
use Zephyrus\Application\Configuration;

class Cryptography
{
    /**
     * Default algorithm to use with encrypt() and decrypt() methods if none is specified otherwise within the security
     * section of the config.yml configuration file as property [encryption -> algorithm].
     */
    private const DEFAULT_ENCRYPTION_ALGORITHM = 'aes-256-cbc';

    /**
     * Default algorithm to use with hashPassword() if none is specified otherwise within the security section of the
     * config.yml configuration file as property [password -> algorithm].
     */
    private const DEFAULT_PASSWORD_HASH_ALGORITHM = PASSWORD_BCRYPT;

    /**
     * Default cost option to use with hashPassword() for BCRYPT if none is specified otherwise within the security
     * section of the config.yml configuration file as property [password -> options -> cost].
     */
    private const DEFAULT_PASSWORD_HASH_COST = 13;

    /**
     * Cryptographically hash a specified string using the default PHP hashing algorithm. This method uses the default
     * hash function included in the PHP core and thus automatically provides a cryptographically random salt. If the
     * property [pepper] is defined in the password security section of the config.yml file, the method will concatenate
     * the password with the configured pepper.
     *
     * This pepper should be unique by project and thus ensure that a given hashed password will work only within a
     * specific project. The pepper is designed to be a "secret" kept within the server. Should be defined as a server
     * environment to ensure maximum security.
     *
     * The algorithm used by default is PASSWORD_CRYPT but can be changed with the property [algorithm] in the password
     * security section of the config.yml file as well as the hash options.
     *
     * @param string $clearTextPassword
     * @return string
     */
    public static function hashPassword(string $clearTextPassword): string
    {
        $config = Configuration::getSecurity("password");
        $pepper = $config['pepper'] ?? "";
        $algorithm = $config['algorithm'] ?? self::DEFAULT_PASSWORD_HASH_ALGORITHM;
        $options = $config['options'] ?? ['cost' => self::DEFAULT_PASSWORD_HASH_COST];
        if ($pepper) {
            $clearTextPassword = $clearTextPassword . $pepper;
        }
        return password_hash($clearTextPassword, $algorithm, $options);
    }

    /**
     * Determines if the specified hash matches the given clear text password. Makes sure to add the pepper if one is
     * defined within the project's config.yml file. See hashPassword method for more information.
     *
     * @param string $clearTextPassword
     * @param string $hash
     * @return bool
     */
    public static function verifyHashedPassword(string $clearTextPassword, string $hash): bool
    {
        $config = Configuration::getSecurity("password");
        $pepper = $config['pepper'] ?? "";
        if ($pepper) {
            $clearTextPassword = $clearTextPassword . $pepper;
        }
        return password_verify($clearTextPassword, $hash);
    }

    /**
     * Hashes the given string with the specified algorithm. By default, will do a basic md5 hashing. This method makes
     * sure to validate the support of the algorithm. Throws InvalidArgumentException otherwise.
     *
     * @param string $string
     * @param string $algorithm
     * @return string
     */
    public static function hash(string $string, string $algorithm = 'md5'): string
    {
        if (!in_array($algorithm, hash_algos())) {
            throw new InvalidArgumentException('Specified hashing algorithm not supported');
        }
        return hash($algorithm, $string);
    }

    /**
     * Hashes the entire content of the given file with the specified algorithm. By default, will do a basic md5
     * hashing. This method makes sure to validate the existence of the file and the support of the algorithm. Throws
     * InvalidArgumentException otherwise.
     *
     * @param string $filename
     * @param string $algorithm
     * @return string
     */
    public static function hashFile(string $filename, string $algorithm = 'md5'): string
    {
        if (!in_array($algorithm, hash_algos())) {
            throw new InvalidArgumentException('Specified hashing algorithm not supported');
        }
        if (!file_exists($filename)) {
            throw new InvalidArgumentException("Specified file to hash does not exist");
        }
        return hash_file($algorithm, $filename);
    }

    /**
     * Returns a random hex of desired length based on the openSSL cryptographic random.
     *
     * @param int $length
     * @return string
     */
    public static function randomHex(int $length = 128): string
    {
        $bytes = ceil($length / 2);
        return bin2hex(self::randomBytes($bytes));
    }

    /**
     * Returns a random integer between the provided min and max using random bytes based on the openSSL cryptographic
     * random. Throws InvalidArgumentException if min and max arguments have inconsistencies.
     *
     * @param int $min
     * @param int $max
     * @return int
     */
    public static function randomInt(int $min, int $max): int
    {
        if ($max <= $min) {
            throw new InvalidArgumentException('Minimum equal or greater than maximum!');
        }
        if ($max < 0 || $min < 0) {
            throw new InvalidArgumentException('Only positive integers supported for now!');
        }

        $difference = $max - $min;
        for ($power = 8; pow(2, $power) < $difference; $power = $power * 2) {
        }
        $powerExp = $power / 8;
        do {
            $randDiff = hexdec(bin2hex(self::randomBytes($powerExp)));
        } while ($randDiff > $difference);
        return $min + $randDiff;
    }

    /**
     * Returns a random string of the desired length using only the given characters. If none is provided, alphanumeric
     * characters ([0-9a-Z]) are used.
     *
     * @param int $length
     * @param string|array|null $characters
     * @return string
     */
    public static function randomString(int $length, string|array $characters = null): string
    {
        if (is_null($characters)) {
            $characters = array_merge(range('a', 'z'), range('A', 'Z'), range('0', '9'));
        }
        if (is_string($characters)) {
            $characters = str_split($characters);
        }
        $result = '';
        $characterCount = count($characters);
        for ($i = 0; $i < $length; ++$i) {
            $result .= $characters[self::randomInt(0, $characterCount - 1)];
        }
        return $result;
    }

    /**
     * Returns random bytes based on openssl. This method is used by all other "random" methods. Throws an exception if
     * the result is not considered strong enough by the openssl lib.
     *
     * @param int $length
     * @return string
     */
    public static function randomBytes(int $length = 1): string
    {
        return openssl_random_pseudo_bytes($length);
    }

    /**
     * Encrypts the given plain text using the configured encryption algorithm and the provided key. Includes a hash
     * authentication processing. Returns a concatenation of the authentication hash (hmac), the generated iv and the
     * cipher. By default, will encrypt using the AES CBC mode 256 bits (aes-256-cbc) algorithm. SHA256 is used to
     * derive hmac key. Use method decrypt to retrieve the original plain text.
     *
     * @param string $plainText
     * @param string|null $key
     * @return string
     */
    public static function encrypt(string $plainText, ?string $key = null): string
    {
        $algorithm = self::getEncryptionAlgorithm();
        $key = !is_null($key) ? $key : self::getEncryptionDefaultKey();
        if (is_null($key)) {
            throw new RuntimeException("The encryption key cannot be null. Be sure to either give one specifically for the operation or set a default key within the config.yml file.");
        }

        $initializationVector = self::randomBytes(openssl_cipher_iv_length($algorithm));
        $keys = self::deriveEncryptionKey($key, $initializationVector); // password is the encryption key
        $encryptionKey = mb_substr($keys, 0, 32, '8bit');
        $hashAuthenticationKey = mb_substr($keys, 32, null, '8bit');
        $cipher = openssl_encrypt($plainText, $algorithm, $encryptionKey, OPENSSL_RAW_DATA, $initializationVector);
        $hmac = hash_hmac('sha256', $initializationVector . $cipher, $hashAuthenticationKey);
        return base64_encode($hmac . $initializationVector . $cipher);
    }

    /**
     * Decrypts the given cipher using the configured encryption algorithm and the provided decryption key. Provided
     * cipher should have been made by the encrypt method. Returns the plain text or null if decryption failed. By
     * default, will decrypt using the AES CBC mode 256 bits (aes-256-cbc) algorithm. Returns null if decryption fails.
     *
     * @param string $cipherText
     * @param string|null $key
     * @return null|string
     */
    public static function decrypt(string $cipherText, ?string $key = null): ?string
    {
        $algorithm = self::getEncryptionAlgorithm();
        $key = !is_null($key) ? $key : self::getEncryptionDefaultKey();
        if (is_null($key)) {
            throw new RuntimeException("The encryption key cannot be null. Be sure to either give one specifically for the operation or set a default key within the config.yml file.");
        }

        $cipherText = base64_decode($cipherText);
        if (strlen($cipherText) < 81) {
            return null;
        }

        $hmac = mb_substr($cipherText, 0, 64, '8bit');
        $initializationVector = mb_substr($cipherText, 64, 16, '8bit');
        $cipher = mb_substr($cipherText, 80, null, '8bit');
        $keys = self::deriveEncryptionKey($key, $initializationVector); // password is the encryption key
        $encryptionKey = mb_substr($keys, 0, 32, '8bit');
        $hashAuthenticationKey = mb_substr($keys, 32, null, '8bit');
        $hmacValidation = hash_hmac('sha256', $initializationVector . $cipher, $hashAuthenticationKey);
        if (!hash_equals($hmac, $hmacValidation)) {
            // Cipher authentication failed
            return null;
        }
        $plainText = openssl_decrypt($cipher, $algorithm, $encryptionKey, OPENSSL_RAW_DATA, $initializationVector);
        if ($plainText === false) {
            return null; // @codeCoverageIgnore
        }
        return $plainText;
    }

    /**
     * Encrypts the entire content of the given file with the specified key. This method overrides the original if no
     * destination is specified. Use the same algorithm as the encrypt function. This method makes sure to validate the
     * existence of the file and the support of the algorithm. Throws InvalidArgumentException otherwise. Warning! Make
     * sure to not lose the key because the file will forever be encrypted.
     *
     * @see encrypt
     * @param string $plainTextFilename
     * @param string $key
     * @param string|null $destination
     */
    public static function encryptFile(string $plainTextFilename, string $key, ?string $destination = null): void
    {
        if (!file_exists($plainTextFilename)) {
            throw new InvalidArgumentException("Specified file to encrypt does not exist");
        }
        $originalContent = file_get_contents($plainTextFilename);
        $cipherText = self::encrypt($originalContent, $key);
        file_put_contents($destination ?? $plainTextFilename, $cipherText);
    }

    /**
     * Decrypts the entire content of the given file with the specified key. This method overrides the original if no
     * destination is specified. Use the same algorithm as the decrypt function. This method makes sure to validate the
     * existence of the file and the support of the algorithm. Throws InvalidArgumentException otherwise.
     *
     * @see encrypt
     * @param string $cipherTextFilename
     * @param string $key
     * @param string|null $destination
     */
    public static function decryptFile(string $cipherTextFilename, string $key, ?string $destination = null): void
    {
        if (!file_exists($cipherTextFilename)) {
            throw new InvalidArgumentException("Specified file to decrypt does not exist");
        }
        $cipherText = file_get_contents($cipherTextFilename);
        $originalContent = self::decrypt($cipherText, $key);
        file_put_contents($destination ?? $cipherTextFilename, $originalContent);
    }

    /**
     * Encrypts the given plain text with the specified encryption key as usual but also authenticate with a hmac using
     * the Encrypt-then-MAC approach for authenticated encryption. The result contains the cipher, the hmac and salt.
     *
     * @param string $plainText
     * @param string $encryptionKey
     * @param string $authenticationKey
     * @return string
     */
    public static function authEncrypt(string $plainText, string $encryptionKey, string $authenticationKey): string
    {
        $cipher = self::encrypt($plainText, $encryptionKey);
        $salt = self::randomBytes(32);
        $hmac = hash_hmac('sha256', $cipher . $salt, $authenticationKey);
        return base64_encode($cipher . '/+' . $hmac . '/+' . $salt);
    }

    /**
     * Decrypts the given cipher text using the encryption key after authenticating the hmac with the given
     * authentication key. Returns null if decryption fails.
     *
     * @param string $cipherText
     * @param string $encryptionKey
     * @param string $authenticationKey
     * @return string|null
     */
    public static function authDecrypt(string $cipherText, string $encryptionKey, string $authenticationKey): ?string
    {
        $rawCipherText = base64_decode($cipherText);
        if (substr_count($rawCipherText, '/+') != 2) {
            return null;
        }
        list($cipher, $hmac, $salt) = explode('/+', $rawCipherText);
        $hmacNow = hash_hmac('sha256', $cipher . $salt, $authenticationKey);
        if (!hash_equals($hmac, $hmacNow)) {
            return null;
        }
        return self::decrypt($cipher, $encryptionKey);
    }

    /**
     * Generates a key from a password based key derivation function (PBKDF) as defined in RFC2898. Uses the SHA256
     * hashing algorithm. This method is useful to attach an encryption key to a user based on his password. The
     * iteration count will greatly affect performances, be sure to use something adapted to your server capacity.
     *
     * @see https://www.ietf.org/rfc/rfc2898.txt
     * @param string $password
     * @param string $salt
     * @param int $length
     * @param int $iteration
     * @return string
     */
    public static function deriveEncryptionKey(string $password, string $salt, int $length = 64, int $iteration = 80000): string
    {
        return hash_pbkdf2('sha256', $password, $salt, $iteration, $length);
    }

    /**
     * Returns the configured baseline encryption algorithm to be used in the application with encrypt and decrypt
     * methods.
     *
     * @return string
     */
    public static function getEncryptionAlgorithm(): string
    {
        $config = Configuration::getSecurity("encryption");
        return $config['algorithm'] ?? self::DEFAULT_ENCRYPTION_ALGORITHM;
    }

    /**
     * Returns the configured default encryption key to be used in the application with encrypt and decrypt
     * methods. Returns null if no default key has been specified.
     *
     * @return string|null
     */
    public static function getEncryptionDefaultKey(): ?string
    {
        $config = Configuration::getSecurity("encryption");
        return $config['key'] ?? self::DEFAULT_ENCRYPTION_ALGORITHM;
    }
}