<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    core
 */

/**
 * Do a hashing, with support for our "ratcheting up" algorithm (i.e. lets the admin increase the complexity over the time, as CPU speeds get faster).
 *
 * @param  SHORT_TEXT $password The password in plain text
 * @param  SHORT_TEXT $salt The salt
 * @return SHORT_TEXT The salted&hashed password
 */
function ratchet_hash(string $password, string $salt) : string
{
    // NB: We don't pass the salt separately, we let password_hash generate its own internal salt also (that builds into the hash). So it is double salted.
    $ratchet = max(10, intval(get_option('crypt_ratchet')));
    return password_hash($salt . md5($password), PASSWORD_BCRYPT, ['cost' => $ratchet]);
}

/**
 * Verify a password is correct by comparison of the hashed version.
 *
 * @param  SHORT_TEXT $password The password in plain text
 * @param  SHORT_TEXT $salt The salt
 * @param  SHORT_TEXT $pass_hash_salted The prior salted&hashed password, which will also include the algorithm/ratcheting level (unless it's old style, in which case we use non-ratcheted md5)
 * @param  integer $legacy_style Legacy hashing style to fallback to
 * @return boolean Whether the password if verified
 */
function ratchet_hash_verify(string $password, string $salt, string $pass_hash_salted, int $legacy_style = 0) : bool
{
    if (strpos($pass_hash_salted, '$') !== false) {
        return password_verify($salt . md5($password), $pass_hash_salted);
    }
    return hash_equals($pass_hash_salted, md5($salt . md5($password)));
}

/**
 * Calculate a reasonable cryptographic ratchet based on the server's CPU speed.
 *
 * @param  float $target_time The ratchet should not exceed this amount of time in seconds when calculating
 * @param  integer $minimum_cost The minimum allowed ratchet; must be between 4 and 31
 * @return ?integer The suggested ratchet to use (null: password_hash is not supported)
 */
function calculate_reasonable_ratchet(float $target_time = 0.1, int $minimum_cost = 4) : ?int
{
    if (!function_exists('password_hash')) {
        return null;
    }

    $cost = ($minimum_cost - 1);

    // Costs < 4 are not supported. This will be increased by 1 in the first iteration.
    if ($cost < 3) {
        $cost = 3;
    }

    do {
        $cost++;
        if ($cost > 31) { // Costs > 31 are not supported
            break;
        }
        $start = microtime(true);
        password_hash('test', PASSWORD_BCRYPT, ['cost' => $cost]);
        $end = microtime(true);
        $elapsed_time = $end - $start;
    } while ($elapsed_time < $target_time);

    return ($cost - 1); // We don't want to use the cost that exceeded our target time; use the one below it.
}


/**
 * Get the site-wide salt. It should be something hard for a hacker to get, so we depend on data gathered both from the database and file-system.
 *
 * @return ID_TEXT The salt
 */
function get_site_salt() : string
{
    $site_salt = get_value('site_salt');
    if ($site_salt === null) {
        $site_salt = get_secure_random_string(32); // We are going to MD5 it so it should be long to increase variability
        set_value('site_salt', $site_salt);
    }
    return md5($site_salt);
}

/**
 * Get a URL-safe alphanumeric randomised string acceptable for use as tokens, etc.
 * Do not use for passwords because it does not contain symbols; use get_secure_random_password() instead.
 *
 * @param  integer The length of the string in bytes
 * @return string The randomised password
 */
function get_secure_random_string(int $bytes = 13) : string
{
    /*
     * We only use lowercase letters due to case sensitivity inconsistencies between operating systems.
     * For maximum cryptographic security, the length must be a power of 2.
     * Therefore, we took letters + numbers (36) and removed 1/l, and 0/o (32) as they look similar.
     */
    $characters = '23456789abcdefghijkmnpqrstuvwxyz';
    $characters_length = strlen($characters);

    $random_string = '';
    for ($i = 0; $i < $bytes; $i++) {
        $random_byte = random_bytes(1);
        $random_index = ord($random_byte) % $characters_length; // Convert byte to index
        $random_string .= $characters[$random_index];
    }
    return $random_string;
}

/**
 * Generate a cryptographically-secure pseudo-random password using uppercase letters, lowercase letters, numbers, and symbols.
 *
 * @param  ?integer $strength The minimum password strength we want; the generated password could be stronger but will never be weaker (null: use configured strength)
 * @param  SHORT_TEXT $username The username of the member for which we are generating a password (blank: do not check username in password strength check)
 * @param  SHORT_TEXT $email_address The e-mail address of the member (blank: do not check e-mail address in password strength check)
 * @param  ?TIME $dob The date of birth of the member (null: do not check dob in password strength check)
 * @return string The random password
 */
