#!/usr/bin/env bash
#
# Rotate NONCE_SALT off the legacy hardcoded value onto a per-install secret.
#
# The legacy salt was committed to the repo (configs/config.php), making it
# identical on every install and effectively public (it remains in git
# history, which is also what makes the pre-rotation backup recoverable).
# This script:
#   1. generates (or adopts an existing) salt at /docker/whp/secrets/nonce-salt
#   2. re-encrypts every persistent ciphertext keyed off NONCE_SALT from the
#      legacy salt to the active one:
#        - whp.backup_targets.secret_key / .password  (AES-256-CBC)
#        - /docker/whp/settings.json smtp.password + bichon.api_token
#        - /var/lib/whp/cpanel-imports/*.json temp_password_encrypted (GCM)
#
# It runs BEFORE update.sh rsyncs web-files, so the live config.php still
# defines the LEGACY salt — we require it to decrypt. The new config.php
# (rsynced moments later) reads the salt file. In the short window between
# this script and the rsync, backup-target/SMTP decrypts will fail and retry;
# that resolves as soon as the new config.php lands.
#
# Side effect (intended): all session nonces and stay-logged-in tokens are
# invalidated — every user re-logs in. Old ciphertexts are saved to a
# root-only backup file before anything is modified.
#
# Idempotent AND resumable: completion is tracked by a sentinel file written
# only after every write-back succeeds. On re-run after a partial failure,
# items already re-encrypted under the active salt are detected (decrypt
# probe) and skipped; remaining legacy items are converted. Only when the
# sentinel exists does the script exit early.

set -euo pipefail

SECRETS_DIR="/docker/whp/secrets"
SALT_FILE="$SECRETS_DIR/nonce-salt"
SENTINEL="$SECRETS_DIR/.nonce-salt-rotated"

if [ -f "$SENTINEL" ]; then
    echo "rotate-nonce-salt: rotation already completed ($SENTINEL) — nothing to do"
    exit 0
fi

if [ ! -f /docker/whp/web/configs/config.php ]; then
    echo "rotate-nonce-salt: no live config.php (fresh install?) — setup.sh handles salt generation"
    exit 0
fi

mkdir -p "$SECRETS_DIR"
chmod 700 "$SECRETS_DIR"

# Adopt an existing salt file (partial earlier run, or setup.sh/config.php
# created one); only generate a new salt when none exists yet.
if [ -s "$SALT_FILE" ]; then
    echo "rotate-nonce-salt: adopting existing salt file (resume/seeded run)"
    ACTIVE_SALT="$(tr -d '[:space:]' < "$SALT_FILE")"
else
    ACTIVE_SALT="$(openssl rand -hex 32)"
fi
export ACTIVE_SALT SALT_FILE SENTINEL

php << 'EOF'
<?php
// php reads this script from stdin, where (on some PHP versions) the CLI
// SAPI does not auto-define the STDIN/STDOUT/STDERR constants.
if (!defined('STDERR')) define('STDERR', fopen('php://stderr', 'w'));

// Legacy NONCE_SALT + MYSQL_PASS come from the still-live OLD config.php
// (update scripts run before the web-files rsync). On a resume run after
// the rsync already happened, config.php may instead define the ACTIVE
// salt — harmless: the decrypt probes below try active first, then legacy.
require_once('/docker/whp/web/configs/config.php');
require_once('/docker/whp/web/libs/mysqlmgmt.php');

// The pre-rotation hardcoded salt, verbatim (also in git history).
$legacySalt = 'FJ192kJKcf8fk389K3fk3929dkf4434Fa9O9#89jk@493ps394k23';
$activeSalt = getenv('ACTIVE_SALT');
$saltFile   = getenv('SALT_FILE');
$sentinel   = getenv('SENTINEL');
if (!$activeSalt || !$saltFile || !$sentinel) {
    fwrite(STDERR, "ACTIVE_SALT/SALT_FILE/SENTINEL env missing\n");
    exit(1);
}

$warnings = [];
$warn = function ($msg) use (&$warnings) {
    // stdout, not stderr: apply-scripts.sh discards stderr on exit 0.
    echo "WARN: $msg\n";
    $warnings[] = $msg;
};

