This commit is contained in:
Javier Casares 2026-08-15 15:37:04 +00:00
commit 69c8e9eb97
5 changed files with 191 additions and 19 deletions

View file

@ -323,7 +323,11 @@ class Robotstxt_Manager_Installer {
$result = $upgrader->install( $result = $upgrader->install(
$tmp_file, $tmp_file,
array( array(
// Option key differs across WP versions: 'overwrite' (5.5-era)
// vs 'overwrite_package' (current). Pass both; the unused key
// is ignored by wp_parse_args().
'overwrite' => $overwrite, 'overwrite' => $overwrite,
'overwrite_package' => $overwrite,
) )
); );
@ -358,7 +362,11 @@ class Robotstxt_Manager_Installer {
} }
if ( ! class_exists( 'Plugin_Upgrader' ) ) { if ( ! class_exists( 'Plugin_Upgrader' ) ) {
// class-wp-upgrader.php bundles WP_Upgrader + the skins (incl.
// Automatic_Upgrader_Skin), but Plugin_Upgrader itself lives in
// its own file since the WP 5.3 split — both are required.
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php';
} }
} }

View file

@ -1,5 +1,42 @@
== Changelog == == Changelog ==
= 0.5.3 =
_Release date: 2026-08-15_
**Added**
* Authenticated encryption for the stored API key (same encrypt-then-MAC scheme as Core 1.6.0): tampered payloads fail closed, and encryption refuses to run without real WordPress salts. Legacy-stored keys keep decrypting and re-encrypt on the next save.
**Changed**
* Plugin version 0.5.2 → 0.5.3. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 0.5.2 =
_Release date: 2026-08-15_
**Fixed**
* Manager-panel Install/Update failed while the native WordPress updater worked. Two defects in the panel's upgrader path (`Robotstxt_Manager_Installer`):
1. `ensure_plugin_functions()` required only `class-wp-upgrader.php`, which since the WordPress 5.3 class split no longer defines `Plugin_Upgrader` (it lives in `class-plugin-upgrader.php`). In a stock `admin-post.php` context nothing else loads it → fatal error. Both files are now required.
2. The overwrite flag was passed to `Plugin_Upgrader::install()` as `'overwrite'`, but current WordPress reads `'overwrite_package'` — the unknown key was silently discarded, `clear_destination` stayed false, and the install failed against the existing folder. Both keys are now passed (the unused one is ignored by `wp_parse_args()`), keeping compatibility across WP versions.
* Verified end-to-end on the live store: panel-path download → overwrite-install succeeds, plugin remains active.
**Changed**
* Plugin version 0.5.1 → 0.5.2. No database schema changes (no custom tables).
**Compatibility**
* WordPress: 4.4 - 7.1 (scan-verified: wp-compat clean from 4.4)
* PHP: 8.0 - 8.5 (scan-verified: PHPCompatibility + manual feature audit)
= 0.5.1 = = 0.5.1 =
_Release date: 2026-08-15_ _Release date: 2026-08-15_

View file

