44
55namespace Bow \Security ;
66
7- use Bow \ Support \ Str ;
7+ use RuntimeException ;
88
99class Crypto
1010{
@@ -22,6 +22,19 @@ class Crypto
2222 */
2323 private static string $ cipher = 'AES-256-CBC ' ;
2424
25+ /**
26+ * Header tagging the authenticated (random-IV + HMAC) payload format.
27+ *
28+ * The ':' is not part of the base64 alphabet, so a value carrying this
29+ * prefix can never be confused with a legacy (base64-only) ciphertext.
30+ */
31+ private const HEADER = 'BOW2: ' ;
32+
33+ /**
34+ * The authentication tag length in bytes (HMAC-SHA256).
35+ */
36+ private const MAC_LENGTH = 32 ;
37+
2538 /**
2639 * Set the key
2740 *
@@ -38,33 +51,137 @@ public static function setKey(string $key, ?string $cipher = null): void
3851 }
3952
4053 /**
41- * Encrypt data
54+ * Encrypt data.
55+ *
56+ * Produces an authenticated payload: a fresh random IV is used for every
57+ * call (so identical plaintexts yield different ciphertexts) and an
58+ * encrypt-then-MAC HMAC-SHA256 tag protects against tampering.
4259 *
4360 * @param string $data
44- * @return string|bool
61+ * @return string
4562 */
46- public static function encrypt (string $ data ): string | bool
63+ public static function encrypt (string $ data ): string
4764 {
48- $ iv_size = openssl_cipher_iv_length (static ::$ cipher );
65+ $ key = static ::resolveKey ();
66+
67+ $ iv_size = (int ) openssl_cipher_iv_length (static ::$ cipher );
68+ $ iv = random_bytes ($ iv_size );
69+
70+ $ cipher_text = openssl_encrypt (
71+ $ data ,
72+ static ::$ cipher ,
73+ static ::deriveKey ('enc ' , $ key ),
74+ OPENSSL_RAW_DATA ,
75+ $ iv
76+ );
77+
78+ if ($ cipher_text === false ) {
79+ throw new RuntimeException ('Unable to encrypt the given data. ' );
80+ }
4981
50- $ iv = Str:: slice ( sha1 ( static ::$ key ), 0 , $ iv_size );
82+ $ mac = hash_hmac ( ' sha256 ' , $ iv . $ cipher_text , static ::deriveKey ( ' auth ' , $ key ), true );
5183
52- return openssl_encrypt ( $ data , static :: $ cipher , static :: $ key , 0 , $ iv );
84+ return self :: HEADER . base64_encode ( $ iv . $ mac . $ cipher_text );
5385 }
5486
5587 /**
56- * decrypt
88+ * Decrypt data.
89+ *
90+ * Authenticated payloads are verified before decryption and fail closed
91+ * (return false) on a bad tag, truncation or wrong key. Values produced by
92+ * the previous unauthenticated format are still readable for backward
93+ * compatibility.
5794 *
5895 * @param string $data
5996 *
6097 * @return string|bool
6198 */
6299 public static function decrypt (string $ data ): string |bool
63100 {
64- $ iv_size = openssl_cipher_iv_length (static ::$ cipher );
101+ $ key = static ::resolveKey ();
102+
103+ if (!str_starts_with ($ data , self ::HEADER )) {
104+ return static ::decryptLegacy ($ data , $ key );
105+ }
106+
107+ $ raw = base64_decode (substr ($ data , strlen (self ::HEADER )), true );
108+
109+ if ($ raw === false ) {
110+ return false ;
111+ }
112+
113+ $ iv_size = (int ) openssl_cipher_iv_length (static ::$ cipher );
114+
115+ if (strlen ($ raw ) <= $ iv_size + self ::MAC_LENGTH ) {
116+ return false ;
117+ }
118+
119+ $ iv = substr ($ raw , 0 , $ iv_size );
120+ $ mac = substr ($ raw , $ iv_size , self ::MAC_LENGTH );
121+ $ cipher_text = substr ($ raw , $ iv_size + self ::MAC_LENGTH );
122+
123+ $ calculated = hash_hmac ('sha256 ' , $ iv . $ cipher_text , static ::deriveKey ('auth ' , $ key ), true );
124+
125+ // Reject tampered or wrong-key payloads before touching the cipher.
126+ if (!hash_equals ($ calculated , $ mac )) {
127+ return false ;
128+ }
129+
130+ return openssl_decrypt (
131+ $ cipher_text ,
132+ static ::$ cipher ,
133+ static ::deriveKey ('enc ' , $ key ),
134+ OPENSSL_RAW_DATA ,
135+ $ iv
136+ );
137+ }
138+
139+ /**
140+ * Decrypt a value produced by the legacy (static IV, unauthenticated)
141+ * format. Kept only so data encrypted before the upgrade keeps working.
142+ *
143+ * @param string $data
144+ * @param string $key
145+ * @return string|bool
146+ */
147+ private static function decryptLegacy (string $ data , string $ key ): string |bool
148+ {
149+ $ iv_size = (int ) openssl_cipher_iv_length (static ::$ cipher );
150+
151+ $ iv = substr (sha1 ($ key ), 0 , $ iv_size );
152+
153+ return openssl_decrypt ($ data , static ::$ cipher , $ key , 0 , $ iv );
154+ }
65155
66- $ iv = Str::slice (sha1 (static ::$ key ), 0 , $ iv_size );
156+ /**
157+ * Derive a purpose-specific 256-bit subkey from the configured key.
158+ *
159+ * Separating the encryption key from the authentication key (domain
160+ * separation) is required for encrypt-then-MAC to be sound, and normalises
161+ * an arbitrary-length configured key to a fixed strong key.
162+ *
163+ * @param string $context
164+ * @param string $key
165+ * @return string
166+ */
167+ private static function deriveKey (string $ context , string $ key ): string
168+ {
169+ return hash_hmac ('sha256 ' , 'BowCrypto|v2| ' . $ context , $ key , true );
170+ }
171+
172+ /**
173+ * Resolve the configured key or fail loudly when it is missing.
174+ *
175+ * @return string
176+ */
177+ private static function resolveKey (): string
178+ {
179+ if (static ::$ key === null || static ::$ key === '' ) {
180+ throw new RuntimeException (
181+ 'The application security key is not set. Define security.key before using Crypto. '
182+ );
183+ }
67184
68- return openssl_decrypt ( $ data , static ::$ cipher , static :: $ key, 0 , $ iv ) ;
185+ return static ::$ key ;
69186 }
70187}
0 commit comments