function get_secure_random_password(?int $strength = null, string $username = '', string $email_address = '', ?int $dob = null) : string
{
    require_code('password_rules');

    if ($strength === null) {
        $strength = intval(get_option('minimum_password_strength'));
    }

    // Define our character groups
    $digits = str_split('0123456789');
    $lowercase = str_split('qwertyuiopasdfghjklzxcvbnm');
    $uppercase = str_split('QWERTYUIOPASDFGHJKLZXCVBNM');
    $special = str_split('!@#$%^&*+=-_?.,:;()/|~`\'"'); // <> is ignored as it is XML, [] is ignored as it is Comcode, and {} is ignored as it is Tempcode

    $password = [];
    $characters = intval(get_option('minimum_password_length')); // Start off with minimum password length
    $failed = false;

    // Start generating our password
    do {
        // Generate our characters
        for ($i = 0; $i < $characters; $i++) {
            switch (random_int(1, 10)) {
                case 1:
                case 2:
                    $password[] = $digits[random_int(0, (count($digits) - 1))];
                    break;
                case 3:
                case 4:
                case 5:
                    $password[] = $lowercase[random_int(0, (count($lowercase) - 1))];
                    break;
                case 6:
                case 7:
                case 8:
                    $password[] = $uppercase[random_int(0, (count($uppercase) - 1))];
                    break;
                case 9:
                case 10:
                    $password[] = $special[random_int(0, (count($special) - 1))];
                    break;
            }
        }

        // Test the password strength
        $password_strength = test_password(implode('', $password), $username, $email_address, $dob);

        if ($password_strength < $strength) {
            // Failed; reset the password to do the loop again, but increase the character count by 1.
            $password = [];
            $characters++;

            // If we exceed the maximum length allowed, bail with an internal error.
            if ($characters > min(255, intval(get_option('maximum_password_length')))) {
                $failed = true;
                warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
            }
        }
    } while (empty($password) && !$failed);

    return implode('', $password);
}

/**
 * Get a secure random number, the best this PHP version / our MySQL schema can do.
 * Will be between 1 and max signed 32 bit integer (so it can be stored in a 32 bit database).
 *
 * @return integer The randomised number
 */
function get_secure_random_number() : int
{
    // TODO: #3046 in tracker
    // 2147483647 is from MySQL limit http://dev.mysql.com/doc/refman/5.6/en/integer-types.html ; PHP_INT_MAX is higher on 64bit machines
    return random_int(1, 2147483647);
}

/**
 * Get obfuscate version of 'mailto:' (which will hopefully fool e-mail scavengers to not pick up these e-mail addresses).
 *
 * @return string The obfuscated 'mailto:' string
 */
function mailto_obfuscated() : string
{
    static $ret = null;
    if ($ret === null) {
        $ret = 'm' . obfuscate_entities('ailto:');
    }
    return $ret;
}

/**
 * Obfuscate the given text using HTML entity encoding.
 *
 * @param  string $val The text to obfuscate
 * @return string The obfuscated version
 */
function obfuscate_entities(string $val) : string
{
    if (strpos($val, '&') !== false) {
        return $val; // Prevent double encoding
    }

    $out = '';
    for ($i = 0; $i < strlen($val); $i++) {
        $char = $val[$i];
        if ($char == '<') {
            $_char = '&lt;';
        } elseif ($char == '>') {
            $_char = '&gt;';
        } elseif ($char == '&') {
            $_char = '&amp;';
        } elseif ($i % 2 == 0) {
            $_char = '&#' . strval(ord($char)) . ';';
        } else {
            $_char = '&#x' . dechex(ord($char)) . ';';
        }

        $out .= $_char;
    }
    if ($GLOBALS['XSS_DETECT']) {
        ocp_mark_as_escaped($out);
    }
    return $out;
}

/**
 * Obfuscate the given e-mail address.
 * This function may want to be modified on a per-site basis, to stop spammers triggering onto the software's default method (possible some already do, although I think it unlikely they would go to this much effort/computation unless it was more widespread to do this).
 *
 * @param  EMAIL $email The e-mail address to obfuscate
 * @return string The obfuscated version
 */