@ -1,6 +1,6 @@
<?php <?php
/** /**
* AES-256-CBC encryption helper for the account-level API key. * AES-256-CBC + HMAC encryption helper for sensitive plugin options.
* *
* @package Robotstxt_Manager * @package Robotstxt_Manager
*/ */
@ -12,9 +12,14 @@ if ( ! defined( 'ABSPATH' ) ) {
/** /**
* Class Robotstxt_Manager_Encryption * Class Robotstxt_Manager_Encryption
* *
* Same symmetric AES-256-CBC pattern Plugins Core uses for its Forgejo * Provides symmetric authenticated encryption (encrypt-then-MAC) using a
* token. Encrypted values are base64-encoded strings with the IV prepended * key derived from WordPress secret keys. Since 1.6.0 new ciphertexts are
* to the ciphertext, safe for wp_options storage. * authenticated with HMAC-SHA256: tampered or truncated payloads fail
* closed. Legacy payloads (unauthenticated AES-256-CBC, format
* base64(iv . ciphertext)) remain decryptable; values re-encrypt to the
* authenticated format the next time they are saved.
*
* Requires the PHP openssl extension (bundled with PHP 8.0+).
*/ */
class Robotstxt_Manager_Encryption { class Robotstxt_Manager_Encryption {
@ -26,25 +31,52 @@ class Robotstxt_Manager_Encryption {
private const CIPHER = 'aes-256-cbc'; private const CIPHER = 'aes-256-cbc';
/** /**
* Context string used to derive a plugin-specific key. * Context string used to derive the encryption key.
*
* Must never change: existing ciphertexts (including legacy ones)
* depend on it.
* *
* @var string * @var string
*/ */
private const CONTEXT = 'robotstxt_manager_encryption_v1'; private const CONTEXT = 'robotstxt_manager_encryption_v1';
/** /**
* Encrypts a plaintext string and returns a base64-encoded payload. * Context string used to derive the MAC key (separate from the
* encryption key by design).
*
* @var string
*/
private const MAC_CONTEXT = 'robotstxt_manager_encryption_v2_mac';
/**
* Prefix marking authenticated (v2) payloads.
*
* @var string
*/
private const PREFIX_V2 = 'v2:';
/**
* Encrypts a plaintext string and returns an authenticated payload.
*
* Format: 'v2:' . base64( iv . ciphertext . hmac_sha256( iv . ciphertext ) ).
* *
* @param string $plaintext The value to encrypt. * @param string $plaintext The value to encrypt.
* *
* @return string Base64-encoded ciphertext, or empty string on failure. * @return string Payload string, or empty string on failure (including
* missing WordPress secret keys encryption without the
* real salts would be recoverable by anyone).
*/ */
public static function encrypt( string $plaintext ): string { public static function encrypt( string $plaintext ): string {
if ( '' === $plaintext ) { if ( '' === $plaintext ) {
return ''; return '';
} }
if ( ! defined( 'AUTH_KEY' ) || ! defined( 'AUTH_SALT' ) ) {
return ''; // Fail closed — never encrypt under fallback keys.
}
$key = self::derive_key(); $key = self::derive_key();
$mac_key = self::derive_mac_key();
$iv_length = openssl_cipher_iv_length( self::CIPHER ); $iv_length = openssl_cipher_iv_length( self::CIPHER );
if ( false === $iv_length ) { if ( false === $iv_length ) {
@ -58,13 +90,20 @@ class Robotstxt_Manager_Encryption {
return ''; return '';
} }
return base64_encode( $iv . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode $mac = hash_hmac( 'sha256', $iv . $ciphertext, $mac_key, true );
return self::PREFIX_V2 . base64_encode( $iv . $ciphertext . $mac ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
} }
/** /**
* Decrypts a base64-encoded payload previously produced by encrypt(). * Decrypts a payload previously produced by encrypt().
* *
* @param string $encoded The base64-encoded ciphertext. * Authenticated (v2) payloads are verified with a constant-time MAC
* comparison before decryption; tampered payloads return an empty
* string. Legacy unauthenticated payloads are decrypted as before so
* existing stored values keep working until re-saved.
*
* @param string $encoded The stored payload.
* *
* @return string The original plaintext, or empty string on failure. * @return string The original plaintext, or empty string on failure.
*/ */
@ -73,28 +112,85 @@ class Robotstxt_Manager_Encryption {
return ''; return '';
} }
if ( 0 === strpos( $encoded, self::PREFIX_V2 ) ) {
return self::decrypt_v2( substr( $encoded, strlen( self::PREFIX_V2 ) ) );
}
$decoded = base64_decode( $encoded, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $decoded = base64_decode( $encoded, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $decoded ) { if ( false === $decoded ) {
return ''; return '';
} }
return self::decrypt_cbc( $decoded );
}
/**
* Decrypts an authenticated v2 payload.
*
* @param string $b64 Base64 portion after the 'v2:' prefix.
*
* @return string Plaintext, or empty string on any failure.
*/
private static function decrypt_v2( string $b64 ): string {
$decoded = base64_decode( $b64, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $decoded ) {
return '';
}
$iv_length = openssl_cipher_iv_length( self::CIPHER ); $iv_length = openssl_cipher_iv_length( self::CIPHER );
if ( false === $iv_length || strlen( $decoded ) <= $iv_length ) { if ( false === $iv_length ) {
return '';
}
$mac_length = 32; // sha256.
if ( strlen( $decoded ) <= $iv_length + $mac_length ) {
return '';
}
$iv = substr( $decoded, 0, $iv_length );
$ciphertext = substr( $decoded, $iv_length, strlen( $decoded ) - $iv_length - $mac_length );
$mac = substr( $decoded, -1 * $mac_length );
$expected = hash_hmac( 'sha256', $iv . $ciphertext, self::derive_mac_key(), true );
if ( ! hash_equals( $expected, $mac ) ) {
return ''; // Tampered or corrupted — fail closed.
}
return self::decrypt_cbc( $iv . $ciphertext );
}
/**
* Performs the raw CBC decryption over iv . ciphertext bytes.
*
* @param string $bytes Raw bytes: iv followed by ciphertext.
*
* @return string Plaintext, or empty string on failure.
*/
private static function decrypt_cbc( string $bytes ): string {
$iv_length = openssl_cipher_iv_length( self::CIPHER );
if ( false === $iv_length || strlen( $bytes ) <= $iv_length ) {
return ''; return '';
} }
$key = self::derive_key(); $key = self::derive_key();
$iv = substr( $decoded, 0, $iv_length ); $iv = substr( $bytes, 0, $iv_length );
$ciphertext = substr( $decoded, $iv_length ); $ciphertext = substr( $bytes, $iv_length );
$plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv ); $plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
return false !== $plaintext ? $plaintext : ''; return false !== $plaintext ? $plaintext : '';
} }
/** /**
* Derives a 32-byte encryption key from WordPress secret constants. * Derives the 32-byte encryption key from WordPress secret constants.
*
* The deterministic fallback only applies while WordPress constants are
* undefined (early install context); encrypt() refuses to run there.
* *
* @return string 32-byte raw key. * @return string 32-byte raw key.
*/ */
@ -108,4 +204,23 @@ class Robotstxt_Manager_Encryption {
32 32
); );
} }
/**
* Derives the 32-byte MAC key from WordPress secret constants.
*
* Derived with a different context string than the encryption key, so
* knowing one reveals nothing about the other.
*
* @return string 32-byte raw key.
*/
private static function derive_mac_key(): string {
$auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined';
$auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined';
return substr(
hash_hmac( 'sha256', self::MAC_CONTEXT, $auth_key . $auth_salt, true ),
0,
32
);
}
} }

