PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / Experiments / Key_Encryption / Secrets_Bridge.php

Secrets_Bridge.php in AI trunk, at includes/Experiments/Key_Encryption/Secrets_Bridge.php

457 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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