Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/experiments/key-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Key Encryption

Opt-in experiment that encrypts AI connector API keys at rest.

## Summary

- Extends `Abstract_Feature`.
- While enabled, every `connectors_ai_*_api_key` option is transparently routed through the
[Displace Secrets Manager](https://github.com/ericmann/displace-secrets-manager) plugin's
`set_secret()` / `get_secret()` API, so the `wp_options` table never contains a plaintext key.
- Existing keys are encrypted on opt-in, restored on opt-out, and restored on plugin deactivation
— users cannot get locked out of their own credentials.

## Requirements

This experiment depends on the
[Displace Secrets Manager](https://github.com/ericmann/displace-secrets-manager) plugin being
installed and active. If the secrets manager plugin is missing, enabling this experiment has no
effect: writes pass through unchanged so user keys are never silently dropped.

For meaningful at-rest security, also define `WP_SECRETS_KEY` in `wp-config.php`. Without it,
Displace Secrets Manager derives an encryption key from existing WordPress salts
(`LOGGED_IN_KEY . LOGGED_IN_SALT`) — usable for a proof-of-concept, but weaker than a dedicated
key. Generate one with `wp secret generate-key`.

## How it works

While enabled, the experiment registers two transparent option filters per connector:

- `pre_update_option_{setting_name}` — encrypts the value via `set_secret()` and forces the
`wp_options` row to remain empty.
- `option_{setting_name}` — decrypts and returns the secret on read; passes through to the
stored value if no secret exists (handles partially-migrated state).

All existing callers — `Connector_Key_Index`, REST dispatch, the AI client registry — keep
working because `get_option()` transparently returns the decrypted value through the read
filter.

## Opt-in / opt-out lifecycle

Migration is driven by the **effective** enabled state — the conjunction of the global features
toggle (`wpai_features_enabled`) and this experiment's individual toggle. Either toggle flipping
off is a transition out of "effectively enabled" and triggers the reverse migration. This matters
because when the global toggle is off the transparent read filter never gets installed at all —
without the reverse migration, the user would be locked out of their own keys.

## Disabling the experiment

Toggle the experiment off from the Experiments settings page. The reverse migration runs as soon
as the toggle (or the global features toggle) flips off.

Avoid using the `wpai_feature_key-encryption_enabled` filter to force-disable this experiment: the
filter only short-circuits `is_enabled()`, so the transparent read filter is never installed —
but no toggle changes, so the reverse migration is never triggered either, and the user is locked
out of encrypted keys. Always change the stored toggle (or the global toggle) instead.
5 changes: 5 additions & 0 deletions includes/Admin/Activation.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

namespace WordPress\AI\Admin;

use WordPress\AI\Experiments\Key_Encryption\Key_Encryption;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

Expand All @@ -29,5 +31,8 @@ final class Activation {
public static function activation_callback(): void {
// Check and run any pending upgrades.
Upgrades::do_upgrades();

// Schedule the Key Encryption experiment to re-encrypt plaintext keys on the next request.
Key_Encryption::flag_resume_migration();
}
}
42 changes: 42 additions & 0 deletions includes/Admin/Deactivation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
/**
* Runs on plugin deactivation.
*
* @package WordPress\AI\Admin
* @since x.x.x
*/

declare( strict_types=1 );

namespace WordPress\AI\Admin;

use WordPress\AI\Experiments\Key_Encryption\Key_Encryption;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
* Deactivation routines.
*
* @internal
*
* @since x.x.x
*/
final class Deactivation {
/**
* Runs on plugin deactivation.
*
* Reverses the Key Encryption experiment when it is
* currently enabled so the user is never locked out of
* their API keys after deactivating the plugin.
*
* @since x.x.x
*/
public static function deactivation_callback(): void {
if ( ! Key_Encryption::is_effectively_enabled() ) {
return;
}

Key_Encryption::get_bridge()->decrypt_all();
}
}
1 change: 1 addition & 0 deletions includes/Experiments/Experiments.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class Experiments {
\WordPress\AI\Experiments\Summarization\Summarization::class,
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
\WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class,
\WordPress\AI\Experiments\Key_Encryption\Key_Encryption::class,
);

/**
Expand Down
Loading
Loading