- F-03: switch_to_live() now sets espinstanda_env=live so environment() actually resolves to Live after go-live (was leaving real quotes on the Test endpoint). - F-04: correct the class-config docblock overstating the at-rest guarantee; it only holds when SPG_INSTANDA_KEY is a constant (else the key is self-provisioned into the same DB as the ciphertext). - F-02: add advisory A5 "exactly one active INSTANDA feed" to catch a stray test-form feed that also creates quotes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
252 lines
9.9 KiB
PHP
252 lines
9.9 KiB
PHP
<?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 "a database dump alone never
|
|
* exposes a usable secret" guarantee holds ONLY when the SPG_INSTANDA_KEY constant is
|
|
* set: without it the key is self-provisioned into the DB (see db_key()), so the key
|
|
* and ciphertext live in the same database and a dump recovers the password. Set
|
|
* SPG_INSTANDA_KEY (and the credential constants) in production for the hardened posture.
|
|
* 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;
|
|
}
|
|
}
|