diff --git a/README.md b/README.md
index 2346b91..515a2d0 100644
--- a/README.md
+++ b/README.md
@@ -131,9 +131,9 @@ and what does not flex.
## Extending
-Three interfaces, outermost first:
+Three abstract classes, outermost first:
-| Interface | Replaces | Reach for it when |
+| Class | Replaces | Reach for it when |
|---|---|---|
| `WP_Secrets_Provider` | Everything — the platform is the system of record | A control panel, Secrets Manager, or an HSM holds the credential |
| `WP_Secrets_Keyring` | How the root key is wrapped | A KMS holds your keys, but secrets stay in WordPress |
@@ -151,7 +151,7 @@ never become this project's. Read its README before writing one: a key-managemen
KMS, Google Cloud KMS) is a `WP_Secrets_Keyring` and takes three methods, while a secret store
(Secrets Manager, Parameter Store) is a `WP_Secrets_Provider` and takes eight. People routinely
pick the wrong one and pay for it in per-operation API calls. Start from
-[`examples/aws-kms-keyring/`](examples/aws-kms-keyring/) — it is the smaller interface, and it is
+[`examples/aws-kms-keyring/`](examples/aws-kms-keyring/) — it is the smaller class, and it is
what most hosts are actually after: key custody moves to the KMS and nothing else changes. Two
`WP_Secrets_Provider` examples ship alongside it, AWS Secrets Manager and HashiCorp Vault KV v2.
`make test-examples` runs all three against Moto and a Vault dev server.
diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh
index 5bb68d6..e2d11ea 100755
--- a/bin/install-wp-tests.sh
+++ b/bin/install-wp-tests.sh
@@ -97,9 +97,29 @@ install_wp() {
mkdir -p "$WP_CORE_DIR"
+ # trunk is core's src/ from the same wordpress-develop tarball as the test suite,
+ # as core runs its own tests. The nightly zip is built about once a day, so a
+ # test suite from trunk can be ahead of it: on 2026-11-10 the suite's mocks
+ # extended classes the nightly still declared as interfaces, and every test
+ # died in the bootstrap. nightly still means the nightly zip, which is what
+ # a site running the nightly actually has.
+ if [ -z "${WP_TESTS_ZIP_URL-}" ] && [[ $WP_VERSION == 'trunk' ]]; then
+ echo "Fetching WordPress from ${DEVELOP_BASE}/archive/refs/heads/trunk.tar.gz (src/)"
+
+ rm -rf "$TMPDIR/wp-develop-core"
+ mkdir -p "$TMPDIR/wp-develop-core"
+ download "${DEVELOP_BASE}/archive/refs/heads/trunk.tar.gz" "$TMPDIR/wp-develop-core.tar.gz"
+ tar --strip-components=1 -zxmf "$TMPDIR/wp-develop-core.tar.gz" -C "$TMPDIR/wp-develop-core"
+ cp -R "$TMPDIR/wp-develop-core/src/." "$WP_CORE_DIR/"
+ rm -rf "$TMPDIR/wp-develop-core" "$TMPDIR/wp-develop-core.tar.gz"
+
+ download "${MIRROR_BASE}/wp-content/db.php" "$WP_CORE_DIR/wp-content/db.php" 2>/dev/null || true
+ return
+ fi
+
if [ -n "${WP_TESTS_ZIP_URL-}" ]; then
ARCHIVE_URL="$WP_TESTS_ZIP_URL"
- elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
+ elif [[ $WP_VERSION == 'nightly' ]]; then
ARCHIVE_URL="${MIRROR_BASE}/nightly-builds/wordpress-latest.zip"
elif [[ $WP_VERSION == 'latest' ]]; then
ARCHIVE_URL="${MIRROR_BASE}/latest.tar.gz"
diff --git a/docs/decisions/0017-abstract-classes-for-the-extension-points.md b/docs/decisions/0017-abstract-classes-for-the-extension-points.md
index 464a295..b0adfee 100644
--- a/docs/decisions/0017-abstract-classes-for-the-extension-points.md
+++ b/docs/decisions/0017-abstract-classes-for-the-extension-points.md
@@ -48,6 +48,11 @@ and the class docblocks say so.
- A drop-in changes `implements` to `extends`. Nothing else in the contract changes. 7.2 has not
shipped, so the only drop-ins affected are those written against the beta or this plugin.
+- A drop-in that still uses `implements` is a fatal error on every request, WP-CLI included. It
+ does not fail closed. PHP reports it while compiling the drop-in, before the `try`/`catch`
+ around the `require` can run. Catching it would mean reading the drop-in's source before
+ requiring it, on every request, which isn't worth it for drop-ins written against a beta. The
+ 0.5.0 changelog and the 7.2 beta 5 announcement call it out instead.
- A drop-in class can no longer extend another base class, such as an SDK client. It can still hold
one and delegate to it, as the examples already do.
- The conformance suites, `WP_Secrets_Provider_Conformance` and `WP_Secrets_Keyring_Conformance`,
diff --git a/docs/index.md b/docs/index.md
index 3c43fb0..17168e2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -55,7 +55,7 @@ directory holds everything longer than that.
- [`examples.md`](reference/examples.md) — the AWS KMS, AWS Secrets Manager, and Vault drop-ins, and which to start from.
- [`host-managed-keys.md`](reference/host-managed-keys.md) — a host owning the site key: environment variable, host keyring, or host provider.
- [`functions.md`](reference/functions.md) — every function, generated from docblocks.
-- [`classes.md`](reference/classes.md) — every class and interface with public constants and methods, generated.
+- [`classes.md`](reference/classes.md) — every class with public constants and methods, generated.
- [`hooks.md`](reference/hooks.md) — every action and filter, generated.
- [`wp-cli.md`](reference/wp-cli.md) — every `wp secret` and `wp network-secret` subcommand, generated.
diff --git a/docs/reference/ci.md b/docs/reference/ci.md
index 246b135..5f011cb 100644
--- a/docs/reference/ci.md
+++ b/docs/reference/ci.md
@@ -132,7 +132,7 @@ person pasted.
|---|---|---|---|
| `static` | 8.3 | — | lint + compat + analyse. Gates everything else. |
| `test` | 7.4, 8.0, 8.3, 8.5; 7.4 | 7.1.x; 6.8 | Single site |
-| `stand-down` | 7.4, 8.5 | trunk | `make test-stand-down`: core serves the API, and the plugin loads none of its own layer |
+| `stand-down` | 7.4, 8.5 | trunk | `make test-stand-down`: core serves the API, and the plugin loads none of its own layer. Core is wordpress-develop trunk's `src/`, from the same tarball as the test suite, not the nightly zip, which can lag a day behind it. |
| `test-multisite` | 8.3 | 7.1.x | Multisite suite |
| `smoke` | 7.4, 8.3, 8.5; 8.3 | 7.1.x; nightly | WP-CLI end to end, single site then multisite. The nightly leg runs the commands on core's implementation. |
| `examples` | 8.3 | 7.1.x | `make test-examples` against Moto (an AWS emulator) and a Vault dev server, both pinned by digest, single site then multisite. Not part of `make ci`. |
diff --git a/docs/reference/classes.md b/docs/reference/classes.md
index ade3077..1f807df 100644
--- a/docs/reference/classes.md
+++ b/docs/reference/classes.md
@@ -151,7 +151,7 @@ public function migrate( array $args = array() ): array
## `Secrets_API_Prototype_Fallback_Store`
-*final class* · implements `WP_Secrets_Store`
+*final class* · extends `WP_Secrets_Store`
Serves a read whose new-format record does not exist yet from the prototype's option row instead, upgrading it to the current format on the way through.
@@ -568,7 +568,7 @@ and this class ships in core, whose minimum supported PHP version is 7.4.
## `WP_Secrets_Broken_Keyring`
-*final class* · implements `WP_Secrets_Keyring`
+*final class* · extends `WP_Secrets_Keyring`
Stands in for the keyring when a drop-in leaves an invalid value behind.
@@ -619,7 +619,7 @@ public function unwrap( string $wrapped ): WP_Error
Always fails closed.
```php
-public function wrap( string $key_material ): WP_Error
+public function wrap( #[\SensitiveParameter] string $key_material ): WP_Error
```
| Parameter | Type | Description |
@@ -632,7 +632,7 @@ public function wrap( string $key_material ): WP_Error
## `WP_Secrets_Broken_Provider`
-*final class* · implements `WP_Secrets_Provider`
+*final class* · extends `WP_Secrets_Provider`
The provider installed when a secrets.php drop-in did not load correctly.
@@ -765,7 +765,7 @@ public function retire_previous( string $name, bool $network = false ): WP_Error
Always an error.
```php
-public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error
+public function set( string $name, #[\SensitiveParameter] $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error
```
| Parameter | Type | Description |
@@ -782,7 +782,7 @@ public function set( string $name, $value, bool $network = false, bool $needs_ro
## `WP_Secrets_Broken_Store`
-*final class* · implements `WP_Secrets_Store`
+*final class* · extends `WP_Secrets_Store`
Stands in for the store when a drop-in leaves an invalid value behind.
@@ -904,7 +904,7 @@ slot's worth of material, encrypt or decrypt it correctly.
Decrypts a single record slot back to its plaintext.
```php
-public function decrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $record )
+public function decrypt_value( #[\SensitiveParameter] string $master_key, string $scope, int $site_id, string $name, string $slot, $record )
```
| Parameter | Type | Description |
@@ -925,7 +925,7 @@ public function decrypt_value( string $master_key, string $scope, int $site_id,
Encrypts a plaintext into a single record slot.
```php
-public function encrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $plaintext )
+public function encrypt_value( #[\SensitiveParameter] string $master_key, string $scope, int $site_id, string $name, string $slot, #[\SensitiveParameter] $plaintext )
```
| Parameter | Type | Description |
@@ -953,7 +953,7 @@ decrypted plaintext and compare – never trust a fingerprint read back from a
record, which sits outside the AAD and is not authenticated.
```php
-public function fingerprint( string $master_key, $plaintext )
+public function fingerprint( #[\SensitiveParameter] string $master_key, #[\SensitiveParameter] $plaintext )
```
| Parameter | Type | Description |
@@ -967,28 +967,34 @@ public function fingerprint( string $master_key, $plaintext )
## `WP_Secrets_Config_Key_Provider`
-*final class* · implements `WP_Secrets_Keyring`
+*final class* · extends `WP_Secrets_Keyring`
The default keyring.
Derives a site key from wp-config.php and uses it to wrap and unwrap the root key
with authenticated encryption.
-Site key derivation, in priority order:
-
-1. WP_SECRETS_KEY is defined and base64-decodes (strictly) to exactly 32 bytes:
- those decoded bytes are used raw. This is the documented, recommended form;
- `wp secret generate-key` emits it.
-2. WP_SECRETS_KEY is defined in any other shape: the literal constant string is
- hashed with a keyed BLAKE2b to 32 bytes. This is the legacy interpretation –
- sites arriving from a prior plugin with a constant of arbitrary shape are not
- locked out of their own credentials by a hard failure here.
-3. WP_SECRETS_KEY is undefined: LOGGED_IN_KEY . LOGGED_IN_SALT is hashed the same
- way. Deliberately byte-identical to the legacy interpretation's hashing, not a
- coincidence – it is what makes salt-fallback sites migrate with zero
- credential re-entry.
-
-WP_SECRETS_KEY_PREVIOUS follows the same three rules and exists only so a site-key
+Site key derivation:
+
+1. WP_SECRETS_KEY base64-decodes (strictly) to exactly 32 bytes: those decoded
+ bytes are used raw. This is the recommended form, such as the output of
+ base64_encode( random_bytes( 32 ) ).
+2. WP_SECRETS_KEY is any other string: the literal constant string is hashed
+ with BLAKE2b to 32 bytes. This is the legacy interpretation – sites arriving
+ from a prior plugin with a constant of arbitrary shape are not locked out of
+ their own credentials by a hard failure here.
+
+There is no fallback. Without a usable WP_SECRETS_KEY, every Secrets API call
+that needs the key returns WP_Error. Deriving a key from the authentication keys
+and salts would make every stored secret unreadable whenever the salts are
+rotated, which is routine advice for them.
+
+The wp-config-sample.php placeholder is not usable, and neither is a value that
+one of the authentication keys and salts also has. A sample that was never
+filled in gives every constant the same phrase, translated in localized samples,
+so the second check rejects the placeholder in any language.
+
+WP_SECRETS_KEY_PREVIOUS follows the same rules and exists only so a site-key
rotation can unwrap under the old key before wrapping under the new one.
**Since:** 7.2.0
@@ -1000,7 +1006,12 @@ rotation can unwrap under the old key before wrapping under the new one.
| Constant | Value | Description |
|---|---|---|
| `AAD` | `'wp-secrets-root-key-v1'` | AAD binding the root key's wrapped form to this purpose. |
-| `KNOWN_PLACEHOLDER` | `'put your unique phrase here'` | The exact text wp-config-sample.php ships for every auth and salt constant. |
+| `AUTH_CONSTANTS` | `array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT', )` | The authentication keys and salts that wp-config-sample.php defines. |
+| `KEY_STATUS_BASE64` | `'base64'` | WP_SECRETS_KEY is the base64 encoding of 32 bytes, the recommended form. |
+| `KEY_STATUS_LEGACY` | `'legacy'` | WP_SECRETS_KEY is usable but in another form, so it is hashed to 32 bytes. |
+| `KEY_STATUS_UNDEFINED` | `'undefined'` | WP_SECRETS_KEY is not defined. |
+| `KEY_STATUS_UNUSABLE` | `'unusable'` | WP_SECRETS_KEY is defined, but empty, the placeholder, or equal to a key or salt. |
+| `KNOWN_PLACEHOLDER` | `'put your unique phrase here'` | The exact text wp-config-sample.php ships for every key and salt constant. |
### Methods
@@ -1030,6 +1041,21 @@ public function get_key_source(): string
**Since:** 7.2.0
+#### `WP_Secrets_Config_Key_Provider::get_key_status()`
+
+Reports the state of WP_SECRETS_KEY.
+
+Site Health and wp_is_secrets_api_available() use this rather than the text of
+get_key_source(), which is translated.
+
+```php
+public function get_key_status(): string
+```
+
+**Returns:** `string` One of the KEY_STATUS_* constants.
+
+**Since:** 7.2.0
+
#### `WP_Secrets_Config_Key_Provider::unwrap()`
Unwraps key material previously wrapped by wrap().
@@ -1051,7 +1077,7 @@ public function unwrap( string $wrapped )
Wraps raw key material under the derived site key.
```php
-public function wrap( string $key_material )
+public function wrap( #[\SensitiveParameter] string $key_material )
```
| Parameter | Type | Description |
@@ -1141,13 +1167,14 @@ public function get_keyring(): WP_Secrets_Keyring
Derives a scope's master key from the root key.
```php
-public function get_master_key( string $scope, ?int $site_id = null )
+public function get_master_key( string $scope, ?int $site_id = null, bool $create_root_key = true )
```
| Parameter | Type | Description |
|---|---|---|
| `$scope` | `string` | 'site' or 'network'. |
| `$site_id` | `int\|null` | Blog ID for site scope. Defaults to the current blog. Ignored for network scope. |
+| `$create_root_key` | `bool` | Optional. Whether to generate and store a root key if none exists. Pass false when only reading secrets. Default true. |
**Returns:** `string|WP_Error` 32-byte master key on success. WP_Error on failure, including when a caller passes an invalid scope or site ID – see WP_Secrets_Cipher::validate_common() for why that is a WP_Error and not an exception.
@@ -1157,10 +1184,18 @@ public function get_master_key( string $scope, ?int $site_id = null )
Returns the root key, generating and persisting one on first use.
+get_site_option() also returns false when its query fails, so a false result is
+checked against the database before a new root key is generated. Generating one
+while another is stored would make every existing secret undecryptable.
+
```php
-public function get_root_key()
+public function get_root_key( bool $create = true )
```
+| Parameter | Type | Description |
+|---|---|---|
+| `$create` | `bool` | Optional. Whether to generate and store a root key if none exists. Default true. |
+
**Returns:** `string|WP_Error` 32 raw bytes on success. WP_Error on failure.
**Since:** 7.2.0
@@ -1188,11 +1223,11 @@ public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Key
## `WP_Secrets_Keyring`
-*interface*
+*abstract class*
Wraps and unwraps the root key that everything else in this API derives from.
-A KMS or HSM lives behind this interface in a production deployment. An
+A KMS or HSM lives behind this class in a production deployment. An
implementation is never handed a plaintext secret, only 32 bytes of key material,
and cannot turn encryption off. There is no method here that
accepts a plaintext secret value at all.
@@ -1200,9 +1235,13 @@ accepts a plaintext secret value at all.
An implementation should run WP_Secrets_Keyring_Conformance against itself before
shipping.
+Implementations extend this class. It is abstract rather than an interface so
+that a later release can add a method with a default implementation, and every
+existing subclass keeps working.
+
**Since:** 7.2.0
-**Source:** [`src/wp-includes/secrets/interface-wp-secrets-keyring.php`](../../src/wp-includes/secrets/interface-wp-secrets-keyring.php)
+**Source:** [`src/wp-includes/secrets/class-wp-secrets-keyring.php`](../../src/wp-includes/secrets/class-wp-secrets-keyring.php)
### Methods
@@ -1211,7 +1250,7 @@ shipping.
A human-readable description of the key source, for Site Health.
```php
-public function get_key_source(): string
+abstract public function get_key_source(): string
```
**Returns:** `string`
@@ -1223,7 +1262,7 @@ public function get_key_source(): string
Unwraps (decrypts) previously wrapped key material.
```php
-public function unwrap( string $wrapped )
+abstract public function unwrap( string $wrapped )
```
| Parameter | Type | Description |
@@ -1243,8 +1282,11 @@ different values. WP_Secrets_Key_Manager::rotate_site_key() stores the
re-wrapped value with update_site_option(), which reports an unchanged value
as a failure, and WP_Secrets_Keyring_Conformance checks this.
+Attributes are not inherited, so an implementation must mark $key_material
+with #[\SensitiveParameter] itself to keep it out of stack traces.
+
```php
-public function wrap( string $key_material )
+abstract public function wrap( #[\SensitiveParameter] string $key_material )
```
| Parameter | Type | Description |
@@ -1257,14 +1299,14 @@ public function wrap( string $key_material )
## `WP_Secrets_Libsodium_Provider`
-*final class* · implements `WP_Secrets_Provider`
+*final class* · extends `WP_Secrets_Provider`
The provider WordPress ships.
Uses libsodium envelope encryption and stores ciphertext in the options tables.
This is the default rather than a privileged case. Everything it does is expressed
-through WP_Secrets_Provider, the same interface a platform implements, so a KMS-
+through WP_Secrets_Provider, the same class a platform extends, so a KMS-
or HSM-backed provider is a peer rather than an exception carved into the side of
this one.
@@ -1434,7 +1476,7 @@ Backs wp_set_secret(), wp_set_network_secret(), and wp_import_option_as_secret()
which select their behavior through the last two parameters.
```php
-public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null )
+public function set( string $name, #[\SensitiveParameter] $value, bool $network = false, bool $needs_rotation = false, ?string $action = null )
```
| Parameter | Type | Description |
@@ -1451,7 +1493,7 @@ public function set( string $name, $value, bool $network = false, bool $needs_ro
## `WP_Secrets_Option_Store`
-*final class* · implements `WP_Secrets_Store`
+*final class* · extends `WP_Secrets_Store`
The default secret store.
@@ -1547,22 +1589,22 @@ public function set( string $name, array $record, bool $network = false )
## `WP_Secrets_Provider`
-*interface*
+*abstract class*
-Interface for a secrets provider.
+Base class for a secrets provider.
A provider is responsible for a secret: storing it, protecting it, and returning
it to a caller. It is the outermost extension point in the Secrets API, and the
-one a hosting platform implements.
+one a hosting platform extends.
WordPress ships WP_Secrets_Libsodium_Provider, which encrypts with libsodium and
stores ciphertext in the options tables. That provider is the default rather than
a privileged case: a platform that protects credentials in a key management
-service, a hardware security module, or its own control panel implements this
-same interface.
+service, a hardware security module, or its own control panel extends this
+same class.
A provider must be stronger than the default, never weaker. Storing a plaintext
-where the default would have stored ciphertext is what this interface exists to
+where the default would have stored ciphertext is what this class exists to
prevent. Receiving a value over an authenticated channel and holding it in a
hardware security module satisfies that requirement.
@@ -1590,9 +1632,13 @@ Every implementation shares these contracts:
caching the raw value alongside it would undo that. Memoize within the request
only.
+Implementations extend this class. It is abstract rather than an interface so
+that a later release can add a method with a default implementation, and every
+existing subclass keeps working.
+
**Since:** 7.2.0
-**Source:** [`src/wp-includes/secrets/interface-wp-secrets-provider.php`](../../src/wp-includes/secrets/interface-wp-secrets-provider.php)
+**Source:** [`src/wp-includes/secrets/class-wp-secrets-provider.php`](../../src/wp-includes/secrets/class-wp-secrets-provider.php)
### Constants
@@ -1614,7 +1660,7 @@ deleted, for the reason given on set(). A provider that can tell nothing was
there to delete fires nothing.
```php
-public function delete( string $name, bool $network = false )
+abstract public function delete( string $name, bool $network = false )
```
| Parameter | Type | Description |
@@ -1636,7 +1682,7 @@ view, and keeping them distinct would require a provider to enumerate names
it has never heard of.
```php
-public function get( string $name, string $version, bool $network = false )
+abstract public function get( string $name, string $version, bool $network = false )
```
| Parameter | Type | Description |
@@ -1658,7 +1704,7 @@ Describes the protection, not the vendor's marketing: "AWS KMS
Never key material, never a credential, never a value.
```php
-public function get_label(): string
+abstract public function get_label(): string
```
**Returns:** `string`
@@ -1676,7 +1722,7 @@ they currently have no way to answer, and the one hosts asked to be able to
answer honestly.
```php
-public function get_protection_boundary(): string
+abstract public function get_protection_boundary(): string
```
**Returns:** `string`
@@ -1693,7 +1739,7 @@ disable its save control before an operator types a credential into a field
that will only reject it.
```php
-public function is_writable(): bool
+abstract public function is_writable(): bool
```
**Returns:** `bool`
@@ -1705,7 +1751,7 @@ public function is_writable(): bool
Lists secrets by name and metadata. Never values.
```php
-public function list_secrets( string $name_prefix = '', bool $network = false )
+abstract public function list_secrets( string $name_prefix = '', bool $network = false )
```
| Parameter | Type | Description |
@@ -1729,7 +1775,7 @@ has been cleared, for the reason given on set(). Nothing fires when there was
no previous version to clear.
```php
-public function retire_previous( string $name, bool $network = false )
+abstract public function retire_previous( string $name, bool $network = false )
```
| Parameter | Type | Description |
@@ -1768,7 +1814,7 @@ but a provider that cannot tell the value is unchanged without an additional
read may write it and report it.
```php
-public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null )
+abstract public function set( string $name, #[\SensitiveParameter] $value, bool $network = false, bool $needs_rotation = false, ?string $action = null )
```
| Parameter | Type | Description |
@@ -1785,7 +1831,7 @@ public function set( string $name, $value, bool $network = false, bool $needs_ro
## `WP_Secrets_Store`
-*interface*
+*abstract class*
Where a secret's encrypted record lives.
@@ -1799,9 +1845,13 @@ by a separate CLI or dashboard, returns WP_Error from set() rather than pretendi
the write succeeded. Providers declare writability up front through
WP_Secrets_Provider::is_writable().
+Implementations extend this class. It is abstract rather than an interface so
+that a later release can add a method with a default implementation, and every
+existing subclass keeps working.
+
**Since:** 7.2.0
-**Source:** [`src/wp-includes/secrets/interface-wp-secrets-store.php`](../../src/wp-includes/secrets/interface-wp-secrets-store.php)
+**Source:** [`src/wp-includes/secrets/class-wp-secrets-store.php`](../../src/wp-includes/secrets/class-wp-secrets-store.php)
### Methods
@@ -1810,7 +1860,7 @@ WP_Secrets_Provider::is_writable().
Deletes a secret's record.
```php
-public function delete( string $name, bool $network = false )
+abstract public function delete( string $name, bool $network = false )
```
| Parameter | Type | Description |
@@ -1827,7 +1877,7 @@ public function delete( string $name, bool $network = false )
Reads a secret's stored record.
```php
-public function get( string $name, bool $network = false )
+abstract public function get( string $name, bool $network = false )
```
| Parameter | Type | Description |
@@ -1844,7 +1894,7 @@ public function get( string $name, bool $network = false )
Lists the names of every secret in this store, for this scope. Never a value.
```php
-public function list_names( bool $network = false )
+abstract public function list_names( bool $network = false )
```
| Parameter | Type | Description |
@@ -1860,7 +1910,7 @@ public function list_names( bool $network = false )
Writes a secret's record.
```php
-public function set( string $name, array $record, bool $network = false )
+abstract public function set( string $name, array $record, bool $network = false )
```
| Parameter | Type | Description |
diff --git a/docs/reference/drop-in-example.php b/docs/reference/drop-in-example.php
index e45d47d..3cb2bbe 100644
--- a/docs/reference/drop-in-example.php
+++ b/docs/reference/drop-in-example.php
@@ -31,7 +31,8 @@
* Before trusting a drop-in in production, run `php -l` over it and load a real
* request. A drop-in that throws or sets a global to the wrong type fails closed
* (every operation returns WP_Error rather than falling back to the default), but a
- * class that implements an interface and omits one of its methods is an
+ * class that extends one of these and omits one of its abstract methods, or
+ * that still uses `implements` on one, is an
* uncatchable PHP fatal that no try/catch can contain.
*
* @package SecretsAPI
@@ -44,9 +45,9 @@
* in its own control panel or key service, and served to WordPress over an
* authenticated channel. Under the proposal's original two extension points that
* deployment was banned, because the store would have been handed something
- * WordPress did not encrypt. The provider interface is the resolution: a provider
+ * WordPress did not encrypt. The provider class is the resolution: a provider
* must be stronger than the default, never weaker, and a platform that meets that
- * bar implements this interface instead of pretending to be a store. See
+ * bar extends this class instead of pretending to be a store. See
* docs/decisions/0001-provider-as-outermost-extension-point.md.
*
* Every method below is a one-line stub. The comments say what a real
@@ -55,7 +56,7 @@
* platform protects it (no wp_cache_set() of a raw value; memoize per request
* only).
*/
-final class Example_Platform_Provider implements WP_Secrets_Provider {
+final class Example_Platform_Provider extends WP_Secrets_Provider {
/**
* Retrieves a secret from the platform. Stubbed to always report absence.
@@ -110,7 +111,14 @@ public function get( string $name, string $version, bool $network = false ) {
*
* @return true|WP_Error
*/
- public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) {
+ public function set(
+ string $name,
+ #[\SensitiveParameter]
+ $value,
+ bool $network = false,
+ bool $needs_rotation = false,
+ ?string $action = null
+ ) {
return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, 'This platform manages credentials outside of WordPress.' );
}
@@ -195,7 +203,7 @@ public function is_writable(): bool {
* wp_set_secret() surfaces the WP_Error rather than silently no-opping or, worse,
* accepting a write it cannot honour.
*/
-final class Example_Platform_Store implements WP_Secrets_Store {
+final class Example_Platform_Store extends WP_Secrets_Store {
/**
* Reads a record from the platform. Stubbed to always report absence.
@@ -281,7 +289,7 @@ public function list_names( bool $network = false ) {
* anything else: every master key, data key, and secret still derives the same
* way, from whatever wrap()/unwrap() hand back.
*/
-final class Example_KMS_Keyring implements WP_Secrets_Keyring {
+final class Example_KMS_Keyring extends WP_Secrets_Keyring {
/**
* Wraps root key material via the platform's KMS. Stubbed to always fail.
@@ -290,7 +298,10 @@ final class Example_KMS_Keyring implements WP_Secrets_Keyring {
*
* @return string|WP_Error Opaque wrapped value on success.
*/
- public function wrap( string $key_material ) {
+ public function wrap(
+ #[\SensitiveParameter]
+ string $key_material
+ ) {
/*
* Replace with a real KMS encrypt call, e.g.:
*
@@ -333,7 +344,7 @@ public function get_key_source(): string {
* The globals a drop-in sets. wp_secrets_api_load_dropin() (or, once this lands
* in core, the equivalent bootstrap code in wp-settings.php) checks the type of
* whatever ends up in each of them immediately after this file is required.
- * Anything other than an instance of the matching interface, including a variable
+ * Anything other than an instance of the matching class, including a variable
* left set to null, a string, or a half-constructed object from a caught error,
* fails the whole API closed for the rest of the request through
* WP_Secrets_Broken_Provider / WP_Secrets_Broken_Store / WP_Secrets_Broken_Keyring.
diff --git a/docs/reference/functions.md b/docs/reference/functions.md
index bf2534f..1cd3d87 100644
--- a/docs/reference/functions.md
+++ b/docs/reference/functions.md
@@ -121,6 +121,41 @@ function wp_import_option_as_secret( string $option, string $name )
**Source:** [`src/wp-includes/secrets.php`](../../src/wp-includes/secrets.php)
+## `wp_is_secrets_api_available()`
+
+Whether the Secrets API can store and read secrets on this site.
+
+A plugin can check this before it moves a credential into the Secrets API, and
+keep its existing storage while it returns false:
+
+ if ( wp_is_secrets_api_available() ) {
+ // Move the credential, then use wp_set_secret() and wp_get_secret().
+ } else {
+ // Keep using the existing storage.
+ }
+
+Returns false when the secrets.php drop-in failed to load, when the default
+keyring has no usable WP_SECRETS_KEY, or when WP_SECRETS_KEY cannot unwrap the
+stored root key. The last happens when a site that already stored secrets adds
+or changes the key without re-wrapping the root key.
+
+A provider or keyring from a working drop-in manages its own keys, so it is
+reported as available. Its backend can still be unreachable on a given request,
+which each call reports as a WP_Error.
+
+Whether the provider accepts writes is a separate question. See
+wp_secrets_provider_is_writable().
+
+```php
+function wp_is_secrets_api_available(): bool
+```
+
+**Returns:** `bool`
+
+**Since:** 7.2.0
+
+**Source:** [`src/wp-includes/secrets.php`](../../src/wp-includes/secrets.php)
+
## `wp_list_network_secrets()`
Lists network-scope secrets by name and metadata. Never a value.
@@ -259,6 +294,10 @@ since _wp_secrets_get_provider(), _wp_secrets_get_store(), and
_wp_secrets_get_key_manager() each cache their result for the request, so this
only ever needs to run once regardless of how many times it is called.
+The drop-in sets its overrides in the `$wp_secrets_provider`, `$wp_secrets_store`
+and `$wp_secrets_keyring` globals. They are declared global before the require, so
+a plain assignment such as `$wp_secrets_keyring = new My_Keyring();` works.
+
Sets $GLOBALS['wp_secrets_dropin_loaded'] as soon as the file is found, whether
or not it then loads cleanly: wp_using_secrets_dropin() reports presence, not
health.
@@ -267,7 +306,7 @@ Sets $GLOBALS['wp_secrets_dropin_broken'] in two cases. First, when the require
throws: a syntax error, a thrown exception, or most runtime errors are caught as
\Throwable. Second, when any of $GLOBALS['wp_secrets_provider'],
$GLOBALS['wp_secrets_store'], or $GLOBALS['wp_secrets_keyring'] is set afterward
-to something that is not an instance of its interface. A global left unset is
+to something that is not an instance of its class. A global left unset is
fine; a drop-in overriding only the keyring legitimately leaves the other two
alone. Once the flag is set, the getters install WP_Secrets_Broken_Provider,
WP_Secrets_Broken_Store, and WP_Secrets_Broken_Keyring for the rest of the
@@ -275,10 +314,11 @@ request, so every operation returns WP_Error rather than falling back to the
default. A malformed drop-in must not turn into a white screen for the rest of
the site, and must not look like a working site with no secrets in it yet.
-This is not airtight: PHP treats some class declaration errors – notably a class
-that `implements` an interface but omits a required method – as an uncatchable
+This is not airtight: PHP treats some class declaration errors as an uncatchable
fatal even inside a try/catch around the require, confirmed empirically on both
-PHP 7.4 and 8.5 before writing this comment. That gap is unavoidable from
+PHP 7.4 and 8.5 before writing this comment. Notably, a class that `extends` one
+of the three abstract classes but omits one of its abstract methods, or a class
+that still uses `implements` on one, as drop-ins written for interfaces did. That gap is unavoidable from
userland and is recorded in docs/journal/test-coverage-gaps.md rather than
silently assumed away.
@@ -412,14 +452,14 @@ function wp_secrets_provider_label(): string
Counts secrets flagged needs_rotation, for one scope.
```php
-function wp_secrets_site_health_count_needing_rotation( bool $network ): int
+function wp_secrets_site_health_count_needing_rotation( bool $network )
```
| Parameter | Type | Description |
|---|---|---|
| `$network` | `bool` | Whether to check network-scope secrets. |
-**Returns:** `int`
+**Returns:** `int|WP_Error` The number of secrets, or WP_Error when the secrets cannot be listed.
**Since:** 7.2.0
@@ -451,14 +491,14 @@ function wp_secrets_site_health_debug_info( $info )
Finds secrets that fail to decrypt, for one scope.
```php
-function wp_secrets_site_health_find_undecryptable( bool $network ): array
+function wp_secrets_site_health_find_undecryptable( bool $network )
```
| Parameter | Type | Description |
|---|---|---|
| `$network` | `bool` | Whether to check network-scope secrets. |
-**Returns:** `array` List of array( 'name' => ..., 'fingerprint' => ... ).
+**Returns:** `array|WP_Error` List of array( 'name' => ..., 'fingerprint' => ... ), or WP_Error when the secrets cannot be listed.
**Since:** 7.2.0
@@ -487,7 +527,7 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta
## `wp_secrets_site_health_test_key_source()`
-Tests whether the site key comes from a dedicated constant or a weaker fallback.
+Tests whether the site key is defined, and in the recommended form.
```php
function wp_secrets_site_health_test_key_source(): array
@@ -551,6 +591,27 @@ function wp_secrets_site_health_tests( $tests )
**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php)
+## `wp_secrets_site_health_uses_keyring()`
+
+Whether the active provider protects secrets with the keyring.
+
+A secrets.php drop-in can replace the whole provider, which then protects secrets
+with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all.
+
+```php
+function wp_secrets_site_health_uses_keyring( WP_Secrets_Provider $provider ): bool
+```
+
+| Parameter | Type | Description |
+|---|---|---|
+| `$provider` | `WP_Secrets_Provider` | The active provider. |
+
+**Returns:** `bool`
+
+**Since:** 7.2.0
+
+**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php)
+
## `wp_secrets_validate_name()`
Validates a secret's name.
@@ -594,7 +655,7 @@ capabilities and separate storage prefixes, with no implicit fallback from one
scope to the other.
```php
-function wp_set_network_secret( string $name, $value )
+function wp_set_network_secret( string $name, #[\SensitiveParameter] $value )
```
| Parameter | Type | Description |
@@ -618,7 +679,7 @@ cron, REST, and front-end requests where no user is logged in. Enforce
capabilities at the operator boundary (CLI, an admin screen) instead.
```php
-function wp_set_secret( string $name, $value )
+function wp_set_secret( string $name, #[\SensitiveParameter] $value )
```
| Parameter | Type | Description |
@@ -812,7 +873,7 @@ function _wp_secrets_retire( string $name, bool $network )
Shared implementation behind wp_set_secret() and wp_set_network_secret().
```php
-function _wp_secrets_set( string $name, $value, bool $network, bool $needs_rotation = false, ?string $action_override = null )
+function _wp_secrets_set( string $name, #[\SensitiveParameter] $value, bool $network, bool $needs_rotation = false, ?string $action_override = null )
```
| Parameter | Type | Description |
diff --git a/docs/reference/host-managed-keys.md b/docs/reference/host-managed-keys.md
index bd9b453..9858e06 100644
--- a/docs/reference/host-managed-keys.md
+++ b/docs/reference/host-managed-keys.md
@@ -58,8 +58,9 @@ is defined but not a non-empty string as an error. Every secret read then fails
3. Remove `WP_SECRETS_KEY_PREVIOUS`.
The panel can keep as many past values as it likes; WordPress only ever needs the one it is
-rotating away from. A site that has never set `WP_SECRETS_KEY` is running on the salt-derived key,
-and its "previous" value is the string `LOGGED_IN_KEY . LOGGED_IN_SALT`. Setting that as
+rotating away from. `WP_SECRETS_KEY` is required, so a site without it can't use the API. A site
+that stored secrets under the salt-derived key of 0.4.0 and earlier, or of a 7.2 beta, has the
+string `LOGGED_IN_KEY . LOGGED_IN_SALT` as its "previous" value. Setting that as
`WP_SECRETS_KEY_PREVIOUS` moves the site onto a host-issued key the same way.
**The limit.** An environment variable is still visible to the PHP process. It can end up in
@@ -75,7 +76,7 @@ extension function, a local agent over a Unix socket, a metadata endpoint, or it
```php
// wp-content/secrets.php, installed and owned by the host.
-final class Host_Panel_Keyring implements WP_Secrets_Keyring {
+final class Host_Panel_Keyring extends WP_Secrets_Keyring {
public function wrap( $key_material ) {
// The host's function wraps under the site's newest key version and
diff --git a/docs/spec/envelope-encryption.md b/docs/spec/envelope-encryption.md
index 35cb99d..8e0b40d 100644
--- a/docs/spec/envelope-encryption.md
+++ b/docs/spec/envelope-encryption.md
@@ -21,12 +21,21 @@ The 0.1.0 code has four layers, not two. Exactly one value is ever stored wrappe
`src/wp-includes/secrets/class-wp-secrets-config-key-provider.php` derives 32 bytes from
`wp-config.php`, in priority order: `WP_SECRETS_KEY` when it is the canonical base64 encoding
of exactly 32 bytes (used raw); `WP_SECRETS_KEY` of any other shape (hashed with
- `sodium_crypto_generichash()` to 32 bytes); otherwise `LOGGED_IN_KEY . LOGGED_IN_SALT` hashed
- the same way. The literal `wp-config-sample.php` placeholder is rejected as unusable.
+ `sodium_crypto_generichash()` to 32 bytes). There is no fallback: without a usable
+ `WP_SECRETS_KEY`, every call that needs the key returns `secret_key_unavailable`. A key that is
+ empty or only whitespace, the `wp-config-sample.php` placeholder, or equal to any of the
+ authentication keys and salts is not usable. `get_key_status()` reports which case applies, and
+ `wp_is_secrets_api_available()` reports whether the API can be used at all. See
+ [ADR 0016](../decisions/0016-require-wp-secrets-key.md).
2. **Root key.** `WP_Secrets_Key_Manager::generate_root_key()` in
`src/wp-includes/secrets/class-wp-secrets-key-manager.php` draws 32 random bytes once per
install, wraps them with the keyring, and stores the result under the `_wp_secrets_root_key` site
- option via `add_site_option()`, handling the two-requests-race by re-reading the winner. The
+ option. Only a write creates it: reading a secret before any root key exists returns an error.
+ `get_site_option()` also returns `false` when its query fails, so a `false` is checked against
+ the database before a key is generated, and a failed check returns `WP_Error` without writing.
+ The key is written with `$wpdb` directly: `INSERT IGNORE` on a single site, so a stored key is
+ never overwritten, and on multisite every request uses the oldest row, and a request that lost
+ the race deletes its own. The
default keyring wraps with `sodium_crypto_aead_xchacha20poly1305_ietf_encrypt()` under the fixed
AAD `wp-secrets-root-key-v1`, storing `nonce . ciphertext` base64-encoded. The key manager keeps
the unwrapped root key in memory for the rest of the request, so a remote keyring is invoked once
diff --git a/docs/spec/extension-points.md b/docs/spec/extension-points.md
index 4241766..0d065ee 100644
--- a/docs/spec/extension-points.md
+++ b/docs/spec/extension-points.md
@@ -20,10 +20,10 @@ missing. See "Two extension points, independently replaceable" and "Feedback wan
for a secret: storing it, protecting it, and handing it back. WordPress ships
`WP_Secrets_Libsodium_Provider`, which encrypts with libsodium and keeps ciphertext in the options
tables. That is the default and nothing more. A platform that protects credentials in a KMS, an
-HSM, or its own control panel implements the same interface and stands on equal footing. The
-interface has eight methods: `get()`, `set()`, `delete()`, `retire_previous()`, `list_secrets()`,
+HSM, or its own control panel extends the same abstract class and stands on equal footing. The
+class has eight abstract methods: `get()`, `set()`, `delete()`, `retire_previous()`, `list_secrets()`,
`get_label()`, `get_protection_boundary()`, and `is_writable()`, in
-`src/wp-includes/secrets/interface-wp-secrets-provider.php`.
+`src/wp-includes/secrets/class-wp-secrets-provider.php`.
Every provider has to be **stronger than the default, never weaker**. A provider still cannot
store a plaintext where the default would have stored ciphertext. Taking a value over an
@@ -66,7 +66,7 @@ so there is a known-good subject to compare failures against. It also runs again
provider example on a real dev server in `make test-examples`, so there is a second known-good
subject whose backend does not share the two-slot shape.
-**The two inner interfaces.** The store and keyring are the internals of the shipped provider,
+**The two inner classes.** The store and keyring are the internals of the shipped provider,
and either can still be replaced on its own. A host who wants their own key custody but is happy
with WordPress's storage swaps the keyring and writes no provider at all. Both live in
`src/wp-includes/secrets/`, and both are part of the API surface intended for core.
@@ -74,11 +74,11 @@ with WordPress's storage swaps the keyring and writes no provider at all. Both l
**`WP_Secrets_Store`: where records live.**
```php
-interface WP_Secrets_Store {
- public function get( $name, $network = false );
- public function set( $name, $record, $network = false );
- public function delete( $name, $network = false );
- public function list_names( $network = false );
+abstract class WP_Secrets_Store {
+ abstract public function get( string $name, bool $network = false );
+ abstract public function set( string $name, array $record, bool $network = false );
+ abstract public function delete( string $name, bool $network = false );
+ abstract public function list_names( bool $network = false );
}
```
@@ -92,7 +92,7 @@ there is no plaintext for a store to see in the first place. See
`get()` returns three things, not two: the record array, `null` if the name has never been set,
or `WP_Error` if the store cannot currently tell which. Collapsing the last two, treating
-"unreachable" as "absent", is exactly the bug this interface exists to make impossible. A network
+"unreachable" as "absent", is exactly the bug this class exists to make impossible. A network
outage must never look like a deleted credential.
There is no capability flag. A store that cannot perform an operation says so from that
@@ -109,16 +109,16 @@ $GLOBALS['wp_secrets_store'] = new My_Platform_Store();
**`WP_Secrets_Keyring`: where the root key lives.**
```php
-interface WP_Secrets_Keyring {
- public function wrap( $key_material );
- public function unwrap( $wrapped );
- public function get_key_source();
+abstract class WP_Secrets_Keyring {
+ abstract public function wrap( string $key_material );
+ abstract public function unwrap( string $wrapped );
+ abstract public function get_key_source(): string;
}
```
-A smaller interface, but everything else depends on it: every other key in the system derives
+A smaller class, but everything else depends on it: every other key in the system derives
from what this one protects. `wrap()` and `unwrap()` only ever handle 32 bytes of root key
-material, never a secret value. In a real deployment a KMS or HSM sits behind this interface. The
+material, never a secret value. In a real deployment a KMS or HSM sits behind this class. The
shipped default, `WP_Secrets_Config_Key_Provider`, wraps the root key with a key derived from
`wp-config.php`, since that is the only thing guaranteed to exist on every WordPress install.
@@ -160,7 +160,7 @@ once, which is what a platform doing its own encryption should do. See
one exists, and checks the type of whatever ends up in `$GLOBALS['wp_secrets_provider']`,
`$GLOBALS['wp_secrets_store']`, and `$GLOBALS['wp_secrets_keyring']` afterward. Leaving a global
unset is fine; plenty of drop-ins set only one. Setting one to something that is not an instance
-of the matching interface is not, and neither is a drop-in that throws or has a syntax error.
+of the matching class is not, and neither is a drop-in that throws or has a syntax error.
Either way the whole drop-in fails closed, through `WP_Secrets_Broken_Provider`,
`WP_Secrets_Broken_Store`, and `WP_Secrets_Broken_Keyring`, which turn every operation into a
`WP_Error` instead of quietly falling back to the default. A broken credential backend must never
@@ -169,7 +169,9 @@ look like a working one that happens to have no secrets in it yet.
There is one gap worth knowing about. PHP treats some class declaration errors in the drop-in as
an uncatchable fatal, even inside the `try`/`catch` around the `require`. The usual culprit is a
-class that `implements` an interface but omits one of its methods. Userland cannot intercept that,
+class that `extends` one of the three but omits one of its abstract methods. A class that still uses
+`implements` on one of them, as drop-ins written for interfaces did before 0.5.0 and 7.2 beta 5,
+is the same kind of fatal: PHP reports it while compiling the drop-in. Userland cannot intercept that,
and it takes down the whole request rather than failing in a contained way. Run `php -l` over a
drop-in and load a real request before trusting it in production. `tests/smoke/smoke.sh` case D
checks the syntax-error, throw-on-load, and wrong-type cases through the real loader; only the
diff --git a/docs/spec/providers-and-keyrings.md b/docs/spec/providers-and-keyrings.md
index a16c2c6..95d3144 100644
--- a/docs/spec/providers-and-keyrings.md
+++ b/docs/spec/providers-and-keyrings.md
@@ -15,20 +15,20 @@ questions, asking whether providers could stand in for a retrieval filter, but n
## As built
-**Three interfaces, outermost first.**
+**Three abstract classes, outermost first.**
-| Interface | File | Methods |
+| Class | File | Methods |
|---|---|---|
-| `WP_Secrets_Provider` | `src/wp-includes/secrets/interface-wp-secrets-provider.php` | `get()`, `set()`, `delete()`, `retire_previous()`, `list_secrets()`, `get_label()`, `get_protection_boundary()`, `is_writable()` |
-| `WP_Secrets_Store` | `src/wp-includes/secrets/interface-wp-secrets-store.php` | `get()`, `set()`, `delete()`, `list_names()` |
-| `WP_Secrets_Keyring` | `src/wp-includes/secrets/interface-wp-secrets-keyring.php` | `wrap()`, `unwrap()`, `get_key_source()` |
+| `WP_Secrets_Provider` | `src/wp-includes/secrets/class-wp-secrets-provider.php` | `get()`, `set()`, `delete()`, `retire_previous()`, `list_secrets()`, `get_label()`, `get_protection_boundary()`, `is_writable()` |
+| `WP_Secrets_Store` | `src/wp-includes/secrets/class-wp-secrets-store.php` | `get()`, `set()`, `delete()`, `list_names()` |
+| `WP_Secrets_Keyring` | `src/wp-includes/secrets/class-wp-secrets-keyring.php` | `wrap()`, `unwrap()`, `get_key_source()` |
`WP_Secrets_Provider` also declares `BOUNDARY_WORDPRESS` and `BOUNDARY_PROVIDER` for
`get_protection_boundary()`.
**Resolution.** `_wp_secrets_get_provider()` in `src/wp-includes/secrets.php`: if the drop-in is
flagged broken, `WP_Secrets_Broken_Provider`; else if `$GLOBALS['wp_secrets_provider']` is set, it
-is used when it implements the interface and replaced with `WP_Secrets_Broken_Provider` when it
+is used when it extends the class and replaced with `WP_Secrets_Broken_Provider` when it
does not; else `new WP_Secrets_Libsodium_Provider( _wp_secrets_get_store(), _wp_secrets_get_key_manager() )`.
`_wp_secrets_get_store()` returns `$GLOBALS['wp_secrets_store']` when set to a `WP_Secrets_Store`,
@@ -62,8 +62,10 @@ copy and are responsible for zeroing it; the manager's own cached copy is not th
**Drop-in loading.** `wp_secrets_api_load_dropin()` in `secrets-api.php` requires
`wp-content/secrets.php` inside `try`/`catch ( \Throwable )`, then type-checks all three globals.
A missing global is fine. A throw or a wrong type sets `$GLOBALS['wp_secrets_dropin_broken']`.
+The three globals are declared before the `require`, so the drop-in can set them as plain
+variables, such as `$wp_secrets_keyring = new My_Keyring();`, as well as through `$GLOBALS`.
`wp_using_secrets_dropin()` reports presence. In the plugin, this runs after every core-bound
-interface has been required, since a drop-in class needs the interface to compile.
+class has been required, since a drop-in class needs its parent class to compile.
[`docs/reference/drop-in-example.php`](../reference/drop-in-example.php) is a skeleton with a
stub of each of the three classes and the globals that install them.
@@ -71,9 +73,15 @@ stub of each of the three classes and the globals that install them.
shipped provider, and remain public. A drop-in may set `wp_secrets_store`, `wp_secrets_keyring`,
both, or neither, and get the shipped provider built around them. Setting `wp_secrets_provider`
replaces the whole thing, and any store or keyring globals are then ignored on the read and
-write path. Site Health still reads the keyring global to describe the key source. The most
+write path. Site Health then says the provider manages its own keys, and Site Health Info marks
+the keyring class and key source as not used by the provider. The most
common host integration is a keyring alone: three methods.
+**Sensitive parameters.** The abstract classes mark the provider's `set()` `$value` and the keyring's
+`wrap()` `$key_material` with `#[\SensitiveParameter]`, so a stack trace from a failed save shows
+neither. Attributes are not inherited, so an implementation adds the attribute itself, as the
+examples and `docs/reference/drop-in-example.php` do. On PHP 7.4 the attribute line is a comment.
+
**Supporting surface.** `wp_secrets_provider_is_writable()` and `wp_secrets_provider_label()` in
`src/wp-includes/secrets.php`. `WP_SECRETS_ERROR_PROVIDER_READ_ONLY` for writes a provider
refuses. A conformance suite, `WP_Secrets_Provider_Conformance` in
diff --git a/docs/spec/scope.md b/docs/spec/scope.md
index a420d78..5417e04 100644
--- a/docs/spec/scope.md
+++ b/docs/spec/scope.md
@@ -20,7 +20,7 @@ scope", and "Timeline" in the [proposal][proposal].
**Core-bound versus plugin-only.** Everything under `src/` is written to be copied into
`wordpress-develop` unchanged: `src/wp-includes/secrets.php` holds the public functions,
-`src/wp-includes/secrets/` holds the classes and interfaces, and
+`src/wp-includes/secrets/` holds the classes, and
`src/wp-admin/includes/secrets-site-health.php` holds Site Health. `plugin/` and `cli/` are never
copied. `tests/phpunit/test-architecture.php` enforces the boundary: no reference to `WP_CLI`,
`plugin/`, or `cli/` under `src/`; no self-guarding `function_exists()` or `class_exists()`; every
diff --git a/examples/README.md b/examples/README.md
index f29fe67..e1f49e5 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -20,10 +20,10 @@ The documentation site covers all three in
[`docs/reference/examples.md`](../docs/reference/examples.md), and a host that wants to own the
site's key in [`docs/reference/host-managed-keys.md`](../docs/reference/host-managed-keys.md).
-## Which interface do you need?
+## Which class do you extend?
Worth getting right before you write anything. **A key-management service is not a secret store**,
-and the two map to different interfaces:
+and the two map to different classes:
| Service | Holds | Implement | Effort |
|---|---|---|---|
@@ -83,7 +83,7 @@ class Tests_My_Platform_Provider extends WP_Secrets_Provider_Conformance {
}
```
-It covers what `implements WP_Secrets_Provider` can't: absence reported as `null`, deleting
+It covers what `extends WP_Secrets_Provider` can't: absence reported as `null`, deleting
something absent succeeding, fingerprints staying stable for the same value, an unchanged write
keeping the previous version, listings never
containing a plaintext, and a read-only declaration actually being honoured. See
diff --git a/examples/aws-kms-keyring/README.md b/examples/aws-kms-keyring/README.md
index e89bca0..337e8ae 100644
--- a/examples/aws-kms-keyring/README.md
+++ b/examples/aws-kms-keyring/README.md
@@ -164,7 +164,7 @@ class Tests_AWS_KMS_Keyring_Conformance extends WP_Secrets_Keyring_Conformance {
}
```
-That checks the properties `implements WP_Secrets_Keyring` cannot: `wrap()` is non-deterministic,
+That checks the properties `extends WP_Secrets_Keyring` cannot: `wrap()` is non-deterministic,
`unwrap()` round-trips exactly the bytes that went in, and garbage, truncated, or tampered input
fails closed as `WP_Error` rather than returning a plausible-looking wrong key. It makes real API
calls, so point it at a throwaway AWS account.
diff --git a/examples/aws-kms-keyring/SPEC.md b/examples/aws-kms-keyring/SPEC.md
index 6b8ee6e..d26adce 100644
--- a/examples/aws-kms-keyring/SPEC.md
+++ b/examples/aws-kms-keyring/SPEC.md
@@ -46,7 +46,7 @@ A single-file drop-in, following the conventions of the AWS Secrets Manager exam
no SDK, SigV4 by hand, `wp_remote_post()`. The SigV4 signer is copied rather than shared, because
each example has to be one file a reviewer can read from top to bottom.
-`final class AWS_KMS_Keyring implements WP_Secrets_Keyring`:
+`final class AWS_KMS_Keyring extends WP_Secrets_Keyring`:
| Method | Behaviour |
|---|---|
diff --git a/examples/aws-kms-keyring/secrets.php b/examples/aws-kms-keyring/secrets.php
index 7f47566..853093c 100644
--- a/examples/aws-kms-keyring/secrets.php
+++ b/examples/aws-kms-keyring/secrets.php
@@ -23,7 +23,7 @@
* 2. How does an existing site move onto a new keyring? `wp secret rotate
* --from=config`, which unwraps with the site's current config key and
* re-wraps with whatever keyring is now active – this one, once installed.
- * 3. What must a keyring guarantee that the interface does not say? That
+ * 3. What must a keyring guarantee that the class does not say? That
* wrap() is non-deterministic (two calls on the same bytes must not return
* the same wrapped value) and that unwrap() fails closed on anything it did
* not produce, rather than returning a plausible-looking wrong key.
@@ -36,7 +36,7 @@
/**
* Wraps and unwraps the site's root key using an AWS KMS customer master key.
*/
-final class AWS_KMS_Keyring implements WP_Secrets_Keyring {
+final class AWS_KMS_Keyring extends WP_Secrets_Keyring {
/**
* Marks a wrapped value as ours. unwrap() uses its absence to give a
@@ -107,7 +107,10 @@ public function __construct( $key_id, $region, $access_key, $secret_key, $endpoi
*
* @return string|WP_Error
*/
- public function wrap( string $key_material ) {
+ public function wrap(
+ #[\SensitiveParameter]
+ string $key_material
+ ) {
if ( ! is_string( $key_material ) || '' === $key_material ) {
return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, 'AWS_KMS_Keyring: key material must be a non-empty string.' );
}
diff --git a/examples/aws-secrets-manager/README.md b/examples/aws-secrets-manager/README.md
index 5fa050e..33a1dc1 100644
--- a/examples/aws-secrets-manager/README.md
+++ b/examples/aws-secrets-manager/README.md
@@ -131,7 +131,7 @@ class Tests_AWS_Secrets_Manager_Provider extends WP_Secrets_Provider_Conformance
}
```
-That checks the properties `implements WP_Secrets_Provider` cannot: absence reported as `null`
+That checks the properties `extends WP_Secrets_Provider` cannot: absence reported as `null`
rather than an error, deleting something absent succeeding, fingerprints stable for the same
value, and listings never containing a plaintext. It makes real API calls, so point it at a
throwaway AWS account. The repository itself now runs this class against Moto, an AWS emulator, in
diff --git a/examples/aws-secrets-manager/secrets.php b/examples/aws-secrets-manager/secrets.php
index 3da89cb..4e934ba 100644
--- a/examples/aws-secrets-manager/secrets.php
+++ b/examples/aws-secrets-manager/secrets.php
@@ -30,7 +30,7 @@
/**
* Serves secrets from AWS Secrets Manager.
*/
-final class AWS_Secrets_Manager_Provider implements WP_Secrets_Provider {
+final class AWS_Secrets_Manager_Provider extends WP_Secrets_Provider {
/**
* Maps this API's version constants onto Secrets Manager staging labels.
@@ -135,7 +135,14 @@ public function get( string $name, string $version, bool $network = false ) {
*
* @return true|WP_Error
*/
- public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) {
+ public function set(
+ string $name,
+ #[\SensitiveParameter]
+ $value,
+ bool $network = false,
+ bool $needs_rotation = false,
+ ?string $action = null
+ ) {
$aws_name = $this->aws_name( $name, $network );
// PutSecretValue makes a new version even for the value AWSCURRENT
diff --git a/examples/vault-provider/SPEC.md b/examples/vault-provider/SPEC.md
index 3939ff4..29acc38 100644
--- a/examples/vault-provider/SPEC.md
+++ b/examples/vault-provider/SPEC.md
@@ -37,7 +37,7 @@ server rather than an emulator.
A single-file drop-in: no Composer, no SDK. Vault's HTTP API needs only a token header, so this
is simpler than the AWS examples.
-`final class Vault_KV2_Provider implements WP_Secrets_Provider`, configured by
+`final class Vault_KV2_Provider extends WP_Secrets_Provider`, configured by
`WP_SECRETS_VAULT_ADDR`, `WP_SECRETS_VAULT_TOKEN`, `WP_SECRETS_VAULT_MOUNT` (default `secret`),
and an optional `WP_SECRETS_VAULT_NAMESPACE`, which is sent as `X-Vault-Namespace` for Vault
Enterprise and HCP. It is installed only when the address and token are non-empty, for the same
diff --git a/examples/vault-provider/secrets.php b/examples/vault-provider/secrets.php
index 9bf043a..17b8756 100644
--- a/examples/vault-provider/secrets.php
+++ b/examples/vault-provider/secrets.php
@@ -37,7 +37,7 @@
*
* The rotation flag uses custom_metadata, which requires Vault 1.9+.
*/
-final class Vault_KV2_Provider implements WP_Secrets_Provider {
+final class Vault_KV2_Provider extends WP_Secrets_Provider {
/** Versions kept per secret in Vault; makes it a two-slot store. See README.md question 2. @var int */
const MAX_VERSIONS = 2;
@@ -159,7 +159,14 @@ public function get( string $name, string $version, bool $network = false ) {
*
* @return true|WP_Error
*/
- public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) {
+ public function set(
+ string $name,
+ #[\SensitiveParameter]
+ $value,
+ bool $network = false,
+ bool $needs_rotation = false,
+ ?string $action = null
+ ) {
$vault_path = $this->vault_path( $name, $network );
$meta = $this->read_metadata( $name, $network );
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 4db6a1f..ef8e276 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -98,6 +98,15 @@
/src/*
+
+
+ /src/*
+
+