get_results( $wpdb->prepare( "SELECT option_name, option_value FROM %i WHERE option_name LIKE %s", $wpdb->options, $wpdb->esc_like(self::$prefix) . '%' ), ARRAY_A ); $options = []; foreach ($results as $row) { $shortKey = substr($row['option_name'], strlen(self::$prefix)); $options[$shortKey] = maybe_unserialize($row['option_value']); } return $options; } /** * Option flag raised when a stored secret can no longer be decrypted — almost * always because the WordPress salts were rotated, which changes the derived key. * * Rotating salts is recommended security practice, so this WILL happen. Without a * visible signal, "credentials undecryptable" was indistinguishable from * "credentials never configured": backups simply stopped reaching the cloud and * nothing said why. The flag drives an admin notice and is reported in /health. */ public const KEY_MISMATCH_FLAG = 'crypto_key_mismatch'; public static function getCloudCredentials(): ?array { $encrypted = self::get('cloud_credentials'); if (empty($encrypted)) { return null; } try { $decrypted = Encryption::decrypt($encrypted); $data = json_decode($decrypted, true); if (is_array($data)) { self::clearKeyMismatch(); return $data; } self::flagKeyMismatch('cloud_credentials', 'contenu déchiffré illisible'); return null; } catch (\Throwable $e) { self::flagKeyMismatch('cloud_credentials', $e->getMessage()); return null; } } /** * Record that a secret could not be decrypted, so an operator can be told to * re-enter it rather than left guessing why uploads went quiet. */ public static function flagKeyMismatch(string $what, string $reason): void { Logger::error('config', "Failed to decrypt {$what}: {$reason}"); self::set(self::KEY_MISMATCH_FLAG, [ 'what' => $what, 'reason' => $reason, 'at' => gmdate('c'), ]); } /** Clear the flag once a secret decrypts again (credentials re-entered). */ public static function clearKeyMismatch(): void { if (self::get(self::KEY_MISMATCH_FLAG) !== null) { delete_option(self::$prefix . self::KEY_MISMATCH_FLAG); } } /** * Details of the last decryption failure, or null if none is outstanding. * * @return array{what: string, reason: string, at: string}|null */ public static function keyMismatch(): ?array { $flag = self::get(self::KEY_MISMATCH_FLAG); return is_array($flag) ? $flag : null; } public static function setCloudCredentials(array $credentials): bool { $json = wp_json_encode($credentials); if ($json === false) { return false; } $encrypted = Encryption::encrypt($json); $stored = self::set('cloud_credentials', $encrypted); // Re-entering credentials is the documented recovery from a salt rotation, so // this is the point where the "unreadable secrets" alert must go away. if ($stored) { self::clearKeyMismatch(); } return $stored; } /** * Marker prefixing the stored (encrypted) API key. It lets us tell a value * written by this code apart from a legacy plaintext key, so a decrypt failure * (e.g. rotated WP salts) is reported instead of being mistaken for plaintext. */ private const API_KEY_ENC_PREFIX = 'enc:v1:'; /** * Read the API key, transparently decrypting it. The key authenticates the * backend's full-control calls, so it is stored encrypted at rest. A value * written before encryption was introduced (no marker) is migrated in place. */ public static function getApiKey(): string { $stored = self::get('api_key'); if (empty($stored) || !is_string($stored)) { return ''; } if (str_starts_with($stored, self::API_KEY_ENC_PREFIX)) { try { return Encryption::decrypt(substr($stored, strlen(self::API_KEY_ENC_PREFIX))); } catch (\Throwable $e) { self::flagKeyMismatch('api_key', $e->getMessage()); return ''; } } // Legacy plaintext key (pre-encryption): upgrade it to ciphertext in place. self::setApiKey($stored); return $stored; } public static function setApiKey(string $key): bool { return self::set('api_key', self::encodeApiKey($key)); } /** * Storable representation of an API key (encrypted + marker, or '' for empty). * Exposed so the Settings API sanitizer stores the exact same format. */ public static function encodeApiKey(string $key): string { if ($key === '') { return ''; } return self::API_KEY_ENC_PREFIX . Encryption::encrypt($key); } }