| 1 |
<?php |
| 2 |
/** |
| 3 |
* Credential storage helpers: at-rest encryption for site credentials. |
| 4 |
* |
| 5 |
* @package MainWP\Dashboard |
| 6 |
* @since 6.0.12 |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace MainWP\Dashboard; |
| 10 |
|
| 11 |
/** |
| 12 |
* Class MainWP_Credential_Storage |
| 13 |
* |
| 14 |
* MWP-1548: encrypt site http_user / http_pass at rest using the same |
| 15 |
* mainwp_encrypt_key_value / mainwp_decrypt_key_value filter pair that |
| 16 |
* 3rd-party API keys (MWP-1546, MWP-1543) use. The wp_mainwp_wp.http_pass |
| 17 |
* column stored these credentials in plaintext for years; a DB-only |
| 18 |
* compromise (backup, dump, log leak) exposed every child site's HTTP |
| 19 |
* Basic Auth password. |
| 20 |
* |
| 21 |
* Storage shape: a serialized envelope `{encrypted_val, file_key}` is |
| 22 |
* produced by MainWP_Keys_Manager and JSON-encoded with a fixed prefix |
| 23 |
* so detection in mixed plaintext/ciphertext storage is unambiguous. |
| 24 |
* |
| 25 |
* __mwpenc__:{"encrypted_val":"...","file_key":"..."} |
| 26 |
* |
| 27 |
* Detection is exact (prefix-based, not content-based) so a real |
| 28 |
* password that happens to look like JSON cannot be misclassified. |
| 29 |
* |
| 30 |
* Legacy plaintext rows continue to work via decrypt_credential()'s |
| 31 |
* pass-through fallback. New writes always produce envelopes when the |
| 32 |
* encryption layer is healthy; failures are surfaced to the caller via |
| 33 |
* a false return so persistence can be refused (fail-closed write). |
| 34 |
*/ |
| 35 |
class MainWP_Credential_Storage { |
| 36 |
|
| 37 |
/** |
| 38 |
* Sentinel prefix that marks a stored value as a credential envelope. |
| 39 |
* |
| 40 |
* The double-underscore + lowercase + colon shape is intentionally |
| 41 |
* unlikely to appear at the start of any real HTTP Basic Auth |
| 42 |
* password. Detection is prefix-exact so we never misclassify a |
| 43 |
* legitimate password that happens to contain JSON-shaped bytes. |
| 44 |
* |
| 45 |
* @var string |
| 46 |
*/ |
| 47 |
const ENVELOPE_PREFIX = '__mwpenc__:'; |
| 48 |
|
| 49 |
/** |
| 50 |
* Encrypt a plaintext credential for at-rest storage. |
| 51 |
* |
| 52 |
* Returns: |
| 53 |
* - the prefixed envelope string on success |
| 54 |
* - the input unchanged when input is empty / null / not a string |
| 55 |
* - the input unchanged when input is already an envelope (idempotent) |
| 56 |
* - false when the encryption layer fails to produce an envelope |
| 57 |
* |
| 58 |
* Callers MUST treat a `false` return as a hard failure and refuse |
| 59 |
* to persist; otherwise the column silently downgrades to plaintext. |
| 60 |
* |
| 61 |
* @param mixed $plaintext Credential to encrypt. |
| 62 |
* @param string $field Field name (used in keyfile prefix). |
| 63 |
* @return string|false|mixed |
| 64 |
*/ |
| 65 |
public static function encrypt_credential( $plaintext, $field = 'http_pass' ) { |
| 66 |
if ( null === $plaintext || '' === $plaintext ) { |
| 67 |
return $plaintext; |
| 68 |
} |
| 69 |
if ( ! is_string( $plaintext ) ) { |
| 70 |
return $plaintext; |
| 71 |
} |
| 72 |
if ( static::is_credential_envelope( $plaintext ) ) { |
| 73 |
return $plaintext; |
| 74 |
} |
| 75 |
$prefix = 'site_credential_' . sanitize_key( $field ) . '_'; |
| 76 |
$envelope = apply_filters( 'mainwp_encrypt_key_value', false, $plaintext, $prefix, false ); |
| 77 |
if ( ! is_array( $envelope ) |
| 78 |
|| empty( $envelope['encrypted_val'] ) |
| 79 |
|| empty( $envelope['file_key'] ) ) { |
| 80 |
return false; |
| 81 |
} |
| 82 |
$encoded = wp_json_encode( $envelope ); |
| 83 |
if ( ! is_string( $encoded ) || '' === $encoded ) { |
| 84 |
return false; |
| 85 |
} |
| 86 |
return self::ENVELOPE_PREFIX . $encoded; |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Decrypt a stored credential. |
| 91 |
* |
| 92 |
* Returns: |
| 93 |
* - plaintext when input is an envelope and decryption succeeds |
| 94 |
* - the input unchanged when input is legacy plaintext |
| 95 |
* - empty string when input is an envelope but decryption fails |
| 96 |
* (drop ciphertext rather than surfacing an unreadable blob to |
| 97 |
* callers who would forward it as a real Basic Auth password) |
| 98 |
* |
| 99 |
* @param mixed $stored Value as read from storage. |
| 100 |
* @return string|mixed |
| 101 |
*/ |
| 102 |
public static function decrypt_credential( $stored ) { |
| 103 |
if ( null === $stored || '' === $stored ) { |
| 104 |
return $stored; |
| 105 |
} |
| 106 |
if ( ! is_string( $stored ) ) { |
| 107 |
return $stored; |
| 108 |
} |
| 109 |
if ( ! static::is_credential_envelope( $stored ) ) { |
| 110 |
return $stored; |
| 111 |
} |
| 112 |
$json = substr( $stored, strlen( self::ENVELOPE_PREFIX ) ); |
| 113 |
$envelope = json_decode( $json, true ); |
| 114 |
if ( ! is_array( $envelope ) |
| 115 |
|| empty( $envelope['encrypted_val'] ) |
| 116 |
|| empty( $envelope['file_key'] ) ) { |
| 117 |
return ''; |
| 118 |
} |
| 119 |
$decrypted = apply_filters( 'mainwp_decrypt_key_value', false, $envelope, '' ); |
| 120 |
if ( is_string( $decrypted ) && '' !== $decrypted ) { |
| 121 |
return $decrypted; |
| 122 |
} |
| 123 |
return ''; |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Is $stored a credential envelope produced by encrypt_credential()? |
| 128 |
* |
| 129 |
* Detection is by prefix only, so it cannot misclassify a real |
| 130 |
* password that happens to be JSON-shaped. |
| 131 |
* |
| 132 |
* @param mixed $stored Value as read from storage. |
| 133 |
* @return bool |
| 134 |
*/ |
| 135 |
public static function is_credential_envelope( $stored ) { |
| 136 |
if ( ! is_string( $stored ) ) { |
| 137 |
return false; |
| 138 |
} |
| 139 |
$prefix_len = strlen( self::ENVELOPE_PREFIX ); |
| 140 |
if ( strlen( $stored ) <= $prefix_len ) { |
| 141 |
return false; |
| 142 |
} |
| 143 |
return 0 === strncmp( $stored, self::ENVELOPE_PREFIX, $prefix_len ); |
| 144 |
} |
| 145 |
} |
| 146 |
|