stored option > default. * While the operating mode is "test" (the default), environment() is CLAMPED to Test * regardless of any stored env value, so no code path can construct a Live call until * an explicit, gated switch to Live - see the Go-Live checklist. * * Secrets entered in the admin UI are encrypted at rest with libsodium * (sodium_crypto_secretbox, XSalsa20-Poly1305). The key comes from the * SPG_INSTANDA_KEY constant, so a database dump alone never exposes a usable secret. * Live credentials are expected as constants and are never written to the database. * * @package ESP\Instanda */ declare(strict_types=1); namespace ESP\Instanda; /** INSTANDA environment - selects the base URL path segment and credential pair. */ enum Environment: string { case Test = 'test'; case Live = 'live'; public function base_url(): string { $segment = $this === self::Live ? 'Live' : 'Test'; return "https://api.instanda.us/{$segment}/v0.1/" . Config::PACKAGE . '/'; } public function label(): string { return $this === self::Live ? 'LIVE' : 'TEST'; } } /** Immutable credential pair that never leaks the password through casts/dumps. */ final readonly class Credentials { public function __construct( public string $username, public string $password, ) {} public function is_complete(): bool { return $this->username !== '' && $this->password !== ''; } public function basic_auth_header(): string { return 'Basic ' . base64_encode($this->username . ':' . $this->password); } public function __toString(): string { return $this->username . ':***'; } /** Keep the password out of var_dump / error backtraces. */ public function __debugInfo(): array { return ['username' => $this->username, 'password' => '***']; } } final class Config { public const PACKAGE = 'Package25536'; private const OPTION_PREFIX = 'espinstanda_'; private const SECRET_KEY_OPTION = 'espinstanda_secret_key'; /* --------------------------------------------------------- operating mode */ /** 'test' (locked) or 'live' (enabled). Constant > option > default 'test'. */ public static function operating_mode(): string { $mode = self::const_val('SPG_INSTANDA_OPERATING_MODE') ?? (string) self::option('operating_mode', 'test'); return $mode === 'live' ? 'live' : 'test'; } public static function operating_mode_pinned(): bool { return self::const_val('SPG_INSTANDA_OPERATING_MODE') !== null; } /* --------------------------------------------------------- environment */ /** * The effective environment. While operating mode is 'test', this is clamped to * Test no matter what env value is stored - the production-safety floor. */ public static function environment(): Environment { if (self::operating_mode() === 'test') { return Environment::Test; } return self::raw_env() === 'live' ? Environment::Live : Environment::Test; } /** The selected env value before the operating-mode clamp (constant > option). */ private static function raw_env(): string { $env = self::const_val('SPG_INSTANDA_ENV') ?? (string) self::option('env', 'test'); return $env === 'live' ? 'live' : 'test'; } public static function base_url(): string { return self::environment()->base_url(); } public static function site_domain(): string { return self::const_val('SPG_INSTANDA_SITEDOMAIN') ?? (string) self::option('site_domain', ''); } /* --------------------------------------------------------- credentials */ /** Credentials for a given environment (default: the active one). Constant > decrypted option. */ public static function credentials(?Environment $env = null): Credentials { $env = $env ?? self::environment(); $suffix = $env === Environment::Live ? 'LIVE' : 'TEST'; $key = strtolower($suffix); // 'live' | 'test' $username = self::username_const($suffix) ?? (string) self::option("username_{$key}", ''); $password_const = self::const_val("SPG_INSTANDA_PASSWORD_{$suffix}"); if ($password_const !== null) { $password = $password_const; } else { $stored = (string) self::option("password_{$key}", ''); $password = $stored !== '' ? (self::decrypt($stored) ?? '') : ''; } return new Credentials($username, $password); } /** * Resolve a username constant for an env suffix (TEST|LIVE), accepting BOTH the * plugin's canonical SPG_INSTANDA_USERNAME_{ENV} and the INSTANDA solution doc's * SPG_INSTANDA_USER_{ENV} spelling. Canonical wins when both are set. This prevents * a silent cutover failure when ops follows INSTANDA's doc verbatim. */ private static function username_const(string $suffix): ?string { return self::const_val("SPG_INSTANDA_USERNAME_{$suffix}") ?? self::const_val("SPG_INSTANDA_USER_{$suffix}"); } /** True when a username constant (either spelling) is set for the env suffix (TEST|LIVE). */ public static function username_is_constant(string $suffix): bool { return self::username_const(strtoupper($suffix)) !== null; } /** True when Live credentials are supplied via constants (the required Live posture). */ public static function live_credentials_are_constants(): bool { return self::username_is_constant('LIVE') && self::const_val('SPG_INSTANDA_PASSWORD_LIVE') !== null; } /** True when the given credential field is supplied by a constant (so the UI locks it). */ public static function field_is_constant(string $field): bool { return self::const_val($field) !== null; } /* --------------------------------------------------------- encryption */ public static function encryption_available(): bool { return function_exists('sodium_crypto_secretbox') && self::key() !== null; } /** Encrypt a UI secret for at-rest storage. Refuses (throws) when no key is configured. */ public static function encrypt(string $plaintext): string { $key = self::key(); if ($key === null) { throw new \RuntimeException( 'SPG_INSTANDA_KEY is missing or invalid; cannot encrypt. Use wp-config.php constants for credentials instead.' ); } $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $cipher = sodium_crypto_secretbox($plaintext, $nonce, $key); return base64_encode($nonce . $cipher); } /** Decrypt a stored secret. Returns null (never throws) on any tamper/format/auth failure. */ public static function decrypt(string $ciphertext): ?string { $key = self::key(); if ($key === null) { return null; } $raw = base64_decode($ciphertext, true); $min = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES; if ($raw === false || strlen($raw) < $min) { return null; } $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $plain = sodium_crypto_secretbox_open($cipher, $nonce, $key); return $plain === false ? null : $plain; } private static function key(): ?string { $b64 = self::const_val('SPG_INSTANDA_KEY') ?? self::db_key(); if ($b64 === null) { return null; } $key = base64_decode($b64, true); if ($key === false || strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { return null; } return $key; } /** * Self-provisioned encryption key, used only when no SPG_INSTANDA_KEY constant is set. * Generated once and stored in the DB (autoload off) so the UI credential path works * without a manual wp-config edit. Live credentials come from constants and never touch * the DB, so this key only ever protects DB-stored Test secrets. For the hardened posture * (a DB dump alone never exposes a usable secret) set the SPG_INSTANDA_KEY constant - it wins. */ private static function db_key(): ?string { if (!function_exists('get_option')) { return null; } $existing = get_option(self::SECRET_KEY_OPTION); if (is_string($existing) && $existing !== '') { return $existing; } if (!function_exists('sodium_crypto_secretbox') || !function_exists('update_option')) { return null; } $generated = base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES)); update_option(self::SECRET_KEY_OPTION, $generated, false); return $generated; } /* --------------------------------------------------------- low-level seams */ /** A defined, non-empty constant's value, else null. */ private static function const_val(string $name): ?string { if (defined($name)) { $value = (string) constant($name); if ($value !== '') { return $value; } } return null; } /** A prefixed plugin option. M5 mirrors GF settings into these on save. */ private static function option(string $key, mixed $default): mixed { if (function_exists('get_option')) { return get_option(self::OPTION_PREFIX . $key, $default); } return $default; } }