Skip to content

Releases: ericmann/secrets-api

v0.3.0 — native types, and a breaking change for drop-ins

Choose a tag to compare

@ericmann ericmann released this 09 Oct 19:56
v0.3.0
1efccbb

Released 2026-10-09. More from review on the core patch,
#66187. This release breaks a provider, store, or
keyring written against 0.2.3: see "If you have written a drop-in" below.

Changed

  • Parameters and return values carry native PHP 7.4 types. This covers the public functions,
    the three interfaces and everything that implements them, the internal classes, the plugin-only
    code, and the WP-CLI command. A return that needs a union type, such as
    WP_Secret|null|WP_Error, stays in the docblock. See
    ADR 0013.
  • A wrong argument type is no longer reported as a WP_Error. null, an array, or an object
    throws a TypeError. A scalar is converted, so wp_get_secret( 123 ) looks up the name '123'.
    The runtime checks the declarations replace are removed.
  • A plaintext value stays untyped, and is still checked. wp_set_secret() with a value that is
    not a string returns WP_SECRETS_ERROR_INVALID_VALUE as before. Declared as string, PHP would
    turn false into an empty secret and report success.
  • Static types are narrower where that holds. A version or slot is one of the two
    WP_Secret_Version constants, a scope is 'site'|'network', a protection boundary is one of the
    BOUNDARY_* constants, a Site Health status is one of its three values, and a count is
    int<0, max>. A secret's name stays string.
  • Comments and messages no longer use -- as a dash. Comments use an en dash. Three messages
    were reworded: the Site Health description for secrets that cannot be decrypted, the key source
    label for a legacy WP_SECRETS_KEY, and the legacy reader's unwrap error.

Fixed

  • Ten docblock continuation lines were indented one column too far.

If you have written a drop-in

The interfaces now declare return types, and PHP refuses a class that implements an interface
without a compatible one. That is a fatal error at class load, and it cannot be caught. Add these
before upgrading:

Interface Method Return type
WP_Secrets_Provider get_label() string
WP_Secrets_Provider get_protection_boundary() string
WP_Secrets_Provider is_writable() bool
WP_Secrets_Keyring get_key_source() string

Parameter types are optional in an implementation, since PHP lets it leave its own parameters
untyped. The three examples and the reference drop-in show the
change.

Full changelog: https://secretsapi.dev/changelog/0-3-0/

v0.2.3 — the change hook reports scope, and src/ passes no text domain

Choose a tag to compare

@ericmann ericmann released this 07 Oct 20:15
v0.2.3
a9a6215

Released 2026-10-07. More from review on the core patch,
#66187. The public functions have the same
signatures as 0.2.2. The wp_secret_changed action gains an argument, which matters to
anyone who has written a provider.

Changed

  • wp_secret_changed passes $network as a seventh argument. It is true for a network-scope
    secret and false for a site-scope one. Before this, a listener could not tell a network secret
    from a site secret of the same name. A listener registered for six arguments or fewer is
    unaffected. See ADR 0012.
  • A provider is now tested for firing the hook. The API does not fire wp_secret_changed on a
    provider's behalf, so a drop-in that left it out silenced a site's audit log without anything
    noticing. WP_Secrets_Provider_Conformance now checks a write, an overridden action, a delete,
    a retired version, a network-scope change, and a refused write. The interface states the
    requirement on delete() and retire_previous() as well as set().
  • src/ passes no text domain to translation functions. Core leaves the argument off for its
    own strings instead of naming 'default'. The strings are unchanged.

Fixed

  • The AWS Secrets Manager example did not fire wp_secret_changed on delete. The new
    conformance tests found it.

If you have written a provider

Add (bool) $network as the last argument of each do_action( 'wp_secret_changed', ... ) call,
and run the conformance suite. Both example providers show the change.

Examples

  • The one translated string in the examples uses the secrets-api text domain. The examples are
    not part of the core patch, so they do not share core's.

Changelog: https://secretsapi.dev/changelog/0-2-3/

v0.2.2 — PHPStan level 10, and the secrets/ subfolder

Choose a tag to compare

@ericmann ericmann released this 07 Oct 19:37
v0.2.2
8c8a86c

Released 2026-10-07. Both changes come from review on the core patch,
#66187. The public API has the same signatures and
the same behavior as 0.2.1, and the core patch carries the same src/.

Changed

  • Everything passes PHPStan at level 10. A reviewer ran the core patch through PHPStan at its
    strictest level and reported 85 findings in src/ and 400 in the tests. src/, plugin/, cli/,
    and the plugin bootstrap are now clean at level 10, and make analyse runs there instead of at
    level 5. Most of the fixes are docblocks that now say what an array holds. A few are code:
    • A decrypt result is checked with is_string() rather than against false, so a result that is
      neither is treated as a failure.
    • The request's cached root key is only served once it is known to be set.
    • Demoting a slot keeps created only when it is an integer, and stores needs_rotation as a
      boolean.
    • A stored option name that is not a string is skipped when listing.
  • The tests are typed too. They are checked at level 10 inside wordpress-develop, where the
    WordPress test classes are available. A test that passes a wrong type on purpose, to prove the
    runtime check behind it, says so on that line. The error-code test is now one case per constant,
    which is why the suite reports six more tests.
  • The 13 class and interface files live in src/wp-includes/secrets/, following abilities-api/
    and ai-client/ in core. The functions file stays at src/wp-includes/secrets.php. Nothing was
    renamed, and a drop-in is unaffected.