function obfuscate_email_address(string $email) : string
{
    /* One possibility (conventional, but annoying)...
    $i = mt_rand(0, strlen($email));
    $rep = '^remove_me^';
    return substr($email, 0, $i) . $rep . substr($email, $i);
    */

    /* One possibility (conventional, but annoying)...
    $at_pos = strpos($email, '@');
    return substr($email, 0, $at_pos) . 'AT' . substr($email, $at_pos + 1);
    */

    /* Randomly mutated e-mail addresses, so that we can block e-mail address mutations that have become spammed. This would be for webmasters who have default mail for the domain forwarded to themselves.
    $at_pos = strpos($email, '@');
    return substr($email, 0, $at_pos) . mt_rand(0, mt_getrandmax()) . substr($email, $at_pos);
    */

    /* Another possibility would be to write some JavaScript that scans the page after loading, and re-write algorithmically mangled addresses. (You'd need to write some JavaScript to match this, we haven't)
    $remap = [
        'a' => 'alpha',
        'b' => 'beta',
        'c' => 'no',
        'd' => 'delta',
        'e' => 'epsilon',
        'f' => 'more',
        'g' => 'gamma',
        'h' => 'eta',
        'i' => 'iota',
        'j' => 'letters',
        'k' => 'kappa',
        'l' => 'lambda',
        'm' => 'mu',
        'n' => 'nu',
        'o' => 'omicron',
        'p' => 'pi',
        'q' => 'xi',
        'r' => 'rho',
        's' => 'psi',
        't' => 'tau',
        'u' => 'theta',
        'v' => 'sigma',
        'w' => 'phi',
        'x' => 'chi',
        'y' => 'upsilon',
        'z' => 'zeta',
    ];
    $out = '';
    for ($i = 0; $i < strlen($email)) {
        $at = $email[$i];
        $out .= (array_key_exists($at, $remap)) ? ('{' . $remap[$at] . '}') : $at;
    }
    return $out;
    */

    return obfuscate_entities($email);
}

/**
 * Replace most characters in an e-mail with asterisks.
 * This will replace all characters except the first and last before the @, the top-level domain, and the first and last character after @ before the top-level domain.
 *
 * @param  SHORT_TEXT $email_address The e-mail address to mask
 * @return SHORT_TEXT The masked e-mail address
 */
function mask_email_address(string $email_address) : string
{
    // Get our e-mail parts
    $email_parts = explode('@', $email_address);
    if (!array_key_exists(1, $email_parts)) { // E-mail does not have an @ symbol; this is invalid.
        return $email_address;
    }
    $_domain_parts = explode('.', $email_parts[1]);
    if (!array_key_exists(1, $_domain_parts)) { // E-mail domain does not have a top-level (.*); this is invalid.
        return $email_address;
    }

    // Email domains might have multiple sub-domains. We only want to expose the top domain; mask everything else.
    $domain_top = $_domain_parts[count($_domain_parts) - 1];
    $domain_sub = '';
    foreach ($_domain_parts as $i => $part) {
        if ($i > 0) {
            $domain_sub .= '.';
        }
        $domain_sub .= $part;
    }

    // Do the actual masking
    $email_name = substr($email_parts[0], 0, 1);
    if (strlen($email_parts[0]) > 2) {
        $email_name .= str_repeat('*', (strlen($email_parts[0]) - 2));
    }
    $email_name .= substr($email_parts[0], -1);
    $email_domain = substr($domain_sub, 0, 1) . str_repeat('*', (strlen($domain_sub) - 2)) . substr($domain_sub, -1);

    return $email_name . '@' . $email_domain . '.' . $domain_top;
}

/**
 * Replace most characters in a phone number with the @ symbol.
 * Since phone numbers can be saved in a wide variety of formats depending on the country, it can be difficult to mask phone numbers.
 *
 * @param  SHORT_TEXT $phone_number The phone number to mask
 * @return SHORT_TEXT The masked phone number
 */
function mask_phone_number(string $phone_number) : string
{
    $number_parts = str_split($phone_number);
    $ret = '';
    $exposed_numbers = 0;

    // Go backwards since we're exposing the last 3 digits and any non-numeric character
    for ($i = (count($number_parts) - 1); $i >= 0; $i--) {
        // These characters are generally acceptable to be visible; do not mask them.
        $acceptable_characters = ['+', '-', '#', '*', ' ', '(', ')', '[', ']', '.'/* might be used instead of - */, 'x'/* extension */];

        $has_acceptable_character = false;
        foreach ($acceptable_characters as $character) {
            if (strpos($number_parts[$i], $character) !== false) {
                $has_acceptable_character = true;
                break;
            }
        }
        if (!$has_acceptable_character) {
            if ($exposed_numbers < 3) {
                $ret = $number_parts[$i] . $ret;
                $exposed_numbers++;
            } else {
                $ret = '@' . $ret; // Do not use * as the mask because * is a legitimate button on phones
            }
        } else {
            $ret = $number_parts[$i] . $ret;
        }
    }

    return $ret;
}
