PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / trunk
ZIP AI – AI Website Builder & AI Agent (Beta) vtrunk
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / utils.php

utils.php in ZIP AI – AI Website Builder & AI Agent (Beta) trunk, at inc/core/utils.php

248 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ZIP AI - Utils.
4 *
5 * This file contains all the utility functions of ZIP AI.
6 * Utilities manipulate data and perform actions that are not directly related to the library.
7 *
8 * @package zip-ai
9 */
10
11 namespace ZipAI\MCP\Classes\Core;
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * The Utils Class.
20 */
21 class Utils {
22
23 /**
24 * Option name for the per-site key salt. A random 32-byte value (base64)
25 * that forms HALF of the encryption-key input — the other half is
26 * `wp_salt()`, which lives in wp-config.php (filesystem), not the
27 * database. An attacker therefore needs BOTH a database dump (this salt +
28 * the ciphertext) AND filesystem access (wp-config salts) to decrypt;
29 * neither surface alone is sufficient.
30 *
31 * @var string
32 */
33 const KEY_SALT_OPTION = 'zipwp_mcp_key_salt';
34
35 /**
36 * Coerce a mixed value to a string (scalars cast; anything else falls back).
37 *
38 * Single source of truth for the scalar-coercion pattern used across the
39 * plugin when reading untyped input (decoded JSON, request args, options).
40 *
41 * @param mixed $value Raw value.
42 * @param string $default Fallback when the value is not a scalar.
43 * @return string
44 */
45 public static function to_str( $value, string $default = '' ): string {
46 return is_scalar( $value ) ? (string) $value : $default;
47 }
48
49 /**
50 * Coerce a mixed value to an int (scalars cast; anything else falls back).
51 *
52 * @param mixed $value Raw value.
53 * @param int $default Fallback when the value is not a scalar.
54 * @return int
55 */
56 public static function to_int( $value, int $default = 0 ): int {
57 return is_scalar( $value ) ? (int) $value : $default;
58 }
59
60 /**
61 * Log a developer diagnostic, gated on WP_DEBUG so production logs stay
62 * quiet. ONE place for the WP_DEBUG gate, the `[zip-ai]` prefix, and the
63 * error_log phpcs allowance — call sites pass a context and the raw detail
64 * (an exception / WP_Error message) that must NEVER be returned to the client.
65 *
66 * @param string $context Human context, e.g. 'Media upload failed'.
67 * @param string $detail Raw diagnostic message. Optional.
68 * @return void
69 */
70 public static function debug_log( string $context, string $detail = '' ): void {
71 if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
72 return;
73 }
74 $line = '' === $detail ? $context : $context . ': ' . $detail;
75 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated developer diagnostic; centralized so call sites don't repeat the gate.
76 error_log( '[zip-ai] ' . $line );
77 }
78
79 /**
80 * Domain-separation context mixed into the HMAC key derivation. Versioned
81 * so the scheme can be rotated later without colliding with old values.
82 *
83 * @var string
84 */
85 const KEY_DERIVATION_INFO = 'zip-ai-enc-v1';
86
87 /**
88 * Prefix identifying sodium-encrypted values.
89 *
90 * @var string
91 */
92 const ENCRYPTED_PREFIX = 'sodium:';
93
94 /**
95 * Derive the 32-byte encryption key.
96 *
97 * The key is NOT stored anywhere. It is derived on demand with HMAC-SHA256
98 * as a PRF — `wp_salt('secure_auth')` is the HMAC key, and a versioned
99 * context plus a per-site salt form the message — over two independent
100 * secrets:
101 *
102 * - `wp_salt('secure_auth')` — bound to the `SECURE_AUTH_KEY` /
103 * `SECURE_AUTH_SALT` constants in wp-config.php (filesystem). The plugin
104 * only READS these; it never needs to define them.
105 * - a per-site random salt persisted in {@see self::KEY_SALT_OPTION}
106 * (database).
107 *
108 * Splitting the secret across the filesystem and the database means a
109 * database-only compromise (SQL injection, a leaked backup, a read
110 * replica) cannot reconstruct the key — the attacker would also need the
111 * wp-config salts. This is the protection a DB-stored key cannot provide.
112 *
113 * Fails closed (returns '') when sodium/`wp_salt()` are unavailable or the
114 * CSPRNG cannot mint the salt — callers treat '' as "not stored" rather
115 * than fataling, matching {@see self::encrypt()} / {@see self::decrypt()}.
116 *
117 * @since 1.1.0
118 * @return string The 32-byte derived key, or '' when unavailable.
119 */
120 private static function get_derived_key() {
121 if ( ! function_exists( 'wp_salt' ) ) {
122 return '';
123 }
124
125 try {
126 $salt = get_option( self::KEY_SALT_OPTION );
127 if ( ! is_string( $salt ) || '' === $salt ) {
128 // First use on this site — mint the DB half of the secret.
129 // add_option() will NOT clobber an existing value, so under a
130 // concurrent first-use race the first writer wins; the re-read
131 // below settles every racer on that same persisted salt.
132 add_option( self::KEY_SALT_OPTION, base64_encode( random_bytes( 32 ) ), '', false ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
133 $salt = get_option( self::KEY_SALT_OPTION );
134 if ( ! is_string( $salt ) || '' === $salt ) {
135 return '';
136 }
137 }
138
139 // Raw 32-byte output = SODIUM_CRYPTO_SECRETBOX_KEYBYTES.
140 return hash_hmac(
141 'sha256',
142 self::KEY_DERIVATION_INFO . '|' . $salt,
143 wp_salt( 'secure_auth' ),
144 true
145 );
146 } catch ( \Exception $e ) {
147 // random_bytes() can throw when the platform CSPRNG is unavailable.
148 return '';
149 }
150 }
151
152 /**
153 * Encrypt data using sodium_crypto_secretbox under the derived key.
154 *
155 * @param string $input The input string which needs to be encrypted.
156 * @since 1.0.0
157 * @return string The encrypted string (prefixed with 'sodium:' and base64 encoded), or ''.
158 */
159 public static function encrypt( $input ) {
160 // If the input is empty, then abandon ship.
161 if ( empty( $input ) ) {
162 return '';
163 }
164
165 // Check if sodium is available.
166 if ( ! function_exists( 'sodium_crypto_secretbox' ) ) {
167 return '';
168 }
169
170 $key = self::get_derived_key();
171 if ( '' === $key ) {
172 return '';
173 }
174
175 try {
176 // Generate a random nonce.
177 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
178
179 // Encrypt the data.
180 $ciphertext = sodium_crypto_secretbox( $input, $nonce, $key );
181
182 // Combine nonce + ciphertext and encode.
183 $encrypted = base64_encode( $nonce . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
184
185 // Add prefix to identify this as sodium-encrypted.
186 return self::ENCRYPTED_PREFIX . $encrypted;
187 } catch ( \Exception $e ) {
188 // If encryption fails, return empty.
189 return '';
190 }
191 }
192
193 /**
194 * Decrypt data using sodium_crypto_secretbox under the derived key.
195 *
196 * @param string $input The input string which needs to be decrypted.
197 * @since 1.0.0
198 * @return string The decrypted string.
199 */
200 public static function decrypt( $input ) {
201 // If the input is empty, then abandon ship.
202 if ( empty( $input ) ) {
203 return '';
204 }
205
206 // Check if this is a sodium-encrypted value.
207 if ( strpos( $input, self::ENCRYPTED_PREFIX ) !== 0 ) {
208 return '';
209 }
210
211 // Check if sodium is available.
212 if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) {
213 return '';
214 }
215
216 $key = self::get_derived_key();
217 if ( '' === $key ) {
218 return '';
219 }
220
221 try {
222 // Remove prefix and decode.
223 $encrypted = substr( $input, strlen( self::ENCRYPTED_PREFIX ) );
224 $decoded = base64_decode( $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
225
226 // Extract nonce and ciphertext.
227 $nonce = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
228 $ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
229
230 if ( strlen( $nonce ) !== SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
231 return '';
232 }
233
234 // Decrypt.
235 $plaintext = sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );
236
237 if ( false === $plaintext ) {
238 // Decryption failed (wrong key or corrupted data).
239 return '';
240 }
241
242 return $plaintext;
243 } catch ( \Exception $e ) {
244 return '';
245 }
246 }
247 }
248