constant_reader = $constant_reader ?? static function ( string $name ) { return defined( $name ) ? constant( $name ) : null; }; } /** * Whether this server can run the cipher. * * @return bool */ public static function is_available(): bool { return function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) && function_exists( 'sodium_crypto_generichash' ); } /** * Whether a name is valid: `namespace/key`, lowercase letters, numbers, `-` * and `_` in each part, neither part starting or ending with `-` or `_`. * * @param string $name Candidate name. * * @return bool */ public static function is_valid_name( string $name ): bool { if ( strlen( $name ) > self::MAX_NAME_LENGTH || 1 !== substr_count( $name, '/' ) ) { return false; } foreach ( explode( '/', $name ) as $segment ) { if ( ! preg_match( '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/', $segment ) ) { return false; } } return true; } /** * Read a secret. * * @param string $name Secret name. * * @return string|null|\WP_Error */ public function get( string $name ) { if ( ! self::is_valid_name( $name ) ) { return new \WP_Error( 'adminoptim_secret_invalid_name', __( 'Invalid secret name.', 'admin-optimizer' ) ); } $record = get_option( self::OPTION_PREFIX . $name, null ); if ( null === $record ) { return null; } // --- 1. Parse the record --- $raw = is_string( $record ) && str_starts_with( $record, self::RECORD_PREFIX ) ? base64_decode( substr( $record, strlen( self::RECORD_PREFIX ) ), true ) : false; if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES ) { return new \WP_Error( 'adminoptim_secret_record_malformed', __( 'The stored secret is malformed.', 'admin-optimizer' ) ); } // --- 2. Decrypt --- $root = $this->root_key( false ); if ( is_wp_error( $root ) ) { return $root; } $nonce = substr( $raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES ); $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( substr( $raw, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES ), self::value_aad( $name ), $nonce, $root ); if ( false === $plaintext ) { return new \WP_Error( 'adminoptim_secret_decryption_failed', __( 'The stored secret could not be decrypted with the current key.', 'admin-optimizer' ) ); } return $plaintext; } /** * Encrypt and store a secret. * * @param string $name Secret name. * @param string $value Plaintext value (non-empty). * * @return true|\WP_Error */ public function set( string $name, string $value ) { if ( ! self::is_valid_name( $name ) ) { return new \WP_Error( 'adminoptim_secret_invalid_name', __( 'Invalid secret name.', 'admin-optimizer' ) ); } if ( '' === $value ) { return new \WP_Error( 'adminoptim_secret_invalid_value', __( 'A secret must not be empty.', 'admin-optimizer' ) ); } if ( ! self::is_available() ) { return new \WP_Error( 'adminoptim_secret_crypto_unavailable', __( 'No libsodium implementation is available.', 'admin-optimizer' ) ); } $root = $this->root_key( true ); if ( is_wp_error( $root ) ) { return $root; } $nonce = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES ); $record = self::RECORD_PREFIX . base64_encode( $nonce . sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $value, self::value_aad( $name ), $nonce, $root ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding ciphertext for storage. // update_option() returns false for "unchanged" as well as failure; the nonce makes every record new, so confirm by reading back. update_option( self::OPTION_PREFIX . $name, $record, false ); if ( get_option( self::OPTION_PREFIX . $name, null ) !== $record ) { return new \WP_Error( 'adminoptim_secret_store_unavailable', __( 'The secret could not be saved.', 'admin-optimizer' ) ); } return true; } /** * Delete a secret. * * @param string $name Secret name. * * @return true|\WP_Error */ public function delete( string $name ) { if ( ! self::is_valid_name( $name ) ) { return new \WP_Error( 'adminoptim_secret_invalid_name', __( 'Invalid secret name.', 'admin-optimizer' ) ); } delete_option( self::OPTION_PREFIX . $name ); return true; } /** * Names of every secret stored here. * * @return string[] */ public function list_names(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- listing option names by prefix; no Options API equivalent. $option_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::OPTION_PREFIX ) . '%' ) ); return array_map( static function ( $option_name ) { return substr( (string) $option_name, strlen( self::OPTION_PREFIX ) ); }, (array) $option_names ); } /** * Always writable while libsodium is available. * * @return bool */ public function is_writable(): bool { return self::is_available(); } /** * Description for admin messages. * * @return string */ public function label(): string { $source = null !== ( $this->constant_reader )( 'WP_SECRETS_KEY' ) ? 'WP_SECRETS_KEY' : 'the logged-in salt'; /* translators: %s: where the encryption key comes from. */ return sprintf( __( 'Admin Optimizer (libsodium, key from %s)', 'admin-optimizer' ), $source ); } /** * Associated data binding a value to this plugin, this blog and this name. * * @param string $name Secret name. * * @return string */ private static function value_aad( string $name ): string { return self::VALUE_AAD . '|' . ( function_exists( 'get_current_blog_id' ) ? get_current_blog_id() : 1 ) . '|' . $name; } /** * Derive a 32-byte site key from WP_SECRETS_KEY / WP_SECRETS_KEY_PREVIOUS or * the logged-in salt. * * @param bool $previous Use WP_SECRETS_KEY_PREVIOUS. * * @return string|null|\WP_Error Key, null when $previous is requested but undefined, or an error. */ private function site_key( bool $previous ) { $configured = ( $this->constant_reader )( $previous ? 'WP_SECRETS_KEY_PREVIOUS' : 'WP_SECRETS_KEY' ); if ( null !== $configured ) { $material = (string) $configured; $decoded = base64_decode( $material, true ); if ( false !== $decoded && 32 === strlen( $decoded ) && base64_encode( $decoded ) === $material ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Strict canonical-form check. return $decoded; } } elseif ( $previous ) { return null; } else { $material = wp_salt( 'logged_in' ); } // A site still on wp-config-sample's placeholder derives a key anyone can derive. if ( '' === $material || false !== strpos( $material, 'put your unique phrase here' ) ) { return new \WP_Error( 'adminoptim_secret_key_unavailable', __( 'No usable encryption key: generate unique keys and salts in wp-config.php.', 'admin-optimizer' ) ); } return sodium_crypto_generichash( $material, self::SITE_KEY_CONTEXT, 32 ); } /** * Wrap or unwrap the root key under a site key. * * @param string $input Root key (wrap) or wrapped value (unwrap). * @param string $key 32-byte site key. * @param bool $wrap True to wrap, false to unwrap. * * @return string|false Wrapped value, root key, or false when unwrapping fails. */ private static function wrap( string $input, string $key, bool $wrap ) { $nonce_length = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; if ( $wrap ) { $nonce = random_bytes( $nonce_length ); return base64_encode( $nonce . sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $input, self::ROOT_AAD, $nonce, $key ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding ciphertext for storage. } $raw = base64_decode( $input, true ); if ( false === $raw || strlen( $raw ) <= $nonce_length ) { return false; } return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( substr( $raw, $nonce_length ), self::ROOT_AAD, substr( $raw, 0, $nonce_length ), $key ); } /** * The unwrapped root key, optionally creating it on first use. * * @param bool $create Generate and store a root key if none exists. * * @return string|\WP_Error */ private function root_key( bool $create ) { $site_key = $this->site_key( false ); if ( is_wp_error( $site_key ) ) { return $site_key; } $stored = (string) get_option( self::ROOT_OPTION, '' ); // --- 1. First use: generate the root key --- if ( '' === $stored ) { if ( ! $create ) { return new \WP_Error( 'adminoptim_secret_key_unavailable', __( 'The encryption key has not been created yet.', 'admin-optimizer' ) ); } $root = random_bytes( 32 ); if ( add_option( self::ROOT_OPTION, self::wrap( $root, $site_key, true ), '', false ) ) { $this->root_cache = [ 'wrapped' => (string) get_option( self::ROOT_OPTION, '' ), 'root' => $root ]; return $root; } // Another request created it first; use theirs. $stored = (string) get_option( self::ROOT_OPTION, '' ); } if ( null !== $this->root_cache && $this->root_cache['wrapped'] === $stored ) { return $this->root_cache['root']; } // --- 2. Unwrap with the current site key, then the previous one --- $root = self::wrap( $stored, $site_key, false ); if ( false === $root ) { $previous = $this->site_key( true ); $root = is_string( $previous ) ? self::wrap( $stored, $previous, false ) : false; if ( false !== $root ) { // Rotation complete: from now on the current key unwraps it. update_option( self::ROOT_OPTION, self::wrap( $root, $site_key, true ), false ); $stored = (string) get_option( self::ROOT_OPTION, '' ); } } if ( false === $root ) { return new \WP_Error( 'adminoptim_secret_key_unavailable', __( 'The stored encryption key cannot be unlocked with the current keys and salts.', 'admin-optimizer' ) ); } $this->root_cache = [ 'wrapped' => $stored, 'root' => $root ]; return $root; } }