| @@ -11,8 +11,13 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | namespace MainWP\Dashboard; |
| 14 | 14 | |
| 15 | +// Exit if accessed directly. | |
| 16 | +if ( ! defined( 'ABSPATH' ) ) { | |
| 17 | + exit; | |
| 18 | +} | |
| 19 | + | |
| 15 | 20 | /** |
| 16 | 21 | * Class MainWP_Api_Manager |
| 17 | 22 | * |
| 18 | 23 | * @package MainWP\Dashboard |
| @@ -117,12 +122,81 @@ | ||
| 117 | 122 | if ( empty( $ext_key ) ) { |
| 118 | 123 | return array(); |
| 119 | 124 | } |
| 120 | 125 | |
| 121 | - return get_option( $ext_key . '_APIManAdder' ); | |
| 126 | + $info = get_option( $ext_key . '_APIManAdder' ); | |
| 127 | + | |
| 128 | + // MWP-1546: per-extension license keys were historically stored as | |
| 129 | + // plaintext inside the option array's 'api_key' field. New writes | |
| 130 | + // (set_activation_info below) replace that field with a | |
| 131 | + // {encrypted_val, file_key} envelope produced by the | |
| 132 | + // mainwp_encrypt_key_value filter (MainWP_Keys_Manager). On read, | |
| 133 | + // the matching mainwp_decrypt_key_value filter reverses the | |
| 134 | + // envelope. | |
| 135 | + // | |
| 136 | + // Encrypt-on-first-read migration: if the stored row still carries | |
| 137 | + // a plaintext api_key string (legacy installs upgraded from before | |
| 138 | + // this change), rewrite it as the encrypted envelope right now. | |
| 139 | + // Existing dashboards migrate transparently as each extension's | |
| 140 | + // option is touched, without waiting for an unrelated activation | |
| 141 | + // event. We persist via update_option directly rather than calling | |
| 142 | + // set_activation_info to avoid churning the activations_cached | |
| 143 | + // option on every legacy read. | |
| 144 | + if ( is_array( $info ) && ! empty( $info['api_key'] ) && is_string( $info['api_key'] ) ) { | |
| 145 | + $rewritten = static::encrypt_activation_info( $ext_key, $info ); | |
| 146 | + if ( is_array( $rewritten ) | |
| 147 | + && isset( $rewritten['api_key'] ) | |
| 148 | + && is_array( $rewritten['api_key'] ) | |
| 149 | + && ! empty( $rewritten['api_key']['encrypted_val'] ) ) { | |
| 150 | + MainWP_Utility::update_option( $ext_key . '_APIManAdder', $rewritten ); | |
| 151 | + } | |
| 152 | + } | |
| 153 | + | |
| 154 | + return static::decrypt_activation_info( $info ); | |
| 122 | 155 | } |
| 123 | 156 | |
| 124 | 157 | /** |
| 158 | + * Reverse the api_key encryption applied by set_activation_info(). | |
| 159 | + * | |
| 160 | + * Returns the input unchanged when api_key is missing, empty, or a | |
| 161 | + * plaintext string (legacy format). Falls back to the original value | |
| 162 | + * if the mainwp_decrypt_key_value filter cannot recover a string, so | |
| 163 | + * a missing keyfile cannot orphan a license activation. | |
| 164 | + * | |
| 165 | + * @param mixed $info Raw option value as returned by get_option(). | |
| 166 | + * @return mixed Same shape as $info, with 'api_key' decrypted to plaintext. | |
| 167 | + */ | |
| 168 | + public static function decrypt_activation_info( $info ) { | |
| 169 | + if ( ! is_array( $info ) || empty( $info['api_key'] ) ) { | |
| 170 | + return $info; | |
| 171 | + } | |
| 172 | + if ( ! is_array( $info['api_key'] ) ) { | |
| 173 | + return $info; // Legacy plaintext string, no envelope to reverse. | |
| 174 | + } | |
| 175 | + if ( empty( $info['api_key']['encrypted_val'] ) ) { | |
| 176 | + // Malformed envelope (array but missing encrypted_val). Drop the | |
| 177 | + // unreadable value rather than returning the raw array; callers | |
| 178 | + // (api-handler.php readers, the hooks filter) do not is_string- | |
| 179 | + // guard the result and would forward the array to the licensing | |
| 180 | + // API as http_build_query garbage. Mirrors the decrypt-failure | |
| 181 | + // branch below. | |
| 182 | + $info['api_key'] = ''; | |
| 183 | + return $info; | |
| 184 | + } | |
| 185 | + $decrypted = apply_filters( 'mainwp_decrypt_key_value', false, $info['api_key'], '' ); | |
| 186 | + if ( is_string( $decrypted ) && '' !== $decrypted ) { | |
| 187 | + $info['api_key'] = $decrypted; | |
| 188 | + } else { | |
| 189 | + // Decrypt failed (e.g. missing keyfile). Drop the unreadable | |
| 190 | + // ciphertext so callers see an empty string rather than the | |
| 191 | + // raw envelope array, which they would treat as truthy and | |
| 192 | + // forward to the licensing API as garbage. | |
| 193 | + $info['api_key'] = ''; | |
| 194 | + } | |
| 195 | + return $info; | |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 125 | 199 | * Store activation info. |
| 126 | 200 | * |
| 127 | 201 | * @param mixed $ext_key Extension key. |
| 128 | 202 | * @param mixed $info Activation information. |
| @@ -138,12 +212,59 @@ | ||
| 138 | 212 | |
| 139 | 213 | // Clear cached of all activations to reload for next loading. |
| 140 | 214 | update_option( 'mainwp_extensions_all_activation_cached', '' ); |
| 141 | 215 | |
| 216 | + // MWP-1546: encrypt the 'api_key' field at rest using the same | |
| 217 | + // mainwp_encrypt_key_value filter that 3rd-party API keys use. Other | |
| 218 | + // array members (activated_key, deactivate_checkbox, product_id, | |
| 219 | + // instance_id, software_version, mainwp_version, product_item_id) | |
| 220 | + // are not credentials and stay plaintext for backwards compatibility | |
| 221 | + // with any reader that consumes them directly. | |
| 222 | + $info = static::encrypt_activation_info( $ext_key, $info ); | |
| 223 | + | |
| 224 | + // Codex follow-up: fail closed when encryption did not produce an | |
| 225 | + // envelope. encrypt_activation_info() returns the input unchanged | |
| 226 | + // when the mainwp_encrypt_key_value filter fails (missing keyfile, | |
| 227 | + // un-writable uploads dir, etc.), which would leave api_key as a | |
| 228 | + // plaintext string and silently downgrade this write back to the | |
| 229 | + // pre-MWP-1546 leak path. Refuse the write and let the caller | |
| 230 | + // surface the error rather than persisting plaintext credentials. | |
| 231 | + if ( is_array( $info ) | |
| 232 | + && isset( $info['api_key'] ) | |
| 233 | + && is_string( $info['api_key'] ) | |
| 234 | + && '' !== $info['api_key'] ) { | |
| 235 | + return false; | |
| 236 | + } | |
| 237 | + | |
| 142 | 238 | return MainWP_Utility::update_option( $ext_key . '_APIManAdder', $info ); |
| 143 | 239 | } |
| 144 | 240 | |
| 145 | 241 | /** |
| 242 | + * Encrypt the api_key field of an activation-info array via the | |
| 243 | + * mainwp_encrypt_key_value filter (MainWP_Keys_Manager). Replaces the | |
| 244 | + * plaintext string with the {encrypted_val, file_key} envelope. | |
| 245 | + * | |
| 246 | + * @param string $ext_key Extension slug; included in the keyfile prefix. | |
| 247 | + * @param mixed $info Activation info as supplied by callers. | |
| 248 | + * @return mixed Same shape as $info, with 'api_key' replaced by the | |
| 249 | + * encryption envelope (or unchanged if encryption fails). | |
| 250 | + */ | |
| 251 | + public static function encrypt_activation_info( $ext_key, $info ) { | |
| 252 | + if ( ! is_array( $info ) ) { | |
| 253 | + return $info; | |
| 254 | + } | |
| 255 | + if ( empty( $info['api_key'] ) || ! is_string( $info['api_key'] ) ) { | |
| 256 | + return $info; | |
| 257 | + } | |
| 258 | + $prefix = 'extension_' . sanitize_key( $ext_key ) . '_'; | |
| 259 | + $envelope = apply_filters( 'mainwp_encrypt_key_value', false, $info['api_key'], $prefix, false ); | |
| 260 | + if ( is_array( $envelope ) && ! empty( $envelope['encrypted_val'] ) ) { | |
| 261 | + $info['api_key'] = $envelope; | |
| 262 | + } | |
| 263 | + return $info; | |
| 264 | + } | |
| 265 | + | |
| 266 | + /** | |
| 146 | 267 | * Remove activation info. |
| 147 | 268 | * |
| 148 | 269 | * @param mixed $ext_key Extension key. |
| 149 | 270 | * |
| @@ -250,9 +371,18 @@ | ||
| 250 | 371 | if ( ! empty( $error ) ) { |
| 251 | 372 | $return['error'] = $error; |
| 252 | 373 | } |
| 253 | 374 | |
| 254 | - $this->set_activation_info( $api_slug, $options ); | |
| 375 | + // MWP-1546 follow-up: surface the fail-closed write so the user is not | |
| 376 | + // told the activation succeeded when only the upstream half landed. | |
| 377 | + // The license slot was already consumed at mainwp.com; without the | |
| 378 | + // local persist the dashboard will offer to re-activate and burn a | |
| 379 | + // second slot. The encrypt filter only fails on broken installs | |
| 380 | + // (missing keyfile, un-writable uploads/mainwp/pk/), but when it | |
| 381 | + // does the operator needs to know. | |
| 382 | + if ( false === $this->set_activation_info( $api_slug, $options ) ) { | |
| 383 | + $return['error'] = esc_html__( 'License activated upstream but local state could not be saved. Check uploads/mainwp/pk/ permissions and try the activation again.', 'mainwp' ); | |
| 384 | + } | |
| 255 | 385 | |
| 256 | 386 | return $return; |
| 257 | 387 | } else { |
| 258 | 388 | return array( 'result' => 'SUCCESS' ); |
| @@ -329,9 +459,14 @@ | ||
| 329 | 459 | $options['api_key'] = ''; |
| 330 | 460 | $options['activated_key'] = 'Deactivated'; |
| 331 | 461 | } |
| 332 | 462 | |
| 333 | - $this->set_activation_info( $api_slug, $options ); | |
| 463 | + // MWP-1546 follow-up: surface the fail-closed write. The slot was | |
| 464 | + // already released at mainwp.com; without the local persist the | |
| 465 | + // dashboard would still display the extension as Activated. | |
| 466 | + if ( false === $this->set_activation_info( $api_slug, $options ) ) { | |
| 467 | + $return['error'] = esc_html__( 'License deactivated upstream but local state could not be cleared. Check uploads/mainwp/pk/ permissions.', 'mainwp' ); | |
| 468 | + } | |
| 334 | 469 | |
| 335 | 470 | return $return; |
| 336 | 471 | } |
| 337 | 472 | // to fix: clear cached of all activations to reload for next loading. |
| @@ -475,9 +610,15 @@ | ||
| 475 | 610 | if ( ! empty( $error ) ) { |
| 476 | 611 | $return['error'] = $error; |
| 477 | 612 | } |
| 478 | 613 | |
| 479 | - $this->set_activation_info( $api_slug, $options ); | |
| 614 | + // MWP-1546 follow-up: surface the fail-closed write. The grab | |
| 615 | + // call may have produced a fresh license key upstream; if the | |
| 616 | + // local persist fails the dashboard would lose track of it | |
| 617 | + // entirely. | |
| 618 | + if ( false === $this->set_activation_info( $api_slug, $options ) ) { | |
| 619 | + $return['error'] = esc_html__( 'License retrieved upstream but local state could not be saved. Check uploads/mainwp/pk/ permissions and try again.', 'mainwp' ); | |
| 620 | + } | |
| 480 | 621 | |
| 481 | 622 | return $return; |
| 482 | 623 | } else { |
| 483 | 624 | return array( 'error' => esc_html__( 'MainWP API key are required in order to grab extensions API keys.', 'mainwp' ) ); |
| @@ -563,8 +704,9 @@ | ||
| 563 | 704 | case 'subscription_expired': |
| 564 | 705 | $return = esc_html__( 'Your membership has expired. Reactivate your membership to install MainWP extensions.', 'mainwp' ); |
| 565 | 706 | break; |
| 566 | 707 | default: // download_revoked. |
| 708 | + /* translators: 1: Extension/software title, 2: URL to account dashboard */ | |
| 567 | 709 | $return = sprintf( esc_html__( 'Download permission for %1$s has been revoked possibly due to a license key or membership expiring. You can reactivate or purchase a license key from your account <a href="%2$s" target="_blank">dashboard</a>.', 'mainwp' ), $software_title, $this->renew_license_url ); |
| 568 | 710 | break; |
| 569 | 711 | } |
| 570 | 712 | return $return; |