v0.5.3
This commit is contained in:
parent
061329c704
commit
69c8e9eb97
5 changed files with 191 additions and 19 deletions
|
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
readme.txt
16
readme.txt
|
|
@ -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_
|
||||||
|
|
|
||||||
|
|
@ -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__ ) );
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue