Files
itflow/functions/security.php
johnnyq 359851c876 Security sweep of the 26.11 work: API contact lookup by phone or mobile kept to the key's clients; API credentials refuse a wrong decrypt password instead of saving unreadable usernames and passwords, link credential_vendor_id and credential_software_id, and drop another client's contact or asset; Products Receive window lists expenses only with Financial access and only for clients the agent can open; account ledger, reconcile page and ledger export show lines for clients the agent can't open as Another client with only date, kind and amount, with search and the client filter kept to reachable clients; Tax Collected folds unreachable exempt clients into one line; client-limited agents no longer see other clients' names in the export window client filters, Transfer to Client, company vendor expense lists, My Activity or the New Client duplicate check; Stripe switched off stops guest pay links and saving cards, and the Stripe return never books a payment twice; Admin > Cron job window is admins only; records per page needs the CSRF token and takes only the listed sizes; uploads/canned_responses closed to direct access; Admin > Debug no longer stops at the footer; report export audit log no longer double-escapes names; bulk interface network refuses a deleted network; API assets/update keeps the primary interface IP, MAC and network, tickets/resolve reports success, software/read by name works, clients/create takes the company currency, certificates/create returns its error, clients/archive logs as Client, and the 405 response no longer echoes the method; changelog database step count corrected to sixty
2026-10-23 12:09:07 -04:00

343 lines
14 KiB
PHP

