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/* + +