Files
itflow/functions/security.php
johnnyq b555a300bc Give the vault session key full entropy and guard empty-key credential writes
generateUserSessionKey() built its AES-128 key with randomString(16), which
draws 12 random bytes and base64url-encodes them into 16 printable characters.
Those 16 characters were handed to openssl_encrypt as the 16-byte key, so the
key that wraps the site master key in the user's session carried 96 bits of
entropy instead of 128. Same for the IV.

The key and IV are now random_bytes(16) and stay raw for the cipher. Base64 is
applied only for transport - the cookie and the session copy of the IV - and
decoded again at the three read sites.

Sessions issued before this change will not decode and are not supported;
anyone logged in at deploy time is prompted to log in again. Nothing is stored
long term in this path, so there is nothing to migrate.

Also guards encryptCredentialEntry against a master key that failed to decrypt.
openssl_encrypt accepts false as a key and silently encrypts under an empty
key, so a session whose cookie had expired could write a credential that no
key would ever recover. It now returns false instead. This was reachable
before this change too, whenever a cookie expired mid-session.
2026-07-27 20:27:23 -04:00

228 lines
10 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 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);
}
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;
}
// 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;
}