<?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
 */

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__crypt()
{
    if (!defined('CRYPT_BASE16')) {
        define('CRYPT_BASE16', '0123456789abcdef');
        define('CRYPT_BASE32', '23456789abcdefghijkmnpqrstuvwxyz');
        define('CRYPT_BASE64', '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'); // Needs to be URL-safe

        // ratchet_hash_verify
        define('CRYPT_LEGACY_V10', 1); // Strictly use v10 style
        define('CRYPT_LEGACY_FALLBACK_TO_V10', 2); // Try v11 and if that fails, try v10.
    }
}

/**
 * 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 = intval(get_option('crypt_ratchet'));
    return password_hash($salt . $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 CRYPT_LEGACY_* constant defining the legacy hashing style to fallback to
 * @return boolean Whether the password is verified
 */
function ratchet_hash_verify(string $password, string $salt, string $pass_hash_salted, int $legacy_style = 0) : bool
{
    $passed = false;

    // v11
    if ($legacy_style != CRYPT_LEGACY_V10) {
        if (strpos($pass_hash_salted, '$') === 0) {
            $passed = password_verify($salt . $password, $pass_hash_salted);
        } else {
            $passed = hash_equals($pass_hash_salted, md5($salt . $password));
        }
    }

    // LEGACY: v10
    if (($legacy_style == CRYPT_LEGACY_V10) || ((!$passed) && ($legacy_style == CRYPT_LEGACY_FALLBACK_TO_V10))) {
        if (strpos($pass_hash_salted, '$') === 0) {
            $passed = password_verify($salt . md5($password), $pass_hash_salted);
        } else {
            $passed = hash_equals($pass_hash_salted, md5($salt . md5($password)));
        }
    }

    return $passed;
}

/**
 * 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 10 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 = 10) : ?int
{
    if (!function_exists('password_hash')) {
        return null;
    }

    $cost = ($minimum_cost - 1);

    // We do not allow costs less than 10 in the software (this is increased by 1 in the first iteration)
    if ($cost < 9) {
        $cost = 9;
    }

    do {
        $cost++;
        if ($cost > 31) { // Costs > 31 are not supported by bcrypt
            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.
}

/**
 * Given a bcrypt hash, get its cost.
 *
 * @param  SHORT_TEXT $hash The bcrypt hash
 * @return ?integer The cost (null: could not find / invalid bcrypt hash)
 */
function get_ratchet_cost(string $hash) : ?int
{
    if (preg_match('/^\$2[ayb]?\$(\d{2})\$/', $hash, $matches)) {
        return intval($matches[1]);
    }

    return null;
}


/**
 * Gets or cryptographically generates the site-wide salt.
 * This should be used to salt sensitive site data or to generate verification hashes. The site salt is enforced to at least 32 characters and will be re-generated if it is less than that.
 *
 * @return ID_TEXT The salt
 */
function get_site_salt() : string
{
    $site_salt = get_value('site_salt');
    if (($site_salt === null) || (strlen($site_salt) < 32)) {
        $site_salt = get_secure_random_string(32, CRYPT_BASE64);
        set_value('site_salt', $site_salt);
    }

    return $site_salt;
}

/**
 * Generate a cryptographically secure pseudo-random string suitable for tokens, salts, etc.
 * Do not use for passwords; use get_secure_random_password() instead.
 *
 * @param  integer $string_length The length of the string
 * @param  string $character_map A CRYPT_* constant defining the character map to use; the string length should be a power of 2
 * @return string The pseudo-random string
 */
function get_secure_random_string(int $string_length = 13, string $character_map = '23456789abcdefghijkmnpqrstuvwxyz') : string
{
    $characters_length = strlen($character_map);

    $random_string = '';
    for ($i = 0; $i < $string_length; $i++) {
        $random_byte = random_bytes(1);
        $random_index = ord($random_byte) % $characters_length; // Convert byte to index
        $random_string .= $character_map[$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 = [];
    $min_characters = intval(get_option('minimum_password_length'));
    $failed = false;

    // Start generating our password
    do {
        $failed = false;

        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;
        }

        // Exceeded the allowed number of characters? Bail out with an error.
        if (count($password) > min(255, intval(get_option('maximum_password_length')))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('6c32a7b68e395de297a68d879343e734')));
            break;
        }

        // Not enough characters? Start the loop again (no need to test password strength yet).
        if (count($password) < $min_characters) {
            $failed = true;
            continue;
        }

        // Test the password strength
        $password_strength = test_password(implode('', $password), $username, $email_address, $dob);
        if ($password_strength < $strength) { // Not strong enough; trigger the loop to run again and add another character.
            $failed = true;
        }
    } while ($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 a secure v1 GUID using the site salt as the node.
 * This should only be used for chronological non-sensitive data as the randomness is only applied to the clock sequence.
 *
 * @param  ?float $time_at Use the provided time in microtime format (null: current time)
 * @return string The GUID
 */
function get_secure_v1_guid(?float $time_at = null) : string
{
    // Initialize clock sequence
    $_clock_sequence = get_value_newer_than('guidv1_clock_sequence', time() - 1, true);

    // Generate a random clock sequence if we do not need to worry about replication
    if ($_clock_sequence === null) {
        $clock_sequence = random_int(0, 0x3FFF);
    } else {
        $clock_sequence = intval($_clock_sequence) & 0x3FFF;
    }

    // Increment sequence for next use
    set_value('guidv1_clock_sequence', strval($clock_sequence + 1), true);

    // Set the sequence variant (RFC 4122)
    $clock_sequence |= 0x8000;

    // Generate 60-bit timestamp for UUID (time in 100-nanosecond intervals since 1582-10-15)
    if ($time_at === null) {
        $time_at = microtime(true);
    }
    $time = ($time_at * 10000000.0) + 0x01B21DD213814000;

    // Convert the timestamp into binary format
    $time_hex = sprintf("%016x", $time);
    $time_low = substr($time_hex, 8, 8);
    $time_mid = substr($time_hex, 4, 4);
    $time_high_and_version = dechex((hexdec(substr($time_hex, 0, 4)) & 0x0FFF) | 0x1000); // Apply version 1

    // MD5 the site salt and use the first 12 characters as the node ID
    $node = substr(md5(get_site_salt()), 0, 12);
    $node[0] = dechex(hexdec($node[0]) & 1);

    // Assemble the GUID
    $guid = sprintf(
        '%s-%s-%s-%04x-%s',
        $time_low,
        $time_mid,
        $time_high_and_version,
        $clock_sequence,
        $node
    );

    return $guid;
}

/**
 * Generate a cryptographically secure random v4 GUID.
 *
 * @return string The GUID
 */
function get_secure_random_v4_guid() : string
{
    $bytes = random_bytes(16);
    $hex = bin2hex($bytes);
    $hex .= $hex[8] . $hex[13] . $hex[18] . $hex[23];
    $hex[8] = '-';
    $hex[13] = '-';
    $hex[14] = '4';
    $hex[18] = '-';
    $hex[19] = '89ab'[ord($bytes[9]) >> 6];
    $hex[23] = '-';
    return $hex;
}

/**
 * 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;
}
