PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
mainwp / class / class-mainwp-credential-storage.php

class-mainwp-credential-storage.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.1, at class/class-mainwp-credential-storage.php

146 lines 5.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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