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(
|
||||
$tmp_file,
|
||||
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_package' => $overwrite,
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -358,7 +362,11 @@ class Robotstxt_Manager_Installer {
|
|||
}
|
||||
|
||||
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-plugin-upgrader.php';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,42 @@
|
|||
== 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 =
|
||||
|
||||
_Release date: 2026-08-15_
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?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
|
||||
*/
|
||||
|
|
@ -12,9 +12,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
/**
|
||||
* Class Robotstxt_Manager_Encryption
|
||||
*
|
||||
* Same symmetric AES-256-CBC pattern Plugins Core uses for its Forgejo
|
||||
* token. Encrypted values are base64-encoded strings with the IV prepended
|
||||
* to the ciphertext, safe for wp_options storage.
|
||||
* Provides symmetric authenticated encryption (encrypt-then-MAC) using a
|
||||
* key derived from WordPress secret keys. Since 1.6.0 new ciphertexts are
|
||||
* 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 {
|
||||
|
||||
|
|
@ -26,25 +31,52 @@ class Robotstxt_Manager_Encryption {
|
|||
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
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @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 {
|
||||
if ( '' === $plaintext ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( ! defined( 'AUTH_KEY' ) || ! defined( 'AUTH_SALT' ) ) {
|
||||
return ''; // Fail closed — never encrypt under fallback keys.
|
||||
}
|
||||
|
||||
$key = self::derive_key();
|
||||
$mac_key = self::derive_mac_key();
|
||||
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
||||
|
||||
if ( false === $iv_length ) {
|
||||
|
|
@ -58,13 +90,20 @@ class Robotstxt_Manager_Encryption {
|
|||
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.
|
||||
*/
|
||||
|
|
@ -73,28 +112,85 @@ class Robotstxt_Manager_Encryption {
|
|||
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
|
||||
|
||||
if ( false === $decoded ) {
|
||||
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 );
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
$key = self::derive_key();
|
||||
$iv = substr( $decoded, 0, $iv_length );
|
||||
$ciphertext = substr( $decoded, $iv_length );
|
||||
$iv = substr( $bytes, 0, $iv_length );
|
||||
$ciphertext = substr( $bytes, $iv_length );
|
||||
$plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
@ -108,4 +204,23 @@ class Robotstxt_Manager_Encryption {
|
|||
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
|
||||
Requires at least: 4.4
|
||||
Tested up to: 7.1
|
||||
Stable tag: 0.5.1
|
||||
Stable tag: 0.5.3
|
||||
Requires PHP: 8.0
|
||||
Version: 0.5.1
|
||||
Version: 0.5.3
|
||||
License: GPL-3.0-or-later
|
||||
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 ==
|
||||
|
||||
= 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 =
|
||||
|
||||
_Release date: 2026-08-15_
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Plugin Name: Manager (by ROBOTSTXT)
|
||||
* 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.
|
||||
* Version: 0.5.1
|
||||
* Version: 0.5.3
|
||||
* Requires at least: 4.4
|
||||
* Requires PHP: 8.0
|
||||
* Author: ROBOTSTXT
|
||||
|
|
@ -21,7 +21,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
}
|
||||
|
||||
/** 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. */
|
||||
define( 'ROBOTSTXT_MANAGER_DIR', plugin_dir_path( __FILE__ ) );
|
||||
|
|
|
|||
Loading…
Reference in a new issue