<?php
// Cryptography, key management, credential encryption & token validation
// Split from the former monolithic functions.php
// Function to generate both crypto & URL safe random strings
function randomString(int $length = 16): string {
$bytes = random_bytes((int) ceil($length * 3 / 4));
return substr(
rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='),
0,
$length
);
}
// Generate a pronounceable password - readable enough to dictate down the phone, while
// still carrying real entropy. Every syllable is one of 90 consonant-vowel pairs, so the
// default three words of three syllables plus the two-digit suffix is roughly 65 bits.
// The floors are deliberate: this feeds real user and contact logins, so a caller asking
// for one short word must not walk away with a guessable password.
function generateReadablePassword(int $word_count = 3, int $syllables_per_word = 3): string {
$consonants = 'bcdfghjklmnprstvwz';
$vowels = 'aeiou';
$word_count = max(2, $word_count);
$syllables_per_word = max(2, $syllables_per_word);
$words = [];
for ($word_index = 0; $word_index < $word_count; $word_index++) {
$word = '';
for ($syllable_index = 0; $syllable_index < $syllables_per_word; $syllable_index++) {
$word .= $consonants[random_int(0, strlen($consonants) - 1)];
$word .= $vowels[random_int(0, strlen($vowels) - 1)];
}
$words[] = ucfirst($word);
}
return implode('-', $words) . '-' . random_int(10, 99);
}
// Generate a cryptographically secure 32-char base32 secret for TOTP
function generateTotpSecret() {
$chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
$key = '';
for ($i = 0; $i < 32; $i++) {
$key .= $chars[random_int(0, strlen($chars) - 1)];
}
return $key;
}
// Called during initial setup
// Encrypts the master key with the user's password
function setupFirstUserSpecificKey($user_password, $site_encryption_master_key) {
$iv = randomString();
$salt = randomString();
//Generate 128-bit (16 byte/char) kdhash of the users password
$user_password_kdhash = hash_pbkdf2('sha256', $user_password, $salt, 100000, 16);
//Encrypt the master key with the users kdf'd hash and the IV
$ciphertext = openssl_encrypt($site_encryption_master_key, 'aes-128-cbc', $user_password_kdhash, 0, $iv);
return $salt . $iv . $ciphertext;
}
/*
* For additional users / password changes (and now the API)
* New Users: Requires the admin setting up their account have a Specific/Session key configured
* Password Changes: Will use the current info in the session.
*/
function encryptUserSpecificKey($user_password) {
$iv = randomString();
$salt = randomString();
// Get the session info.
$user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext'];
$user_encryption_session_iv = base64_decode($_SESSION['user_encryption_session_iv'] ?? '');
$user_encryption_session_key = base64_decode($_COOKIE['user_encryption_session_key'] ?? '');
// Decrypt the session key to get the master key
$site_encryption_master_key = openssl_decrypt($user_encryption_session_ciphertext, 'aes-128-cbc', $user_encryption_session_key, 0, $user_encryption_session_iv);
// Generate 128-bit (16 byte/char) kdhash of the users (new) password
$user_password_kdhash = hash_pbkdf2('sha256', $user_password, $salt, 100000, 16);
// Encrypt the master key with the users kdf'd hash and the IV
$ciphertext = openssl_encrypt($site_encryption_master_key, 'aes-128-cbc', $user_password_kdhash, 0, $iv);
return $salt . $iv . $ciphertext;
}
// Given a ciphertext (incl. IV) and the user's (or API key) password, returns the site master key
// Ran at login, to facilitate generateUserSessionKey
function decryptUserSpecificKey($user_encryption_ciphertext, $user_password) {
//Get the IV, salt and ciphertext
$salt = substr($user_encryption_ciphertext, 0, 16);
$iv = substr($user_encryption_ciphertext, 16, 16);
$ciphertext = substr($user_encryption_ciphertext, 32);
//Generate 128-bit (16 byte/char) kdhash of the users password
$user_password_kdhash = hash_pbkdf2('sha256', $user_password, $salt, 100000, 16);
//Use this hash to get the original/master key
return openssl_decrypt($ciphertext, 'aes-128-cbc', $user_password_kdhash, 0, $iv);
}
/*
Generates what is probably best described as a session key (ephemeral-ish)
- Allows us to store the master key on the server whilst the user is using the application, without prompting to type their password everytime they want to decrypt a credential
- Ciphertext/IV is stored on the server in the users' session, encryption key is controlled/provided by the user as a cookie
- Only the user can decrypt their session ciphertext to get the master key
- Encryption key never hits the disk in cleartext
*/
function generateUserSessionKey($site_encryption_master_key) {
$user_encryption_session_key = random_bytes(16);
$user_encryption_session_iv = random_bytes(16);
$user_encryption_session_ciphertext = openssl_encrypt($site_encryption_master_key, 'aes-128-cbc', $user_encryption_session_key, 0, $user_encryption_session_iv);
// Store ciphertext in the user's session
$_SESSION['user_encryption_session_ciphertext'] = $user_encryption_session_ciphertext;
$_SESSION['user_encryption_session_iv'] = base64_encode($user_encryption_session_iv);
// Give the user "their" key as a cookie
include 'config.php';
if ($config_https_only) {
setcookie("user_encryption_session_key", base64_encode($user_encryption_session_key), ['path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'None']);
} else {
setcookie("user_encryption_session_key", base64_encode($user_encryption_session_key), 0, "/");
$_SESSION['alert_message'] = "Unencrypted connection flag set: Using non-secure cookies.";
}
}
// Decrypts an encrypted password (website/asset credentials), returns it as a string
function decryptCredentialEntry($credential_password_ciphertext) {
// Split the credential into IV and Ciphertext
$credential_iv = substr($credential_password_ciphertext, 0, 16);
$credential_ciphertext = $salt = substr($credential_password_ciphertext, 16);
// Get the user session info.
$user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext'];
$user_encryption_session_iv = base64_decode($_SESSION['user_encryption_session_iv'] ?? '');
$user_encryption_session_key = base64_decode($_COOKIE['user_encryption_session_key'] ?? '');
// Decrypt the session key to get the master key
$site_encryption_master_key = openssl_decrypt($user_encryption_session_ciphertext, 'aes-128-cbc', $user_encryption_session_key, 0, $user_encryption_session_iv);
// Decrypt the credential password using the master key
return openssl_decrypt($credential_ciphertext, 'aes-128-cbc', $site_encryption_master_key, 0, $credential_iv);
}
// Encrypts a website/asset credential password
function encryptCredentialEntry($credential_password_cleartext) {
$iv = randomString();
// Get the user session info.
$user_encryption_session_ciphertext = $_SESSION['user_encryption_session_ciphertext'];
$user_encryption_session_iv = base64_decode($_SESSION['user_encryption_session_iv'] ?? '');
$user_encryption_session_key = base64_decode($_COOKIE['user_encryption_session_key'] ?? '');
//Decrypt the session key to get the master key
$site_encryption_master_key = openssl_decrypt($user_encryption_session_ciphertext, 'aes-128-cbc', $user_encryption_session_key, 0, $user_encryption_session_iv);
// Never write a credential under an empty key if the session didn't open
if (empty($site_encryption_master_key)) {
return false;
}
//Encrypt the website/asset credential using the master key
$ciphertext = openssl_encrypt($credential_password_cleartext, 'aes-128-cbc', $site_encryption_master_key, 0, $iv);
return $iv . $ciphertext;
}
function apiDecryptCredentialEntry($credential_ciphertext, $api_key_decrypt_hash, #[\SensitiveParameter]$api_key_decrypt_password)
{
// Split the Credential entry (username/password) into IV and Ciphertext
$credential_iv = substr($credential_ciphertext, 0, 16);
$credential_ciphertext = $salt = substr($credential_ciphertext, 16);
// Decrypt the api hash to get the master key
$site_encryption_master_key = decryptUserSpecificKey($api_key_decrypt_hash, $api_key_decrypt_password);
// Decrypt the credential password using the master key
return openssl_decrypt($credential_ciphertext, 'aes-128-cbc', $site_encryption_master_key, 0, $credential_iv);
}
/**
* Does this decrypt password open the API key's copy of the master key? A
* wrong one decrypts to nothing (or rubbish), and a credential saved under
* that could never be read again.
*/
function apiDecryptPasswordOpens($api_key_decrypt_hash, #[\SensitiveParameter]$api_key_decrypt_password) {
$key = decryptUserSpecificKey((string) $api_key_decrypt_hash, (string) $api_key_decrypt_password);
return is_string($key) && $key !== '';
}
function apiEncryptCredentialEntry(#[\SensitiveParameter]$credential_cleartext, $api_key_decrypt_hash, #[\SensitiveParameter]$api_key_decrypt_password) {
$iv = randomString();
// Decrypt the api hash to get the master key
$site_encryption_master_key = decryptUserSpecificKey($api_key_decrypt_hash, $api_key_decrypt_password);
// Encrypt the credential using the master key
$ciphertext = openssl_encrypt($credential_cleartext, 'aes-128-cbc', $site_encryption_master_key, 0, $iv);
return $iv . $ciphertext;
}
/*
* Longest cleartext a credential username or password may be.
* Both encrypt functions above return a 16-char IV followed by base64 AES-128-CBC
* ciphertext, which expands about 1.37x, so 350 is the most that still fits the
* varchar(500) columns. Keep in step with the maxlength on the credential/asset forms.
*/
define('CREDENTIAL_ENTRY_MAX_LENGTH', 350);
/*
* Checks a credential's cleartext fields against what the columns can actually store.
* Form maxlength is client-side only, so the CSV import, the API and any hand-rolled POST
* reach the INSERT with nothing stopping an overlong value - and MySQL rejects it outright
* rather than truncating, taking the request down with it.
*
* Returns the name of the first field that is too long, or an empty string when they all
* fit. Only keys actually present are checked, so partial updates are fine.
*/
function checkCredentialLengths(array $fields) {
// Encrypted before storage - ciphertext size follows the BYTE length of the cleartext.
$byte_limits = [
'username' => CREDENTIAL_ENTRY_MAX_LENGTH,
'password' => CREDENTIAL_ENTRY_MAX_LENGTH,
];
// Stored as given - MySQL measures varchar in CHARACTERS, not bytes.
$char_limits = [
'name' => 200,
'description' => 500,
'uri' => 500,
'uri_2' => 500,
'otp_secret' => 200,
];
foreach ($byte_limits as $field => $limit) {
if (isset($fields[$field]) && strlen($fields[$field]) > $limit) {
return $field;
}
}
foreach ($char_limits as $field => $limit) {
if (!isset($fields[$field]) || strlen($fields[$field]) <= $limit) {
continue; // byte length caps character count, so this already fits
}
// Only worth counting characters once the cheap check fails. preg keeps this
// free of an mbstring dependency, which nothing else in the tree relies on.
if (preg_match_all('/./us', $fields[$field]) > $limit) {
return $field;
}
}
return '';
}
// Cross-Site Request Forgery check for sensitive functions
// Validates the CSRF token provided matches the one in the users session
function validateCSRFToken(?string $token = null) {
// Read the token straight from the request when the caller doesn't pass one
$token ??= $_POST['csrf_token'] ?? $_GET['csrf_token'] ?? '';
if ($token !== '' && hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
return true;
}
// 403 rather than a redirect. A 302 to index.php means anything following
// redirects - browsers, curl -L, security scanners - records a final 200
// and reports the request as accepted.
if (!headers_sent()) {
http_response_code(403);
header("Content-Type: text/html; charset=UTF-8");
header("X-Content-Type-Options: nosniff");
header("Cache-Control: no-store");
}
echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
. '<title>403 Forbidden</title></head><body>'
. '<h1>403 Forbidden</h1>'
. '<p>CSRF token verification failed. Your session may have expired.</p>'
. '<p><a href="/">Return to ITFlow</a> and try again.</p>'
. '</body></html>';
exit();
}
function validateWhitelabelKey($key) {
$public_key = "-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr0k+4ZJudkdGMCFLx5b9
H/sOozvWphFJsjVIF0vPVx9J0bTdml65UdS+32JagIHfPtEUTohaMnI3IAxxCDzl
655qmtjL7RHHdx9UMIKCmtAZOtd2u6rEyZH7vB7cKA49ysKGIaQSGwTQc8DCgsrK
uxRuX04xq9T7T+zuzROw3Y9WjFy9RwrONqLuG8LqO0j7bk5LKYeLAV7u3E/QiqNx
lEljN2UVJ3FZ/LkXeg8ORkV+IHs/toRIfPs/4VQnjEwk5BU6DX2STOvbeZnTqwP3
zgjRYR/zGN5l+az6RB3+0mJRdZdv/y2aRkBlwTxx2gOrPbQAco4a/IOmkE3EbHe7
6wIDAQAP
-----END PUBLIC KEY-----";
if (openssl_public_decrypt(base64_decode($key), $decrypted, $public_key)) {
$key_info = json_decode($decrypted, true);
if ($key_info['expires'] > date('Y-m-d H:i:s', strtotime('-7 day'))) {
return $key_info;
}
}
return false;
}
// Atomically claim one view against a shared item's view limit.
// Returns true only if this request won the view; false means the share is
// inactive, expired, or out of views. The UPDATE is the claim, so concurrent
// requests cannot all pass - call this before any shared content is disclosed.
function claimSharedItemView($item_id) {
global $mysqli;
$item_id = intval($item_id);
mysqli_query($mysqli, "UPDATE shared_items
SET item_views = item_views + 1
WHERE item_id = $item_id
AND item_active = 1
AND item_expire_at > NOW()
AND (COALESCE(item_view_limit, 0) = 0 OR item_views < item_view_limit)"
);
// -1 (query error) and 0 (limit reached / revoked / expired) both deny
return mysqli_affected_rows($mysqli) === 1;
}