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

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

208 lines 6.1 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 * Domain-separation context mixed into the HMAC key derivation. Versioned
37 * so the scheme can be rotated later without colliding with old values.
38 *
39 * @var string
40 */
41 const KEY_DERIVATION_INFO = 'zip-ai-enc-v1';
42
43 /**
44 * Prefix identifying sodium-encrypted values.
45 *
46 * @var string
47 */
48 const ENCRYPTED_PREFIX = 'sodium:';
49
50 /**
51 * Derive the 32-byte encryption key.
52 *
53 * The key is NOT stored anywhere. It is derived on demand with HMAC-SHA256
54 * as a PRF — `wp_salt('secure_auth')` is the HMAC key, and a versioned
55 * context plus a per-site salt form the message — over two independent
56 * secrets:
57 *
58 * - `wp_salt('secure_auth')` — bound to the `SECURE_AUTH_KEY` /
59 * `SECURE_AUTH_SALT` constants in wp-config.php (filesystem). The plugin
60 * only READS these; it never needs to define them.
61 * - a per-site random salt persisted in {@see self::KEY_SALT_OPTION}
62 * (database).
63 *
64 * Splitting the secret across the filesystem and the database means a
65 * database-only compromise (SQL injection, a leaked backup, a read
66 * replica) cannot reconstruct the key — the attacker would also need the
67 * wp-config salts. This is the protection a DB-stored key cannot provide.
68 *
69 * Fails closed (returns '') when sodium/`wp_salt()` are unavailable or the
70 * CSPRNG cannot mint the salt — callers treat '' as "not stored" rather
71 * than fataling, matching {@see self::encrypt()} / {@see self::decrypt()}.
72 *
73 * @since 1.1.0
74 * @return string The 32-byte derived key, or '' when unavailable.
75 */
76 private static function get_derived_key() {
77 if ( ! function_exists( 'wp_salt' ) ) {
78 return '';
79 }
80
81 try {
82 $salt = get_option( self::KEY_SALT_OPTION );
83 if ( ! is_string( $salt ) || '' === $salt ) {
84 // First use on this site — mint the DB half of the secret.
85 // add_option() will NOT clobber an existing value, so under a
86 // concurrent first-use race the first writer wins; the re-read
87 // below settles every racer on that same persisted salt.
88 add_option( self::KEY_SALT_OPTION, base64_encode( random_bytes( 32 ) ), '', false ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
89 $salt = get_option( self::KEY_SALT_OPTION );
90 if ( ! is_string( $salt ) || '' === $salt ) {
91 return '';
92 }
93 }
94
95 // Raw 32-byte output = SODIUM_CRYPTO_SECRETBOX_KEYBYTES.
96 return hash_hmac(
97 'sha256',
98 self::KEY_DERIVATION_INFO . '|' . $salt,
99 wp_salt( 'secure_auth' ),
100 true
101 );
102 } catch ( \Exception $e ) {
103 // random_bytes() can throw when the platform CSPRNG is unavailable.
104 return '';
105 }
106 }
107
108 /**
109 * Encrypt data using sodium_crypto_secretbox under the derived key.
110 *
111 * @param string $input The input string which needs to be encrypted.
112 * @since 1.0.0
113 * @return string The encrypted string (prefixed with 'sodium:' and base64 encoded), or ''.
114 */
115 public static function encrypt( $input ) {
116 // If the input is empty or not a string, then abandon ship.
117 if ( empty( $input ) || ! is_string( $input ) ) {
118 return '';
119 }
120
121 // Check if sodium is available.
122 if ( ! function_exists( 'sodium_crypto_secretbox' ) ) {
123 return '';
124 }
125
126 $key = self::get_derived_key();
127 if ( '' === $key ) {
128 return '';
129 }
130
131 try {
132 // Generate a random nonce.
133 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
134
135 // Encrypt the data.
136 $ciphertext = sodium_crypto_secretbox( $input, $nonce, $key );
137
138 // Combine nonce + ciphertext and encode.
139 $encrypted = base64_encode( $nonce . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
140
141 // Add prefix to identify this as sodium-encrypted.
142 return self::ENCRYPTED_PREFIX . $encrypted;
143 } catch ( \Exception $e ) {
144 // If encryption fails, return empty.
145 return '';
146 }
147 }
148
149 /**
150 * Decrypt data using sodium_crypto_secretbox under the derived key.
151 *
152 * @param string $input The input string which needs to be decrypted.
153 * @since 1.0.0
154 * @return string The decrypted string.
155 */
156 public static function decrypt( $input ) {
157 // If the input is empty or not a string, then abandon ship.
158 if ( empty( $input ) || ! is_string( $input ) ) {
159 return '';
160 }
161
162 // Check if this is a sodium-encrypted value.
163 if ( strpos( $input, self::ENCRYPTED_PREFIX ) !== 0 ) {
164 return '';
165 }
166
167 // Check if sodium is available.
168 if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) {
169 return '';
170 }
171
172 $key = self::get_derived_key();
173 if ( '' === $key ) {
174 return '';
175 }
176
177 try {
178 // Remove prefix and decode.
179 $encrypted = substr( $input, strlen( self::ENCRYPTED_PREFIX ) );
180 $decoded = base64_decode( $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
181
182 if ( false === $decoded ) {
183 return '';
184 }
185
186 // Extract nonce and ciphertext.
187 $nonce = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
188 $ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
189
190 if ( strlen( $nonce ) !== SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
191 return '';
192 }
193
194 // Decrypt.
195 $plaintext = sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );
196
197 if ( false === $plaintext ) {
198 // Decryption failed (wrong key or corrupted data).
199 return '';
200 }
201
202 return $plaintext;
203 } catch ( \Exception $e ) {
204 return '';
205 }
206 }
207 }
208