initial
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
/**
|
||||
* Config - the single source of truth for environment, credentials, and the
|
||||
* operating-mode lock. This is the enforcement core of the test-mode posture.
|
||||
*
|
||||
* Resolution order for every value: wp-config.php constant > 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user