| 1 |
<?php |
| 2 |
/** |
| 3 |
* Bridges WordPress connector option storage to the bundled Secrets API. |
| 4 |
* |
| 5 |
* @package WordPress\AI |
| 6 |
*/ |
| 7 |
|
| 8 |
declare( strict_types=1 ); |
| 9 |
|
| 10 |
namespace WordPress\AI\Experiments\Key_Encryption; |
| 11 |
|
| 12 |
use WordPress\AI\Vendor\Secrets\Secrets; |
| 13 |
use WordPress\AI\Vendor\Secrets\Secrets_Manager; |
| 14 |
use WordPress\AI\Vendor\Secrets\Secrets_Provider; |
| 15 |
use WordPress\AI\Vendor\Secrets\Secrets_Provider_Encrypted_Options; |
| 16 |
|
| 17 |
use function WordPress\AI\get_ai_connectors; |
| 18 |
|
| 19 |
// Exit if accessed directly. |
| 20 |
defined( 'ABSPATH' ) || exit; |
| 21 |
|
| 22 |
/** |
| 23 |
* Encrypts and decrypts connector API keys at rest via the bundled Secrets API. |
| 24 |
* |
| 25 |
* Stateless filter handlers; safe to instantiate per request. The class never reaches into |
| 26 |
* connector internals — it relies only on the `authentication.setting_name` field exposed by |
| 27 |
* `get_ai_connectors()` and the vendored {@see \WordPress\AI\Vendor\Secrets\Secrets} facade. |
| 28 |
* |
| 29 |
* Secrets live under the `ai/` namespace and every call passes an explicit `['plugin' => 'ai']` |
| 30 |
* context so the Secrets access-control layer grants self-namespace access regardless of the |
| 31 |
* current user. This matters because these option filters run in unauthenticated contexts |
| 32 |
* (cron, front-end, REST) where no user holds the `manage_secrets` capability. |
| 33 |
* |
| 34 |
* @since 1.1.0 |
| 35 |
*/ |
| 36 |
final class Secrets_Bridge { |
| 37 |
|
| 38 |
/** |
| 39 |
* Secret-key namespace used for every AI connector API key. |
| 40 |
* |
| 41 |
* @since 1.1.0 |
| 42 |
*/ |
| 43 |
public const SECRET_NAMESPACE = 'ai'; |
| 44 |
|
| 45 |
/** |
| 46 |
* Whether the read filter is currently bypassed. |
| 47 |
* |
| 48 |
* Used to allow internal `get_option()` calls during migration to read the raw stored value |
| 49 |
* without being intercepted by the read filter (which would otherwise short-circuit and |
| 50 |
* return the empty placeholder). |
| 51 |
* |
| 52 |
* @since 1.1.0 |
| 53 |
* @var bool |
| 54 |
*/ |
| 55 |
private bool $bypass_read_filter = false; |
| 56 |
|
| 57 |
/** |
| 58 |
* Registers transparent read/write filters for every connector API key option. |
| 59 |
* |
| 60 |
* @since 1.1.0 |
| 61 |
*/ |
| 62 |
public function register_option_filters(): void { |
| 63 |
foreach ( $this->get_connector_setting_names() as $setting_name ) { |
| 64 |
$write_hook = "pre_update_option_{$setting_name}"; |
| 65 |
$read_hook = "option_{$setting_name}"; |
| 66 |
$default_hook = "default_option_{$setting_name}"; |
| 67 |
|
| 68 |
if ( false === has_filter( $write_hook, array( $this, 'on_write' ) ) ) { |
| 69 |
add_filter( $write_hook, array( $this, 'on_write' ), 10, 1 ); |
| 70 |
} |
| 71 |
|
| 72 |
if ( false === has_filter( $read_hook, array( $this, 'on_read' ) ) ) { |
| 73 |
add_filter( $read_hook, array( $this, 'on_read' ), 10, 2 ); |
| 74 |
} |
| 75 |
|
| 76 |
if ( false !== has_filter( $default_hook, array( $this, 'on_read_default' ) ) ) { |
| 77 |
continue; |
| 78 |
} |
| 79 |
|
| 80 |
add_filter( $default_hook, array( $this, 'on_read_default' ), 11, 2 ); |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* Unregisters every transparent option filter previously installed. |
| 86 |
* |
| 87 |
* Called before `decrypt_all()` so the plaintext writes during |
| 88 |
* reversal are not re-encrypted by the very filters we are tearing down. |
| 89 |
* |
| 90 |
* @since 1.1.0 |
| 91 |
*/ |
| 92 |
public function unregister_option_filters(): void { |
| 93 |
foreach ( $this->get_connector_setting_names() as $setting_name ) { |
| 94 |
remove_filter( "pre_update_option_{$setting_name}", array( $this, 'on_write' ), 10 ); |
| 95 |
remove_filter( "option_{$setting_name}", array( $this, 'on_read' ), 10 ); |
| 96 |
remove_filter( "default_option_{$setting_name}", array( $this, 'on_read_default' ), 11 ); |
| 97 |
} |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Encrypts every existing plaintext connector API key into the secrets store. |
| 102 |
* |
| 103 |
* Reads each `connectors_ai_*_api_key` option, stores it as a secret, and |
| 104 |
* writes the wp_options row back to an empty string. Skips empty values. |
| 105 |
* After completion, registers the read filter so subsequent reads in |
| 106 |
* the same request return the decrypted value. |
| 107 |
* |
| 108 |
* @since 1.1.0 |
| 109 |
* |
| 110 |
* @return int Number of keys encrypted. |
| 111 |
*/ |
| 112 |
public function encrypt_all(): int { |
| 113 |
if ( ! $this->is_secrets_manager_available() ) { |
| 114 |
return 0; |
| 115 |
} |
| 116 |
|
| 117 |
// Tear down filters first so the `update_option` calls below |
| 118 |
// don't get intercepted by `on_write` which would "helpfully" |
| 119 |
// delete the secret we just stored. |
| 120 |
$this->unregister_option_filters(); |
| 121 |
|
| 122 |
$count = 0; |
| 123 |
foreach ( $this->get_connector_setting_names() as $connector_id => $setting_name ) { |
| 124 |
$plaintext = $this->read_raw_option( $setting_name ); |
| 125 |
if ( '' === $plaintext ) { |
| 126 |
continue; |
| 127 |
} |
| 128 |
|
| 129 |
$secret_key = $this->secret_key( $connector_id ); |
| 130 |
|
| 131 |
$stored = Secrets::set( $secret_key, $plaintext, $this->secret_context() ); |
| 132 |
if ( ! $stored ) { |
| 133 |
continue; |
| 134 |
} |
| 135 |
|
| 136 |
// Verify the secret actually persisted before we drop the plaintext. |
| 137 |
if ( Secrets::get( $secret_key, $this->secret_context() ) !== $plaintext ) { |
| 138 |
continue; |
| 139 |
} |
| 140 |
|
| 141 |
update_option( $setting_name, '' ); |
| 142 |
++$count; |
| 143 |
} |
| 144 |
|
| 145 |
// Flush the alloptions cache so subsequent get_option() calls in the same request don't |
| 146 |
// serve stale plaintext from cache before our read filter is in place. |
| 147 |
wp_cache_delete( 'alloptions', 'options' ); |
| 148 |
|
| 149 |
$this->register_option_filters(); |
| 150 |
|
| 151 |
return $count; |
| 152 |
} |
| 153 |
|
| 154 |
/** |
| 155 |
* Decrypts every secret back into plaintext wp_options storage and removes the secret. |
| 156 |
* |
| 157 |
* Used when the user opts out of the experiment or deactivates the |
| 158 |
* plugin while the experiment is enabled, so the user is never locked out |
| 159 |
* of their own credentials. |
| 160 |
* |
| 161 |
* @since 1.1.0 |
| 162 |
* |
| 163 |
* @return int Number of keys restored. |
| 164 |
*/ |
| 165 |
public function decrypt_all(): int { |
| 166 |
if ( ! $this->is_secrets_manager_available() ) { |
| 167 |
return 0; |
| 168 |
} |
| 169 |
|
| 170 |
// Tear down the transparent filters first so the plaintext writes below are not |
| 171 |
// immediately re-encrypted by `on_write`. |
| 172 |
$this->unregister_option_filters(); |
| 173 |
|
| 174 |
$count = 0; |
| 175 |
foreach ( $this->get_connector_setting_names() as $connector_id => $setting_name ) { |
| 176 |
$plaintext = Secrets::get( $this->secret_key( $connector_id ), $this->secret_context() ); |
| 177 |
if ( null === $plaintext || '' === $plaintext ) { |
| 178 |
continue; |
| 179 |
} |
| 180 |
|
| 181 |
update_option( $setting_name, $plaintext ); |
| 182 |
Secrets::delete( $this->secret_key( $connector_id ), $this->secret_context() ); |
| 183 |
++$count; |
| 184 |
} |
| 185 |
|
| 186 |
wp_cache_delete( 'alloptions', 'options' ); |
| 187 |
|
| 188 |
return $count; |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* Filter callback for `pre_update_option_{$setting_name}`. |
| 193 |
* |
| 194 |
* Stores the secret out-of-band and forces the wp_options row to remain empty. |
| 195 |
* |
| 196 |
* @since 1.1.0 |
| 197 |
* |
| 198 |
* @param mixed $value New value being written. |
| 199 |
* @return string Always empty — the real value lives in the secrets store. |
| 200 |
*/ |
| 201 |
public function on_write( $value ): string { |
| 202 |
if ( ! is_string( $value ) || '' === $value ) { |
| 203 |
$this->delete_secret_for_current_filter(); |
| 204 |
return ''; |
| 205 |
} |
| 206 |
|
| 207 |
if ( ! $this->is_secrets_manager_available() ) { |
| 208 |
// Without the secrets manager we cannot encrypt, so fail safe by passing the value |
| 209 |
// through unmodified rather than dropping the user's key on the floor. |
| 210 |
return $value; |
| 211 |
} |
| 212 |
|
| 213 |
$connector_id = $this->connector_id_for_current_filter(); |
| 214 |
if ( null === $connector_id ) { |
| 215 |
return $value; |
| 216 |
} |
| 217 |
|
| 218 |
Secrets::set( $this->secret_key( $connector_id ), $value, $this->secret_context() ); |
| 219 |
return ''; |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Filter callback for `option_{$setting_name}`. |
| 224 |
* |
| 225 |
* Returns the decrypted secret if one is stored; otherwise passes |
| 226 |
* through to the stored value (which may be a not-yet-migrated plaintext key). |
| 227 |
* |
| 228 |
* @since 1.1.0 |
| 229 |
* |
| 230 |
* @param mixed $value Stored option value. |
| 231 |
* @param string $option Option name. |
| 232 |
* @return mixed Decrypted value, or the original stored value. |
| 233 |
*/ |
| 234 |
public function on_read( $value, string $option ) { |
| 235 |
if ( $this->bypass_read_filter ) { |
| 236 |
return $value; |
| 237 |
} |
| 238 |
|
| 239 |
if ( ! $this->is_secrets_manager_available() ) { |
| 240 |
return $value; |
| 241 |
} |
| 242 |
|
| 243 |
$connector_id = $this->connector_id_from_setting_name( $option ); |
| 244 |
if ( null === $connector_id ) { |
| 245 |
return $value; |
| 246 |
} |
| 247 |
|
| 248 |
$secret = Secrets::get( $this->secret_key( $connector_id ), $this->secret_context() ); |
| 249 |
if ( null === $secret ) { |
| 250 |
return $value; |
| 251 |
} |
| 252 |
|
| 253 |
return $secret; |
| 254 |
} |
| 255 |
|
| 256 |
/** |
| 257 |
* Filter callback for `default_option_{$setting_name}`. |
| 258 |
* |
| 259 |
* Fires when `get_option()` finds no stored row for the key. Returns the decrypted |
| 260 |
* secret if one is stored so the key is readable without a backing row; otherwise passes the |
| 261 |
* default through untouched. |
| 262 |
* |
| 263 |
* @since 1.1.0 |
| 264 |
* |
| 265 |
* @param mixed $default_value The default value WordPress would return. |
| 266 |
* @param string $option Option name. |
| 267 |
* @return mixed The decrypted secret, or the original default value. |
| 268 |
*/ |
| 269 |
public function on_read_default( $default_value, string $option ) { |
| 270 |
if ( $this->bypass_read_filter ) { |
| 271 |
return $default_value; |
| 272 |
} |
| 273 |
|
| 274 |
if ( ! $this->is_secrets_manager_available() ) { |
| 275 |
return $default_value; |
| 276 |
} |
| 277 |
|
| 278 |
$connector_id = $this->connector_id_from_setting_name( $option ); |
| 279 |
if ( null === $connector_id ) { |
| 280 |
return $default_value; |
| 281 |
} |
| 282 |
|
| 283 |
$secret = Secrets::get( $this->secret_key( $connector_id ), $this->secret_context() ); |
| 284 |
if ( null === $secret || '' === $secret ) { |
| 285 |
return $default_value; |
| 286 |
} |
| 287 |
|
| 288 |
return $secret; |
| 289 |
} |
| 290 |
|
| 291 |
/** |
| 292 |
* Returns whether the bundled secrets backend can encrypt in this environment. |
| 293 |
* |
| 294 |
* @since 1.1.0 |
| 295 |
* |
| 296 |
* @return bool Whether an encryption provider is available. |
| 297 |
*/ |
| 298 |
public function is_secrets_manager_available(): bool { |
| 299 |
return null !== $this->active_provider(); |
| 300 |
} |
| 301 |
|
| 302 |
/** |
| 303 |
* Returns the explicit caller context passed to every Secrets operation. |
| 304 |
* |
| 305 |
* @since 1.1.0 |
| 306 |
* |
| 307 |
* @return array<string, string> The caller context. |
| 308 |
*/ |
| 309 |
private function secret_context(): array { |
| 310 |
return array( 'plugin' => self::SECRET_NAMESPACE ); |
| 311 |
} |
| 312 |
|
| 313 |
/** |
| 314 |
* Lazily registers the bundled encryption provider and returns the active provider. |
| 315 |
* |
| 316 |
* @since 1.1.0 |
| 317 |
* |
| 318 |
* @return \WordPress\AI\Vendor\Secrets\Secrets_Provider|null The active provider, or null. |
| 319 |
*/ |
| 320 |
private function active_provider(): ?Secrets_Provider { |
| 321 |
$manager = Secrets_Manager::get_instance(); |
| 322 |
|
| 323 |
if ( null === $manager->get_active_provider_id() ) { |
| 324 |
if ( null === $manager->get_provider( 'encrypted-options' ) ) { |
| 325 |
$manager->register_provider( new Secrets_Provider_Encrypted_Options() ); |
| 326 |
} |
| 327 |
$manager->select_provider(); |
| 328 |
} |
| 329 |
|
| 330 |
return $manager->get_active_provider(); |
| 331 |
} |
| 332 |
|
| 333 |
/** |
| 334 |
* Returns a map of connector_id => setting_name for every connector that uses api_key auth. |
| 335 |
* |
| 336 |
* Includes inactive connectors so we can clean up keys stored by |
| 337 |
* previously-active connectors. |
| 338 |
* |
| 339 |
* @since 1.1.0 |
| 340 |
* |
| 341 |
* @return array<string, string> |
| 342 |
*/ |
| 343 |
public function get_connector_setting_names(): array { |
| 344 |
$map = array(); |
| 345 |
|
| 346 |
foreach ( get_ai_connectors( false ) as $connector_id => $data ) { |
| 347 |
$auth = $data['authentication'] ?? array(); |
| 348 |
|
| 349 |
if ( ! is_array( $auth ) ) { |
| 350 |
continue; |
| 351 |
} |
| 352 |
|
| 353 |
if ( ( $auth['method'] ?? '' ) !== 'api_key' ) { |
| 354 |
continue; |
| 355 |
} |
| 356 |
|
| 357 |
$setting_name = $auth['setting_name'] ?? ''; |
| 358 |
if ( ! is_string( $setting_name ) || '' === $setting_name ) { |
| 359 |
continue; |
| 360 |
} |
| 361 |
|
| 362 |
$map[ $connector_id ] = $setting_name; |
| 363 |
} |
| 364 |
|
| 365 |
return $map; |
| 366 |
} |
| 367 |
|
| 368 |
/** |
| 369 |
* Reads a wp_option without triggering our read filter (returns the actual stored value). |
| 370 |
* |
| 371 |
* @since 1.1.0 |
| 372 |
* |
| 373 |
* @param string $option_name The wp_option name. |
| 374 |
* @return string The raw option value. |
| 375 |
*/ |
| 376 |
private function read_raw_option( string $option_name ): string { |
| 377 |
$this->bypass_read_filter = true; |
| 378 |
try { |
| 379 |
$value = get_option( $option_name, '' ); |
| 380 |
} finally { |
| 381 |
$this->bypass_read_filter = false; |
| 382 |
} |
| 383 |
|
| 384 |
return is_string( $value ) ? $value : ''; |
| 385 |
} |
| 386 |
|
| 387 |
/** |
| 388 |
* Builds the namespaced secret key for a given connector id. |
| 389 |
* |
| 390 |
* @since 1.1.0 |
| 391 |
* |
| 392 |
* @param string $connector_id The connector id. |
| 393 |
* @return string The namespaced secret key. |
| 394 |
*/ |
| 395 |
private function secret_key( string $connector_id ): string { |
| 396 |
return self::SECRET_NAMESPACE . '/' . $connector_id . '_api_key'; |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Reverse-lookup: given the wp_option name from the current filter context, find the connector id. |
| 401 |
* |
| 402 |
* @since 1.1.0 |
| 403 |
* |
| 404 |
* @param string $setting_name The wp_option name. |
| 405 |
* @return string|null The connector id, or null if not found. |
| 406 |
*/ |
| 407 |
private function connector_id_from_setting_name( string $setting_name ): ?string { |
| 408 |
foreach ( $this->get_connector_setting_names() as $connector_id => $candidate ) { |
| 409 |
if ( $candidate === $setting_name ) { |
| 410 |
return $connector_id; |
| 411 |
} |
| 412 |
} |
| 413 |
return null; |
| 414 |
} |
| 415 |
|
| 416 |
/** |
| 417 |
* Resolves the connector id from the current `pre_update_option_{name}` filter. |
| 418 |
* |
| 419 |
* WordPress strips the prefix before invoking the callback, so we |
| 420 |
* recover the option name from `current_filter()` and then map it |
| 421 |
* to a connector id. |
| 422 |
* |
| 423 |
* @since 1.1.0 |
| 424 |
* |
| 425 |
* @return string|null The connector id, or null if not found. |
| 426 |
*/ |
| 427 |
private function connector_id_for_current_filter(): ?string { |
| 428 |
$filter = current_filter(); |
| 429 |
if ( ! is_string( $filter ) || 0 !== strpos( $filter, 'pre_update_option_' ) ) { |
| 430 |
return null; |
| 431 |
} |
| 432 |
|
| 433 |
$setting_name = substr( $filter, strlen( 'pre_update_option_' ) ); |
| 434 |
return $this->connector_id_from_setting_name( $setting_name ); |
| 435 |
} |
| 436 |
|
| 437 |
/** |
| 438 |
* Deletes the secret tied to the current write-filter context, if any. |
| 439 |
* |
| 440 |
* Called when an empty value is being written (treat as "clear the key"). |
| 441 |
* |
| 442 |
* @since 1.1.0 |
| 443 |
*/ |
| 444 |
private function delete_secret_for_current_filter(): void { |
| 445 |
if ( ! $this->is_secrets_manager_available() ) { |
| 446 |
return; |
| 447 |
} |
| 448 |
|
| 449 |
$connector_id = $this->connector_id_for_current_filter(); |
| 450 |
if ( null === $connector_id ) { |
| 451 |
return; |
| 452 |
} |
| 453 |
|
| 454 |
Secrets::delete( $this->secret_key( $connector_id ), $this->secret_context() ); |
| 455 |
} |
| 456 |
} |
| 457 |
|