Fixed

  • wp secret set --stdin stored an empty secret when STDIN could not be read. It now stops with
    an error.
  • A WP-CLI option given without its value, such as a bare --format, was passed along as
    true. It is now treated as not given.

Documentation

  • New pages for the platform examples and for host-managed keys.
  • The CI reference describes the static analysis setup.

Changelog: https://secretsapi.dev/changelog/0-2-2/

v0.2.1 — tests run clean on PHP 8.5

Choose a tag to compare

@ericmann ericmann released this 25 Sep 22:02
v0.2.1
8e38838

Released 2026-09-25. A test-only release: nothing under src/, plugin/, or cli/ changed, and the
API behaves exactly as in 0.2.0.

Fixed

  • The tests failed on PHP 8.5. PHP 8.5 deprecates ReflectionMethod::setAccessible() and
    ReflectionProperty::setAccessible(), which have done nothing since PHP 8.1. The test suite
    treats deprecations as errors, so the handful of tests that reach a private member through
    reflection errored. Those calls now run only when PHP_VERSION_ID < 80100, the same guard core's
    own tests use. The core patch on #66187 carries
    the same fix. Its PHP 8.5 CI jobs are what found this.

Changed

  • CI adds PHP 8.5 to the test matrix and to the WP-CLI smoke test, so the plugin's CI tops out
    where core's does. See the CI reference.

Changelog: https://secretsapi.dev/changelog/0-2-1/

v0.2.0 — KMS and Vault examples, CLI smoke test, multisite root-key fix

Choose a tag to compare

@ericmann ericmann released this 25 Sep 18:51
v0.2.0
8e4d627

Released 2026-09-25. This is the last release before the Trac patch
(ADR 0008). The public API
has the same signatures as 0.1.0. Two changes under src/ go into the patch: the root-key cache
and the multisite fix below.

Added

  • examples/aws-kms-keyring/, the first real WP_Secrets_Keyring. It is a single-file SigV4
    drop-in that moves custody of the root key to AWS KMS. Verified against live KMS.

  • examples/vault-provider/, a HashiCorp Vault KV v2 provider. It caps each secret at two
    versions, and PREVIOUS is strictly the version before the current one, so retiring a version
    never brings back an older one
    (ADR 0010). Verified against
    Vault 2.1.1, including a sealed server, and against OpenBao 2.7.0.

  • wp secret rotate --from=config-previous|config re-wraps the root key under the active
    keyring. --from=config is how an existing site moves onto a KMS keyring.

  • WP_Secrets_Keyring_Conformance, the keyring counterpart of the provider conformance suite.

  • make test-examples and an examples CI job. They run the three examples against Moto and
    a Vault dev server, on single site and multisite, including an automated conformance run for
    the AWS Secrets Manager example.

  • make smoke, a WP-CLI smoke test that drives a real, pinned wp binary against a
    throwaway install. It checks:

    • every subcommand and flag
    • the 0/1/2 exit-code contract
    • site-key rotation from end to end
    • drop-in loading through the real loader
    • a pass after converting to multisite

    It is part of make ci, and it runs in a smoke CI job on PHP 7.4 and 8.3.

Changed

  • WP_Secrets_Key_Manager keeps the unwrapped root key for the rest of the request, tied to the
    stored wrapped value. With a remote keyring, that means one call per request instead of one per
    secret. A rotation or re-wrap replaces the cached key, and an unwrap error is never cached
    (ADR 0009).
  • The WP_Secrets_Keyring::wrap() docblock now requires wrap() to be non-deterministic.
    rotate_site_key() already relied on it.
  • Breaking for anyone using the AWS Secrets Manager example: site-scope secrets now live at
    wp/site/<blog_id>/<name> instead of wp/<name>. See the example's README for the upgrade.

Fixed

  • Converting a single site to multisite made every secret unreadable. The wrapped root key
    stayed in wp_options while reads moved to wp_sitemeta, so the first read after conversion
    generated a new root key. The key manager now moves the key across instead.
  • The AWS Secrets Manager example shared one AWS secret across every site on a network.
  • The AWS Secrets Manager example reported has_previous as false for every secret, and for a
    minute or two after a delete it reported the secret as unreachable instead of absent.
  • make smoke failed on a host without wp-env, because WP-CLI ran out of memory at PHP's default
    128M limit.
  • The recorded cause of the old --version=previous bug was wrong. It was wp-env's argument
    parser, not WP-CLI.

Security

  • The docs site's sharp dependency was raised to 0.35.4, for libvips and libheif advisories.
    The plugin itself was not affected.

Documentation

Full documentation: https://secrets-api-docs.view.fast/ · Changelog: https://secrets-api-docs.view.fast/changelog/0-2-0/

v0.1.0 — Secrets API feature plugin

Choose a tag to compare

@ericmann ericmann released this 04 Sep 20:04
v0.1.0
f4bd725

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() and is_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.