Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Encrypter
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
7 / 7
35
100.00% covered (success)
100.00%
1 / 1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 isAvailable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValid
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 generateKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 encrypt
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 decrypt
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
18
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Crypt\Encryption;
16
17/**
18 * Pop Crypt encrypter
19 *
20 * @category   Pop
21 * @package    Pop\Crypt
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    4.0.0
26 */
27class Encrypter extends AbstractEncrypter
28{
29
30    /**
31     * Cipher constants
32     */
33    const AES_128_CBC = 'aes-128-cbc';
34    const AES_256_CBC = 'aes-256-cbc';
35    const AES_128_GCM = 'aes-128-gcm';
36    const AES_256_GCM = 'aes-256-gcm';
37
38    /**
39     * Available ciphers
40     *
41     * @var array
42     */
43    private static $ciphers = [
44        'aes-128-cbc' => ['size' => 16, 'aead' => false],
45        'aes-256-cbc' => ['size' => 32, 'aead' => false],
46        'aes-128-gcm' => ['size' => 16, 'aead' => true],
47        'aes-256-gcm' => ['size' => 32, 'aead' => true],
48    ];
49
50    /**
51     * HKDF info string for the derived AES subkey
52     */
53    private const HKDF_ENCRYPTION_INFO = 'pop-crypt|encryption-key';
54
55    /**
56     * HKDF info string for the derived HMAC subkey
57     */
58    private const HKDF_MAC_INFO = 'pop-crypt|mac-key';
59
60    /**
61     * Create encrypter object
62     *
63     * @param  string $cipher
64     * @param  bool   $raw
65     * @return static
66     */
67    public static function create(string $cipher = 'aes-256-cbc', bool $raw = true): static
68    {
69        return new static(static::generateKey($cipher), $cipher, $raw);
70    }
71
72    /**
73     * Load encrypter object from $_ENV
74     *
75     * Defaults to treating APP_KEY/APP_PREVIOUS_KEYS as base64-encoded strings,
76     * since that's the standard way to store binary key material in a .env file.
77     *
78     * @param  bool $raw
79     * @throws Exception
80     * @return static
81     */
82    public static function load(bool $raw = false): static
83    {
84        $cipher       = null;
85        $key          = null;
86        $previousKeys = null;
87
88        if (!empty($_ENV['APP_CIPHER_METHOD'])) {
89            $cipher = trim($_ENV['APP_CIPHER_METHOD']);
90        }
91        if (!empty($_ENV['APP_KEY'])) {
92            $key = trim($_ENV['APP_KEY']);
93        }
94        if (!empty($_ENV['APP_PREVIOUS_KEYS'])) {
95            $previousKeys = array_map('trim', explode(',', $_ENV['APP_PREVIOUS_KEYS']));
96        }
97
98        if (empty($cipher) || empty($key)) {
99            throw new Exception('Error: The encryption properties could not be loaded.');
100        }
101
102        $encrypter = new static($key, $cipher, $raw);
103
104        if (!empty($previousKeys)) {
105            $encrypter->setPreviousKeys($previousKeys, $raw);
106        }
107
108        return $encrypter;
109    }
110
111    /**
112     * Determine if the cipher is available
113     *
114     * @param  string $cipher
115     * @return bool
116     */
117    public static function isAvailable(string $cipher): bool
118    {
119        return isset(self::$ciphers[strtolower($cipher)]);
120    }
121
122    /**
123     * Determine if the key and cipher combination is valid
124     *
125     * @param  string $key
126     * @param  string $cipher
127     * @param  bool   $raw
128     * @return bool
129     */
130    public static function isValid(string $key, string $cipher, bool $raw = true): bool
131    {
132        $cipher = strtolower($cipher);
133        if (!isset(self::$ciphers[$cipher])) {
134            return false;
135        }
136        if (!$raw) {
137            $key = base64_decode($key);
138        }
139        return (mb_strlen($key, '8bit') === self::$ciphers[$cipher]['size']);
140    }
141
142    /**
143     * Generate encryption key
144     *
145     * @param  string $cipher
146     * @param  bool   $raw
147     * @return string
148     */
149    public static function generateKey(string $cipher, bool $raw = true): string
150    {
151        $key = random_bytes((self::$ciphers[strtolower($cipher)]['size'] ?? 32));
152        return ($raw) ? $key : base64_encode($key);
153    }
154
155    /**
156     * Encrypt value
157     *
158     * @param  string $value
159     * @return string
160     */
161    public function encrypt(#[\SensitiveParameter] string $value): string
162    {
163        $aead   = self::$ciphers[$this->cipher]['aead'];
164        $iv     = random_bytes(openssl_cipher_iv_length(strtolower($this->cipher)));
165        $tag    = '';
166        $encKey = ($aead) ? $this->key : hash_hkdf('sha256', $this->key, 32, self::HKDF_ENCRYPTION_INFO);
167        $value  = openssl_encrypt($value, $this->cipher, $encKey, 0, $iv, $tag);
168        $iv     = base64_encode($iv);
169        $tag    = base64_encode(($tag ?? ''));
170        $mac    = (!$aead) ?
171            hash_hmac('sha256', $iv . $value, hash_hkdf('sha256', $this->key, 32, self::HKDF_MAC_INFO)) : '';
172
173        $json = json_encode([
174            'iv'    => $iv,
175            'value' => $value,
176            'mac'   => $mac,
177            'tag'   => $tag,
178        ], JSON_UNESCAPED_SLASHES);
179
180        return base64_encode($json);
181    }
182
183    /**
184     * Decrypt value
185     *
186     * @param  string $payload
187     * @throws Exception
188     * @return string
189     */
190    public function decrypt(string $payload): string
191    {
192        $payload = json_decode(base64_decode($payload), true);
193
194        if (!is_array($payload) || (!isset($payload['iv']) || !isset($payload['value']))) {
195            throw new Exception('Error: The payload is not valid data.');
196        }
197
198        $aead = self::$ciphers[$this->cipher]['aead'];
199
200        // Validate that iv and value are strings (prevent TypeError from base64_decode)
201        if (!is_string($payload['iv']) || !is_string($payload['value'])) {
202            throw new Exception('Error: The payload is not valid data.');
203        }
204
205        // Non-AEAD (CBC) ciphers require a string 'mac'; AEAD ciphers don't use one.
206        if (!$aead && (!isset($payload['mac']) || !is_string($payload['mac']))) {
207            throw new Exception('Error: The payload is not valid data.');
208        }
209
210        // 'tag' is optional, but if present it must be a string (prevent TypeError from base64_decode)
211        if (isset($payload['tag']) && !is_string($payload['tag'])) {
212            throw new Exception('Error: The payload is not valid data.');
213        }
214
215        $iv        = base64_decode($payload['iv']);
216        $tag       = (!empty($payload['tag'])) ? base64_decode($payload['tag']) : '';
217        $decrypted = false;
218        $validMac  = null;
219
220        foreach ($this->getAllKeys() as $key) {
221            if ($aead) {
222                $encKey = $key;
223            } else {
224                $macKey   = hash_hkdf('sha256', $key, 32, self::HKDF_MAC_INFO);
225                $validMac = hash_equals(hash_hmac('sha256', $payload['iv'] . $payload['value'], $macKey), $payload['mac']);
226                if (!$validMac) {
227                    continue;
228                }
229                $encKey = hash_hkdf('sha256', $key, 32, self::HKDF_ENCRYPTION_INFO);
230            }
231
232            $decrypted = openssl_decrypt($payload['value'], $this->cipher, $encKey, 0, $iv, $tag);
233
234            if ($decrypted !== false) {
235                break;
236            }
237        }
238
239        if ($validMac === false) {
240            throw new Exception('Error: Invalid MAC value.');
241        }
242        if ($decrypted === false) {
243            throw new Exception('Error: Unable to decrypt the data.');
244        }
245
246        return $decrypted;
247    }
248
249}