Files
itflow/functions/security.php
johnnyq 3b5c11c552 Add managed mode, so one ITFlow can manage others - Admin > Managed Instances adds an instance and gives a one-time connection code (a week) that the other ITFlow pastes into the new Admin > Managed By, after which it checks in every minute from the new Manager Check-in cron job with a health report (versions, update waiting and whether it has security fixes, cron, last backup, errors today, agents against the seat limit, clients, storage) and takes back what the manager sets; the manager lists each instance's version, health, seats and last check-in with a page per instance showing what needs a look, its requests and everything it reported, can update it by hand or automatically after a set number of days (security releases straight away, not retried for six hours after a failure), set its seat limit and white-label key (locked on the instance while set), make it read-only (look, export and download only, across agents, portal, guest links and the API) or suspended (a notice with the manager's message and contact, 503 for the API) with other cron jobs paused until it is active again, sign in to it as support through a signed one-time two-minute link into a two-hour session that takes no seat, can't open the vault and can't set passwords or make API keys, and remove it, while the new Managed Instances job notifies when one stops checking in and closes requests never answered; instances only call the manager, can disconnect unless config.php sets $config_manager_required, and the Update page and queue_update defer to the manager; database 3.5.8 adds managed_instances, managed_instance_commands, settings.config_manager_keys and settings.config_managed, the logic is in the new functions/managed.php with api/managed/enroll.php, api/managed/checkin.php, login_support.php and includes/managed_blocked.php, cronJobClaim() takes a paused reason that leaves a due job due, getAgentUserLimit() and getActiveAgentUserCount() honour the manager's limit and leave out the support user, the vault helpers read a missing session key as empty, the audit log knows the new types and actions, and the email parser no longer logs an App Logs error every minute while parsing is switched off; changelog updated
2026-11-05 15:22:41 -05: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;
}