From 866bb90f2a1e0a0fde94b32edfef7232de092f4e Mon Sep 17 00:00:00 2001 From: netkas Date: Tue, 7 Jan 2025 14:15:07 -0500 Subject: [PATCH] Add OTP support with implementation for creation, deletion, and verification. --- .idea/sqldialects.xml | 1 + src/Socialbox/Classes/Configuration.php | 11 + .../Configuration/SecurityConfiguration.php | 59 +++++ src/Socialbox/Classes/OtpCryptography.php | 50 ++-- .../StandardMethods/SettingsDeleteOtp.php | 93 +++++++ .../StandardMethods/SettingsSetOtp.php | 90 +++++++ .../VerificationOtpAuthentication.php | 63 +++++ .../Managers/OneTimePasswordManager.php | 233 ++++++++++++++++++ 8 files changed, 572 insertions(+), 28 deletions(-) create mode 100644 src/Socialbox/Classes/StandardMethods/SettingsDeleteOtp.php create mode 100644 src/Socialbox/Classes/StandardMethods/SettingsSetOtp.php create mode 100644 src/Socialbox/Classes/StandardMethods/VerificationOtpAuthentication.php create mode 100644 src/Socialbox/Managers/OneTimePasswordManager.php diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index a2d3934..4bbb771 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -7,6 +7,7 @@ + diff --git a/src/Socialbox/Classes/Configuration.php b/src/Socialbox/Classes/Configuration.php index c3abd6f..cf3c621 100644 --- a/src/Socialbox/Classes/Configuration.php +++ b/src/Socialbox/Classes/Configuration.php @@ -45,6 +45,17 @@ $config->setDefault('security.display_internal_exceptions', false); $config->setDefault('security.resolved_servers_ttl', 600); $config->setDefault('security.captcha_ttl', 200); + // Server-side OTP Security options + // The time step in seconds for the OTP generation + // Default: 30 seconds + $config->setDefault('security.otp_secret_key_length', 32); + $config->setDefault('security.otp_time_step', 30); + // The number of digits in the OTP + $config->setDefault('security.otp_digits', 6); + // The hash algorithm to use for the OTP generation (sha1, sha256, sha512) + $config->setDefault('security.otp_hash_algorithm', 'sha512'); + // The window of time steps to allow for OTP verification + $config->setDefault('security.otp_window', 1); // Cryptography Configuration // The Unix Timestamp for when the host's keypair should expire diff --git a/src/Socialbox/Classes/Configuration/SecurityConfiguration.php b/src/Socialbox/Classes/Configuration/SecurityConfiguration.php index 72854c2..09ccf2a 100644 --- a/src/Socialbox/Classes/Configuration/SecurityConfiguration.php +++ b/src/Socialbox/Classes/Configuration/SecurityConfiguration.php @@ -7,6 +7,11 @@ private bool $displayInternalExceptions; private int $resolvedServersTtl; private int $captchaTtl; + private int $otpSecretKeyLength; + private int $otpTimeStep; + private int $otpDigits; + private string $otpHashAlgorithm; + private int $otpWindow; /** * Constructor method for initializing class properties. @@ -20,6 +25,11 @@ $this->displayInternalExceptions = $data['display_internal_exceptions']; $this->resolvedServersTtl = $data['resolved_servers_ttl']; $this->captchaTtl = $data['captcha_ttl']; + $this->otpSecretKeyLength = $data['otp_secret_key_length']; + $this->otpTimeStep = $data['otp_time_step']; + $this->otpDigits = $data['otp_digits']; + $this->otpHashAlgorithm = $data['otp_hash_algorithm']; + $this->otpWindow = $data['otp_window']; } /** @@ -52,4 +62,53 @@ return $this->captchaTtl; } + /** + * Retrieves the length of the secret key used for OTP generation. + * + * @return int The length of the secret key used for OTP generation. + */ + public function getOtpSecretKeyLength(): int + { + return $this->otpSecretKeyLength; + } + + /** + * Retrieves the time step value for OTP generation. + * + * @return int The time step value for OTP generation. + */ + public function getOtpTimeStep(): int + { + return $this->otpTimeStep; + } + + /** + * Retrieves the number of digits in the OTP. + * + * @return int The number of digits in the OTP. + */ + public function getOtpDigits(): int + { + return $this->otpDigits; + } + + /** + * Retrieves the hash algorithm used for OTP generation. + * + * @return string The hash algorithm used for OTP generation. + */ + public function getOtpHashAlgorithm(): string + { + return $this->otpHashAlgorithm; + } + + /** + * Retrieves the window value for OTP generation. + * + * @return int The window value for OTP generation. + */ + public function getOtpWindow(): int + { + return $this->otpWindow; + } } \ No newline at end of file diff --git a/src/Socialbox/Classes/OtpCryptography.php b/src/Socialbox/Classes/OtpCryptography.php index a53373f..21a2b77 100644 --- a/src/Socialbox/Classes/OtpCryptography.php +++ b/src/Socialbox/Classes/OtpCryptography.php @@ -7,15 +7,17 @@ class OtpCryptography { + private const string URI_FORMAT = 'otpauth://totp/%s?secret=%s%s&algorithm=%s&digits=%d&period=%d'; + /** * Generates a random secret key of the specified length. * * @param int $length The length of the secret key in bytes. Default is 32. * @return string Returns the generated secret key as a hexadecimal string. - * @throws CryptographyException - * @throws RandomException + * @throws CryptographyException If the length is less than or equal to 0. + * @throws RandomException If an error occurs while generating random bytes. */ - public static function generateSecretKey(int $length = 32): string + public static function generateSecretKey(int $length=32): string { if($length <= 0) { @@ -32,11 +34,11 @@ * @param int $timeStep The time step in seconds used for OTP generation. Default is 30 seconds. * @param int $digits The number of digits in the OTP. Default is 6. * @param int|null $counter Optional counter value. If not provided, it is calculated based on the current time and time step. - * @param string $hashAlgorithm The hash algorithm used for OTP generation. Default is 'sha1'. + * @param string $hashAlgorithm The hash algorithm used for OTP generation. Default is 'sha512'. * @return string Returns the generated OTP as a string with the specified number of digits. * @throws CryptographyException If the generated hash length is less than 20 bytes. */ - public static function generateOTP(string $secretKey, int $timeStep=30, int $digits=6, int $counter=null, string $hashAlgorithm='sha1'): string + public static function generateOTP(string $secretKey, int $timeStep=30, int $digits=6, int $counter=null, string $hashAlgorithm='sha512'): string { if ($counter === null) { @@ -73,6 +75,7 @@ * @param int $digits The number of digits in the OTP. Default is 6. * @param string $hashAlgorithm The hash algorithm used for OTP generation. Default is 'sha512'. * @return bool Returns true if the OTP is valid within the provided parameters, otherwise false. + * @throws CryptographyException If the generated hash length is less than 20 bytes. */ public static function verifyOTP(string $secretKey, string $otp, int $timeStep=30, int $window=1, int $digits=6, string $hashAlgorithm='sha512'): bool { @@ -93,28 +96,20 @@ } /** - * Generates a QR code payload for a TOTP-based authentication system. + * Generates a key URI for use in configuring an authenticator application. * - * The method constructs a URI in the format compatible with TOTP applications. - * - * @param string $account The account name or identifier associated with the QR code. - * @param string $secretKey The secret key to be included in the payload. - * @param string $issuer The issuer name to identify the organization or service. - * - * @return string A formatted string representing the QR code payload. - * - * @throws CryptographyException If the domain configuration is missing. + * @param string $label A unique label to identify the account (e.g., user or service name). + * @param string $secretKey The secret key used for generating the OTP. + * @param string|null $issuer The name of the organization or service issuing the key. Default is null. + * @param int $timeStep The time step in seconds used for OTP generation. Default is 30 seconds. + * @param int $digits The number of digits in the generated OTP. Default is 6. + * @param string $hashAlgorithm The hash algorithm used for OTP generation. Default is 'sha512'. + * @return string Returns the URI string formatted */ - public static function generateQrPayload(string $account, string $secretKey, string $issuer): string + public static function generateKeyUri(string $label, string $secretKey, ?string $issuer = null, int $timeStep=30, int $digits=6, string $hashAlgorithm='sha512'): string { - $domain = Configuration::getInstanceConfiguration()->getDomain(); - - if (!$domain) - { - throw new CryptographyException("Domain configuration is missing."); - } - - return sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", rawurlencode($domain), rawurlencode($account), rawurlencode($secretKey), rawurlencode($issuer)); + $issuerPart = $issuer ? "&issuer=" . rawurlencode($issuer) : ''; + return sprintf(self::URI_FORMAT, rawurlencode($label), $secretKey, $issuerPart, strtoupper($hashAlgorithm), $digits, $timeStep); } /** @@ -123,15 +118,14 @@ * @param string $algorithm The hashing algorithm to be used (e.g., 'sha1', 'sha256', 'sha384', 'sha512'). * @param string $data The data to be hashed. * @param string $key The secret key used for the HMAC generation. - * * @return string The generated HMAC as a raw binary string. - * - * @*/ + * @throws CryptographyException If the algorithm is not supported. + */ private static function hashHmac(string $algorithm, string $data, string $key): string { return match($algorithm) { - 'sha1', 'sha256', 'sha384', 'sha512' => hash_hmac($algorithm, $data, $key, true), + 'sha1', 'sha256', 'sha512' => hash_hmac($algorithm, $data, $key, true), default => throw new CryptographyException('Algorithm not supported') }; } diff --git a/src/Socialbox/Classes/StandardMethods/SettingsDeleteOtp.php b/src/Socialbox/Classes/StandardMethods/SettingsDeleteOtp.php new file mode 100644 index 0000000..d36b5ce --- /dev/null +++ b/src/Socialbox/Classes/StandardMethods/SettingsDeleteOtp.php @@ -0,0 +1,93 @@ +isOtpRequired()) + { + return $rpcRequest->produceError(StandardError::METHOD_NOT_ALLOWED, 'One Time Password is required for this server'); + } + + $peer = $request->getPeer(); + + try + { + if (!OneTimePasswordManager::usesOtp($peer->getUuid())) + { + return $rpcRequest->produceError(StandardError::METHOD_NOT_ALLOWED, "Cannot delete One Time Password when none is set"); + } + } + catch (DatabaseOperationException $e) + { + throw new StandardException('Failed to check One Time Password due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + + try + { + $usesPassword = PasswordManager::usesPassword($peer); + } + catch (DatabaseOperationException $e) + { + throw new StandardException('Failed to check password usage due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + + // Password verification is required to set an OTP if a password is set + if($usesPassword) + { + if(!$rpcRequest->containsParameter('password')) + { + return $rpcRequest->produceError(StandardError::RPC_INVALID_ARGUMENTS, 'When a password is set, the current password must be provided to delete an OTP'); + } + + if(!Cryptography::validateSha512($rpcRequest->getParameter('password'))) + { + return $rpcRequest->produceError(StandardError::RPC_INVALID_ARGUMENTS, 'The provided password is not a valid SHA-512 hash'); + } + + try + { + if(!PasswordManager::verifyPassword($peer, $rpcRequest->getParameter('password'))) + { + return $rpcRequest->produceError(StandardError::FORBIDDEN, 'The provided password is incorrect'); + } + } + catch(Exception $e) + { + throw new StandardException('Failed to verify password due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + } + + try + { + // Delete the OTP + OneTimePasswordManager::deleteOtp($peer); + } + catch(Exception $e) + { + throw new StandardException('Failed to set password due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + + return $rpcRequest->produceResponse(true); + } + } \ No newline at end of file diff --git a/src/Socialbox/Classes/StandardMethods/SettingsSetOtp.php b/src/Socialbox/Classes/StandardMethods/SettingsSetOtp.php new file mode 100644 index 0000000..1c959cf --- /dev/null +++ b/src/Socialbox/Classes/StandardMethods/SettingsSetOtp.php @@ -0,0 +1,90 @@ +getPeer(); + + try + { + if (OneTimePasswordManager::usesOtp($peer->getUuid())) + { + return $rpcRequest->produceError(StandardError::METHOD_NOT_ALLOWED, "Cannot set One Time Password when one is already set, use 'settingsUpdateOtp' instead"); + } + } + catch (DatabaseOperationException $e) + { + throw new StandardException('Failed to check One Time Password due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + + try + { + $usesPassword = PasswordManager::usesPassword($peer); + } + catch (DatabaseOperationException $e) + { + throw new StandardException('Failed to check password usage due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + + // Password verification is required to set an OTP if a password is set + if($usesPassword) + { + if(!$rpcRequest->containsParameter('password')) + { + return $rpcRequest->produceError(StandardError::RPC_INVALID_ARGUMENTS, 'When a password is set, the current password must be provided to set an OTP'); + } + + if(!Cryptography::validateSha512($rpcRequest->getParameter('password'))) + { + return $rpcRequest->produceError(StandardError::RPC_INVALID_ARGUMENTS, 'The provided password is not a valid SHA-512 hash'); + } + + try + { + if(!PasswordManager::verifyPassword($peer, $rpcRequest->getParameter('password'))) + { + return $rpcRequest->produceError(StandardError::FORBIDDEN, 'The provided password is incorrect'); + } + } + catch(Exception $e) + { + throw new StandardException('Failed to verify password due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + } + + try + { + // Create a new OTP and return the OTP URI to the client + $totpUri = OneTimePasswordManager::createOtp($peer); + + // Remove the SET_PASSWORD flag & update the session flow if necessary + SessionManager::updateFlow($request->getSession(), [SessionFlags::SET_OTP]); + } + catch(Exception $e) + { + throw new StandardException('Failed to set password due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + + return $rpcRequest->produceResponse($totpUri); + } + } \ No newline at end of file diff --git a/src/Socialbox/Classes/StandardMethods/VerificationOtpAuthentication.php b/src/Socialbox/Classes/StandardMethods/VerificationOtpAuthentication.php new file mode 100644 index 0000000..f92c23e --- /dev/null +++ b/src/Socialbox/Classes/StandardMethods/VerificationOtpAuthentication.php @@ -0,0 +1,63 @@ +containsParameter('code')) + { + return $rpcRequest->produceError(StandardError::RPC_INVALID_ARGUMENTS, "Missing 'code' parameter"); + } + + if(strlen($rpcRequest->getParameter('code')) !== Configuration::getSecurityConfiguration()->getOtpDigits()) + { + return $rpcRequest->produceError(StandardError::RPC_INVALID_ARGUMENTS, "Invalid 'code' parameter length"); + } + + $session = $request->getSession(); + if(!$session->flagExists(SessionFlags::VER_OTP)) + { + return $rpcRequest->produceError(StandardError::FORBIDDEN, 'One Time Password verification is not required at this time'); + } + + try + { + $result = OneTimePasswordManager::verifyOtp($request->getPeer(), $rpcRequest->getParameter('code')); + + if($result) + { + // If the OTP is verified, remove the OTP verification flag + SessionManager::updateFlow($session, [SessionFlags::VER_OTP]); + } + } + catch (CryptographyException) + { + return $rpcRequest->produceResponse(false); + } + catch (Exception $e) + { + throw new StandardException('Failed to verify password due to an internal exception', StandardError::INTERNAL_SERVER_ERROR, $e); + } + + return $rpcRequest->produceResponse($result); + } + } \ No newline at end of file diff --git a/src/Socialbox/Managers/OneTimePasswordManager.php b/src/Socialbox/Managers/OneTimePasswordManager.php new file mode 100644 index 0000000..cb437bb --- /dev/null +++ b/src/Socialbox/Managers/OneTimePasswordManager.php @@ -0,0 +1,233 @@ +getUuid(); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT COUNT(*) FROM authentication_otp WHERE peer_uuid=:uuid'); + $stmt->bindParam(':uuid', $peerUuid); + $stmt->execute(); + } + catch (PDOException $e) + { + throw new DatabaseOperationException('An error occurred while checking the OTP usage in the database', $e); + } + + return $stmt->fetchColumn() > 0; + } + + /** + * Creates and stores a new OTP (One-Time Password) secret for the specified peer, and generates a key URI. + * + * @param string|RegisteredPeerRecord $peer The unique identifier of the peer, either as a string UUID + * or an instance of RegisteredPeerRecord. + * @return string The generated OTP key URI that can be used for applications like authenticator apps. + * @throws DatabaseOperationException If there is an error during the database operation. + */ + public static function createOtp(string|RegisteredPeerRecord $peer): string + { + if(is_string($peer)) + { + $peer = RegisteredPeerManager::getPeer($peer); + } + + $secret = OtpCryptography::generateSecretKey(Configuration::getSecurityConfiguration()->getOtpSecretKeyLength()); + $encryptionKey = Configuration::getCryptographyConfiguration()->getRandomInternalEncryptionKey(); + $encryptedSecret = Cryptography::encryptMessage($secret, $encryptionKey, Configuration::getCryptographyConfiguration()->getEncryptionKeysAlgorithm()); + + try + { + $stmt = Database::getConnection()->prepare("INSERT INTO authentication_otp (peer_uuid, secret) VALUES (:peer_uuid, :secret)"); + $stmt->bindParam(':peer_uuid', $peer); + $stmt->bindParam(':secret', $encryptedSecret); + $stmt->execute(); + } + catch(PDOException $e) + { + throw new DatabaseOperationException('An error occurred while creating the OTP secret in the database', $e); + } + + return OtpCryptography::generateKeyUri($peer->getAddress(), $secret, + Configuration::getInstanceConfiguration()->getDomain(), + Configuration::getSecurityConfiguration()->getOtpTimeStep(), + Configuration::getSecurityConfiguration()->getOtpDigits(), + Configuration::getSecurityConfiguration()->getOtpHashAlgorithm() + ); + } + + /** + * Verifies the provided OTP (One-Time Password) against the stored secret associated with the specified peer. + * + * @param string|RegisteredPeerRecord $peerUuid The unique identifier of the peer, either as a string UUID + * or an instance of RegisteredPeerRecord. + * @param string $otp The OTP to be verified. + * @return bool Returns true if the OTP is valid; otherwise, false. + * @throws DatabaseOperationException If there is an error during the database operation. + * @throws CryptographyException If there is a failure in decrypting the stored OTP secret. + */ + public static function verifyOtp(string|RegisteredPeerRecord $peerUuid, string $otp): bool + { + if($peerUuid instanceof RegisteredPeerRecord) + { + $peerUuid = $peerUuid->getUuid(); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT secret FROM authentication_otp WHERE peer_uuid=:uuid'); + $stmt->bindParam(':uuid', $peerUuid); + $stmt->execute(); + + $encryptedSecret = $stmt->fetchColumn(); + + if($encryptedSecret === false) + { + return false; + } + } + catch(PDOException $e) + { + throw new DatabaseOperationException('An error occurred while retrieving the OTP secret from the database', $e); + } + + $decryptedSecret = null; + foreach(Configuration::getCryptographyConfiguration()->getInternalEncryptionKeys() as $encryptionKey) + { + try + { + $decryptedSecret = Cryptography::decryptMessage($encryptedSecret, $encryptionKey, Configuration::getCryptographyConfiguration()->getEncryptionKeysAlgorithm()); + } + catch(CryptographyException) + { + continue; + } + } + + if($decryptedSecret === null) + { + throw new CryptographyException('Failed to decrypt the OTP secret'); + } + + return OtpCryptography::verifyOTP($decryptedSecret, $otp, + Configuration::getSecurityConfiguration()->getOtpTimeStep(), + Configuration::getSecurityConfiguration()->getOtpWindow(), + Configuration::getSecurityConfiguration()->getOtpDigits(), + Configuration::getSecurityConfiguration()->getOtpHashAlgorithm() + ); + } + + /** + * Deletes the OTP record associated with the specified peer. + * + * @param string|RegisteredPeerRecord $peerUuid The peer's UUID or an instance of RegisteredPeerRecord whose OTP record needs to be deleted. + * @return void + * @throws DatabaseOperationException if the database operation fails. + */ + public static function deleteOtp(string|RegisteredPeerRecord $peerUuid): void + { + if($peerUuid instanceof RegisteredPeerRecord) + { + $peerUuid = $peerUuid->getUuid(); + } + + try + { + $stmt = Database::getConnection()->prepare('DELETE FROM authentication_otp WHERE peer_uuid=:uuid'); + $stmt->bindParam(':uuid', $peerUuid); + $stmt->execute(); + } + catch(PDOException $e) + { + throw new DatabaseOperationException('An error occurred while deleting the OTP secret from the database', $e); + } + } + + /** + * Retrieves the last updated timestamp for the OTP record of the specified peer. + * + * @param string|RegisteredPeerRecord $peerUuid The peer's UUID or an instance of RegisteredPeerRecord whose OTP record's last updated timestamp needs to be retrieved + * @return int The last updated timestamp of the OTP record, or 0 if no such record exists + */ + public static function getLastUpdated(string|RegisteredPeerRecord $peerUuid): int + { + if($peerUuid instanceof RegisteredPeerRecord) + { + $peerUuid = $peerUuid->getUuid(); + } + + try + { + $stmt = Database::getConnection()->prepare('SELECT updated FROM authentication_otp WHERE peer_uuid=:uuid'); + $stmt->bindParam(':uuid', $peerUuid); + $stmt->execute(); + + /** @var \DateTime $updated */ + $updated = $stmt->fetchColumn(); + + if($updated === false) + { + return 0; + } + } + catch(PDOException $e) + { + throw new DatabaseOperationException('An error occurred while retrieving the last updated timestamp from the database', $e); + } + + return $updated->getTimestamp(); + } + + /** + * Updates the last updated timestamp for the OTP record of the specified peer. + * + * @param string|RegisteredPeerRecord $peerUuid The peer's UUID or an instance of RegisteredPeerRecord whose OTP record needs to be updated. + * @return void + * @throws DatabaseOperationException if the database operation fails. + */ + public static function updateOtp(string|RegisteredPeerRecord $peerUuid): void + { + if($peerUuid instanceof RegisteredPeerRecord) + { + $peerUuid = $peerUuid->getUuid(); + } + + try + { + $stmt = Database::getConnection()->prepare('UPDATE authentication_otp SET updated=:updated WHERE peer_uuid=:uuid'); + $updated = (new DateTime())->setTimestamp(time()); + $stmt->bindParam(':updated', $updated); + $stmt->bindParam(':uuid', $peerUuid); + $stmt->execute(); + } + catch(PDOException $e) + { + throw new DatabaseOperationException(sprintf('Failed to update the OTP secret for user %s', $peerUuid), $e); + } + } + } \ No newline at end of file