// --- key derivations, replicated exactly from the consuming libs ----------
// BackupTarget::getEncryptionKey(): hex sha256 string; IV = first 16 chars.
// openssl options=0 means the lib double-base64s — replicate verbatim.
$btKey = function ($salt) { return hash('sha256', 'WHPBackup2025' . $salt . 'SecretSalt'); };
$btDec = function ($data, $salt) use ($btKey) {
    $k = $btKey($salt);
    return openssl_decrypt(base64_decode($data), 'AES-256-CBC', $k, 0, substr($k, 0, 16));
};
$btEnc = function ($data, $salt) use ($btKey) {
    $k = $btKey($salt);
    return base64_encode(openssl_encrypt($data, 'AES-256-CBC', $k, 0, substr($k, 0, 16)));
};
// SmtpMailer::getEncryptionKey(): raw sha256; random IV prepended, RAW_DATA.
$smDec = function ($encoded, $salt) {
    $k = hash('sha256', $salt, true);
    $data = base64_decode($encoded);
    if ($data === false || strlen($data) < 17) return false;
    return openssl_decrypt(substr($data, 16), 'AES-256-CBC', $k, OPENSSL_RAW_DATA, substr($data, 0, 16));
};
$smEnc = function ($plain, $salt) {
    $k = hash('sha256', $salt, true);
    $iv = openssl_random_pseudo_bytes(16);
    return base64_encode($iv . openssl_encrypt($plain, 'AES-256-CBC', $k, OPENSSL_RAW_DATA, $iv));
};
// CpanelImportSummary: HKDF per importId, AES-256-GCM, iv(12)+ct+tag(16).
$ciDec = function ($encoded, $importId, $salt) {
    $k = hash_hkdf('sha256', $salt, 32, 'cpanel-import-temp-password:' . $importId);
    $blob = base64_decode($encoded, true);
    if ($blob === false || strlen($blob) < 29) return false;
    return openssl_decrypt(substr($blob, 12, -16), 'aes-256-gcm', $k, OPENSSL_RAW_DATA,
                           substr($blob, 0, 12), substr($blob, -16));
};
$ciEnc = function ($plain, $importId, $salt) {
    $k = hash_hkdf('sha256', $salt, 32, 'cpanel-import-temp-password:' . $importId);
    $iv = random_bytes(12); $tag = '';
    $ct = openssl_encrypt($plain, 'aes-256-gcm', $k, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
    return base64_encode($iv . $ct . $tag);
};

// mysqlmgmt connects with ERRMODE_EXCEPTION — any DB failure below throws,
// the heredoc exits nonzero, update.sh aborts, and a re-run resumes.
$db = (new mysqlmgmt())->getMySQLConnection();

// --- phase 1: read + decrypt everything into memory (no writes yet) -------
// Each item is probed against the ACTIVE salt first (already rotated by a
// previous partial run, or natively encrypted on a setup.sh-seeded install)
// and skipped if it decrypts; otherwise converted from the legacy salt.
$targets = $db->query("SELECT id, secret_key, password FROM whp.backup_targets")->fetchAll(PDO::FETCH_ASSOC);
$targetCountAtRead = count($targets);
$targetPlain = [];
foreach ($targets as $t) {
    foreach (['secret_key', 'password'] as $col) {
        $v = $t[$col];
        if ($v === null || $v === '') continue;
        if ($btDec($v, $activeSalt) !== false) continue; // already rotated
        $plain = $btDec($v, $legacySalt);
        if ($plain === false) {
            $warn("backup_targets id {$t['id']} $col decrypts with neither salt (pre-existing corruption?) — left untouched");
            continue;
        }
        $targetPlain[$t['id']][$col] = $plain;
    }
}

$settingsFile = '/docker/whp/settings.json';
$settings = null; $settingsPlain = [];
if (is_file($settingsFile)) {
    $settings = json_decode(file_get_contents($settingsFile), true);
    if (is_array($settings)) {
        foreach ([['smtp', 'password'], ['bichon', 'api_token']] as [$sec, $field]) {
            if (!empty($settings[$sec][$field])) {
                if ($smDec($settings[$sec][$field], $activeSalt) !== false) continue;
                $plain = $smDec($settings[$sec][$field], $legacySalt);
                if ($plain === false) {
                    $warn("settings.json $sec.$field decrypts with neither salt — left untouched");
                } else {
                    $settingsPlain["$sec.$field"] = $plain;
                }
            }
        }
    } elseif ($settings === null && trim((string)file_get_contents($settingsFile)) !== '') {
        $warn("settings.json is not valid JSON — skipped entirely");
        $settings = null;
    }
}

$summaryDir = '/var/lib/whp/cpanel-imports';
$summaryPlain = []; // file => [jsonPointerPath => plaintext]
foreach (glob($summaryDir . '/*.json') ?: [] as $sf) {
    $importId = basename($sf, '.json');
    $doc = json_decode(file_get_contents($sf), true);
    if (!is_array($doc)) continue;
    $walk = function (&$node, $path) use (&$walk, &$summaryPlain, $ciDec, $importId, $legacySalt, $activeSalt, $sf, $warn) {
        if (!is_array($node)) return;
        foreach ($node as $k => &$v) {
            if ($k === 'temp_password_encrypted' && is_string($v) && $v !== '') {
                if ($ciDec($v, $importId, $activeSalt) !== false) continue; // already rotated
                $plain = $ciDec($v, $importId, $legacySalt);
                if ($plain === false) {
                    $warn("$sf $path/$k decrypts with neither salt — left untouched");
                } else {
                    $summaryPlain[$sf]["$path/$k"] = $plain;
                }
            } elseif (is_array($v)) {
                $walk($v, "$path/$k");
            }
        }
    };
    $walk($doc, '');
}

$todo = count($targetPlain) + count($settingsPlain) + count($summaryPlain);
echo "Items to rotate: " . count($targetPlain) . " backup-target row(s), "
   . count($settingsPlain) . " settings field(s), "
   . count($summaryPlain) . " import summary file(s)\n";

// --- phase 2: backup old ciphertexts (root-only); abort if it fails -------
// Nothing has been modified yet, so aborting here is free. This file is the
// recovery path for any later partial failure. It lives in the 0700 secrets
// dir (NOT /root/) because the safety-net open_basedir allow-list only
// permits specific /root entries — and the secrets dir shares a backup
// lifecycle with the salt anyway.
$backupPath = dirname($saltFile) . '/nonce-salt-rotation-backup-' . date('Ymd-His') . '.json';
$old = ['backup_targets' => $targets, 'warnings' => &$warnings];
if ($settings !== null) $old['settings_json_raw'] = file_get_contents($settingsFile);
foreach (array_keys($summaryPlain) as $sf) $old['summaries'][$sf] = file_get_contents($sf);
$backupJson = json_encode($old, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE);
if ($backupJson === false) {
    fwrite(STDERR, "FATAL: could not serialize pre-rotation backup: " . json_last_error_msg() . "\n");
    exit(1);
}
if (file_put_contents($backupPath, $backupJson) !== strlen($backupJson)) {
    fwrite(STDERR, "FATAL: could not write pre-rotation backup $backupPath — aborting before any modification\n");
    exit(1);
}
chmod($backupPath, 0600);
echo "Old ciphertexts backed up to $backupPath\n";

// --- phase 3: install the salt FIRST (verified), then write back ----------
// Salt-before-writes ordering: if anything below crashes, the salt file
// already matches what the re-encrypted items use, so config.php's
// self-heal can never mint a third, mismatched salt; a re-run resumes via
// the decrypt probes above. The write is read-back verified because an
// unnoticed failure here WOULD cause exactly that mismatch.
if (!is_file($saltFile) || trim((string)file_get_contents($saltFile)) !== $activeSalt) {
    if (file_put_contents($saltFile, $activeSalt . "\n") === false) {
        fwrite(STDERR, "FATAL: could not write $saltFile — aborting before any re-encryption\n");
        exit(1);
    }
    chmod($saltFile, 0600);
}
if (trim((string)file_get_contents($saltFile)) !== $activeSalt) {
    fwrite(STDERR, "FATAL: $saltFile read-back mismatch — aborting before any re-encryption\n");
    exit(1);
}
echo "Active salt installed at $saltFile\n";

// Targets created/edited through the live panel after our phase-1 snapshot
// would be missed or clobbered — detect and warn (the panel stays up
// during updates, but backup-target edits mid-update are very unlikely).
$nowCount = (int)$db->query("SELECT COUNT(*) FROM whp.backup_targets")->fetchColumn();
if ($nowCount !== $targetCountAtRead) {
    $warn("backup_targets row count changed during rotation ($targetCountAtRead -> $nowCount) — re-run this script (it resumes safely) or check the newest target");
}

$db->beginTransaction();
foreach ($targetPlain as $id => $cols) {
    $sets = []; $params = ['id' => $id];
    foreach ($cols as $col => $plain) {
        $sets[] = "$col = :$col";
        $params[$col] = $btEnc($plain, $activeSalt);
    }
    $u = $db->prepare("UPDATE whp.backup_targets SET " . implode(', ', $sets) . " WHERE id = :id");
    $u->execute($params);
}
$db->commit();
echo "Re-encrypted " . count($targetPlain) . " backup_targets row(s)\n";

$atomicWrite = function ($path, $content) {
    // Preserve the destination's existing mode; verify every step.
    $mode = is_file($path) ? (fileperms($path) & 0777) : 0600;
    $tmp = $path . '.tmp.' . getmypid();
    if (file_put_contents($tmp, $content) !== strlen($content)) {
        @unlink($tmp);
        fwrite(STDERR, "FATAL: could not write $tmp\n");
        exit(1);
    }
    chmod($tmp, $mode);
    if (!rename($tmp, $path)) {
        @unlink($tmp);
        fwrite(STDERR, "FATAL: could not rename $tmp into place\n");
        exit(1);
    }
};

if ($settings !== null && $settingsPlain) {
    foreach ($settingsPlain as $key => $plain) {
        [$sec, $field] = explode('.', $key, 2);
        $settings[$sec][$field] = $smEnc($plain, $activeSalt);
    }
    $json = json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    if ($json === false) {
        fwrite(STDERR, "FATAL: settings.json re-serialize failed: " . json_last_error_msg() . " — original left untouched\n");
        exit(1);
    }
    $atomicWrite($settingsFile, $json);
    echo "Re-encrypted " . count($settingsPlain) . " settings.json field(s)\n";
}

foreach ($summaryPlain as $sf => $fields) {
    $importId = basename($sf, '.json');
    $doc = json_decode(file_get_contents($sf), true);
    $rewrap = function (&$node, $path) use (&$rewrap, $fields, $ciEnc, $importId, $activeSalt) {
        if (!is_array($node)) return;
        foreach ($node as $k => &$v) {
            if ($k === 'temp_password_encrypted' && isset($fields["$path/$k"])) {
                $v = $ciEnc($fields["$path/$k"], $importId, $activeSalt);
            } elseif (is_array($v)) {
                $rewrap($v, "$path/$k");
            }
        }
    };
    $rewrap($doc, '');
    $json = json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    if ($json === false) {
        fwrite(STDERR, "FATAL: $sf re-serialize failed: " . json_last_error_msg() . " — original left untouched\n");
        exit(1);
    }
    $atomicWrite($sf, $json);
}
if ($summaryPlain) echo "Re-encrypted temp passwords in " . count($summaryPlain) . " cpanel-import summary file(s)\n";

// Persist any warnings collected during write-back into the backup file too.
if ($warnings) {
    file_put_contents($backupPath, json_encode($old, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE));
}

// --- phase 4: completion sentinel (written ONLY after all writes) ---------
if (file_put_contents($sentinel, date('c') . "\n") === false) {
    fwrite(STDERR, "FATAL: rotation succeeded but sentinel write failed — next run will resume (harmless no-op)\n");
    exit(1);
}
chmod($sentinel, 0600);

echo "Rotation complete (" . count($warnings) . " warning(s)). All sessions/persist tokens are now invalid — users must re-log in.\n";
exit(0);
EOF

echo "rotate-nonce-salt: done"
