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 / Key_Encryption.php

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

304 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Key Encryption experiment.
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\Abstracts\Abstract_Feature;
13 use WordPress\AI\Experiments\Experiment_Category;
14 use WordPress\AI\Settings\Settings_Registration;
15
16 // Exit if accessed directly.
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * Opt-in experiment that encrypts AI connector API keys at rest.
21 *
22 * While enabled, every `connectors_ai_*_api_key` option is transparently redirected through the
23 * bundled secrets API so the `wp_options` table never contains a plaintext provider
24 * credential. Existing keys are migrated on opt-in and restored on opt-out (or on plugin
25 * deactivation) so users are never locked out of their own credentials.
26 *
27 * @since 1.1.0
28 */
29 class Key_Encryption extends Abstract_Feature {
30
31 /**
32 * Option that records re-encryption is needed on the next request.
33 *
34 * Set by `Activation::activation_callback()` so re-activating the plugin while the experiment
35 * is still toggled on re-encrypts the plaintext keys that the previous deactivation restored.
36 *
37 * @since 1.1.0
38 */
39 public const RESUME_MIGRATION_OPTION = 'wpai_key_encryption_resume_migration';
40
41 /**
42 * Process-wide bridge instance.
43 *
44 * Hooks are registered against this single bridge so that re-instantiation of the experiment
45 * (in tests or in code that calls `register_settings()` multiple times) does not produce
46 * duplicate callbacks.
47 *
48 * @since 1.1.0
49 * @var \WordPress\AI\Experiments\Key_Encryption\Secrets_Bridge|null
50 */
51 private static ?Secrets_Bridge $bridge = null;
52
53 /**
54 * Returns the process-wide Secrets_Bridge singleton.
55 *
56 * @since 1.1.0
57 */
58 public static function get_bridge(): Secrets_Bridge {
59 if ( null === self::$bridge ) {
60 self::$bridge = new Secrets_Bridge();
61 }
62 return self::$bridge;
63 }
64
65 /**
66 * Resets the cached bridge.
67 *
68 * @since 1.1.0
69 *
70 * @internal
71 */
72 public static function reset_bridge(): void {
73 self::$bridge = null;
74 }
75
76 /**
77 * {@inheritDoc}
78 */
79 public static function get_id(): string {
80 return 'key-encryption';
81 }
82
83 /**
84 * {@inheritDoc}
85 */
86 protected function load_metadata(): array {
87 return array(
88 'label' => __( 'Key Encryption', 'ai' ),
89 'description' => __( 'Encrypts AI provider API keys at rest using bundled libsodium encryption. Keys are transparently decrypted on read and re-encrypted on write. Disabling the experiment or deactivating the plugin restores plaintext keys.', 'ai' ),
90 'category' => Experiment_Category::ADMIN,
91 'capability' => 'none',
92 );
93 }
94
95 /**
96 * {@inheritDoc}
97 *
98 * @since 1.1.0
99 */
100 public function register(): void {
101 self::get_bridge()->register_option_filters();
102 }
103
104 /**
105 * Returns the option name for this experiment's individual toggle.
106 *
107 * @since 1.1.0
108 */
109 public static function get_toggle_option_name(): string {
110 return 'wpai_feature_' . self::get_id() . '_enabled';
111 }
112
113 /**
114 * Returns whether the experiment is effectively enabled (global AND individual toggle on).
115 *
116 * Does not consult `Abstract_Feature::is_enabled()` because that
117 * method caches per-instance, which would be stale immediately after
118 * a toggle change inside the same request.
119 *
120 * @since 1.1.0
121 */
122 public static function is_effectively_enabled(): bool {
123 $global = self::coerce_bool( get_option( Settings_Registration::GLOBAL_OPTION, false ) );
124 $individual = self::coerce_bool( get_option( self::get_toggle_option_name(), false ) );
125 return $global && $individual;
126 }
127
128 /**
129 * {@inheritDoc}
130 *
131 * @since 1.1.0
132 */
133 public function register_settings(): void {
134 $individual = self::get_toggle_option_name();
135 $global = Settings_Registration::GLOBAL_OPTION;
136
137 self::ensure_action( "update_option_{$individual}", array( self::class, 'handle_individual_toggle_update' ), 10, 2 );
138 self::ensure_action( "add_option_{$individual}", array( self::class, 'handle_individual_toggle_add' ), 10, 2 );
139
140 self::ensure_action( "update_option_{$global}", array( self::class, 'handle_global_toggle_update' ), 10, 2 );
141 self::ensure_action( "add_option_{$global}", array( self::class, 'handle_global_toggle_add' ), 10, 2 );
142
143 // Process any deferred re-encryption flagged by the activation hook. Priority 16 runs
144 // after `_wp_connectors_init` (priority 15), so `get_ai_connectors()` is populated.
145 self::ensure_action( 'init', array( self::class, 'maybe_resume_migration' ), 16, 0 );
146 }
147
148 /**
149 * Sets the deferred-migration flag.
150 *
151 * Called from the plugin activation hook so the migration runs
152 * on the next request, when the connector registry has been populated.
153 *
154 * @since 1.1.0
155 */
156 public static function flag_resume_migration(): void {
157 update_option( self::RESUME_MIGRATION_OPTION, '1', false );
158 }
159
160 /**
161 * Consumes the deferred-migration flag and re-encrypts plaintext keys if effectively enabled.
162 *
163 * @since 1.1.0
164 */
165 public static function maybe_resume_migration(): void {
166 if ( '1' !== get_option( self::RESUME_MIGRATION_OPTION, '' ) ) {
167 return;
168 }
169
170 delete_option( self::RESUME_MIGRATION_OPTION );
171
172 if ( ! self::is_effectively_enabled() ) {
173 return;
174 }
175
176 self::get_bridge()->encrypt_all();
177 }
178
179 /**
180 * Idempotent `add_action` wrapper used for the toggle hooks.
181 *
182 * @since 1.1.0
183 *
184 * @param string $hook Hook name.
185 * @param callable $callback Callback to register.
186 * @param int $priority Hook priority.
187 * @param int $accepted_args Number of accepted args.
188 */
189 private static function ensure_action( string $hook, callable $callback, int $priority, int $accepted_args ): void {
190 if ( false !== has_action( $hook, $callback ) ) {
191 return;
192 }
193 add_action( $hook, $callback, $priority, $accepted_args );
194 }
195
196 /**
197 * Handles updates to this experiment's individual toggle.
198 *
199 * @since 1.1.0
200 *
201 * @param mixed $old_value Previous option value.
202 * @param mixed $new_value New option value.
203 */
204 public static function handle_individual_toggle_update( $old_value, $new_value ): void {
205 $global = self::coerce_bool( get_option( Settings_Registration::GLOBAL_OPTION, false ) );
206 $was_on = $global && self::coerce_bool( $old_value );
207 $now_on = $global && self::coerce_bool( $new_value );
208 self::sync_effective_state( $was_on, $now_on );
209 }
210
211 /**
212 * Handles the first-time write of this experiment's individual toggle.
213 *
214 * @since 1.1.0
215 *
216 * @param string $option Option name.
217 * @param mixed $new_value New option value.
218 */
219 public static function handle_individual_toggle_add( $option, $new_value ): void {
220 unset( $option );
221 $global = self::coerce_bool( get_option( Settings_Registration::GLOBAL_OPTION, false ) );
222 $now_on = $global && self::coerce_bool( $new_value );
223 self::sync_effective_state( false, $now_on );
224 }
225
226 /**
227 * Handles updates to the global features toggle.
228 *
229 * @since 1.1.0
230 *
231 * @param mixed $old_value Previous option value.
232 * @param mixed $new_value New option value.
233 */
234 public static function handle_global_toggle_update( $old_value, $new_value ): void {
235 $individual = self::coerce_bool( get_option( self::get_toggle_option_name(), false ) );
236 $was_on = self::coerce_bool( $old_value ) && $individual;
237 $now_on = self::coerce_bool( $new_value ) && $individual;
238 self::sync_effective_state( $was_on, $now_on );
239 }
240
241 /**
242 * Handles the first-time write of the global features toggle.
243 *
244 * @since 1.1.0
245 *
246 * @param string $option Option name.
247 * @param mixed $new_value New option value.
248 */
249 public static function handle_global_toggle_add( $option, $new_value ): void {
250 unset( $option );
251 $individual = self::coerce_bool( get_option( self::get_toggle_option_name(), false ) );
252 $now_on = self::coerce_bool( $new_value ) && $individual;
253 self::sync_effective_state( false, $now_on );
254 }
255
256 /**
257 * Drives encrypt/decrypt migration when the effective enabled state transitions.
258 *
259 * @since 1.1.0
260 *
261 * @param bool $was_enabled Previous effective state.
262 * @param bool $is_enabled New effective state.
263 */
264 private static function sync_effective_state( bool $was_enabled, bool $is_enabled ): void {
265 if ( $was_enabled === $is_enabled ) {
266 return;
267 }
268
269 if ( $is_enabled ) {
270 self::get_bridge()->encrypt_all();
271 return;
272 }
273
274 self::get_bridge()->decrypt_all();
275 }
276
277 /**
278 * Coerces a stored option value to a boolean.
279 *
280 * Settings stored via the REST API can arrive as
281 * `'1'`, `'0'`, `''`, `true`, `false`, etc.
282 *
283 * @since 1.1.0
284 *
285 * @param mixed $value Raw option value.
286 * @return bool The coerced boolean value.
287 */
288 private static function coerce_bool( $value ): bool {
289 if ( is_bool( $value ) ) {
290 return $value;
291 }
292
293 if ( is_string( $value ) ) {
294 return '' !== $value && '0' !== $value && 'false' !== strtolower( $value );
295 }
296
297 if ( is_numeric( $value ) ) {
298 return 0 !== (int) $value;
299 }
300
301 return (bool) $value;
302 }
303 }
304