Releases: ericmann/secrets-api
Release list
v0.1.0 — Secrets API feature plugin
First public release of the feature plugin implementing the Secrets API proposed for WordPress 7.2.
This is a working implementation, not a prototype. Everything under src/ is written to be copied into wordpress-develop unchanged: same paths, core coding standards, the default text domain, @since 7.2.0, and a PHP 7.4 syntax floor.
What it does
Encrypted, versioned credential storage, with a key hierarchy in which exactly one value is ever stored wrapped:
site key → root key → master key → data key → secret value
The root key is the only wrapped value on the site. Master keys are derived per scope on demand and never stored, data keys are per secret and per slot. Rotating the site key re-wraps one value, whether the install has one site or five hundred.
Encryption is sodium_crypto_aead_xchacha20poly1305_ietf_* throughout, with key derivation via sodium_crypto_kdf_derive_from_key(). Every ciphertext is bound by AAD to its purpose, scope, site, name, and slot, so a record cannot be replayed under any of them.
Two version slots
Every secret has a CURRENT and a PREVIOUS slot. Rotate the credential at the provider, deploy, let in-flight requests drain against the old value, then retire it explicitly. There are no timers and no cron; retirement is an operator action.
Three states that never collapse
wp_get_secret() returns a WP_Secret, null when the secret does not exist, or a WP_Error when it exists but could not be produced. Never a bare false.
This is the property most of the design exists to protect. An unreachable backend reported as "absent" is how somebody talks themselves into regenerating a credential they still had.
API surface
Functions
wp_get_secret() · wp_set_secret() · wp_delete_secret() · wp_list_secrets() · wp_retire_secret_version() · wp_import_option_as_secret()
Each has a network-scope counterpart: wp_get_network_secret(), wp_set_network_secret(), wp_delete_network_secret(), wp_list_network_secrets(), wp_retire_network_secret_version(). Network and site scope use separate capabilities and separate storage prefixes, with no implicit fallback between them.
Also wp_secrets_validate_name(), wp_using_secrets_dropin(), wp_secrets_provider_label(), wp_secrets_provider_is_writable(), and wp_secrets_memzero().
Classes and interfaces
WP_Secret · WP_Secret_Version · WP_Secrets_Provider · WP_Secrets_Store · WP_Secrets_Keyring · WP_Secrets_Libsodium_Provider · WP_Secrets_Option_Store · WP_Secrets_Config_Key_Provider · WP_Secrets_Cipher · WP_Secrets_Key_Manager, plus WP_Secrets_Broken_Provider / _Store / _Keyring as fail-closed sentinels.
One action, no filters
wp_secret_changed fires on every change, with $name, $action (created, updated, deleted, imported, retired), $actor_id, $timestamp, $old_fingerprint, $new_fingerprint. Fingerprints only; never a value.
There are zero apply_filters() calls in src/, and an architectural test enforces that. Nothing may observe or alter a credential between storage and caller.
Ten error codes, from WP_SECRETS_ERROR_INVALID_NAME through WP_SECRETS_ERROR_PROVIDER_READ_ONLY, so a caller can tell a bad argument from an unreachable keyring from a record written by a newer format version.
The provider model
Three hosts said in the proposal comments that the published extension contract could not express their deployments. They were right, and the resolution was a reframe rather than an exception.
The proposal said that neither drop-in extension point "is ever handed a plaintext secret." That sentence did two jobs, and only the first one mattered. Encryption cannot be turned off is non-negotiable. The store never sees plaintext was one way of achieving it, rather than the thing itself. As Rafael Meneses put it: a provider can be stronger than the default, never weaker.
So WP_Secrets_Provider is the outermost extension point, and WP_Secrets_Libsodium_Provider is one implementation of it rather than the privileged case. A KMS, an HSM, or a control panel implements the same interface.
Routing is declared, never inferred. A wp-content/secrets.php drop-in sets one of three globals:
| Global | Replaces | Effort |
|---|---|---|
wp_secrets_provider |
Everything; the platform owns the secret | 8 methods |
wp_secrets_keyring |
How the root key is wrapped | 3 methods |
wp_secrets_store |
Where records live | 4 methods |
Setting none of them is supported and common. A drop-in that swaps only the keyring gets the shipped provider built around that keyring, which is what most hosts actually want.
A drop-in that throws, or sets any of those globals to something that does not implement the matching interface, fails closed: every operation returns WP_Error through a Broken_* sentinel. It never silently reverts to the default, because a site whose platform integration just broke must not start answering from a different credential store and report its credentials missing.
examples/aws-secrets-manager/ contains a working AWS Secrets Manager drop-in: about 300 lines, one SigV4 signature, no SDK. Secrets Manager's AWSCURRENT and AWSPREVIOUS staging labels map directly onto WP_Secret_Version::CURRENT and ::PREVIOUS, so the two-slot model needed no emulation.
WP-CLI
wp secret get · set · delete · list · rotate · retire · health · dropin · generate-key · import-option · migrate-legacy, plus wp network-secret for network scope.
Reads are masked by default, to a fixed eight-asterisk width so the mask does not leak length. --reveal warns before printing. wp secret list has no flag that prints a value.
Site Health
Three tests: whether the site key is a dedicated constant or a weaker fallback, whether every stored secret still decrypts, and whether any secrets are flagged as needing rotation. Debug info reports counts and class names only, never values or fingerprints.
Requirements
- PHP 7.4 or later
- WordPress 6.6 or later
- libsodium, via the extension or core's bundled
sodium_compat
Once core ships the API, the plugin detects it, stands down, and says so in an admin notice rather than shadowing core's implementation.
Coexisting with the Displace prototype
Some plugins were built against an earlier prototype. There is no compatibility shim. What exists instead: a read for a secret that only exists in the prototype's format is upgraded into the current format on first read and flagged needs_rotation. The prototype's own rows are never modified or deleted, and an architectural test enforces that. wp secret migrate-legacy does the same thing in bulk, additively. There is no delete step anywhere.
Deliberately not included
- No per-plugin isolation. Namespacing groups secrets by owner. Any plugin that can run PHP can read any secret. Masking is hygiene, not a privilege boundary.
- No admin settings screen. The proposal defers it to 7.3. The hooks and accessors one would need are here; the screen is not.
- No export.
WP_Secret::reveal()is the only path to a plaintext. Migrations mean re-entry at the destination. - Drop-ins are fully trusted code.
get_protection_boundary()andis_writable()are declarations for humans and Site Health, never enforcement, and their docblocks say so.
Testing
455 tests, 1,023 assertions single-site and 1,035 multisite. CI runs PHP 7.4 / 8.0 / 8.3 against WordPress latest and trunk, plus a multisite job, with static analysis (phpcs, PHPCompatibilityWP at 7.4, PHPStan) gating the matrix. Every GitHub Action is pinned by full commit SHA.
WP_Secrets_Provider_Conformance is an abstract suite you can extend to check your own provider for the properties implements cannot verify.
Known gaps are recorded honestly in docs/open-questions.md, including that CLI dispatch has no automated coverage and that sodium_compat is never exercised by the suite.
What this release is asking for
Not "does this look right." The provider interface is shaped by three hosts describing what they need, not by anyone having built against it. The first real implementation will turn up something those descriptions missed.
Implement WP_Secrets_Provider against a real platform and report what broke. See CONTRIBUTING.md. Security issues go through SECURITY.md, never a public issue.