Opt-in experiment that encrypts AI connector API keys at rest.
- While enabled, every
connectors_ai_*_api_keyoption is transparently routed through a bundled libsodium-based secrets API, so thewp_optionstable 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.
Encrypting keys at rest raises the cost of database-level exposure. It is not a sandbox around the credentials, and it cannot be one.
Protects against — anything that reveals the contents of wp_options without also revealing wp-config.php:
- A stolen or leaked database dump or backup.
- A SQL-injection read in an unrelated plugin.
- Shared-host or third-party access scoped to the database: support tooling, analytics ETL, a read-only replica, a screenshot of phpMyAdmin.
- Query logs, slow-query logs, or error output that echo option values.
Does not protect against — anything executing inside the WordPress PHP process, or anything that can read the filesystem:
- Any active plugin, theme, mu-plugin, or drop-in. WordPress has no privilege separation between them; code execution in any one of them is code execution as the whole site. While the experiment is enabled,
get_option( 'connectors_ai_openai_api_key' )returns the decrypted key to any in-process caller. That is what "transparent" means, and every existing caller (Connector_Key_Index, REST dispatch, the AI client registry) depends on it. - Filesystem read access. The encryption key is
WP_SECRETS_KEY, orLOGGED_IN_KEY . LOGGED_IN_SALTwhen that constant is not defined. Both live inwp-config.php, so anyone who can read that file can decrypt the_secret_*rows directly. Encryption at rest is worth much less against a full filesystem compromise than against a database-only one. - Users who can already see the key. Anyone able to manage AI settings can read it through the settings screen and the REST API, by design.
Every call into the bundled SDK passes a caller-asserted context ([ 'plugin' => 'ai' ]). The namespace check this feeds is a collision guard: it keeps well-behaved plugins out of each other's namespaces and gives the secrets_access filter a sensible default to refine. It is not, and cannot be, a boundary between plugins:
- The value is supplied by the caller, so in-process code can assert any namespace it likes.
- Deriving the caller from a backtrace instead would not change that — in-process code can steer a backtrace, for example by invoking the SDK from a core hook so the nearest frame belongs to whichever file core dispatched from.
- It would not matter if it could. The same code can read the decrypted value straight out of
get_option(), decrypt the_secret_*row using the salts, or simply returntruefrom thesecrets_accessfilter.
No separate plugin needs to be installed. The encryption backend is a minimal, namespaced copy of
Displace Secrets Manager vendored into this
plugin at includes/Vendor/Secrets/ under the WordPress\AI\Vendor\Secrets namespace. Only the
runtime SDK is bundled (facade, manager, encrypted-options provider) — the upstream plugin
bootstrap, admin UI, and WP-CLI commands are intentionally omitted. See
includes/Vendor/Secrets/README.md for the exact
upstream commit and the list of modifications.
Secrets are stored under the ai/ namespace (e.g. ai/openai_api_key). The experiment calls the
vendored Secrets::get() / Secrets::set() / Secrets::delete() facade directly and never defines
the global get_secret() / set_secret() functions, so the PHP symbols do not collide if a site
also installs the real Displace Secrets Manager plugin.
Storage is shared with that plugin, though. The vendored provider is byte-identical to upstream,
so both write to _secret_{namespace}/{key} options, share the _secrets_master_key option, and
derive the same key from WP_SECRETS_KEY (or the salts). Running both is interoperable rather than
conflicting — each reads what the other wrote, and rotating WP_SECRETS_KEY covers both. The
consequence worth knowing: on such a site the store holds other plugins' secrets alongside ai/*,
and the namespace check does not isolate them from in-process code (see
Threat model).
-
libsodium. The encrypted-options provider needs
sodium_crypto_secretbox(). This is built into PHP 7.2+ and WordPress also ships asodium_compatfallback, so it is effectively always available. If, in some unusual environment, sodium is unavailable, the experiment fails safe: writes pass through unchanged so user keys are never silently dropped (just stored as plaintext, as they would be without the experiment). -
A dedicated encryption key (recommended). For meaningful at-rest security, define
WP_SECRETS_KEYinwp-config.php. Without it, the provider derives an encryption key from existing WordPress salts (LOGGED_IN_KEY . LOGGED_IN_SALT) — usable, but it ties your encrypted secrets to those salts, so rotating the salts would make stored keys undecryptable.Any sufficiently long, random string works as the key (it is hashed to a 32-byte key with BLAKE2b). Generate one with a password manager or the WordPress.org salt generator, then add it to
wp-config.php:define( 'WP_SECRETS_KEY', 'a-long-random-secret-string' );
To rotate WP_SECRETS_KEY, set the old value as WP_SECRETS_KEY_PREVIOUS alongside the new
WP_SECRETS_KEY. On the next read the provider transparently re-encrypts the internal master key
under the new key. The previous key can be removed afterward.
define( 'WP_SECRETS_KEY', 'the-new-random-secret-string' );
define( 'WP_SECRETS_KEY_PREVIOUS', 'the-old-random-secret-string' );While enabled, the experiment registers two transparent option filters per connector:
pre_update_option_{setting_name}— encrypts the value viaSecrets::set()and forces thewp_optionsrow to remain empty.option_{setting_name}— decrypts and returns the secret on read viaSecrets::get(); passes through to the stored value if no secret exists (handles partially-migrated state).
Each call passes an explicit [ 'plugin' => 'ai' ] context. The bundled secrets manager runs a
namespace check on every operation, granting "self-namespace" access when the caller's plugin slug
matches the key's namespace. Passing the context explicitly guarantees that match — these filters
run in unauthenticated request contexts (cron, front-end, REST) where no user holds the
manage_secrets capability and backtrace-based caller detection is unreliable. That check is a
collision guard, not an isolation boundary; see Threat model.
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.
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.
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.