View file

@ -3,9 +3,9 @@ Contributors: javiercasares, robotstxt
Tags: dashboard, catalog, updates, subscriptions, management Tags: dashboard, catalog, updates, subscriptions, management
Requires at least: 4.4 Requires at least: 4.4
Tested up to: 7.1 Tested up to: 7.1
Stable tag: 0.5.1 Stable tag: 0.5.3
Requires PHP: 8.0 Requires PHP: 8.0
Version: 0.5.1 Version: 0.5.3
License: GPL-3.0-or-later License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -89,6 +89,18 @@ Encrypted at rest using AES-256-CBC with a key derived from your site's WordPres
== Changelog == == Changelog ==
= 0.5.3 =
_Release date: 2026-08-15_
* Authenticated encryption (encrypt-then-MAC, matching Core 1.6.0) for the stored API key: tampered payloads fail closed; legacy-stored keys keep working and upgrade on next save.
= 0.5.2 =
_Release date: 2026-08-15_
* Fixed: updating (and installing) a plugin from the Manager catalog page failed with a fatal or a bare "Installation failed." Two defects in the panel's upgrader path: `Plugin_Upgrader` was never loaded in the `admin-post` context (it lives in its own file since the WordPress 5.3 class split, and only `class-wp-upgrader.php` was required), and the overwrite option was passed under the key `overwrite`, which current WordPress reads as `overwrite_package` — so the existing folder was never cleared. Verified end-to-end against the live store.
= 0.5.1 = = 0.5.1 =
_Release date: 2026-08-15_ _Release date: 2026-08-15_

View file

@ -3,7 +3,7 @@
* Plugin Name: Manager (by ROBOTSTXT) * Plugin Name: Manager (by ROBOTSTXT)
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-manager * Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-manager
* Description: Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and installs, activates, and updates plugins directly from the store. * Description: Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and installs, activates, and updates plugins directly from the store.
* Version: 0.5.1 * Version: 0.5.3
* Requires at least: 4.4 * Requires at least: 4.4
* Requires PHP: 8.0 * Requires PHP: 8.0
* Author: ROBOTSTXT * Author: ROBOTSTXT
@ -21,7 +21,7 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
/** Plugin version. */ /** Plugin version. */
define( 'ROBOTSTXT_MANAGER_VERSION', '0.5.1' ); define( 'ROBOTSTXT_MANAGER_VERSION', '0.5.3' );
/** Absolute path to the plugin directory, with trailing slash. */ /** Absolute path to the plugin directory, with trailing slash. */
define( 'ROBOTSTXT_MANAGER_DIR', plugin_dir_path( __FILE__ ) ); define( 'ROBOTSTXT_MANAGER_DIR', plugin_dir_path( __FILE__ ) );